Skip to content

Commit 2021560

Browse files
committed
[agent] feat: add anthropic-sdk adapter as separate adapter type
- Restore anthropic.ts to original raw-fetch implementation (no SDK dep) - New anthropic-sdk.ts: standalone adapter using @anthropic-ai/sdk executeStream - server.ts: register 'anthropic-sdk' adapter alongside existing 'anthropic' - Users can choose: adapter: 'anthropic' (raw fetch) or 'anthropic-sdk' (SDK)
1 parent 96146d2 commit 2021560

3 files changed

Lines changed: 232 additions & 102 deletions

File tree

src/adapters/anthropic-sdk.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import Anthropic from "@anthropic-ai/sdk";
2+
import type { RawMessageStreamEvent } from "@anthropic-ai/sdk/resources/messages.mjs";
3+
import type { ProviderAdapter } from "./base";
4+
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
5+
import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPrefix, stripClaudeToolPrefix } from "../oauth/anthropic";
6+
import { parseDataUrl } from "./image";
7+
8+
function toContentPart(p: OcxContentPart): unknown {
9+
if (p.type === "image") {
10+
const data = parseDataUrl(p.imageUrl);
11+
return data
12+
? { type: "image", source: { type: "base64", media_type: data.mediaType, data: data.base64 } }
13+
: { type: "image", source: { type: "url", url: p.imageUrl } };
14+
}
15+
return { type: "text", text: p.text };
16+
}
17+
18+
const DEFAULT_MAX_TOKENS = 8192;
19+
const REASONING_MAX_TOKENS_CEILING = 32_000;
20+
const MIN_THINKING_BUDGET = 1024;
21+
const OUTPUT_HEADROOM = 8192;
22+
const OUTPUT_FLOOR = 4096;
23+
24+
function reasoningBudget(effort: string): number {
25+
switch (effort) {
26+
case "minimal": return 1024;
27+
case "low": return 4096;
28+
case "high": return 16384;
29+
case "xhigh": return 24576;
30+
case "max": return 32000;
31+
case "medium":
32+
default: return 8192;
33+
}
34+
}
35+
36+
function usageFromSdk(usage: { output_tokens: number } | undefined): OcxUsage | undefined {
37+
if (!usage) return undefined;
38+
return { inputTokens: 0, outputTokens: usage.output_tokens };
39+
}
40+
41+
function buildMessages(parsed: OcxParsedRequest, isOAuth: boolean): { system: unknown; messages: unknown[] } {
42+
const systemText = parsed.context.systemPrompt?.join("\n\n") || undefined;
43+
const system = isOAuth
44+
? [{ type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION }, ...(systemText ? [{ type: "text", text: systemText }] : [])]
45+
: systemText;
46+
47+
const messages: unknown[] = [];
48+
for (const msg of parsed.context.messages) {
49+
switch (msg.role) {
50+
case "user":
51+
case "developer": {
52+
const content = typeof msg.content === "string"
53+
? msg.content
54+
: (msg.content as OcxContentPart[]).map(toContentPart);
55+
messages.push({ role: "user", content });
56+
break;
57+
}
58+
case "assistant": {
59+
const aMsg = msg as OcxAssistantMessage;
60+
const content: unknown[] = [];
61+
for (const part of aMsg.content) {
62+
if (part.type === "text") content.push({ type: "text", text: (part as OcxTextContent).text });
63+
else if (part.type === "thinking") {
64+
const t = part as OcxThinkingContent;
65+
content.push({ type: "thinking", thinking: t.thinking, ...(t.signature ? { signature: t.signature } : {}) });
66+
} else if (part.type === "toolCall") {
67+
const tc = part as OcxToolCall;
68+
content.push({ type: "tool_use", id: tc.id, name: isOAuth ? applyClaudeToolPrefix(tc.name) : tc.name, input: tc.arguments });
69+
}
70+
}
71+
messages.push({ role: "assistant", content });
72+
break;
73+
}
74+
case "toolResult": {
75+
const trContent = typeof msg.content === "string"
76+
? msg.content
77+
: (msg.content as OcxContentPart[]).map(toContentPart);
78+
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: msg.toolCallId, content: trContent }] });
79+
break;
80+
}
81+
}
82+
}
83+
return { system, messages };
84+
}
85+
86+
function buildTools(parsed: OcxParsedRequest, isOAuth: boolean): unknown[] | undefined {
87+
if (!parsed.context.tools?.length) return undefined;
88+
return parsed.context.tools.map(t => ({
89+
name: isOAuth ? applyClaudeToolPrefix(t.name) : t.name,
90+
description: t.description,
91+
input_schema: t.parameters,
92+
}));
93+
}
94+
95+
export function createAnthropicSdkAdapter(provider: OcxProviderConfig): ProviderAdapter {
96+
const isOAuth = provider.authMode === "oauth";
97+
98+
return {
99+
name: "anthropic-sdk",
100+
101+
buildRequest(parsed: OcxParsedRequest) {
102+
const { system, messages } = buildMessages(parsed, isOAuth);
103+
const tools = buildTools(parsed, isOAuth);
104+
const body: Record<string, unknown> = {
105+
model: parsed.modelId, messages, stream: parsed.stream,
106+
max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS,
107+
};
108+
if (system) body.system = system;
109+
if (tools) body.tools = tools;
110+
if (parsed.options.temperature !== undefined) body.temperature = parsed.options.temperature;
111+
if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP;
112+
if (parsed.options.stopSequences) body.stop_sequences = parsed.options.stopSequences;
113+
if (parsed.options.reasoning) {
114+
const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
115+
const wantBudget = reasoningBudget(parsed.options.reasoning);
116+
const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM));
117+
const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR));
118+
body.max_tokens = maxTokens;
119+
body.thinking = { type: "enabled", budget_tokens: budget };
120+
delete body.temperature;
121+
delete body.top_p;
122+
}
123+
if (parsed.options.toolChoice) {
124+
const tc = parsed.options.toolChoice;
125+
if (tc === "auto") body.tool_choice = { type: "auto" };
126+
else if (tc === "none") body.tool_choice = { type: "none" };
127+
else if (tc === "required") body.tool_choice = { type: "any" };
128+
else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: isOAuth ? applyClaudeToolPrefix(tc.name) : tc.name };
129+
}
130+
const url = `${provider.baseUrl}/v1/messages`;
131+
const headers: Record<string, string> = { "Content-Type": "application/json", "anthropic-version": "2023-06-01" };
132+
if (isOAuth) {
133+
if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
134+
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
135+
} else if (provider.apiKey) {
136+
headers["x-api-key"] = provider.apiKey;
137+
}
138+
if (provider.headers) Object.assign(headers, provider.headers);
139+
return { url, method: "POST", headers, body: JSON.stringify(body) };
140+
},
141+
142+
async *parseStream(): AsyncGenerator<AdapterEvent> {
143+
yield { type: "error", message: "anthropic-sdk adapter uses executeStream; parseStream should not be called directly" };
144+
},
145+
146+
async *executeStream(parsed: OcxParsedRequest, signal?: AbortSignal): AsyncGenerator<AdapterEvent> {
147+
const { system, messages } = buildMessages(parsed, isOAuth);
148+
const tools = buildTools(parsed, isOAuth);
149+
150+
const params: Record<string, unknown> = {
151+
model: parsed.modelId, messages, stream: true,
152+
max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS,
153+
};
154+
if (system) params.system = system;
155+
if (tools) params.tools = tools;
156+
if (parsed.options.temperature !== undefined) params.temperature = parsed.options.temperature;
157+
if (parsed.options.topP !== undefined) params.top_p = parsed.options.topP;
158+
if (parsed.options.stopSequences) params.stop_sequences = parsed.options.stopSequences;
159+
160+
if (parsed.options.reasoning) {
161+
const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
162+
const wantBudget = reasoningBudget(parsed.options.reasoning);
163+
const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM));
164+
const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR));
165+
params.max_tokens = maxTokens;
166+
params.thinking = { type: "enabled", budget_tokens: budget };
167+
delete params.temperature;
168+
delete params.top_p;
169+
}
170+
171+
if (parsed.options.toolChoice) {
172+
const tc = parsed.options.toolChoice;
173+
if (tc === "auto") params.tool_choice = { type: "auto" };
174+
else if (tc === "none") params.tool_choice = { type: "none" };
175+
else if (tc === "required") params.tool_choice = { type: "any" };
176+
else if (typeof tc === "object" && "name" in tc) params.tool_choice = { type: "tool", name: isOAuth ? applyClaudeToolPrefix(tc.name) : tc.name };
177+
}
178+
179+
const sdkHeaders: Record<string, string> = {};
180+
if (isOAuth) sdkHeaders["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
181+
if (provider.headers) Object.assign(sdkHeaders, provider.headers);
182+
183+
const client = new Anthropic({
184+
apiKey: provider.apiKey ?? "",
185+
baseURL: `${provider.baseUrl}/v1`,
186+
maxRetries: 2,
187+
defaultHeaders: sdkHeaders,
188+
...(isOAuth ? { authToken: provider.apiKey } : {}),
189+
});
190+
191+
let stream: AsyncIterable<RawMessageStreamEvent>;
192+
try {
193+
stream = await client.messages.create(
194+
params as unknown as Parameters<typeof client.messages.create>[0],
195+
{ signal },
196+
) as AsyncIterable<RawMessageStreamEvent>;
197+
} catch (err) {
198+
yield { type: "error", message: err instanceof Error ? err.message : String(err) };
199+
return;
200+
}
201+
202+
let inToolUse = false;
203+
for await (const event of stream) {
204+
switch (event.type) {
205+
case "content_block_start":
206+
if (event.content_block.type === "tool_use") {
207+
const name = isOAuth ? stripClaudeToolPrefix(event.content_block.name) : event.content_block.name;
208+
inToolUse = true;
209+
yield { type: "tool_call_start", id: event.content_block.id, name };
210+
}
211+
break;
212+
case "content_block_delta":
213+
if (event.delta.type === "text_delta") yield { type: "text_delta", text: event.delta.text };
214+
else if (event.delta.type === "thinking_delta") yield { type: "thinking_delta", thinking: event.delta.thinking };
215+
else if (event.delta.type === "input_json_delta") yield { type: "tool_call_delta", arguments: event.delta.partial_json };
216+
break;
217+
case "content_block_stop":
218+
if (inToolUse) { yield { type: "tool_call_end" }; inToolUse = false; }
219+
break;
220+
case "message_delta":
221+
yield { type: "done", usage: usageFromSdk(event.usage) };
222+
break;
223+
default:
224+
break;
225+
}
226+
}
227+
},
228+
};
229+
}

src/adapters/anthropic.ts

Lines changed: 0 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import Anthropic from "@anthropic-ai/sdk";
2-
import type { RawMessageStreamEvent } from "@anthropic-ai/sdk/resources/messages.mjs";
31
import type { ProviderAdapter } from "./base";
42
import { debugDroppedFrame } from "../debug";
53
import type {
@@ -315,105 +313,5 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
315313
return events;
316314
},
317315

318-
async *executeStream(parsed: OcxParsedRequest, signal?: AbortSignal): AsyncGenerator<AdapterEvent> {
319-
const { system, messages } = messagesToAnthropicFormat(parsed, isOAuth);
320-
const tools = toolsToAnthropicFormat(parsed, isOAuth);
321-
322-
const sdkParams: Record<string, unknown> = {
323-
model: parsed.modelId,
324-
messages,
325-
max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS,
326-
stream: true,
327-
};
328-
if (isOAuth) {
329-
sdkParams.system = [
330-
{ type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION },
331-
...(system ? [{ type: "text", text: system }] : []),
332-
];
333-
} else if (system) {
334-
sdkParams.system = system;
335-
}
336-
if (tools) sdkParams.tools = tools;
337-
if (parsed.options.temperature !== undefined) sdkParams.temperature = parsed.options.temperature;
338-
if (parsed.options.topP !== undefined) sdkParams.top_p = parsed.options.topP;
339-
if (parsed.options.stopSequences) sdkParams.stop_sequences = parsed.options.stopSequences;
340-
341-
if (parsed.options.reasoning) {
342-
const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
343-
const wantBudget = reasoningBudget(parsed.options.reasoning);
344-
const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM));
345-
const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR));
346-
sdkParams.max_tokens = maxTokens;
347-
sdkParams.thinking = { type: "enabled", budget_tokens: budget };
348-
delete sdkParams.temperature;
349-
delete sdkParams.top_p;
350-
}
351-
352-
if (parsed.options.toolChoice) {
353-
const tc = parsed.options.toolChoice;
354-
if (tc === "auto") sdkParams.tool_choice = { type: "auto" };
355-
else if (tc === "none") sdkParams.tool_choice = { type: "none" };
356-
else if (tc === "required") sdkParams.tool_choice = { type: "any" };
357-
else if (typeof tc === "object" && "name" in tc) sdkParams.tool_choice = { type: "tool", name: isOAuth ? applyClaudeToolPrefix(tc.name) : tc.name };
358-
}
359-
360-
const headers: Record<string, string> = {};
361-
if (isOAuth) headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
362-
if (provider.headers) Object.assign(headers, provider.headers);
363-
364-
const client = new Anthropic({
365-
apiKey: provider.apiKey ?? "",
366-
baseURL: `${provider.baseUrl}/v1`,
367-
maxRetries: 2,
368-
defaultHeaders: headers,
369-
...(isOAuth ? { authToken: provider.apiKey } : {}),
370-
});
371-
372-
const stream = await client.messages.create(
373-
sdkParams as unknown as Parameters<typeof client.messages.create>[0],
374-
{ signal },
375-
);
376-
377-
let currentToolName = "";
378-
for await (const event of stream as AsyncIterable<RawMessageStreamEvent>) {
379-
switch (event.type) {
380-
case "content_block_start": {
381-
if (event.content_block.type === "tool_use") {
382-
const name = isOAuth ? stripClaudeToolPrefix(event.content_block.name) : event.content_block.name;
383-
currentToolName = name;
384-
yield { type: "tool_call_start", id: event.content_block.id, name };
385-
}
386-
break;
387-
}
388-
case "content_block_delta": {
389-
if (event.delta.type === "text_delta") {
390-
yield { type: "text_delta", text: event.delta.text };
391-
} else if (event.delta.type === "thinking_delta") {
392-
yield { type: "thinking_delta", thinking: event.delta.thinking };
393-
} else if (event.delta.type === "input_json_delta") {
394-
yield { type: "tool_call_delta", arguments: event.delta.partial_json };
395-
}
396-
break;
397-
}
398-
case "content_block_stop": {
399-
if (currentToolName) {
400-
yield { type: "tool_call_end" };
401-
currentToolName = "";
402-
}
403-
break;
404-
}
405-
case "message_delta": {
406-
const u = event.usage;
407-
yield {
408-
type: "done",
409-
usage: u ? usageFromAnthropic({ output_tokens: u.output_tokens }) : undefined,
410-
};
411-
break;
412-
}
413-
default:
414-
break;
415-
}
416-
}
417-
},
418316
};
419317
}

src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync } from "node:fs";
22
import { extname, join } from "node:path";
33
import { createAnthropicAdapter } from "./adapters/anthropic";
4+
import { createAnthropicSdkAdapter } from "./adapters/anthropic-sdk";
45
import { createAzureAdapter } from "./adapters/azure";
56
import { createGoogleAdapter } from "./adapters/google";
67
import { createOpenAIChatAdapter } from "./adapters/openai-chat";
@@ -92,6 +93,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig) {
9293
return createOpenAIChatAdapter(providerConfig);
9394
case "anthropic":
9495
return createAnthropicAdapter(providerConfig);
96+
case "anthropic-sdk":
97+
return createAnthropicSdkAdapter(providerConfig);
9598
case "openai-responses":
9699
return createResponsesPassthroughAdapter(providerConfig);
97100
case "google":

0 commit comments

Comments
 (0)