Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ Examples after the configuration above:

Claude Code can also send a `/v1/messages` request with an `openai/*` model. CC-Router translates that Anthropic Messages request into an OpenAI Responses request and converts JSON or basic text SSE responses back into Anthropic-shaped message responses.

Current limitation: OpenAI-to-Anthropic streaming currently covers text deltas and final usage. Streaming tool-call normalization is still experimental.
OpenAI-to-Anthropic conversion supports text and function tool calls in both streaming and non-streaming responses.

OpenAI subscription account records are separated from Claude accounts with `provider: "openai_subscription"` so they do not enter the Anthropic token pool:

Expand Down
46 changes: 15 additions & 31 deletions src/__tests__/anthropic-to-openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("anthropicToOpenAIResponses", () => {
});
});

it("maps Anthropic tools, assistant tool_use, and user tool_result", () => {
it("keeps assistant text and tool calls in valid Responses input items", () => {
const result = anthropicToOpenAIResponses({
model: "openai/gpt-5.5",
messages: [
Expand All @@ -40,42 +40,26 @@ describe("anthropicToOpenAIResponses", () => {
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_1", content: "CC-Router" },
],
content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "CC-Router" }],
},
],
tools: [
{
name: "read_file",
description: "Read a file",
input_schema: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
],
});

expect(result.tools).toEqual([
{
type: "function",
tools: [{
name: "read_file",
description: "Read a file",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
]);
expect(result.input[0].content).toEqual([
{ type: "input_text", text: "I will inspect it." },
input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
}],
});

expect(result.input).toEqual([
{ role: "assistant", content: [{ type: "output_text", text: "I will inspect it." }] },
{ type: "function_call", call_id: "toolu_1", name: "read_file", arguments: "{\"path\":\"README.md\"}" },
]);
expect(result.input[1].content).toEqual([
{ type: "function_call_output", call_id: "toolu_1", output: "CC-Router" },
]);
expect(result.tools?.[0]).toEqual({
type: "function",
name: "read_file",
description: "Read a file",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
});
});
});
131 changes: 131 additions & 0 deletions src/__tests__/messages-cross-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,137 @@ describe("mountMessagesCrossProviderRoute", () => {
}
});

it("streams function calls back as Anthropic tool_use events", async () => {
const app = express();

mountMessagesCrossProviderRoute(app, {
getOpenAIAccount: () => ({
id: "openai-victor",
provider: "openai_subscription",
accessToken: "access",
refreshToken: "refresh",
expiresAt: Date.now() + 60 * 60 * 1000,
enabled: true,
}),
forwardOpenAI: async () => new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const push = (event: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
push({ type: "response.created", response: { id: "resp_tool", model: "gpt-5.5" } });
push({
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "read_file" },
});
push({ type: "response.function_call_arguments.delta", output_index: 0, delta: "{\"path\":\"README.md\"}" });
push({ type: "response.output_item.done", output_index: 0 });
push({ type: "response.completed", response: { id: "resp_tool", usage: { input_tokens: 8, output_tokens: 4 } } });
controller.close();
},
}) as BodyInit,
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
});

const server = createServer(app);
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port");

try {
const res = await fetch(`http://127.0.0.1:${address.port}/v1/messages`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "openai/gpt-5.5",
messages: [{ role: "user", content: "Read README.md" }],
stream: true,
}),
});
const body = await res.text();

expect(body).toContain('"content_block":{"type":"tool_use","id":"call_1","name":"read_file","input":{}}');
expect(body).toContain('"delta":{"type":"input_json_delta","partial_json":"{\\"path\\":\\"README.md\\"}"}');
expect(body).toContain('"stop_reason":"tool_use"');
} finally {
await new Promise<void>((resolve, reject) => {
server.close(err => err ? reject(err) : resolve());
});
}
});


it("collapses a function-call stream into an Anthropic tool_use response", async () => {
const app = express();

mountMessagesCrossProviderRoute(app, {
getOpenAIAccount: () => ({
id: "openai-victor",
provider: "openai_subscription",
accessToken: "access",
refreshToken: "refresh",
expiresAt: Date.now() + 60 * 60 * 1000,
enabled: true,
}),
forwardOpenAI: async () => new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const push = (event: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
push({ type: "response.created", response: { id: "resp_tool", model: "gpt-5.5" } });
push({
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "read_file", arguments: "" },
});
push({
type: "response.function_call_arguments.done",
output_index: 0,
arguments: "{\"path\":\"README.md\"}",
});
push({ type: "response.output_item.done", output_index: 0 });
push({ type: "response.completed", response: { id: "resp_tool", model: "gpt-5.5", usage: { input_tokens: 8, output_tokens: 4 } } });
controller.close();
},
}) as BodyInit,
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
});

const server = createServer(app);
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port");

try {
const res = await fetch(`http://127.0.0.1:${address.port}/v1/messages`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "openai/gpt-5.5",
messages: [{ role: "user", content: "Read README.md" }],
stream: false,
}),
});

expect(await res.json()).toEqual({
id: "resp_tool",
type: "message",
role: "assistant",
model: "gpt-5.5",
content: [{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "README.md" } }],
stop_reason: "tool_use",
stop_sequence: null,
usage: { input_tokens: 8, output_tokens: 4 },
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close(err => err ? reject(err) : resolve());
});
}
});

it("passes non-openai models to later Anthropic proxy middleware with replayable raw body", async () => {
const app = express();
const nextSpy = vi.fn();
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/openai-response-to-anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,22 @@ describe("openAIResponseToAnthropicMessage", () => {
},
});
});

it("maps function calls to ordered Anthropic tool blocks", () => {
const result = openAIResponseToAnthropicMessage({
id: "resp_2",
model: "gpt-5.5",
output: [
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Checking." }] },
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "get_weather", arguments: "{\"city\":\"Seoul\"}" },
],
usage: { input_tokens: 12, output_tokens: 3 },
});

expect(result.content).toEqual([
{ type: "text", text: "Checking." },
{ type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Seoul" } },
]);
expect(result.stop_reason).toBe("tool_use");
});
});
98 changes: 98 additions & 0 deletions src/__tests__/openai-stream-to-anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,102 @@ describe("openAIStreamEventToAnthropicEvents", () => {
},
]);
});

it("streams function calls as Anthropic tool_use blocks", () => {
const normalizer = createOpenAIStreamToAnthropicNormalizer();
const events = [
...normalizer.convert({ type: "response.created", response: { id: "resp_2", model: "gpt-5.5" } }),
...normalizer.convert({
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "get_weather" },
}),
...normalizer.convert({
type: "response.function_call_arguments.delta",
output_index: 0,
delta: "{\"city\":\"Seoul\"}",
}),
...normalizer.convert({ type: "response.output_item.done", output_index: 0 }),
...normalizer.convert({
type: "response.completed",
response: { id: "resp_2", usage: { input_tokens: 10, output_tokens: 4 } },
}),
];

expect(events).toEqual([
{
type: "message_start",
message: {
id: "resp_2",
type: "message",
role: "assistant",
model: "gpt-5.5",
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
},
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_1", name: "get_weather", input: {} },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: "{\"city\":\"Seoul\"}" },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "tool_use", stop_sequence: null },
usage: { output_tokens: 4 },
},
{ type: "message_stop" },
]);
});

it("keeps text and tool block indexes separate", () => {
const normalizer = createOpenAIStreamToAnthropicNormalizer();
normalizer.convert({ type: "response.created", response: { id: "resp_3" } });

expect(normalizer.convert({ type: "response.output_text.delta", output_index: 0, delta: "Checking." })[0])
.toEqual({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
normalizer.convert({ type: "response.output_item.done", output_index: 0 });

expect(normalizer.convert({
type: "response.output_item.added",
output_index: 2,
item: { type: "function_call", call_id: "call_2", name: "read_file" },
})[0]).toEqual({
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "call_2", name: "read_file", input: {} },
});
});

it("uses arguments from output_item.done when no argument events were sent", () => {
const normalizer = createOpenAIStreamToAnthropicNormalizer();
normalizer.convert({ type: "response.created", response: { id: "resp_4" } });
normalizer.convert({
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_3", name: "read_file" },
});

expect(normalizer.convert({
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", arguments: "{\"path\":\"README.md\"}" },
})).toEqual([
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: "{\"path\":\"README.md\"}" },
},
{ type: "content_block_stop", index: 0 },
]);
});

});
43 changes: 12 additions & 31 deletions src/__tests__/openai-to-anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,47 +27,28 @@ describe("openAIResponsesToAnthropic", () => {
});
});

it("maps function calls and outputs to Anthropic tool blocks", () => {
it("maps top-level function call items to adjacent Anthropic messages", () => {
const result = openAIResponsesToAnthropic({
model: "anthropic/claude-opus-4-1",
input: [
{
role: "assistant",
content: [
{ type: "function_call", call_id: "call_1", name: "read_file", arguments: "{\"path\":\"README.md\"}" },
],
},
{
role: "tool",
content: [
{ type: "function_call_output", call_id: "call_1", output: "CC-Router" },
],
},
],
tools: [
{
type: "function",
name: "read_file",
parameters: { type: "object", properties: { path: { type: "string" } } },
},
{ role: "user", content: [{ type: "input_text", text: "Read it." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Reading." }] },
{ type: "function_call", call_id: "call_1", name: "read_file", arguments: "{\"path\":\"README.md\"}" },
{ type: "function_call_output", call_id: "call_1", output: "CC-Router" },
],
tools: [{ type: "function", name: "read_file", parameters: { type: "object" } }],
});

expect(result.messages).toEqual([
{ role: "user", content: "Read it." },
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "README.md" } }],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "call_1", content: "CC-Router" }],
},
]);
expect(result.tools).toEqual([
{
name: "read_file",
input_schema: { type: "object", properties: { path: { type: "string" } } },
content: [
{ type: "text", text: "Reading." },
{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "README.md" } },
],
},
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: "CC-Router" }] },
]);
});
});
Loading