@timo972/cc-router 0.12.0-rc.0 → 0.12.0-rc.1

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/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-rc.1",
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": {