@timo972/cc-router 0.12.0-rc.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +1 -1
- package/dist/protocol/anthropic-to-openai.js +31 -19
- package/dist/protocol/openai-function-call.js +16 -0
- package/dist/protocol/openai-response-to-anthropic.js +34 -6
- package/dist/protocol/openai-stream-to-anthropic.js +116 -16
- package/dist/protocol/openai-to-anthropic.js +54 -29
- package/dist/proxy/messages-cross-route.js +113 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,23 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## [0.12.0-rc.1] — 2026-08-30
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- OpenAI Responses function calls and their outputs remain top-level input
|
|
18
|
+
items across the Anthropic Messages bridge. JSON and SSE responses now
|
|
19
|
+
preserve call IDs, streamed or atomic arguments, refusal text, and
|
|
20
|
+
`tool_use` stop reasons. Invalid metadata, malformed arguments, and tool
|
|
21
|
+
streams that end before completion fail closed instead of fabricating a
|
|
22
|
+
successful assistant turn.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## [0.12.0-rc.0] — 2026-08-30
|
|
27
|
+
|
|
11
28
|
### Added
|
|
12
29
|
|
|
13
30
|
- `cc-router cli claude` and `cc-router cli codex` toggle Claude Code / Codex
|
|
@@ -22,6 +39,33 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
22
39
|
is focused).
|
|
23
40
|
- Status dashboard `rst` column shows Codex banked usage-limit reset count
|
|
24
41
|
(`0` = none available); Claude cells are `—`.
|
|
42
|
+
- Grok subscriptions are first-class accounts: device login and token import,
|
|
43
|
+
persisted account records, live plan/usage refresh, CLI account management,
|
|
44
|
+
allowance reporting, and grouped dashboard rows with credits and reset data.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- Codex configuration rewrites now use TOML-aware parsing and validation rather
|
|
49
|
+
than line-oriented edits, including safe CLI start/stop toggles for the
|
|
50
|
+
managed block.
|
|
51
|
+
- The status dashboard uses compact provider groups, keeps account headers
|
|
52
|
+
visible in short terminals, and exposes the fleet-wide weekly-full count.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- `/v1/models` reports real context windows and includes bare `gpt-*` slugs used
|
|
57
|
+
by the Codex CLI.
|
|
58
|
+
- Codex routing accepts both dashed session-header spellings, logs route
|
|
59
|
+
reasons, and includes calendar dates in proxy log timestamps.
|
|
60
|
+
- Grok overview fixtures resolve portably across local and CI working
|
|
61
|
+
directories.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## [0.11.0] — 2026-08-30
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
25
69
|
- Automatic upstream failover and retry on both providers. A 429 or 5xx
|
|
26
70
|
received before any response byte is relayed no longer passes straight
|
|
27
71
|
through to the client: the router applies the existing cooldown/affinity
|
|
@@ -125,6 +169,12 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
125
169
|
during an idle stretch with nothing routing or polling in the meantime. A
|
|
126
170
|
model-scoped limit never emits one, since the account kept serving every
|
|
127
171
|
other family and so never left the rotation to rejoin.
|
|
172
|
+
- Codex cache accounting separates cached and uncached input before updating
|
|
173
|
+
shared activity and aggregate totals, preventing cached tokens from being
|
|
174
|
+
counted twice in cache-hit percentages and token totals.
|
|
175
|
+
- `/v1/responses` accepts JSON request bodies up to 32 MiB, so long-running
|
|
176
|
+
Codex sessions with large retained tool outputs are not rejected locally by
|
|
177
|
+
Express's former 10 MiB parser limit.
|
|
128
178
|
|
|
129
179
|
---
|
|
130
180
|
|
|
@@ -620,6 +670,9 @@ cache-aware session routing and a round of security hardening.
|
|
|
620
670
|
- `http-proxy-middleware` 3.0.5 → 3.0.7 for GHSA-gcq2-9pq2-cxqm (high). The
|
|
621
671
|
affected APIs are not used here.
|
|
622
672
|
|
|
673
|
+
[0.12.0-rc.1]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0-rc.1
|
|
674
|
+
[0.12.0-rc.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0-rc.0
|
|
675
|
+
[0.11.0]: https://github.com/Timo972/cc-router/releases/tag/v0.11.0
|
|
623
676
|
[0.9.0]: https://github.com/Timo972/cc-router/releases/tag/v0.9.0
|
|
624
677
|
[0.8.3]: https://github.com/Timo972/cc-router/releases/tag/v0.8.3
|
|
625
678
|
[0.8.2]: https://github.com/Timo972/cc-router/releases/tag/v0.8.2
|
package/README.md
CHANGED
|
@@ -356,7 +356,7 @@ Examples after the configuration above:
|
|
|
356
356
|
|
|
357
357
|
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.
|
|
358
358
|
|
|
359
|
-
|
|
359
|
+
OpenAI-to-Anthropic conversion supports text and function tool calls in both streaming and non-streaming responses.
|
|
360
360
|
|
|
361
361
|
OpenAI subscription account records are separated from Claude accounts with `provider: "openai_subscription"` so they do not enter the Anthropic token pool:
|
|
362
362
|
|
|
@@ -6,39 +6,51 @@ function stringifySystem(system) {
|
|
|
6
6
|
return system;
|
|
7
7
|
return system.map(block => block.text).join("\n");
|
|
8
8
|
}
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
function messageToOpenAIItems(role, content) {
|
|
10
|
+
const textType = role === "assistant" ? "output_text" : "input_text";
|
|
11
|
+
if (typeof content === "string") {
|
|
12
|
+
return [{ role, content: [{ type: textType, text: content }] }];
|
|
13
|
+
}
|
|
14
|
+
const items = [];
|
|
15
|
+
let pendingText = [];
|
|
16
|
+
const flushText = () => {
|
|
17
|
+
if (pendingText.length === 0)
|
|
18
|
+
return;
|
|
19
|
+
items.push({ role, content: pendingText });
|
|
20
|
+
pendingText = [];
|
|
21
|
+
};
|
|
22
|
+
for (const block of content) {
|
|
23
|
+
if (block.type === "text") {
|
|
24
|
+
pendingText.push({ type: textType, text: block.text });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
flushText();
|
|
15
28
|
if (block.type === "tool_use") {
|
|
16
|
-
|
|
29
|
+
items.push({
|
|
17
30
|
type: "function_call",
|
|
18
31
|
call_id: block.id,
|
|
19
32
|
name: block.name,
|
|
20
33
|
arguments: JSON.stringify(block.input ?? {}),
|
|
21
|
-
};
|
|
34
|
+
});
|
|
35
|
+
continue;
|
|
22
36
|
}
|
|
23
|
-
|
|
24
|
-
? block.content
|
|
25
|
-
: block.content.map(item => item.text).join("\n");
|
|
26
|
-
return {
|
|
37
|
+
items.push({
|
|
27
38
|
type: "function_call_output",
|
|
28
39
|
call_id: block.tool_use_id,
|
|
29
|
-
output
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
output: typeof block.content === "string"
|
|
41
|
+
? block.content
|
|
42
|
+
: block.content.map(item => item.text).join("\n"),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
flushText();
|
|
46
|
+
return items;
|
|
32
47
|
}
|
|
33
48
|
export function anthropicToOpenAIResponses(req, modelRouting = {}) {
|
|
34
49
|
const parsed = parseModelRef(req.model, modelRouting);
|
|
35
50
|
return {
|
|
36
51
|
model: parsed.upstreamModel,
|
|
37
52
|
instructions: stringifySystem(req.system),
|
|
38
|
-
input: req.messages.
|
|
39
|
-
role: message.role,
|
|
40
|
-
content: contentToOpenAI(message.content),
|
|
41
|
-
})),
|
|
53
|
+
input: req.messages.flatMap(message => messageToOpenAIItems(message.role, message.content)),
|
|
42
54
|
tools: req.tools?.map(tool => ({
|
|
43
55
|
type: "function",
|
|
44
56
|
name: tool.name,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class OpenAIProtocolError extends Error {
|
|
2
|
+
name = "OpenAIProtocolError";
|
|
3
|
+
}
|
|
4
|
+
export function parseOpenAIFunctionArguments(argumentsJson) {
|
|
5
|
+
let parsed;
|
|
6
|
+
try {
|
|
7
|
+
parsed = JSON.parse(argumentsJson);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new OpenAIProtocolError("Invalid OpenAI function call arguments");
|
|
11
|
+
}
|
|
12
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
13
|
+
throw new OpenAIProtocolError("Invalid OpenAI function call arguments");
|
|
14
|
+
}
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OpenAIProtocolError, parseOpenAIFunctionArguments } from "./openai-function-call.js";
|
|
1
2
|
/**
|
|
2
3
|
* Anthropic stop reason for a terminal Responses payload. Shared by both
|
|
3
4
|
* translation paths — this module for a collected response, and the streaming
|
|
@@ -14,18 +15,45 @@ export function anthropicStopReasonForResponse(response) {
|
|
|
14
15
|
return response?.incomplete_details?.reason === "max_output_tokens" ? "max_tokens" : "end_turn";
|
|
15
16
|
}
|
|
16
17
|
export function openAIResponseToAnthropicMessage(response) {
|
|
17
|
-
const content =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
const content = [];
|
|
19
|
+
let sawRefusal = false;
|
|
20
|
+
for (const item of response.output ?? []) {
|
|
21
|
+
if (item.type === "message") {
|
|
22
|
+
for (const part of item.content) {
|
|
23
|
+
if (part.type === "output_text") {
|
|
24
|
+
content.push({ type: "text", text: part.text });
|
|
25
|
+
}
|
|
26
|
+
else if (part.type === "refusal") {
|
|
27
|
+
sawRefusal = true;
|
|
28
|
+
content.push({ type: "text", text: part.refusal });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (item.type !== "function_call")
|
|
34
|
+
continue;
|
|
35
|
+
if (!item.call_id?.trim() || !item.name?.trim()) {
|
|
36
|
+
throw new OpenAIProtocolError("Invalid OpenAI function call metadata");
|
|
37
|
+
}
|
|
38
|
+
content.push({
|
|
39
|
+
type: "tool_use",
|
|
40
|
+
id: item.call_id,
|
|
41
|
+
name: item.name,
|
|
42
|
+
input: parseOpenAIFunctionArguments(item.arguments),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const stopReason = content.some(block => block.type === "tool_use")
|
|
46
|
+
? "tool_use"
|
|
47
|
+
: sawRefusal
|
|
48
|
+
? "refusal"
|
|
49
|
+
: anthropicStopReasonForResponse(response);
|
|
22
50
|
return {
|
|
23
51
|
id: response.id,
|
|
24
52
|
type: "message",
|
|
25
53
|
role: "assistant",
|
|
26
54
|
model: response.model ?? "",
|
|
27
55
|
content,
|
|
28
|
-
stop_reason:
|
|
56
|
+
stop_reason: stopReason,
|
|
29
57
|
stop_sequence: null,
|
|
30
58
|
usage: {
|
|
31
59
|
input_tokens: response.usage?.input_tokens ?? 0,
|
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
import { anthropicStopReasonForResponse } from "./openai-response-to-anthropic.js";
|
|
2
|
+
import { OpenAIProtocolError, parseOpenAIFunctionArguments } from "./openai-function-call.js";
|
|
2
3
|
import { terminalResponsePayload } from "./openai-responses-collect.js";
|
|
3
4
|
export function createOpenAIStreamToAnthropicNormalizer() {
|
|
4
|
-
let
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
let blocks = new Map();
|
|
6
|
+
let nextIndex = 0;
|
|
7
|
+
let sawToolUse = false;
|
|
8
|
+
let sawRefusal = false;
|
|
9
|
+
const openTextBlock = (outputIndex) => {
|
|
10
|
+
if (blocks.has(outputIndex))
|
|
7
11
|
return [];
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
const block = { index: nextIndex++, kind: "text", sentArguments: false, argumentsJson: "" };
|
|
13
|
+
blocks.set(outputIndex, block);
|
|
14
|
+
return [{
|
|
11
15
|
type: "content_block_start",
|
|
12
|
-
index:
|
|
16
|
+
index: block.index,
|
|
13
17
|
content_block: { type: "text", text: "" },
|
|
14
|
-
}
|
|
15
|
-
|
|
18
|
+
}];
|
|
19
|
+
};
|
|
20
|
+
const closeBlock = (outputIndex) => {
|
|
21
|
+
const block = blocks.get(outputIndex);
|
|
22
|
+
if (!block)
|
|
23
|
+
return [];
|
|
24
|
+
blocks.delete(outputIndex);
|
|
25
|
+
return [{ type: "content_block_stop", index: block.index }];
|
|
16
26
|
};
|
|
17
27
|
const reset = () => {
|
|
18
|
-
|
|
28
|
+
blocks = new Map();
|
|
29
|
+
nextIndex = 0;
|
|
30
|
+
sawToolUse = false;
|
|
31
|
+
sawRefusal = false;
|
|
19
32
|
};
|
|
20
33
|
return {
|
|
21
34
|
reset,
|
|
22
35
|
convert(event) {
|
|
36
|
+
const outputIndex = event.output_index ?? 0;
|
|
23
37
|
if (event.type === "response.created") {
|
|
24
38
|
reset();
|
|
25
39
|
return [
|
|
@@ -38,16 +52,96 @@ export function createOpenAIStreamToAnthropicNormalizer() {
|
|
|
38
52
|
},
|
|
39
53
|
];
|
|
40
54
|
}
|
|
55
|
+
if (event.type === "response.output_item.added") {
|
|
56
|
+
if (event.item?.type !== "function_call" || blocks.has(outputIndex))
|
|
57
|
+
return [];
|
|
58
|
+
if (!event.item.call_id?.trim() || !event.item.name?.trim()) {
|
|
59
|
+
throw new OpenAIProtocolError("Invalid OpenAI function call metadata");
|
|
60
|
+
}
|
|
61
|
+
const block = {
|
|
62
|
+
index: nextIndex++,
|
|
63
|
+
kind: "tool_use",
|
|
64
|
+
sentArguments: false,
|
|
65
|
+
argumentsJson: "",
|
|
66
|
+
};
|
|
67
|
+
blocks.set(outputIndex, block);
|
|
68
|
+
sawToolUse = true;
|
|
69
|
+
return [{
|
|
70
|
+
type: "content_block_start",
|
|
71
|
+
index: block.index,
|
|
72
|
+
content_block: {
|
|
73
|
+
type: "tool_use",
|
|
74
|
+
id: event.item.call_id,
|
|
75
|
+
name: event.item.name,
|
|
76
|
+
input: {},
|
|
77
|
+
},
|
|
78
|
+
}];
|
|
79
|
+
}
|
|
41
80
|
if (event.type === "response.output_text.delta") {
|
|
81
|
+
const prefix = openTextBlock(outputIndex);
|
|
82
|
+
const block = blocks.get(outputIndex);
|
|
42
83
|
return [
|
|
43
|
-
...
|
|
84
|
+
...prefix,
|
|
44
85
|
{
|
|
45
86
|
type: "content_block_delta",
|
|
46
|
-
index: 0,
|
|
87
|
+
index: block?.index ?? 0,
|
|
47
88
|
delta: { type: "text_delta", text: event.delta ?? "" },
|
|
48
89
|
},
|
|
49
90
|
];
|
|
50
91
|
}
|
|
92
|
+
if (event.type === "response.refusal.delta") {
|
|
93
|
+
sawRefusal = true;
|
|
94
|
+
const prefix = openTextBlock(outputIndex);
|
|
95
|
+
const block = blocks.get(outputIndex);
|
|
96
|
+
return [...prefix, {
|
|
97
|
+
type: "content_block_delta",
|
|
98
|
+
index: block?.index ?? 0,
|
|
99
|
+
delta: { type: "text_delta", text: event.delta ?? "" },
|
|
100
|
+
}];
|
|
101
|
+
}
|
|
102
|
+
if (event.type === "response.function_call_arguments.delta") {
|
|
103
|
+
const block = blocks.get(outputIndex);
|
|
104
|
+
if (!block || block.kind !== "tool_use")
|
|
105
|
+
return [];
|
|
106
|
+
block.sentArguments = true;
|
|
107
|
+
block.argumentsJson += event.delta ?? "";
|
|
108
|
+
return [{
|
|
109
|
+
type: "content_block_delta",
|
|
110
|
+
index: block.index,
|
|
111
|
+
delta: { type: "input_json_delta", partial_json: event.delta ?? "" },
|
|
112
|
+
}];
|
|
113
|
+
}
|
|
114
|
+
if (event.type === "response.function_call_arguments.done") {
|
|
115
|
+
const block = blocks.get(outputIndex);
|
|
116
|
+
if (!block || block.kind !== "tool_use" || block.sentArguments || !event.arguments)
|
|
117
|
+
return [];
|
|
118
|
+
block.sentArguments = true;
|
|
119
|
+
block.argumentsJson = event.arguments;
|
|
120
|
+
return [{
|
|
121
|
+
type: "content_block_delta",
|
|
122
|
+
index: block.index,
|
|
123
|
+
delta: { type: "input_json_delta", partial_json: event.arguments },
|
|
124
|
+
}];
|
|
125
|
+
}
|
|
126
|
+
if (event.type === "response.output_item.done") {
|
|
127
|
+
const block = blocks.get(outputIndex);
|
|
128
|
+
const atomicArguments = block?.kind === "tool_use" && !block.sentArguments
|
|
129
|
+
? event.item?.arguments ?? ""
|
|
130
|
+
: "";
|
|
131
|
+
if (block?.kind === "tool_use") {
|
|
132
|
+
if (atomicArguments)
|
|
133
|
+
block.argumentsJson = atomicArguments;
|
|
134
|
+
parseOpenAIFunctionArguments(block.argumentsJson);
|
|
135
|
+
}
|
|
136
|
+
const argumentEvent = block?.kind === "tool_use" && atomicArguments
|
|
137
|
+
? [{
|
|
138
|
+
type: "content_block_delta",
|
|
139
|
+
index: block.index,
|
|
140
|
+
delta: { type: "input_json_delta", partial_json: atomicArguments },
|
|
141
|
+
}]
|
|
142
|
+
: [];
|
|
143
|
+
return [...argumentEvent, ...closeBlock(outputIndex)];
|
|
144
|
+
}
|
|
51
145
|
// Both terminal Responses events must close the Anthropic message.
|
|
52
146
|
// Emitting nothing for `response.incomplete` would end the HTTP stream
|
|
53
147
|
// without `message_stop`, leaving the client waiting on a turn that is
|
|
@@ -63,10 +157,16 @@ export function createOpenAIStreamToAnthropicNormalizer() {
|
|
|
63
157
|
// a stream that never reached a terminal event looks like, and what
|
|
64
158
|
// clients already detect and surface as an error.
|
|
65
159
|
if (terminalResponsePayload(event) !== undefined) {
|
|
160
|
+
if ([...blocks.values()].some(block => block.kind === "tool_use")) {
|
|
161
|
+
throw new OpenAIProtocolError("OpenAI function call ended before completion");
|
|
162
|
+
}
|
|
66
163
|
const usage = event.response?.usage ?? {};
|
|
67
|
-
const prefix =
|
|
68
|
-
|
|
69
|
-
|
|
164
|
+
const prefix = [...blocks.keys()].flatMap(closeBlock);
|
|
165
|
+
const stopReason = sawToolUse
|
|
166
|
+
? "tool_use"
|
|
167
|
+
: sawRefusal
|
|
168
|
+
? "refusal"
|
|
169
|
+
: anthropicStopReasonForResponse(event.response);
|
|
70
170
|
reset();
|
|
71
171
|
return [
|
|
72
172
|
...prefix,
|
|
@@ -74,7 +174,7 @@ export function createOpenAIStreamToAnthropicNormalizer() {
|
|
|
74
174
|
type: "message_delta",
|
|
75
175
|
// Same helper the collected-response translator uses, so an
|
|
76
176
|
// incomplete turn reports the same stop reason on both paths.
|
|
77
|
-
delta: { stop_reason:
|
|
177
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
78
178
|
usage: { output_tokens: usage.output_tokens ?? 0 },
|
|
79
179
|
},
|
|
80
180
|
{ type: "message_stop" },
|
|
@@ -8,48 +8,73 @@ function parseArguments(args) {
|
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
10
|
function textFromOpenAI(block) {
|
|
11
|
-
|
|
12
|
-
return block.text;
|
|
13
|
-
return null;
|
|
11
|
+
return block.text;
|
|
14
12
|
}
|
|
15
13
|
function messageContentToAnthropic(message) {
|
|
16
|
-
const blocks = message.content.map((block) => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
type: "tool_use",
|
|
23
|
-
id: block.call_id,
|
|
24
|
-
name: block.name,
|
|
25
|
-
input: parseArguments(block.arguments),
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
if (block.type === "function_call_output") {
|
|
29
|
-
return {
|
|
30
|
-
type: "tool_result",
|
|
31
|
-
tool_use_id: block.call_id,
|
|
32
|
-
content: block.output,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
return null;
|
|
36
|
-
}).filter((block) => block !== null);
|
|
37
|
-
if (blocks.length === 1 && blocks[0].type === "text")
|
|
14
|
+
const blocks = message.content.map((block) => ({
|
|
15
|
+
type: "text",
|
|
16
|
+
text: textFromOpenAI(block),
|
|
17
|
+
}));
|
|
18
|
+
if (blocks.length === 1)
|
|
38
19
|
return blocks[0].text;
|
|
39
20
|
return blocks;
|
|
40
21
|
}
|
|
41
22
|
function normalizeRole(role) {
|
|
42
23
|
return role === "assistant" ? "assistant" : "user";
|
|
43
24
|
}
|
|
25
|
+
function isFunctionCall(item) {
|
|
26
|
+
return "type" in item && item.type === "function_call";
|
|
27
|
+
}
|
|
28
|
+
function isFunctionCallOutput(item) {
|
|
29
|
+
return "type" in item && item.type === "function_call_output";
|
|
30
|
+
}
|
|
31
|
+
function inputItemsToAnthropicMessages(input) {
|
|
32
|
+
const messages = [];
|
|
33
|
+
const append = (role, blocks) => {
|
|
34
|
+
if (blocks.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
const last = messages.at(-1);
|
|
37
|
+
if (last?.role === role) {
|
|
38
|
+
const existing = typeof last.content === "string"
|
|
39
|
+
? [{ type: "text", text: last.content }]
|
|
40
|
+
: last.content;
|
|
41
|
+
last.content = [...existing, ...blocks];
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
messages.push({ role, content: blocks });
|
|
45
|
+
};
|
|
46
|
+
for (const item of input) {
|
|
47
|
+
if (isFunctionCall(item)) {
|
|
48
|
+
append("assistant", [{
|
|
49
|
+
type: "tool_use",
|
|
50
|
+
id: item.call_id,
|
|
51
|
+
name: item.name,
|
|
52
|
+
input: parseArguments(item.arguments),
|
|
53
|
+
}]);
|
|
54
|
+
}
|
|
55
|
+
else if (isFunctionCallOutput(item)) {
|
|
56
|
+
append("user", [{ type: "tool_result", tool_use_id: item.call_id, content: item.output }]);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const content = messageContentToAnthropic(item);
|
|
60
|
+
append(normalizeRole(item.role), typeof content === "string"
|
|
61
|
+
? [{ type: "text", text: content }]
|
|
62
|
+
: content);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return messages.map(message => {
|
|
66
|
+
if (Array.isArray(message.content) && message.content.length === 1 && message.content[0].type === "text") {
|
|
67
|
+
return { ...message, content: message.content[0].text };
|
|
68
|
+
}
|
|
69
|
+
return message;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
44
72
|
export function openAIResponsesToAnthropic(req) {
|
|
45
73
|
const parsed = parseModelRef(req.model);
|
|
46
74
|
return {
|
|
47
75
|
model: parsed.upstreamModel,
|
|
48
76
|
system: req.instructions,
|
|
49
|
-
messages: req.input
|
|
50
|
-
role: normalizeRole(message.role),
|
|
51
|
-
content: messageContentToAnthropic(message),
|
|
52
|
-
})),
|
|
77
|
+
messages: inputItemsToAnthropicMessages(req.input),
|
|
53
78
|
tools: req.tools?.map(tool => ({
|
|
54
79
|
name: tool.name,
|
|
55
80
|
description: tool.description,
|
|
@@ -2,6 +2,7 @@ import express from "express";
|
|
|
2
2
|
import { selectRoute } from "../providers/route-selector.js";
|
|
3
3
|
import { anthropicToOpenAIResponses } from "../protocol/anthropic-to-openai.js";
|
|
4
4
|
import { openAIResponseToAnthropicMessage } from "../protocol/openai-response-to-anthropic.js";
|
|
5
|
+
import { OpenAIProtocolError } from "../protocol/openai-function-call.js";
|
|
5
6
|
import { createOpenAIStreamToAnthropicNormalizer } from "../protocol/openai-stream-to-anthropic.js";
|
|
6
7
|
import { encodeSseEvent, parseSseLines } from "../protocol/sse.js";
|
|
7
8
|
import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js";
|
|
@@ -143,16 +144,21 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
|
|
|
143
144
|
let remainder = "";
|
|
144
145
|
let id = "";
|
|
145
146
|
let model = "";
|
|
146
|
-
let text = "";
|
|
147
147
|
let failure;
|
|
148
148
|
let completed = false;
|
|
149
149
|
let usage = {};
|
|
150
150
|
let status;
|
|
151
151
|
let incompleteDetails;
|
|
152
|
+
const textByIndex = new Map();
|
|
153
|
+
const refusalByIndex = new Map();
|
|
154
|
+
const argumentsByIndex = new Map();
|
|
155
|
+
const pendingCallsByIndex = new Map();
|
|
156
|
+
const callsByIndex = new Map();
|
|
152
157
|
const applyEvent = (event) => {
|
|
153
158
|
if (typeof event !== "object" || event === null)
|
|
154
159
|
return;
|
|
155
160
|
const openAIEvent = event;
|
|
161
|
+
const outputIndex = openAIEvent.output_index ?? 0;
|
|
156
162
|
// Reported the moment it is seen, not when this function returns: the
|
|
157
163
|
// read after it can be cut short by a client disconnect, and losing the
|
|
158
164
|
// verdict there turns a real backend failure into a benign cancellation.
|
|
@@ -172,7 +178,57 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
|
|
|
172
178
|
return;
|
|
173
179
|
}
|
|
174
180
|
if (openAIEvent.type === "response.output_text.delta") {
|
|
175
|
-
|
|
181
|
+
textByIndex.set(outputIndex, (textByIndex.get(outputIndex) ?? "") + (openAIEvent.delta ?? ""));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (openAIEvent.type === "response.refusal.delta") {
|
|
185
|
+
refusalByIndex.set(outputIndex, (refusalByIndex.get(outputIndex) ?? "") + (openAIEvent.delta ?? ""));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (openAIEvent.type === "response.output_item.added") {
|
|
189
|
+
const item = openAIEvent.item;
|
|
190
|
+
if (item?.type === "function_call") {
|
|
191
|
+
if (!item.call_id?.trim() || !item.name?.trim()) {
|
|
192
|
+
throw new OpenAIProtocolError("Invalid OpenAI function call metadata");
|
|
193
|
+
}
|
|
194
|
+
pendingCallsByIndex.set(outputIndex, {
|
|
195
|
+
type: "function_call",
|
|
196
|
+
call_id: item.call_id,
|
|
197
|
+
name: item.name,
|
|
198
|
+
arguments: item.arguments ?? "",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (openAIEvent.type === "response.function_call_arguments.delta") {
|
|
204
|
+
if (!pendingCallsByIndex.has(outputIndex))
|
|
205
|
+
return;
|
|
206
|
+
argumentsByIndex.set(outputIndex, (argumentsByIndex.get(outputIndex) ?? "") + (openAIEvent.delta ?? ""));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (openAIEvent.type === "response.function_call_arguments.done") {
|
|
210
|
+
if (pendingCallsByIndex.has(outputIndex) && !argumentsByIndex.has(outputIndex) && openAIEvent.arguments) {
|
|
211
|
+
argumentsByIndex.set(outputIndex, openAIEvent.arguments);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (openAIEvent.type === "response.output_item.done") {
|
|
216
|
+
const item = openAIEvent.item;
|
|
217
|
+
const pending = pendingCallsByIndex.get(outputIndex);
|
|
218
|
+
if (item?.type === "function_call" || pending) {
|
|
219
|
+
const callId = item?.call_id || pending?.call_id;
|
|
220
|
+
const name = item?.name || pending?.name;
|
|
221
|
+
if (!callId?.trim() || !name?.trim()) {
|
|
222
|
+
throw new OpenAIProtocolError("Invalid OpenAI function call metadata");
|
|
223
|
+
}
|
|
224
|
+
callsByIndex.set(outputIndex, {
|
|
225
|
+
type: "function_call",
|
|
226
|
+
call_id: callId,
|
|
227
|
+
name,
|
|
228
|
+
arguments: item?.arguments || argumentsByIndex.get(outputIndex) || pending?.arguments || "",
|
|
229
|
+
});
|
|
230
|
+
pendingCallsByIndex.delete(outputIndex);
|
|
231
|
+
}
|
|
176
232
|
return;
|
|
177
233
|
}
|
|
178
234
|
if (terminalResponsePayload(event) !== undefined) {
|
|
@@ -199,15 +255,34 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
|
|
|
199
255
|
if (tail || remainder) {
|
|
200
256
|
parseSseLines(remainder + tail + "\n", { tolerant: true }).events.forEach(applyEvent);
|
|
201
257
|
}
|
|
258
|
+
const output = [...new Set([
|
|
259
|
+
...textByIndex.keys(),
|
|
260
|
+
...refusalByIndex.keys(),
|
|
261
|
+
...callsByIndex.keys(),
|
|
262
|
+
])]
|
|
263
|
+
.sort((a, b) => a - b)
|
|
264
|
+
.flatMap((index) => {
|
|
265
|
+
const call = callsByIndex.get(index);
|
|
266
|
+
if (call)
|
|
267
|
+
return [{ ...call, arguments: call.arguments || argumentsByIndex.get(index) || "" }];
|
|
268
|
+
const text = textByIndex.get(index);
|
|
269
|
+
const refusal = refusalByIndex.get(index);
|
|
270
|
+
const content = [
|
|
271
|
+
...(text ? [{ type: "output_text", text }] : []),
|
|
272
|
+
...(refusal ? [{ type: "refusal", refusal }] : []),
|
|
273
|
+
];
|
|
274
|
+
return content.length > 0 ? [{ type: "message", role: "assistant", content }] : [];
|
|
275
|
+
});
|
|
276
|
+
const protocolFailure = pendingCallsByIndex.size > 0
|
|
277
|
+
? "OpenAI function call ended before completion"
|
|
278
|
+
: undefined;
|
|
279
|
+
if (protocolFailure)
|
|
280
|
+
report.upstreamReportedFailure = true;
|
|
202
281
|
return {
|
|
203
282
|
message: openAIResponseToAnthropicMessage({
|
|
204
283
|
id,
|
|
205
284
|
model,
|
|
206
|
-
output
|
|
207
|
-
type: "message",
|
|
208
|
-
role: "assistant",
|
|
209
|
-
content: [{ type: "output_text", text }],
|
|
210
|
-
}] : [],
|
|
285
|
+
output,
|
|
211
286
|
usage,
|
|
212
287
|
...(status ? { status } : {}),
|
|
213
288
|
...(incompleteDetails ? { incomplete_details: incompleteDetails } : {}),
|
|
@@ -222,7 +297,9 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
|
|
|
222
297
|
// mid-flight) is a failure rather than an empty success. An explicit
|
|
223
298
|
// `response.failed`/`error` message wins, since it says more about what
|
|
224
299
|
// went wrong.
|
|
225
|
-
failure: failure
|
|
300
|
+
failure: failure
|
|
301
|
+
?? protocolFailure
|
|
302
|
+
?? (completed ? undefined : "Upstream stream ended without a terminal response event"),
|
|
226
303
|
};
|
|
227
304
|
}
|
|
228
305
|
/** Returns the upstream failure message when the stream ended in one. */
|
|
@@ -250,6 +327,16 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
|
|
|
250
327
|
let totals;
|
|
251
328
|
let failure;
|
|
252
329
|
let completed = false;
|
|
330
|
+
const pendingToolCalls = new Set();
|
|
331
|
+
const writeProtocolError = () => {
|
|
332
|
+
res.write(encodeSseEvent({
|
|
333
|
+
type: "error",
|
|
334
|
+
error: {
|
|
335
|
+
type: "api_error",
|
|
336
|
+
message: "Invalid or incomplete response from OpenAI",
|
|
337
|
+
},
|
|
338
|
+
}));
|
|
339
|
+
};
|
|
253
340
|
const inspect = (event) => {
|
|
254
341
|
totals = usageFromTerminalEvent(event) ?? totals;
|
|
255
342
|
if (typeof event !== "object" || event === null)
|
|
@@ -268,6 +355,12 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
|
|
|
268
355
|
else if (terminalResponsePayload(event) !== undefined) {
|
|
269
356
|
completed = true;
|
|
270
357
|
}
|
|
358
|
+
if (typed.type === "response.output_item.added" && typed.item?.type === "function_call") {
|
|
359
|
+
pendingToolCalls.add(typed.output_index ?? 0);
|
|
360
|
+
}
|
|
361
|
+
else if (typed.type === "response.output_item.done") {
|
|
362
|
+
pendingToolCalls.delete(typed.output_index ?? 0);
|
|
363
|
+
}
|
|
271
364
|
};
|
|
272
365
|
const relayEvents = (events) => {
|
|
273
366
|
for (const event of events) {
|
|
@@ -298,6 +391,18 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
|
|
|
298
391
|
if (tail || remainder) {
|
|
299
392
|
relayEvents(parseSseLines(remainder + tail + "\n", { tolerant: true }).events);
|
|
300
393
|
}
|
|
394
|
+
if (!completed && pendingToolCalls.size > 0 && failure === undefined) {
|
|
395
|
+
failure = "OpenAI function call ended before completion";
|
|
396
|
+
report.upstreamReportedFailure = true;
|
|
397
|
+
writeProtocolError();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (!(error instanceof OpenAIProtocolError))
|
|
402
|
+
throw error;
|
|
403
|
+
failure = error.message;
|
|
404
|
+
report.upstreamReportedFailure = true;
|
|
405
|
+
writeProtocolError();
|
|
301
406
|
}
|
|
302
407
|
finally {
|
|
303
408
|
res.end();
|
package/package.json
CHANGED