Skip to content

Commit a434d3a

Browse files
ralyodioclaude
andcommitted
M2: real provider adapters (OpenAI-compatible + Anthropic) + factory
- adapter-openai: chat/completions + SSE streaming; covers openai, deepseek, perplexity, kimi, qwen, google(compat), ollama/lmstudio/vllm - adapter-anthropic: Messages API (system lifted out), SSE streaming - endpoints: default base URLs per provider - createProvider(id, {apiKey,baseUrl?,fetchImpl}) factory - 5 adapter tests with mocked fetch (11 total) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1739404 commit a434d3a

6 files changed

Lines changed: 433 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Anthropic (Claude) provider adapter — uses the Messages API, which differs
3+
* from the OpenAI chat shape: the system prompt is a top-level field.
4+
*/
5+
6+
import type {
7+
ModelProvider,
8+
CompletionRequest,
9+
CompletionResponse,
10+
CompletionChunk,
11+
ChatMessage,
12+
} from './index.js';
13+
import { sseLines } from './adapter-openai.js';
14+
15+
const ANTHROPIC_VERSION = '2023-06-01';
16+
const DEFAULT_MAX_TOKENS = 1024;
17+
18+
export interface AnthropicAdapterConfig {
19+
baseUrl: string;
20+
apiKey: string;
21+
fetchImpl?: typeof fetch;
22+
}
23+
24+
/** Splits a flat message list into Anthropic's (system, messages) shape. */
25+
function splitSystem(messages: ChatMessage[]): {
26+
system: string | undefined;
27+
rest: { role: 'user' | 'assistant'; content: string }[];
28+
} {
29+
const system = messages.find((m) => m.role === 'system')?.content;
30+
const rest = messages
31+
.filter((m) => m.role === 'user' || m.role === 'assistant')
32+
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));
33+
return { system, rest };
34+
}
35+
36+
export class AnthropicProvider implements ModelProvider {
37+
readonly id = 'anthropic' as const;
38+
readonly local = false;
39+
40+
constructor(private readonly config: AnthropicAdapterConfig) {}
41+
42+
private get fetch(): typeof fetch {
43+
return this.config.fetchImpl ?? fetch;
44+
}
45+
46+
private headers(): Record<string, string> {
47+
return {
48+
'content-type': 'application/json',
49+
'x-api-key': this.config.apiKey,
50+
'anthropic-version': ANTHROPIC_VERSION,
51+
};
52+
}
53+
54+
async listModels(): Promise<string[]> {
55+
const res = await this.fetch(`${this.config.baseUrl}/models`, { headers: this.headers() });
56+
if (!res.ok) throw new Error(`anthropic listModels failed: ${res.status}`);
57+
const body = (await res.json()) as { data?: { id: string }[] };
58+
return (body.data ?? []).map((m) => m.id);
59+
}
60+
61+
async complete(req: CompletionRequest): Promise<CompletionResponse> {
62+
const { system, rest } = splitSystem(req.messages);
63+
const res = await this.fetch(`${this.config.baseUrl}/messages`, {
64+
method: 'POST',
65+
headers: this.headers(),
66+
body: JSON.stringify({
67+
model: req.model,
68+
max_tokens: req.maxTokens ?? DEFAULT_MAX_TOKENS,
69+
temperature: req.temperature,
70+
...(system ? { system } : {}),
71+
messages: rest,
72+
}),
73+
});
74+
if (!res.ok) throw new Error(`anthropic completion failed: ${res.status} ${await res.text()}`);
75+
const body = (await res.json()) as {
76+
content?: { type: string; text?: string }[];
77+
usage?: { input_tokens: number; output_tokens: number };
78+
};
79+
const text = (body.content ?? [])
80+
.filter((b) => b.type === 'text')
81+
.map((b) => b.text ?? '')
82+
.join('');
83+
const out: CompletionResponse = { text, model: req.model };
84+
if (body.usage) {
85+
out.usage = {
86+
promptTokens: body.usage.input_tokens,
87+
completionTokens: body.usage.output_tokens,
88+
};
89+
}
90+
return out;
91+
}
92+
93+
async *stream(req: CompletionRequest): AsyncIterable<CompletionChunk> {
94+
const { system, rest } = splitSystem(req.messages);
95+
const res = await this.fetch(`${this.config.baseUrl}/messages`, {
96+
method: 'POST',
97+
headers: this.headers(),
98+
body: JSON.stringify({
99+
model: req.model,
100+
max_tokens: req.maxTokens ?? DEFAULT_MAX_TOKENS,
101+
temperature: req.temperature,
102+
...(system ? { system } : {}),
103+
messages: rest,
104+
stream: true,
105+
}),
106+
});
107+
if (!res.ok || !res.body) throw new Error(`anthropic stream failed: ${res.status}`);
108+
for await (const data of sseLines(res.body)) {
109+
try {
110+
const evt = JSON.parse(data) as {
111+
type: string;
112+
delta?: { type?: string; text?: string };
113+
};
114+
if (evt.type === 'content_block_delta' && evt.delta?.text) {
115+
yield { delta: evt.delta.text, done: false };
116+
} else if (evt.type === 'message_stop') {
117+
yield { delta: '', done: true };
118+
return;
119+
}
120+
} catch {
121+
// ignore non-JSON events
122+
}
123+
}
124+
yield { delta: '', done: true };
125+
}
126+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* OpenAI-compatible provider adapter. Works for OpenAI, DeepSeek, Perplexity,
3+
* Kimi (Moonshot), Qwen (DashScope), Google (Gemini OpenAI-compat endpoint),
4+
* and local runtimes (Ollama, LM Studio, vLLM).
5+
*/
6+
7+
import type { ProviderId } from './index.js';
8+
import type {
9+
ModelProvider,
10+
CompletionRequest,
11+
CompletionResponse,
12+
CompletionChunk,
13+
} from './index.js';
14+
15+
export interface OpenAIAdapterConfig {
16+
baseUrl: string;
17+
apiKey: string;
18+
/** Defaults to global fetch; injectable for tests. */
19+
fetchImpl?: typeof fetch;
20+
}
21+
22+
export class OpenAICompatibleProvider implements ModelProvider {
23+
constructor(
24+
readonly id: ProviderId,
25+
readonly local: boolean,
26+
private readonly config: OpenAIAdapterConfig,
27+
) {}
28+
29+
private get fetch(): typeof fetch {
30+
return this.config.fetchImpl ?? fetch;
31+
}
32+
33+
private headers(): Record<string, string> {
34+
const h: Record<string, string> = { 'content-type': 'application/json' };
35+
if (this.config.apiKey) h['authorization'] = `Bearer ${this.config.apiKey}`;
36+
return h;
37+
}
38+
39+
async listModels(): Promise<string[]> {
40+
const res = await this.fetch(`${this.config.baseUrl}/models`, { headers: this.headers() });
41+
if (!res.ok) throw new Error(`${this.id} listModels failed: ${res.status}`);
42+
const body = (await res.json()) as { data?: { id: string }[] };
43+
return (body.data ?? []).map((m) => m.id);
44+
}
45+
46+
async complete(req: CompletionRequest): Promise<CompletionResponse> {
47+
const res = await this.fetch(`${this.config.baseUrl}/chat/completions`, {
48+
method: 'POST',
49+
headers: this.headers(),
50+
body: JSON.stringify({
51+
model: req.model,
52+
messages: req.messages,
53+
temperature: req.temperature,
54+
max_tokens: req.maxTokens,
55+
stream: false,
56+
}),
57+
});
58+
if (!res.ok) throw new Error(`${this.id} completion failed: ${res.status} ${await res.text()}`);
59+
const body = (await res.json()) as {
60+
choices?: { message?: { content?: string } }[];
61+
usage?: { prompt_tokens: number; completion_tokens: number };
62+
};
63+
const text = body.choices?.[0]?.message?.content ?? '';
64+
const out: CompletionResponse = { text, model: req.model };
65+
if (body.usage) {
66+
out.usage = {
67+
promptTokens: body.usage.prompt_tokens,
68+
completionTokens: body.usage.completion_tokens,
69+
};
70+
}
71+
return out;
72+
}
73+
74+
async *stream(req: CompletionRequest): AsyncIterable<CompletionChunk> {
75+
const res = await this.fetch(`${this.config.baseUrl}/chat/completions`, {
76+
method: 'POST',
77+
headers: this.headers(),
78+
body: JSON.stringify({
79+
model: req.model,
80+
messages: req.messages,
81+
temperature: req.temperature,
82+
max_tokens: req.maxTokens,
83+
stream: true,
84+
}),
85+
});
86+
if (!res.ok || !res.body) {
87+
throw new Error(`${this.id} stream failed: ${res.status}`);
88+
}
89+
for await (const data of sseLines(res.body)) {
90+
if (data === '[DONE]') {
91+
yield { delta: '', done: true };
92+
return;
93+
}
94+
try {
95+
const json = JSON.parse(data) as { choices?: { delta?: { content?: string } }[] };
96+
const delta = json.choices?.[0]?.delta?.content;
97+
if (delta) yield { delta, done: false };
98+
} catch {
99+
// ignore keep-alive / non-JSON lines
100+
}
101+
}
102+
yield { delta: '', done: true };
103+
}
104+
}
105+
106+
/** Yields the payload of each `data:` line from an SSE response stream. */
107+
export async function* sseLines(body: ReadableStream<Uint8Array>): AsyncIterable<string> {
108+
const reader = body.getReader();
109+
const decoder = new TextDecoder();
110+
let buffer = '';
111+
for (;;) {
112+
const { value, done } = await reader.read();
113+
if (done) break;
114+
buffer += decoder.decode(value, { stream: true });
115+
let nl: number;
116+
while ((nl = buffer.indexOf('\n')) >= 0) {
117+
const line = buffer.slice(0, nl).trim();
118+
buffer = buffer.slice(nl + 1);
119+
if (line.startsWith('data:')) yield line.slice(5).trim();
120+
}
121+
}
122+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { createProvider } from './index.js';
3+
import { sseLines } from './adapter-openai.js';
4+
5+
function jsonResponse(body: unknown, ok = true, status = 200): Response {
6+
return {
7+
ok,
8+
status,
9+
json: async () => body,
10+
text: async () => JSON.stringify(body),
11+
} as Response;
12+
}
13+
14+
/** Builds a Response whose body streams the given SSE chunks. */
15+
function sseResponse(chunks: string[]): Response {
16+
const encoder = new TextEncoder();
17+
let i = 0;
18+
const body = {
19+
getReader() {
20+
return {
21+
read: async () => (i < chunks.length
22+
? { value: encoder.encode(chunks[i++]), done: false }
23+
: { value: undefined, done: true }),
24+
};
25+
},
26+
} as unknown as ReadableStream<Uint8Array>;
27+
return { ok: true, status: 200, body } as Response;
28+
}
29+
30+
describe('OpenAI-compatible adapter', () => {
31+
it('completes via /chat/completions', async () => {
32+
const fetchImpl = vi.fn(async () =>
33+
jsonResponse({
34+
choices: [{ message: { content: 'hi there' } }],
35+
usage: { prompt_tokens: 3, completion_tokens: 2 },
36+
}),
37+
) as unknown as typeof fetch;
38+
39+
const provider = createProvider('deepseek', { apiKey: 'k', fetchImpl });
40+
const res = await provider.complete({ model: 'deepseek-chat', messages: [{ role: 'user', content: 'hi' }] });
41+
42+
expect(res.text).toBe('hi there');
43+
expect(res.usage).toEqual({ promptTokens: 3, completionTokens: 2 });
44+
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
45+
expect(url).toBe('https://api.deepseek.com/v1/chat/completions');
46+
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer k' });
47+
});
48+
49+
it('streams deltas from SSE', async () => {
50+
const fetchImpl = vi.fn(async () =>
51+
sseResponse([
52+
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n',
53+
'data: {"choices":[{"delta":{"content":"lo"}}]}\n',
54+
'data: [DONE]\n',
55+
]),
56+
) as unknown as typeof fetch;
57+
58+
const provider = createProvider('openai', { apiKey: 'k', fetchImpl });
59+
const out: string[] = [];
60+
for await (const c of provider.stream({ model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] })) {
61+
if (c.delta) out.push(c.delta);
62+
}
63+
expect(out.join('')).toBe('Hello');
64+
});
65+
66+
it('throws on a non-ok response', async () => {
67+
const fetchImpl = vi.fn(async () => jsonResponse({ error: 'nope' }, false, 401)) as unknown as typeof fetch;
68+
const provider = createProvider('openai', { apiKey: 'bad', fetchImpl });
69+
await expect(provider.complete({ model: 'gpt-4o', messages: [] })).rejects.toThrow(/401/);
70+
});
71+
});
72+
73+
describe('Anthropic adapter', () => {
74+
it('lifts the system prompt out of messages', async () => {
75+
const fetchImpl = vi.fn(async () =>
76+
jsonResponse({
77+
content: [{ type: 'text', text: 'pong' }],
78+
usage: { input_tokens: 5, output_tokens: 1 },
79+
}),
80+
) as unknown as typeof fetch;
81+
82+
const provider = createProvider('anthropic', { apiKey: 'sk-ant', fetchImpl });
83+
const res = await provider.complete({
84+
model: 'claude-opus-4-8',
85+
messages: [
86+
{ role: 'system', content: 'be terse' },
87+
{ role: 'user', content: 'ping' },
88+
],
89+
});
90+
expect(res.text).toBe('pong');
91+
92+
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
93+
expect(url).toBe('https://api.anthropic.com/v1/messages');
94+
const sent = JSON.parse((init as RequestInit).body as string);
95+
expect(sent.system).toBe('be terse');
96+
expect(sent.messages).toEqual([{ role: 'user', content: 'ping' }]);
97+
expect((init as RequestInit).headers).toMatchObject({ 'x-api-key': 'sk-ant' });
98+
});
99+
});
100+
101+
describe('sseLines', () => {
102+
it('extracts data payloads across chunk boundaries', async () => {
103+
const encoder = new TextEncoder();
104+
let i = 0;
105+
const parts = ['data: a\nda', 'ta: b\n'];
106+
const body = {
107+
getReader: () => ({
108+
read: async () => (i < parts.length
109+
? { value: encoder.encode(parts[i++]), done: false }
110+
: { value: undefined, done: true }),
111+
}),
112+
} as unknown as ReadableStream<Uint8Array>;
113+
const got: string[] = [];
114+
for await (const d of sseLines(body)) got.push(d);
115+
expect(got).toEqual(['a', 'b']);
116+
});
117+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Default API base URLs per provider. Local providers point at their default
3+
* loopback ports. All except Anthropic speak the OpenAI-compatible chat API
4+
* (Gemini via its OpenAI-compatibility endpoint).
5+
*/
6+
7+
import type { ProviderId } from './index.js';
8+
9+
export const DEFAULT_BASE_URLS: Record<ProviderId, string> = {
10+
anthropic: 'https://api.anthropic.com/v1',
11+
openai: 'https://api.openai.com/v1',
12+
google: 'https://generativelanguage.googleapis.com/v1beta/openai',
13+
deepseek: 'https://api.deepseek.com/v1',
14+
perplexity: 'https://api.perplexity.ai',
15+
huggingface: 'https://router.huggingface.co/v1',
16+
kimi: 'https://api.moonshot.ai/v1',
17+
qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
18+
ollama: 'http://localhost:11434/v1',
19+
lmstudio: 'http://localhost:1234/v1',
20+
vllm: 'http://localhost:8000/v1',
21+
};
22+
23+
/** Anthropic uses its own Messages API; everything else is OpenAI-compatible. */
24+
export function usesAnthropicApi(id: ProviderId): boolean {
25+
return id === 'anthropic';
26+
}

0 commit comments

Comments
 (0)