@amir83nasr/10router 0.1.14 → 0.1.15

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.
Files changed (187) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +6 -6
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js +2 -2
  6. package/.next/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page_client-reference-manifest.js +1 -1
  7. package/.next/server/app/(dashboard)/dashboard/cli-tools/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/(dashboard)/dashboard/combos/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/(dashboard)/dashboard/console-log/page_client-reference-manifest.js +1 -1
  10. package/.next/server/app/(dashboard)/dashboard/endpoint/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/(dashboard)/dashboard/media-providers/systemone/[id]/page_client-reference-manifest.js +1 -1
  12. package/.next/server/app/(dashboard)/dashboard/media-providers/systemone/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/(dashboard)/dashboard/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/(dashboard)/dashboard/profile/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/(dashboard)/dashboard/providers/[id]/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/(dashboard)/dashboard/providers/new/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/(dashboard)/dashboard/providers/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/(dashboard)/dashboard/proxy-pools/page_client-reference-manifest.js +1 -1
  19. package/.next/server/app/(dashboard)/dashboard/pxpipe/page_client-reference-manifest.js +1 -1
  20. package/.next/server/app/(dashboard)/dashboard/quota/page_client-reference-manifest.js +1 -1
  21. package/.next/server/app/(dashboard)/dashboard/token-saver/page_client-reference-manifest.js +1 -1
  22. package/.next/server/app/(dashboard)/dashboard/usage/page_client-reference-manifest.js +1 -1
  23. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  24. package/.next/server/app/_global-error.html +1 -1
  25. package/.next/server/app/_global-error.rsc +1 -1
  26. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  29. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  30. package/.next/server/app/_not-found.html +1 -1
  31. package/.next/server/app/_not-found.rsc +5 -5
  32. package/.next/server/app/_not-found.segments/_full.segment.rsc +5 -5
  33. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +5 -5
  34. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  35. package/.next/server/app/api/cli-tools/all-statuses/route.js +1 -1
  36. package/.next/server/app/api/cli-tools/claude-settings/route.js +2 -2
  37. package/.next/server/app/api/cli-tools/codex-settings/route.js +1 -1
  38. package/.next/server/app/api/cli-tools/cowork-mcp-registry/route.js +1 -1
  39. package/.next/server/app/api/cli-tools/cowork-mcp-tools/route.js +1 -1
  40. package/.next/server/app/api/cli-tools/cowork-settings/route.js +1 -1
  41. package/.next/server/app/api/cli-tools/dsh-settings/route.js +1 -1
  42. package/.next/server/app/api/cli-tools/opencode-settings/route.js +1 -1
  43. package/.next/server/app/api/oauth/[provider]/[action]/route.js +1 -1
  44. package/.next/server/app/api/oauth/codex/bulk-import/route.js +1 -1
  45. package/.next/server/app/api/oauth/codex/import-token/route.js +1 -1
  46. package/.next/server/app/api/providers/client/route.js +1 -1
  47. package/.next/server/app/api/translator/send/route.js +1 -1
  48. package/.next/server/app/api/translator/translate/route.js +1 -1
  49. package/.next/server/app/api/tunnel/tailscale-install/route.js +2 -2
  50. package/.next/server/app/api/usage/[connectionId]/codex-reset-credits/route.js +1 -1
  51. package/.next/server/app/api/usage/[connectionId]/route.js +1 -1
  52. package/.next/server/app/api/version/route.js +1 -1
  53. package/.next/server/app/callback/page_client-reference-manifest.js +1 -1
  54. package/.next/server/app/callback.html +1 -1
  55. package/.next/server/app/callback.rsc +6 -6
  56. package/.next/server/app/callback.segments/_full.segment.rsc +6 -6
  57. package/.next/server/app/callback.segments/_tree.segment.rsc +1 -1
  58. package/.next/server/app/callback.segments/callback/__PAGE__.segment.rsc +6 -6
  59. package/.next/server/app/dashboard/cli-tools.html +1 -1
  60. package/.next/server/app/dashboard/cli-tools.rsc +8 -8
  61. package/.next/server/app/dashboard/cli-tools.segments/!KGRhc2hib2FyZCk/dashboard/cli-tools/__PAGE__.segment.rsc +7 -7
  62. package/.next/server/app/dashboard/cli-tools.segments/_full.segment.rsc +8 -8
  63. package/.next/server/app/dashboard/cli-tools.segments/_tree.segment.rsc +1 -1
  64. package/.next/server/app/dashboard/combos.html +1 -1
  65. package/.next/server/app/dashboard/combos.rsc +7 -7
  66. package/.next/server/app/dashboard/combos.segments/!KGRhc2hib2FyZCk/dashboard/combos/__PAGE__.segment.rsc +7 -7
  67. package/.next/server/app/dashboard/combos.segments/_full.segment.rsc +7 -7
  68. package/.next/server/app/dashboard/combos.segments/_tree.segment.rsc +1 -1
  69. package/.next/server/app/dashboard/endpoint.html +1 -1
  70. package/.next/server/app/dashboard/endpoint.rsc +8 -8
  71. package/.next/server/app/dashboard/endpoint.segments/!KGRhc2hib2FyZCk/dashboard/endpoint/__PAGE__.segment.rsc +7 -7
  72. package/.next/server/app/dashboard/endpoint.segments/_full.segment.rsc +8 -8
  73. package/.next/server/app/dashboard/endpoint.segments/_tree.segment.rsc +1 -1
  74. package/.next/server/app/dashboard/media-providers/systemone.html +1 -1
  75. package/.next/server/app/dashboard/media-providers/systemone.rsc +7 -7
  76. package/.next/server/app/dashboard/media-providers/systemone.segments/!KGRhc2hib2FyZCk/dashboard/media-providers/systemone/__PAGE__.segment.rsc +7 -7
  77. package/.next/server/app/dashboard/media-providers/systemone.segments/_full.segment.rsc +7 -7
  78. package/.next/server/app/dashboard/media-providers/systemone.segments/_tree.segment.rsc +1 -1
  79. package/.next/server/app/dashboard/profile.html +1 -1
  80. package/.next/server/app/dashboard/profile.rsc +7 -7
  81. package/.next/server/app/dashboard/profile.segments/!KGRhc2hib2FyZCk/dashboard/profile/__PAGE__.segment.rsc +7 -7
  82. package/.next/server/app/dashboard/profile.segments/_full.segment.rsc +7 -7
  83. package/.next/server/app/dashboard/profile.segments/_tree.segment.rsc +1 -1
  84. package/.next/server/app/dashboard/providers/new.html +1 -1
  85. package/.next/server/app/dashboard/providers/new.rsc +7 -7
  86. package/.next/server/app/dashboard/providers/new.segments/!KGRhc2hib2FyZCk/dashboard/providers/new/__PAGE__.segment.rsc +7 -7
  87. package/.next/server/app/dashboard/providers/new.segments/_full.segment.rsc +7 -7
  88. package/.next/server/app/dashboard/providers/new.segments/_tree.segment.rsc +1 -1
  89. package/.next/server/app/dashboard/providers.html +1 -1
  90. package/.next/server/app/dashboard/providers.rsc +7 -7
  91. package/.next/server/app/dashboard/providers.segments/!KGRhc2hib2FyZCk/dashboard/providers/__PAGE__.segment.rsc +7 -7
  92. package/.next/server/app/dashboard/providers.segments/_full.segment.rsc +7 -7
  93. package/.next/server/app/dashboard/providers.segments/_tree.segment.rsc +1 -1
  94. package/.next/server/app/dashboard/proxy-pools.html +1 -1
  95. package/.next/server/app/dashboard/proxy-pools.rsc +7 -7
  96. package/.next/server/app/dashboard/proxy-pools.segments/!KGRhc2hib2FyZCk/dashboard/proxy-pools/__PAGE__.segment.rsc +7 -7
  97. package/.next/server/app/dashboard/proxy-pools.segments/_full.segment.rsc +7 -7
  98. package/.next/server/app/dashboard/proxy-pools.segments/_tree.segment.rsc +1 -1
  99. package/.next/server/app/dashboard/pxpipe.html +1 -1
  100. package/.next/server/app/dashboard/pxpipe.rsc +6 -6
  101. package/.next/server/app/dashboard/pxpipe.segments/!KGRhc2hib2FyZCk/dashboard/pxpipe/__PAGE__.segment.rsc +6 -6
  102. package/.next/server/app/dashboard/pxpipe.segments/_full.segment.rsc +6 -6
  103. package/.next/server/app/dashboard/pxpipe.segments/_tree.segment.rsc +1 -1
  104. package/.next/server/app/dashboard/quota.html +2 -2
  105. package/.next/server/app/dashboard/quota.rsc +8 -8
  106. package/.next/server/app/dashboard/quota.segments/!KGRhc2hib2FyZCk/dashboard/quota/__PAGE__.segment.rsc +8 -8
  107. package/.next/server/app/dashboard/quota.segments/_full.segment.rsc +8 -8
  108. package/.next/server/app/dashboard/quota.segments/_tree.segment.rsc +1 -1
  109. package/.next/server/app/dashboard/settings/pricing/page_client-reference-manifest.js +1 -1
  110. package/.next/server/app/dashboard/settings/pricing.html +1 -1
  111. package/.next/server/app/dashboard/settings/pricing.rsc +5 -5
  112. package/.next/server/app/dashboard/settings/pricing.segments/_full.segment.rsc +5 -5
  113. package/.next/server/app/dashboard/settings/pricing.segments/_tree.segment.rsc +1 -1
  114. package/.next/server/app/dashboard/settings/pricing.segments/dashboard/settings/pricing/__PAGE__.segment.rsc +5 -5
  115. package/.next/server/app/dashboard/token-saver.html +1 -1
  116. package/.next/server/app/dashboard/token-saver.rsc +6 -6
  117. package/.next/server/app/dashboard/token-saver.segments/!KGRhc2hib2FyZCk/dashboard/token-saver/__PAGE__.segment.rsc +6 -6
  118. package/.next/server/app/dashboard/token-saver.segments/_full.segment.rsc +6 -6
  119. package/.next/server/app/dashboard/token-saver.segments/_tree.segment.rsc +1 -1
  120. package/.next/server/app/dashboard/usage.html +2 -2
  121. package/.next/server/app/dashboard/usage.rsc +7 -7
  122. package/.next/server/app/dashboard/usage.segments/!KGRhc2hib2FyZCk/dashboard/usage/__PAGE__.segment.rsc +7 -7
  123. package/.next/server/app/dashboard/usage.segments/_full.segment.rsc +7 -7
  124. package/.next/server/app/dashboard/usage.segments/_tree.segment.rsc +1 -1
  125. package/.next/server/app/dashboard.html +1 -1
  126. package/.next/server/app/dashboard.rsc +8 -8
  127. package/.next/server/app/dashboard.segments/!KGRhc2hib2FyZCk/dashboard/__PAGE__.segment.rsc +7 -7
  128. package/.next/server/app/dashboard.segments/_full.segment.rsc +8 -8
  129. package/.next/server/app/dashboard.segments/_tree.segment.rsc +1 -1
  130. package/.next/server/app/index.html +1 -1
  131. package/.next/server/app/index.rsc +5 -5
  132. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  133. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  134. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  135. package/.next/server/app/landing/page_client-reference-manifest.js +1 -1
  136. package/.next/server/app/landing.html +1 -1
  137. package/.next/server/app/landing.rsc +6 -6
  138. package/.next/server/app/landing.segments/_full.segment.rsc +6 -6
  139. package/.next/server/app/landing.segments/_tree.segment.rsc +1 -1
  140. package/.next/server/app/landing.segments/landing/__PAGE__.segment.rsc +6 -6
  141. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  142. package/.next/server/app/login.html +1 -1
  143. package/.next/server/app/login.rsc +5 -5
  144. package/.next/server/app/login.segments/_full.segment.rsc +5 -5
  145. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  146. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +5 -5
  147. package/.next/server/app/page_client-reference-manifest.js +1 -1
  148. package/.next/server/app-paths-manifest.json +6 -6
  149. package/.next/server/chunks/1011.js +1 -1
  150. package/.next/server/chunks/1194.js +1 -1
  151. package/.next/server/chunks/1289.js +1 -1
  152. package/.next/server/chunks/1633.js +1 -1
  153. package/.next/server/chunks/2282.js +7 -7
  154. package/.next/server/chunks/2339.js +1 -1
  155. package/.next/server/chunks/2801.js +1 -1
  156. package/.next/server/chunks/3425.js +1 -1
  157. package/.next/server/chunks/6056.js +1 -1
  158. package/.next/server/chunks/6343.js +6 -6
  159. package/.next/server/chunks/9429.js +1 -1
  160. package/.next/server/middleware-build-manifest.js +1 -1
  161. package/.next/server/pages/404.html +1 -1
  162. package/.next/server/pages/500.html +1 -1
  163. package/.next/server/server-reference-manifest.js +1 -1
  164. package/.next/server/server-reference-manifest.json +1 -1
  165. package/.next/static/chunks/{1278-1bef1ffb32ea7f41.js → 1278-5800a984ad8ad005.js} +1 -1
  166. package/.next/static/chunks/{1660-25564899e7912146.js → 1660-7f2e79bde5eff607.js} +1 -1
  167. package/.next/static/chunks/{1699-49be310981e2759e.js → 1699-13ee6864a8a31a96.js} +2 -2
  168. package/.next/static/chunks/{3577-1f02993330d7fba7.js → 3577-cd98d40e3099e1b0.js} +1 -1
  169. package/.next/static/chunks/{5862-6f3a3a6d4c00100d.js → 5862-f98c2f3d480b80d2.js} +1 -1
  170. package/.next/static/chunks/{9150-32b4247b1af5e206.js → 9150-8316559e1687b4a8.js} +1 -1
  171. package/.next/static/chunks/{9ce8c186-6cd204e9c27ecee1.js → 9ce8c186-3c7d7dbc5d428052.js} +1 -1
  172. package/.next/static/chunks/app/(dashboard)/dashboard/cli-tools/[toolId]/{page-c913b130003c1672.js → page-dd70f2cee9c910a9.js} +3 -3
  173. package/CHANGELOG.md +11 -0
  174. package/open-sse/handlers/chatCore/claudeMessageBody.js +357 -0
  175. package/open-sse/handlers/chatCore/nonStreamingHandler.js +36 -110
  176. package/open-sse/handlers/chatCore/sseToJsonHandler.js +147 -4
  177. package/open-sse/handlers/chatCore.js +48 -12
  178. package/open-sse/translator/concerns/classifier.js +70 -0
  179. package/open-sse/translator/concerns/thinkingUnified.js +18 -6
  180. package/open-sse/translator/request/claude-to-openai.js +20 -0
  181. package/open-sse/translator/request/openai-to-claude.js +11 -0
  182. package/package.json +1 -1
  183. package/src/app/(dashboard)/dashboard/cli-tools/components/cards/ClaudeToolCard.tsx +12 -0
  184. package/src/app/api/cli-tools/claude-settings/route.js +10 -0
  185. package/src/shared/constants/cliTools.js +8 -0
  186. /package/.next/static/{wBARQPScPhD6SLn7WQvI9 → 8QVpvFFKxoB53L449Kgan}/_buildManifest.js +0 -0
  187. /package/.next/static/{wBARQPScPhD6SLn7WQvI9 → 8QVpvFFKxoB53L449Kgan}/_ssgManifest.js +0 -0
@@ -0,0 +1,357 @@
1
+ // ── CLAUDE MESSAGE BODY ──────────────────────────────────────────────
2
+ // A Claude-format client (/v1/messages) must always receive an Anthropic
3
+ // `message` body, whichever wire format the upstream spoke. Claude Code reads
4
+ // `message.content[]`; a raw `chat.completion` makes it throw a TypeError,
5
+ // which auto mode surfaces as "Auto mode check unavailable" — every classifier
6
+ // call is a non-streaming /v1/messages request, so the whole mode fails closed.
7
+ //
8
+ // This module owns both directions and the Anthropic SSE aggregation used when
9
+ // a forced-streaming upstream has to answer a JSON client. It imports no
10
+ // handler, so both non-streaming paths can use it without a circular import.
11
+
12
+ import { FORMATS } from "../../translator/formats.js";
13
+ import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
14
+ import { extractReasoningText } from "../../translator/concerns/reasoning.js";
15
+ import { ROLE, CLAUDE_BLOCK } from "../../translator/schema/index.js";
16
+
17
+ // ── HELPERS ─────────────────────────────────────────────────────────
18
+
19
+ function parseToolArguments(value) {
20
+ if (!value) return {};
21
+ if (typeof value === "object") return value;
22
+ try {
23
+ const parsed = JSON.parse(value);
24
+ return parsed && typeof parsed === "object" ? parsed : {};
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ /** Text carried by an OpenAI message whose `content` is a parts array. */
31
+ function textFromContentParts(content) {
32
+ if (!Array.isArray(content)) return "";
33
+ return content
34
+ .filter((part) => typeof part?.text === "string")
35
+ .map((part) => part.text)
36
+ .join("");
37
+ }
38
+
39
+ function claudeUsageFromOpenAI(usage) {
40
+ const cacheRead =
41
+ usage.prompt_tokens_details?.cached_tokens || usage.cache_read_input_tokens || 0;
42
+ const cacheCreate = usage.prompt_tokens_details?.cache_creation_tokens || 0;
43
+ // OpenAI counts cached tokens inside prompt_tokens; Anthropic reports them
44
+ // outside input_tokens. Subtract so a client summing the three matches — but
45
+ // only from prompt_tokens. An `input_tokens`-shaped body is already
46
+ // cache-exclusive, and subtracting there would count the cache twice.
47
+ const inputTokens =
48
+ usage.prompt_tokens !== undefined
49
+ ? Math.max(0, usage.prompt_tokens - cacheRead - cacheCreate)
50
+ : usage.input_tokens || 0;
51
+
52
+ const claudeUsage = {
53
+ input_tokens: inputTokens,
54
+ output_tokens: usage.completion_tokens || usage.output_tokens || 0,
55
+ };
56
+ if (cacheRead > 0) claudeUsage.cache_read_input_tokens = cacheRead;
57
+ if (cacheCreate > 0) claudeUsage.cache_creation_input_tokens = cacheCreate;
58
+ return claudeUsage;
59
+ }
60
+
61
+ // ── OPENAI CHAT → CLAUDE MESSAGE ────────────────────────────────────
62
+
63
+ /**
64
+ * Convert an OpenAI Chat Completions body into an Anthropic `message`.
65
+ * A body that is already Claude-shaped (has a `content` array) is passed through.
66
+ *
67
+ * Usage follows the Anthropic contract: `input_tokens` EXCLUDES cached tokens,
68
+ * so the cache counters are surfaced separately (`cache_read_input_tokens`,
69
+ * `cache_creation_input_tokens`) exactly as the streaming translator does.
70
+ */
71
+ export function chatCompletionToClaudeMessage(responseBody) {
72
+ if (!responseBody || typeof responseBody !== "object") return responseBody;
73
+ // Upstream error payload — keep it so the failure is not masked as an empty turn.
74
+ if (responseBody.error) return responseBody;
75
+ // Already a Claude message (e.g. an upstream that speaks Claude on the wire).
76
+ if (Array.isArray(responseBody.content) && !responseBody.choices) return responseBody;
77
+
78
+ const choice = responseBody?.choices?.[0];
79
+ const message = choice?.message || {};
80
+ const content = [];
81
+
82
+ const reasoning = extractReasoningText(message);
83
+ if (reasoning) {
84
+ content.push({ type: CLAUDE_BLOCK.THINKING, thinking: reasoning });
85
+ }
86
+ const text =
87
+ typeof message.content === "string" ? message.content : textFromContentParts(message.content);
88
+ if (text.length > 0) {
89
+ content.push({ type: CLAUDE_BLOCK.TEXT, text });
90
+ }
91
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
92
+ for (const toolCall of toolCalls) {
93
+ const fn = toolCall.function || {};
94
+ content.push({
95
+ type: CLAUDE_BLOCK.TOOL_USE,
96
+ id: toolCall.id || `toolu_${Date.now()}_${content.length}`,
97
+ name: fn.name || toolCall.name || "",
98
+ input: parseToolArguments(fn.arguments ?? toolCall.arguments),
99
+ });
100
+ }
101
+ // A missing/empty choice still has to leave the client a readable message.
102
+ if (content.length === 0) content.push({ type: CLAUDE_BLOCK.TEXT, text: "" });
103
+
104
+ // Some upstreams omit finish_reason; tool calls imply a tool_use stop.
105
+ const finishReason = choice?.finish_reason || (toolCalls.length ? "tool_calls" : "stop");
106
+
107
+ return {
108
+ id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""),
109
+ type: "message",
110
+ role: ROLE.ASSISTANT,
111
+ model: responseBody.model || "unknown",
112
+ content,
113
+ stop_reason: fromOpenAIFinish(finishReason, FORMATS.CLAUDE),
114
+ stop_sequence: null,
115
+ usage: claudeUsageFromOpenAI(responseBody.usage || {}),
116
+ };
117
+ }
118
+
119
+ // ── CLAUDE MESSAGE → OPENAI CHAT COMPLETION ─────────────────────────
120
+
121
+ /**
122
+ * Convert an Anthropic `message` into an OpenAI Chat Completions body.
123
+ *
124
+ * A body that already carries `choices` (an upstream that answers in chat shape
125
+ * regardless of the requested format) is passed through, as is a body whose
126
+ * `content` is not an array (some gateways return a bare string).
127
+ */
128
+ export function claudeMessageToChatCompletion(responseBody) {
129
+ if (!responseBody || typeof responseBody !== "object") return responseBody;
130
+ if (responseBody.choices) return responseBody;
131
+ if (responseBody.content && !Array.isArray(responseBody.content)) return responseBody;
132
+
133
+ let textContent = "",
134
+ thinkingContent = "";
135
+ const toolCalls = [];
136
+
137
+ for (const block of responseBody.content || []) {
138
+ if (block.type === CLAUDE_BLOCK.TEXT) {
139
+ // Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```)
140
+ const raw = block.text ?? "";
141
+ const text = raw.replace(/^\s*```\s*json\s*\n?/i, "").replace(/\n?\s*```\s*$/i, "");
142
+ textContent += text;
143
+ } else if (block.type === CLAUDE_BLOCK.THINKING) thinkingContent += block.thinking || "";
144
+ else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
145
+ toolCalls.push({
146
+ id: block.id,
147
+ type: "function",
148
+ function: { name: block.name, arguments: JSON.stringify(block.input || {}) },
149
+ });
150
+ }
151
+ }
152
+
153
+ const message = { role: ROLE.ASSISTANT };
154
+ if (textContent) message.content = textContent;
155
+ if (thinkingContent) message.reasoning_content = thinkingContent;
156
+ if (toolCalls.length > 0) message.tool_calls = toolCalls;
157
+ if (!message.content && !message.tool_calls) message.content = "";
158
+
159
+ let finishReason = responseBody.stop_reason || "stop";
160
+ if (finishReason === "end_turn") finishReason = "stop";
161
+ if (finishReason === "tool_use") finishReason = "tool_calls";
162
+
163
+ const result = {
164
+ id: `chatcmpl-${responseBody.id || Date.now()}`,
165
+ object: "chat.completion",
166
+ created: Math.floor(Date.now() / 1000),
167
+ model: responseBody.model || "claude",
168
+ choices: [{ index: 0, message, finish_reason: finishReason }],
169
+ };
170
+
171
+ const usage = responseBody.usage;
172
+ if (usage) {
173
+ result.usage = {
174
+ prompt_tokens: usage.input_tokens || 0,
175
+ completion_tokens: usage.output_tokens || 0,
176
+ total_tokens: (usage.input_tokens || 0) + (usage.output_tokens || 0),
177
+ };
178
+ if (usage.cache_read_input_tokens)
179
+ result.usage.prompt_tokens_details = {
180
+ ...(result.usage.prompt_tokens_details || {}),
181
+ cached_tokens: usage.cache_read_input_tokens,
182
+ };
183
+ }
184
+ return result;
185
+ }
186
+
187
+ // ── CLAUDE SSE → CLAUDE MESSAGE ─────────────────────────────────────
188
+
189
+ function parseSSEPayloads(rawSSE) {
190
+ const events = [];
191
+ for (const line of String(rawSSE || "").split("\n")) {
192
+ const trimmed = line.trim();
193
+ if (!trimmed.startsWith("data:")) continue;
194
+ const payload = trimmed.slice(5).trim();
195
+ if (!payload || payload === "[DONE]") continue;
196
+ try {
197
+ const parsed = JSON.parse(payload);
198
+ if (parsed && typeof parsed === "object") events.push(parsed);
199
+ } catch {
200
+ /* ignore malformed lines */
201
+ }
202
+ }
203
+ return events;
204
+ }
205
+
206
+ function appendDelta(block, parts, delta) {
207
+ switch (delta.type) {
208
+ case "text_delta":
209
+ parts.push(String(delta.text ?? ""));
210
+ break;
211
+ case "thinking_delta":
212
+ parts.push(String(delta.thinking ?? ""));
213
+ break;
214
+ case "input_json_delta":
215
+ parts.push(String(delta.partial_json ?? ""));
216
+ break;
217
+ case "signature_delta":
218
+ block.signature = `${block.signature || ""}${delta.signature || ""}`;
219
+ break;
220
+ case "citations_delta":
221
+ if (delta.citation) block.citations = [...(block.citations || []), delta.citation];
222
+ break;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Assemble an Anthropic `message` from an Anthropic Messages SSE stream.
228
+ *
229
+ * Used when a forced-streaming provider speaks Claude on the wire (for example
230
+ * opencode's `union-alpha`) but the client asked for JSON. Mirrors the
231
+ * streaming translator's block model: text/thinking deltas concatenate, tool
232
+ * `input_json_delta` fragments parse into `tool_use.input`, and usage is merged
233
+ * so the cache counters from `message_start` survive the `message_delta` total.
234
+ *
235
+ * @returns {object|null} the message, `{ error }` on an `event: error`, or null
236
+ * when the stream carried no Anthropic event (the caller then aggregates it in
237
+ * the dialect the upstream actually used).
238
+ */
239
+ export function parseClaudeSSEToMessage(rawSSE, fallbackModel) {
240
+ const events = parseSSEPayloads(rawSSE);
241
+ let streamError = null;
242
+ let message = {};
243
+ // Only an Anthropic lifecycle event proves the upstream spoke Claude.
244
+ // (`completed` is not tracked separately: a truncated stream is still assembled
245
+ // so a partial answer is not thrown away.)
246
+ // `targetFormat` is what we asked for, not what came back: a provider can
247
+ // answer its Claude transport in OpenAI dialect, and parsing that as Anthropic
248
+ // would silently drop the answer.
249
+ let sawAnthropicEvent = false;
250
+ const blocks = new Map(); // index -> content block
251
+ const parts = new Map(); // index -> accumulated delta strings
252
+
253
+ for (const event of events) {
254
+ const type = event.type;
255
+ if (type === "error" || event.error) {
256
+ streamError = event.error || {
257
+ type: "api_error",
258
+ message: event.message || "upstream error",
259
+ };
260
+ continue;
261
+ }
262
+ if (type === "message_start") {
263
+ sawAnthropicEvent = true;
264
+ if (event.message && typeof event.message === "object") message = { ...event.message };
265
+ } else if (type === "content_block_start") {
266
+ const idx = event.index;
267
+ if (
268
+ typeof idx === "number" &&
269
+ event.content_block &&
270
+ typeof event.content_block === "object"
271
+ ) {
272
+ sawAnthropicEvent = true;
273
+ blocks.set(idx, { ...event.content_block });
274
+ if (!parts.has(idx)) parts.set(idx, []);
275
+ }
276
+ } else if (type === "content_block_delta") {
277
+ const idx = event.index;
278
+ const delta = event.delta;
279
+ if (typeof idx !== "number" || !delta || typeof delta !== "object") continue;
280
+ // A delta alone is not proof of dialect — OpenAI chunks also carry one.
281
+ // Only the Anthropic-typed `content_block_start` / `message_start` above
282
+ // are.
283
+ if (!blocks.has(idx)) {
284
+ blocks.set(idx, { type: CLAUDE_BLOCK.TEXT, text: "" });
285
+ parts.set(idx, []);
286
+ }
287
+ appendDelta(blocks.get(idx), parts.get(idx), delta);
288
+ } else if (type === "message_delta") {
289
+ const delta = event.delta;
290
+ if (delta && typeof delta === "object") {
291
+ if (delta.stop_reason) message.stop_reason = delta.stop_reason;
292
+ if ("stop_sequence" in delta) message.stop_sequence = delta.stop_sequence;
293
+ }
294
+ if (event.usage && typeof event.usage === "object") {
295
+ const merged = { ...(message.usage || {}) };
296
+ for (const [key, value] of Object.entries(event.usage)) {
297
+ if (value !== undefined) merged[key] = value;
298
+ }
299
+ message.usage = merged;
300
+ }
301
+ } else if (type === "message_stop") {
302
+ sawAnthropicEvent = true;
303
+ }
304
+ }
305
+
306
+ if (streamError) return { error: streamError };
307
+ // No Anthropic lifecycle event at all: this is not our dialect (or there was
308
+ // nothing to read). Returning null lets the caller aggregate it as a chat
309
+ // stream instead of answering with the empty message this parser would build.
310
+ if (!sawAnthropicEvent) return null;
311
+
312
+ const content = [];
313
+ for (const idx of [...blocks.keys()].sort((a, b) => a - b)) {
314
+ const block = blocks.get(idx);
315
+ const accumulated = (parts.get(idx) || []).join("");
316
+ if (block.type === CLAUDE_BLOCK.TEXT) {
317
+ block.text = `${block.text || ""}${accumulated}`;
318
+ } else if (block.type === CLAUDE_BLOCK.THINKING) {
319
+ block.thinking = `${block.thinking || ""}${accumulated}`;
320
+ } else if (block.type === CLAUDE_BLOCK.TOOL_USE && accumulated) {
321
+ let input;
322
+ try {
323
+ input = JSON.parse(accumulated);
324
+ } catch {
325
+ input = undefined;
326
+ }
327
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
328
+ // The client cannot call a tool whose input is not an object; failing
329
+ // here is better than emitting a half-parsed tool_use block.
330
+ throw new Error("Claude tool_use input is not a JSON object");
331
+ }
332
+ block.input = input;
333
+ }
334
+ content.push(block);
335
+ }
336
+
337
+ // A stream that only carried message_start/stop keeps whatever it declared.
338
+ if (content.length === 0 && Array.isArray(message.content) && message.content.length > 0) {
339
+ content.push(...message.content);
340
+ }
341
+
342
+ const usage = message.usage && typeof message.usage === "object" ? { ...message.usage } : {};
343
+ if (usage.input_tokens === undefined) usage.input_tokens = 0;
344
+ if (usage.output_tokens === undefined) usage.output_tokens = 0;
345
+
346
+ return {
347
+ ...message,
348
+ id: message.id || `msg_${Date.now()}`,
349
+ type: "message",
350
+ role: ROLE.ASSISTANT,
351
+ model: message.model || fallbackModel || "unknown",
352
+ content,
353
+ stop_reason: message.stop_reason || "end_turn",
354
+ stop_sequence: message.stop_sequence ?? null,
355
+ usage,
356
+ };
357
+ }
@@ -1,6 +1,5 @@
1
1
  import { FORMATS } from "../../translator/formats.js";
2
2
  import { needsTranslation } from "../../translator/index.js";
3
- import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
4
3
  import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
5
4
  import { createErrorResult } from "../../utils/error.js";
6
5
  import { HTTP_STATUS } from "../../config/runtimeConfig.js";
@@ -16,57 +15,11 @@ import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
16
15
  import { decloakToolNames } from "../../utils/claudeCloaking.js";
17
16
  import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
18
17
  import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
19
-
20
- function parseToolArguments(value) {
21
- if (!value) return {};
22
- if (typeof value === "object") return value;
23
- try {
24
- return JSON.parse(value);
25
- } catch {
26
- return {};
27
- }
28
- }
29
-
30
- function openAICompletionToClaudeMessage(responseBody) {
31
- if (!responseBody?.choices?.[0]) return responseBody;
32
- const choice = responseBody.choices[0];
33
- const message = choice.message || {};
34
- const content = [];
35
-
36
- const reasoning =
37
- message.reasoning_content || message.provider_specific_fields?.reasoning_content || "";
38
- if (reasoning) {
39
- content.push({ type: "thinking", thinking: reasoning });
40
- }
41
- if (typeof message.content === "string" && message.content.length > 0) {
42
- content.push({ type: "text", text: message.content });
43
- }
44
- for (const toolCall of message.tool_calls || []) {
45
- const fn = toolCall.function || {};
46
- content.push({
47
- type: "tool_use",
48
- id: toolCall.id || `toolu_${Date.now()}_${content.length}`,
49
- name: fn.name || toolCall.name || "",
50
- input: parseToolArguments(fn.arguments || toolCall.arguments),
51
- });
52
- }
53
- if (content.length === 0) content.push({ type: "text", text: "" });
54
-
55
- const usage = responseBody.usage || {};
56
- return {
57
- id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""),
58
- type: "message",
59
- role: "assistant",
60
- model: responseBody.model || "unknown",
61
- content,
62
- stop_reason: fromOpenAIFinish(choice.finish_reason, FORMATS.CLAUDE),
63
- stop_sequence: null,
64
- usage: {
65
- input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
66
- output_tokens: usage.completion_tokens || usage.output_tokens || 0,
67
- },
68
- };
69
- }
18
+ import {
19
+ chatCompletionToClaudeMessage,
20
+ claudeMessageToChatCompletion,
21
+ parseClaudeSSEToMessage,
22
+ } from "./claudeMessageBody.js";
70
23
 
71
24
  /**
72
25
  * Convert an OpenAI Chat Completions non-streaming response body into the
@@ -174,7 +127,7 @@ export function translateNonStreamingResponse(
174
127
  return openAICompletionToResponses(responseBody, customToolNames);
175
128
  }
176
129
  if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.CLAUDE) {
177
- return openAICompletionToClaudeMessage(responseBody);
130
+ return chatCompletionToClaudeMessage(responseBody);
178
131
  }
179
132
  if (targetFormat === FORMATS.OPENAI) return responseBody;
180
133
 
@@ -240,7 +193,9 @@ export function translateNonStreamingResponse(
240
193
  result.usage.completion_tokens_details = { reasoning_tokens: usage.thoughtsTokenCount };
241
194
  }
242
195
  }
243
- return result;
196
+ // Gemini is the last branch that could hand a Claude client a
197
+ // `chat.completion`; the client reads `message.content[]` and throws.
198
+ return sourceFormat === FORMATS.CLAUDE ? chatCompletionToClaudeMessage(result) : result;
244
199
  }
245
200
 
246
201
  // Claude
@@ -249,61 +204,9 @@ export function translateNonStreamingResponse(
249
204
  // missing/null (e.g. M3 with max_tokens:1 spends the budget on thinking
250
205
  // and returns `content: null`). Returning the raw body would leave the
251
206
  // OpenAI client without a `choices` array and surface as a UI test error.
252
- // Early return if the response is already in OpenAI format (has choices array)
253
- // or if it has content as a non-array value (likely a different non-Claude format).
254
- // Some providers (e.g. xiaomi-tokenplan) return OpenAI-format responses even when
255
- // the request was translated to Claude format — the targetFormat is Claude but the
256
- // actual response is OpenAI-native and needs no further translation.
257
- if (responseBody.choices || (responseBody.content && !Array.isArray(responseBody.content)))
258
- return responseBody;
259
-
260
- let textContent = "",
261
- thinkingContent = "";
262
- const toolCalls = [];
263
-
264
- for (const block of responseBody.content || []) {
265
- if (block.type === "text") {
266
- // Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```)
267
- const raw = block.text ?? "";
268
- const text = raw.replace(/^\s*```\s*json\s*\n?/i, "").replace(/\n?\s*```\s*$/i, "");
269
- textContent += text;
270
- } else if (block.type === "thinking") thinkingContent += block.thinking || "";
271
- else if (block.type === "tool_use") {
272
- toolCalls.push({
273
- id: block.id,
274
- type: "function",
275
- function: { name: block.name, arguments: JSON.stringify(block.input || {}) },
276
- });
277
- }
278
- }
279
-
280
- const message = { role: "assistant" };
281
- if (textContent) message.content = textContent;
282
- if (thinkingContent) message.reasoning_content = thinkingContent;
283
- if (toolCalls.length > 0) message.tool_calls = toolCalls;
284
- if (!message.content && !message.tool_calls) message.content = "";
285
-
286
- let finishReason = responseBody.stop_reason || "stop";
287
- if (finishReason === "end_turn") finishReason = "stop";
288
- if (finishReason === "tool_use") finishReason = "tool_calls";
289
-
290
- const result = {
291
- id: `chatcmpl-${responseBody.id || Date.now()}`,
292
- object: "chat.completion",
293
- created: Math.floor(Date.now() / 1000),
294
- model: responseBody.model || "claude",
295
- choices: [{ index: 0, message, finish_reason: finishReason }],
296
- };
297
-
298
- if (responseBody.usage) {
299
- result.usage = {
300
- prompt_tokens: responseBody.usage.input_tokens || 0,
301
- completion_tokens: responseBody.usage.output_tokens || 0,
302
- total_tokens:
303
- (responseBody.usage.input_tokens || 0) + (responseBody.usage.output_tokens || 0),
304
- };
305
- }
306
- return result;
207
+ // A body already in OpenAI shape (or with non-array content) passes through
208
+ // untouched — some providers answer chat-native regardless of the request.
209
+ return claudeMessageToChatCompletion(responseBody);
307
210
  }
308
211
 
309
212
  return responseBody;
@@ -342,7 +245,30 @@ export async function handleNonStreamingResponse({
342
245
 
343
246
  if (contentType.includes("text/event-stream")) {
344
247
  const sseText = await providerResponse.text();
345
- const parsed = parseSSEToOpenAIResponse(sseText, model);
248
+ // The upstream chose to stream a body we wanted as JSON. Aggregate in the
249
+ // format it actually used: a Claude-dialect provider emits Anthropic events
250
+ // that the chat aggregator would fold into an empty message. The parser
251
+ // returns null for a non-Anthropic body, and throws on a tool block whose
252
+ // arguments are not an object — both must reach the caller as an error
253
+ // result, never as an uncaught throw.
254
+ let claudeMessage = null;
255
+ try {
256
+ const isClaudeUpstream =
257
+ targetFormat === FORMATS.CLAUDE || /"type"\s*:\s*"message_start"/.test(sseText);
258
+ claudeMessage = isClaudeUpstream ? parseClaudeSSEToMessage(sseText, model) : null;
259
+ } catch (err) {
260
+ appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
261
+ log?.error?.("CHAT", `Claude SSE aggregation failed for ${provider}: ${err.message}`);
262
+ return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message);
263
+ }
264
+ if (claudeMessage?.error) {
265
+ appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
266
+ return createErrorResult(
267
+ HTTP_STATUS.BAD_GATEWAY,
268
+ claudeMessage.error.message || "Upstream SSE stream failed",
269
+ );
270
+ }
271
+ const parsed = claudeMessage || parseSSEToOpenAIResponse(sseText, model);
346
272
  if (!parsed) {
347
273
  appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
348
274
  return createErrorResult(