Skip to content

Commit 0c551d4

Browse files
committed
Merge branch 'main' into dev
2 parents be0b620 + 2021560 commit 0c551d4

6 files changed

Lines changed: 281 additions & 26 deletions

File tree

bun.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"release:watch": "bun scripts/release.ts watch"
3030
},
3131
"dependencies": {
32+
"@anthropic-ai/sdk": "^0.105.0",
3233
"zod": "^4.0.0"
3334
},
3435
"devDependencies": {

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,5 +312,6 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
312312
});
313313
return events;
314314
},
315+
315316
};
316317
}

src/adapters/base.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,8 @@ export interface ProviderAdapter {
1717

1818
parseStream(response: Response): AsyncGenerator<AdapterEvent>;
1919
parseResponse?(response: Response): Promise<AdapterEvent[]>;
20+
21+
/** Optional: adapter handles its own fetch + streaming via a provider SDK. When present,
22+
* server.ts uses this instead of buildRequest → fetch → parseStream. */
23+
executeStream?(parsed: OcxParsedRequest, signal?: AbortSignal): AsyncGenerator<AdapterEvent>;
2024
}

src/server.ts

Lines changed: 31 additions & 26 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";
@@ -104,6 +105,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig) {
104105
return createOpenAIChatAdapter(providerConfig);
105106
case "anthropic":
106107
return createAnthropicAdapter(providerConfig);
108+
case "anthropic-sdk":
109+
return createAnthropicSdkAdapter(providerConfig);
107110
case "openai-responses":
108111
return createResponsesPassthroughAdapter(providerConfig);
109112
case "google":
@@ -218,19 +221,35 @@ async function handleResponses(
218221
});
219222
}
220223

221-
const request = adapter.buildRequest(parsed, { headers: req.headers });
222-
223-
// Abort the upstream fetch if the client (Codex) disconnects mid-stream, so a cancelled turn does
224-
// not leak the upstream connection or keep draining tokens. The bridge's cancel() fires upstream.abort() (RC2).
225224
const upstream = new AbortController();
226225
linkAbortSignal(upstream, options.abortSignal);
226+
227+
if (parsed.stream && adapter.executeStream) {
228+
const eventStream = adapter.executeStream(parsed, upstream.signal);
229+
const toolNsMap = new Map<string, { namespace: string; name: string }>();
230+
const freeformToolNames = new Set<string>();
231+
const toolSearchToolNames = new Set<string>();
232+
for (const t of parsed.context.tools ?? []) {
233+
if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
234+
if (t.freeform) freeformToolNames.add(t.name);
235+
if (t.toolSearch) toolSearchToolNames.add(t.name);
236+
}
237+
const sseStream = bridgeToResponsesSSE(
238+
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
239+
() => upstream.abort(), 2_000,
240+
options.forceEmptyResponseId ? { responseId: "" } : undefined,
241+
);
242+
return new Response(sseStream, {
243+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
244+
});
245+
}
246+
247+
// Fallback: buildRequest → fetch → parseStream (used by all non-SDK adapters and web-search sidecar)
248+
const request = adapter.buildRequest(parsed, { headers: req.headers });
227249
let upstreamResponse: Response;
228250
try {
229251
upstreamResponse = await fetch(request.url, {
230-
method: request.method,
231-
headers: request.headers,
232-
body: request.body,
233-
signal: upstream.signal,
252+
method: request.method, headers: request.headers, body: request.body, signal: upstream.signal,
234253
});
235254
} catch (err) {
236255
return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
@@ -243,8 +262,6 @@ async function handleResponses(
243262

244263
if (parsed.stream) {
245264
const eventStream = adapter.parseStream(upstreamResponse);
246-
// Map flattened MCP tool names back to {namespace, name} so the bridge can restore the
247-
// namespace field Codex needs to route the call to the right MCP server.
248265
const toolNsMap = new Map<string, { namespace: string; name: string }>();
249266
const freeformToolNames = new Set<string>();
250267
const toolSearchToolNames = new Set<string>();
@@ -254,31 +271,19 @@ async function handleResponses(
254271
if (t.toolSearch) toolSearchToolNames.add(t.name);
255272
}
256273
const sseStream = bridgeToResponsesSSE(
257-
eventStream,
258-
parsed.modelId,
259-
toolNsMap,
260-
freeformToolNames,
261-
toolSearchToolNames,
262-
() => upstream.abort(),
263-
2_000,
274+
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
275+
() => upstream.abort(), 2_000,
264276
options.forceEmptyResponseId ? { responseId: "" } : undefined,
265277
);
266278
return new Response(sseStream, {
267-
headers: {
268-
"Content-Type": "text/event-stream",
269-
"Cache-Control": "no-cache",
270-
"Connection": "keep-alive",
271-
"X-Accel-Buffering": "no",
272-
},
279+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
273280
});
274281
}
275282

276283
if (adapter.parseResponse) {
277284
const events = await adapter.parseResponse(upstreamResponse);
278285
const json = buildResponseJSON(events, parsed.modelId);
279-
return new Response(JSON.stringify(json), {
280-
headers: { "Content-Type": "application/json" },
281-
});
286+
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
282287
}
283288

284289
return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");

0 commit comments

Comments
 (0)