@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 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
- Current limitation: OpenAI-to-Anthropic streaming currently covers text deltas and final usage. Streaming tool-call normalization is still experimental.
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 contentToOpenAI(content) {
10
- if (typeof content === "string")
11
- return [{ type: "input_text", text: content }];
12
- return content.map(block => {
13
- if (block.type === "text")
14
- return { type: "input_text", text: block.text };
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
- return {
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
- const output = typeof block.content === "string"
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.map(message => ({
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 = (response.output ?? [])
18
- .filter(item => item.type === "message")
19
- .flatMap(item => item.content)
20
- .filter(item => item.type === "output_text")
21
- .map(item => ({ type: "text", text: item.text }));
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: anthropicStopReasonForResponse(response),
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 textBlockStarted = false;
5
- const ensureTextBlockStarted = () => {
6
- if (textBlockStarted)
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
- textBlockStarted = true;
9
- return [
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: 0,
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
- textBlockStarted = false;
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
- ...ensureTextBlockStarted(),
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 = textBlockStarted
68
- ? [{ type: "content_block_stop", index: 0 }]
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: anthropicStopReasonForResponse(event.response), stop_sequence: null },
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
- if (block.type === "input_text" || block.type === "output_text")
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
- const text = textFromOpenAI(block);
18
- if (text !== null)
19
- return { type: "text", text };
20
- if (block.type === "function_call") {
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.map(message => ({
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
- text += openAIEvent.delta ?? "";
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: text ? [{
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 ?? (completed ? undefined : "Upstream stream ended without a terminal response event"),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.12.0-rc.0",
3
+ "version": "0.12.0",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {