@bitkyc08/opencodex 2.55.0-preview.20260914 → 2.56.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.
Files changed (167) hide show
  1. package/gui/dist/assets/{index-DH2PUHqr.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,346 @@
1
+ import { isNativeOpenAIChatTarget, stripBracketedModelSuffix } from "./wire";
2
+ import { reasoningDetailSegmentForWire } from "./response-events";
3
+ import { isVolcengineArkPaygChatTarget } from "./tool-schema";
4
+ import { contentPartsToText } from "../image";
5
+ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation";
6
+ import { identifyRoutedModel } from "../identity";
7
+ import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge";
8
+ import { registryEntryForProviderDestination } from "../../providers/registry";
9
+ import { peekReasoningForCall } from "../../responses/reasoning-replay-cache";
10
+ import type { OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types";
11
+ import { modelInList, namespacedToolName } from "../../types";
12
+
13
+ /**
14
+ * The translated Chat route has no video mapping: this adapter does not implement one,
15
+ * and the marker records that fact so the payload is not dropped in silence.
16
+ *
17
+ * The wording is deliberately about opencodex's own translation, not the provider or
18
+ * model. An earlier revision said "unsupported by this provider", which attributed an
19
+ * opencodex mapping limit to upstream capability the proxy has not established. Native
20
+ * Chat passthrough and Google inline video are unaffected by this route.
21
+ */
22
+ const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has no video mapping]";
23
+
24
+ export function developerSystemText(message: OcxMessage): string | undefined {
25
+ if (message.role !== "developer") return undefined;
26
+ if (typeof message.content === "string") return message.content;
27
+ if (message.content.some(part => part.type === "image")) return undefined;
28
+ return message.content.map(part => (part as OcxTextContent).text).join("");
29
+ }
30
+
31
+ /**
32
+ * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
33
+ * content is text-only on every chat provider, so these ride in a follow-up user message instead of
34
+ * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
35
+ * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
36
+ */
37
+ export function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string {
38
+ // An empty content array is a present-but-empty result; `contentPartsToText` would
39
+ // otherwise fall back to the "[image]" marker and hide the emptiness from the model.
40
+ if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION;
41
+ if (typeof content === "string") {
42
+ if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION;
43
+ return content;
44
+ }
45
+ const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
46
+ // A whitespace-only text-part array is the array twin of a blank string; the
47
+ // shared emptiness contract (same module as the Responses adapter) annotates it
48
+ // instead of forwarding whitespace the model silently accepts. Image parts and
49
+ // any other non-text part keep the array non-empty.
50
+ if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) {
51
+ return EMPTY_TOOL_OUTPUT_ANNOTATION;
52
+ }
53
+ if (text) {
54
+ const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
55
+ return `${text}${"[image]".repeat(untransportableImages)}`;
56
+ }
57
+ return contentPartsToText(content);
58
+ }
59
+
60
+ export function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
61
+ if (typeof content === "string") return [];
62
+ const parts: unknown[] = [];
63
+ for (const p of content) {
64
+ if (p.type !== "image" || !p.imageUrl) continue;
65
+ parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
66
+ }
67
+ return parts;
68
+ }
69
+
70
+ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
71
+ const out: unknown[] = [];
72
+ const { context, options } = parsed;
73
+ const replayCacheScope = parsed._reasoningReplayScope;
74
+
75
+ interface PendingToolCall { id: string; name: string }
76
+ let pendingToolCalls: PendingToolCall[] = [];
77
+ let deferredBarrierMessages: unknown[] = [];
78
+ let pendingToolResultImageParts: unknown[] = [];
79
+ let mintedIdSeq = 0;
80
+ const seenWireCallIds = new Set<string>();
81
+
82
+ const mintCallId = (): string => {
83
+ let id = "";
84
+ do {
85
+ id = `call_ocx_minted_${++mintedIdSeq}`;
86
+ } while (seenWireCallIds.has(id));
87
+ seenWireCallIds.add(id);
88
+ return id;
89
+ };
90
+
91
+ const releaseDeferredBarriers = (): void => {
92
+ if (deferredBarrierMessages.length === 0) return;
93
+ out.push(...deferredBarrierMessages);
94
+ deferredBarrierMessages = [];
95
+ };
96
+
97
+ const flushToolResultImages = (): void => {
98
+ if (pendingToolResultImageParts.length === 0) return;
99
+ out.push({
100
+ role: "user",
101
+ content: [
102
+ { type: "text", text: "[ocx] image output from the preceding tool result(s):" },
103
+ ...pendingToolResultImageParts,
104
+ ],
105
+ });
106
+ pendingToolResultImageParts = [];
107
+ };
108
+
109
+ const flushPendingToolCalls = (): void => {
110
+ if (pendingToolCalls.length === 0) return;
111
+ for (const call of pendingToolCalls) {
112
+ out.push({
113
+ role: "tool",
114
+ tool_call_id: call.id,
115
+ content: `[ocx] no tool result was recorded for "${call.name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`,
116
+ });
117
+ }
118
+ pendingToolCalls = [];
119
+ flushToolResultImages();
120
+ releaseDeferredBarriers();
121
+ };
122
+
123
+ const nativeOpenAI = isNativeOpenAIChatTarget(provider);
124
+ // Hoisting a newly appended reminder rewrites the reusable prompt prefix.
125
+ // Keep this compatibility exception on the destination/model tested with OCG.
126
+ const chronologicalSystem = parsed.modelId === "deepseek-v4.1-flash"
127
+ && registryEntryForProviderDestination(provider)?.id === "opencode-go";
128
+ const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
129
+ ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
130
+ : undefined;
131
+ const developerSystemParts = nativeOpenAI || chronologicalSystem
132
+ ? []
133
+ : context.messages
134
+ .map(developerSystemText)
135
+ .filter((part): part is string => part !== undefined && part.length > 0);
136
+ const systemParts = [
137
+ ...(context.systemPrompt ?? []),
138
+ ...developerSystemParts,
139
+ ...(toolCatalogNudge ? [toolCatalogNudge] : []),
140
+ ];
141
+ if (systemParts.length > 0) {
142
+ const wireModelId = provider.modelSuffixBracketStrip
143
+ ? stripBracketedModelSuffix(parsed.modelId)
144
+ : parsed.modelId;
145
+ const sys = identifyRoutedModel(systemParts.join("\n\n"), wireModelId);
146
+ out.push({ role: "system", content: sys });
147
+ }
148
+
149
+ for (const msg of context.messages) {
150
+ switch (msg.role) {
151
+ case "user":
152
+ case "developer": {
153
+ const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
154
+ const hasImages = parts?.some(p => p.type === "image") ?? false;
155
+ let chatMsg: Record<string, unknown>;
156
+ if (msg.role === "developer" && !hasImages) {
157
+ if (!nativeOpenAI && !chronologicalSystem) break;
158
+ const text = typeof msg.content === "string"
159
+ ? msg.content
160
+ : parts!.map(p => (p as OcxTextContent).text).join("");
161
+ // A non-text timeline part (video, for example) serializes to nothing here.
162
+ // The generic path drops such a message; the chronological exception must not
163
+ // turn it into an empty system message that some upstreams reject.
164
+ if (!nativeOpenAI && text.length === 0) break;
165
+ chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text };
166
+ } else if (typeof msg.content === "string") {
167
+ chatMsg = { role: "user", content: msg.content };
168
+ } else if (!hasImages) {
169
+ // A video part has no `text`, so joining it produced "" and the whole message
170
+ // was dropped: a video-only or text-plus-video turn vanished silently. OpenAI's
171
+ // Chat Completions wire has no video content part, so state the omission
172
+ // instead of losing it. Scoped to this adapter's wire, not a claim about video
173
+ // support in general — native Chat passthrough and Google inline video are
174
+ // unaffected.
175
+ chatMsg = {
176
+ role: "user",
177
+ content: parts!.map(p => (p.type === "video"
178
+ ? VIDEO_UNSUPPORTED_MARKER
179
+ : (p as OcxTextContent).text)).join(""),
180
+ };
181
+ } else {
182
+ const chatParts = parts!.map(p => {
183
+ if (p.type === "image") {
184
+ return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } };
185
+ }
186
+ // Previously this produced { type: "text", text: undefined } for a video
187
+ // part — a malformed part, worse than a drop because it can fail upstream
188
+ // schema validation.
189
+ if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER };
190
+ return { type: "text", text: (p as OcxTextContent).text };
191
+ });
192
+ chatMsg = { role: "user", content: chatParts };
193
+ }
194
+ if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
195
+ else out.push(chatMsg);
196
+ break;
197
+ }
198
+ case "assistant": {
199
+ const aMsg = msg as OcxAssistantMessage;
200
+ const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[];
201
+ const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
202
+ const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
203
+ const chatMsg: Record<string, unknown> = { role: "assistant" };
204
+ if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join("");
205
+ let reasoningContent = thinkingParts.map(p => p.thinking).join("");
206
+ if (
207
+ reasoningContent.length === 0
208
+ && toolCalls.length > 0
209
+ && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
210
+ ) {
211
+ const cached = toolCalls
212
+ .map(tc => (tc.id ? peekReasoningForCall(tc.id, replayCacheScope) : undefined))
213
+ .filter((text): text is string => typeof text === "string" && text.length > 0);
214
+ // Parallel calls share one preceding reasoning block, which is
215
+ // recorded under every call id — join unique texts only.
216
+ if (cached.length > 0) {
217
+ reasoningContent = [...new Set(cached)].join("\n");
218
+ } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) {
219
+ // Fallback (extends #950, closes #1193): the replay cache is
220
+ // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on
221
+ // long sessions, and some tool rounds carry no recorded reasoning
222
+ // at all. DeepSeek thinking mode rejects ANY tool_call assistant
223
+ // message missing reasoning_content with HTTP 400, so inject a
224
+ // minimal placeholder rather than emit a bare continuation the
225
+ // upstream will reject. Scoped to requiresReasoningPlaceholderModels
226
+ // (defaulting to the preserve list): preserve-listed providers with
227
+ // toggleable thinking (MiniMax low effort) opt out with `[]` so
228
+ // non-thinking histories are never given a fabricated placeholder.
229
+ reasoningContent = " ";
230
+ }
231
+ }
232
+ if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
233
+ // MiniMax's interleaved-thinking contract requires the structured
234
+ // reasoning_details array back on the next turn; a reasoning_content
235
+ // string is the native-format pass-back the docs mark unsupported.
236
+ if (modelInList(provider.reasoningDetailsModels, parsed.modelId)) {
237
+ chatMsg.reasoning_details = [reasoningDetailSegmentForWire(reasoningContent)];
238
+ } else {
239
+ chatMsg.reasoning_content = reasoningContent;
240
+ }
241
+ }
242
+ const hasReplayedReasoning = chatMsg.reasoning_content !== undefined || chatMsg.reasoning_details !== undefined;
243
+ if (chatMsg.content === undefined && toolCalls.length === 0 && !hasReplayedReasoning) break;
244
+ flushPendingToolCalls();
245
+ const wireToolCalls = toolCalls.map(tc => {
246
+ let id = tc.id;
247
+ if (!id) id = mintCallId();
248
+ else seenWireCallIds.add(id);
249
+ return { tc, id };
250
+ });
251
+ if (wireToolCalls.length > 0) {
252
+ chatMsg.tool_calls = wireToolCalls.map(({ tc, id }) => ({
253
+ id,
254
+ type: "function",
255
+ function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
256
+ }));
257
+ if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
258
+ }
259
+ if (hasReplayedReasoning && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
260
+ chatMsg.content = emptyAssistantContent(provider);
261
+ }
262
+ out.push(chatMsg);
263
+ pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) }));
264
+ break;
265
+ }
266
+ case "toolResult": {
267
+ let toolCallId = msg.toolCallId;
268
+ const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1;
269
+ if (matchIdx >= 0 && toolCallId) {
270
+ out.push({
271
+ role: "tool",
272
+ tool_call_id: toolCallId,
273
+ content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
274
+ });
275
+ pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
276
+ pendingToolCalls.splice(matchIdx, 1);
277
+ if (pendingToolCalls.length === 0) {
278
+ flushToolResultImages();
279
+ releaseDeferredBarriers();
280
+ }
281
+ } else {
282
+ if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
283
+ flushPendingToolCalls();
284
+ const name = safeToolName(msg.toolName);
285
+ const cachedReasoning =
286
+ toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
287
+ ? peekReasoningForCall(toolCallId, replayCacheScope)
288
+ : undefined;
289
+ // Same fallback as the main-assistant path: never emit a bare orphan
290
+ // tool_call continuation on a thinking-mode provider — inject a
291
+ // placeholder when the replay cache missed (the bounded cache can
292
+ // always miss on long sessions), or DeepSeek thinking mode 400s.
293
+ // Gate on the preserve list too: reasoning_content is only ever
294
+ // serialized for preserve-listed models, so a requires-only custom
295
+ // entry must not fabricate it on this path (P2 on #1205).
296
+ // `||` (not `??`): the cache never stores empty strings, but treat a
297
+ // falsy hit as a miss so the placeholder still fires.
298
+ const orphanReasoning =
299
+ cachedReasoning
300
+ || (modelInList(provider.preserveReasoningContentModels, parsed.modelId)
301
+ && modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)
302
+ ? " "
303
+ : undefined);
304
+ const orphanReasoningFields: Record<string, unknown> = !orphanReasoning
305
+ ? {}
306
+ : modelInList(provider.reasoningDetailsModels, parsed.modelId)
307
+ ? { reasoning_details: [reasoningDetailSegmentForWire(orphanReasoning)] }
308
+ : { reasoning_content: orphanReasoning };
309
+ out.push({
310
+ role: "assistant",
311
+ content: emptyAssistantContent(provider),
312
+ ...orphanReasoningFields,
313
+ tool_calls: [{
314
+ id: toolCallId,
315
+ type: "function",
316
+ function: { name, arguments: "{}" },
317
+ }],
318
+ });
319
+ seenWireCallIds.add(toolCallId);
320
+ out.push({
321
+ role: "tool",
322
+ tool_call_id: toolCallId,
323
+ content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
324
+ });
325
+ pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
326
+ flushToolResultImages();
327
+ }
328
+ break;
329
+ }
330
+ }
331
+ }
332
+
333
+ flushPendingToolCalls();
334
+ releaseDeferredBarriers();
335
+ return out;
336
+ }
337
+
338
+ export function safeToolName(name: string | undefined): string {
339
+ const raw = name && name.trim().length > 0 ? name : "tool_result";
340
+ const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_");
341
+ return sanitized;
342
+ }
343
+
344
+ export function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
345
+ return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : "";
346
+ }
@@ -0,0 +1,146 @@
1
+ import { openAIChatTransport, stripBracketedModelSuffix } from "./wire";
2
+ import type { AdapterRequest } from "../base";
3
+ import { frameAgentRouterMessages } from "../agentrouter";
4
+ import { openRouterProviderPayload, resolveOpenRouterRouting } from "../../providers/openrouter-routing";
5
+ import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../../providers/vercel-gateway-routing";
6
+ import { fastPolicyForModel } from "../../providers/service-tier";
7
+ import { canonicalFastTierMarker, decideTier, type ResolvedFastPolicy } from "../../providers/fastwire";
8
+ import { debugProviderDiagnostic } from "../../lib/debug";
9
+ import { isDebugEnabled } from "../../lib/debug-settings";
10
+ import { modelRecordValue } from "../../reasoning-effort";
11
+ import { modelInList, type OcxProviderConfig } from "../../types";
12
+
13
+ const CHAT_PASSTHROUGH_FIELDS = [
14
+ "audio",
15
+ "frequency_penalty",
16
+ "logit_bias",
17
+ "logprobs",
18
+ "max_completion_tokens",
19
+ "max_tokens",
20
+ "metadata",
21
+ "modalities",
22
+ "n",
23
+ "prediction",
24
+ "presence_penalty",
25
+ "reasoning_effort",
26
+ "response_format",
27
+ "seed",
28
+ "stop",
29
+ "store",
30
+ "temperature",
31
+ "tool_choice",
32
+ "tools",
33
+ "top_logprobs",
34
+ "top_p",
35
+ "user",
36
+ "web_search_options",
37
+ ] as const;
38
+
39
+ /**
40
+ * Build a provider request from an inbound Chat Completions body without translating it
41
+ * through the Responses contract. This is deliberately a whitelist: Chat-only caller
42
+ * fields retain their exact wire representation, while provider capability gates remain
43
+ * centralized beside the ordinary openai-chat adapter.
44
+ */
45
+ export function buildOpenAIChatPassthroughRequest(
46
+ provider: OcxProviderConfig,
47
+ rawBody: Record<string, unknown>,
48
+ modelId: string,
49
+ stream: boolean,
50
+ fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"),
51
+ fastMode?: boolean,
52
+ ): AdapterRequest {
53
+ const { url, headers, hasCredential } = openAIChatTransport(provider);
54
+
55
+ const body: Record<string, unknown> = {
56
+ model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
57
+ messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages),
58
+ stream,
59
+ };
60
+ for (const field of CHAT_PASSTHROUGH_FIELDS) {
61
+ if (rawBody[field] !== undefined) body[field] = rawBody[field];
62
+ }
63
+ const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts;
64
+ if (modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0) {
65
+ delete body.reasoning_effort;
66
+ }
67
+
68
+ const openRouterRouting = resolveOpenRouterRouting(provider, modelId);
69
+ if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
70
+ const vercelRouting = resolveVercelGatewayRouting(provider, modelId);
71
+ if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting);
72
+
73
+ if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature;
74
+ if (modelInList(provider.noTopPModels, modelId)) delete body.top_p;
75
+ if (modelInList(provider.noPenaltyModels, modelId)) {
76
+ delete body.presence_penalty;
77
+ delete body.frequency_penalty;
78
+ }
79
+ // Exact match, unlike the gates above: `noStructuredOutputModels` is documented as
80
+ // "only an exact requested-model match omits the field" (#1424), and the Responses
81
+ // ingress enforces exactly that. A prefix match here would strip response_format from
82
+ // `<listed>:<tag>` siblings the operator never opted out, silently returning prose.
83
+ if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;
84
+ // Narrower neighbour: the model takes `json_object` but rejects `json_schema`. Downgrade
85
+ // rather than drop, so a caller that asked for JSON still gets JSON. The type check also
86
+ // makes the kill switch above win without an else — after its `delete` there is no type
87
+ // left to match.
88
+ const passthroughFormat = body.response_format;
89
+ if (provider.noJsonSchemaModels?.includes(modelId)
90
+ && typeof passthroughFormat === "object" && passthroughFormat !== null
91
+ && (passthroughFormat as { type?: unknown }).type === "json_schema") {
92
+ body.response_format = { type: "json_object" };
93
+ }
94
+
95
+ // Run the same complete Fast policy as the translated Chat path, including explicit
96
+ // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still
97
+ // retains the caller's exact spelling; forced Fast uses the policy-owned wire value.
98
+ const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined;
99
+ const tierDecision = decideTier(fastPolicy, fastMode, callerTier);
100
+ if (tierDecision.kind === "set") {
101
+ body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined
102
+ ? callerTier
103
+ : tierDecision.value;
104
+ } else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) {
105
+ body.service_tier = rawBody.service_tier;
106
+ }
107
+ if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) {
108
+ body.prompt_cache_key = rawBody.prompt_cache_key;
109
+ }
110
+ if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) {
111
+ if (provider.parallelToolCalls === true) {
112
+ body.parallel_tool_calls = rawBody.parallel_tool_calls !== false;
113
+ } else if (provider.parallelToolCalls === false
114
+ && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) {
115
+ body.parallel_tool_calls = false;
116
+ }
117
+ }
118
+ if (stream) {
119
+ const callerOptions = rawBody.stream_options !== null
120
+ && typeof rawBody.stream_options === "object"
121
+ && !Array.isArray(rawBody.stream_options)
122
+ ? rawBody.stream_options as Record<string, unknown>
123
+ : {};
124
+ body.stream_options = { ...callerOptions, include_usage: true };
125
+ } else if (rawBody.stream_options !== undefined) {
126
+ body.stream_options = rawBody.stream_options;
127
+ }
128
+
129
+ const bodyJson = JSON.stringify(body);
130
+
131
+ if (isDebugEnabled()) {
132
+ let host = "upstream";
133
+ try { host = new URL(url).host; } catch { /* keep fallback */ }
134
+ debugProviderDiagnostic("openai-chat", "passthrough-request", {
135
+ host,
136
+ model: body.model,
137
+ stream,
138
+ messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
139
+ toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
140
+ hasCredential,
141
+ bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
142
+ });
143
+ }
144
+
145
+ return { url, method: "POST", headers, body: bodyJson };
146
+ }
@@ -0,0 +1,117 @@
1
+ import { diagnoseInvalidToolCalls, isRecord, type InvalidToolCallDiagnostic } from "./tool-call-validation";
2
+ import type { AdapterEvent, OcxUsage } from "../../types";
3
+
4
+ export function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined {
5
+ return finishReason === "length"
6
+ ? "max_tokens"
7
+ : finishReason === "content_filter"
8
+ ? "content_filter"
9
+ : undefined;
10
+ }
11
+
12
+ export function reasoningTextFrom(record: Record<string, unknown>): string | undefined {
13
+ return typeof record.reasoning_content === "string" && record.reasoning_content.length > 0
14
+ ? record.reasoning_content
15
+ : typeof record.reasoning === "string" && record.reasoning.length > 0
16
+ ? record.reasoning
17
+ : undefined;
18
+ }
19
+
20
+ export interface ReasoningDetailSegment {
21
+ key: string;
22
+ text: string;
23
+ }
24
+
25
+ /**
26
+ * Structured `reasoning_details` array (MiniMax M-series with `reasoning_split`).
27
+ * Each segment's key scopes cumulative-snapshot tracking: upstream repeats the
28
+ * full text-so-far under a stable `id`/`index` instead of sending increments.
29
+ */
30
+ export function reasoningDetailSegmentsFrom(record: Record<string, unknown>): ReasoningDetailSegment[] {
31
+ const raw = record.reasoning_details;
32
+ if (!Array.isArray(raw)) return [];
33
+ const segments: ReasoningDetailSegment[] = [];
34
+ for (let i = 0; i < raw.length; i++) {
35
+ const item: unknown = raw[i];
36
+ if (!isRecord(item)) continue;
37
+ if (typeof item.text !== "string" || item.text.length === 0) continue;
38
+ const key = typeof item.id === "string" && item.id.length > 0
39
+ ? `id:${item.id}`
40
+ : typeof item.index === "number"
41
+ ? `i:${item.index}`
42
+ : `n:${i}`;
43
+ segments.push({ key, text: item.text });
44
+ }
45
+ return segments;
46
+ }
47
+
48
+ /** Single-segment `reasoning_details` entry for replaying preserved reasoning (MiniMax wire shape). */
49
+ export function reasoningDetailSegmentForWire(text: string): Record<string, unknown> {
50
+ return { type: "reasoning.text", id: "reasoning-text-1", format: "MiniMax-response-v1", index: 0, text };
51
+ }
52
+
53
+ export function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
54
+ return {
55
+ type: "error",
56
+ message: "upstream response contained invalid choices",
57
+ ...(usage !== undefined ? { usage } : {}),
58
+ };
59
+ }
60
+
61
+ export function invalidToolCallsEvent(
62
+ rawToolCalls: unknown,
63
+ mode: "stream" | "response",
64
+ usage?: OcxUsage,
65
+ diagnosticOverride?: InvalidToolCallDiagnostic,
66
+ ): Extract<AdapterEvent, { type: "error" }> {
67
+ // The streamed accumulator knows things a rescan cannot: which field on which pending call
68
+ // was actually rejected. Without the override, a stream carrying accepted padding on call 0
69
+ // and a real defect on call 1 blames call 0, because the stateless scan stops at the first
70
+ // structurally odd value it sees.
71
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
72
+ const detail = diagnostic
73
+ ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})`
74
+ : "";
75
+ return {
76
+ type: "error",
77
+ status: 502,
78
+ errorType: "upstream_error",
79
+ message: `upstream response contained invalid tool calls${detail}`,
80
+ ...(usage !== undefined ? { usage } : {}),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * A streamed tool call is only dispatchable once the upstream has named the function.
86
+ *
87
+ * The OpenAI streaming convention puts `function.name` in the first chunk for a tool-call
88
+ * index and leaves later chunks carrying only `arguments` deltas, so a stream that never
89
+ * sends a name is non-conforming for every provider rather than quirky for one. The
90
+ * reference implementations accumulate such a call with an empty name and let the caller
91
+ * fail; we sit at the boundary where it would become a Codex tool-call contract event, so
92
+ * the equivalent is to refuse to emit it.
93
+ *
94
+ * Failing closed rather than dropping is deliberate, and matches #1325: a claimed tool call
95
+ * that silently disappears can leave the matching result orphaned on the next turn. Naming
96
+ * it ourselves is worse still — the id is synthesizable because it is an opaque correlation
97
+ * handle, but a function name is a guess at intent.
98
+ */
99
+ export function unnamedToolCallEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
100
+ return {
101
+ type: "error",
102
+ message: "upstream streamed a tool call without a function name — cannot dispatch",
103
+ ...(usage !== undefined ? { usage } : {}),
104
+ };
105
+ }
106
+
107
+ export function usageFromOpenAIChat(usage: Record<string, unknown> | undefined): OcxUsage | undefined {
108
+ if (!usage) return undefined;
109
+ const promptDetails = usage.prompt_tokens_details as Record<string, number> | undefined;
110
+ const completionDetails = usage.completion_tokens_details as Record<string, number> | undefined;
111
+ return {
112
+ inputTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
113
+ outputTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
114
+ ...(promptDetails?.cached_tokens !== undefined ? { cachedInputTokens: promptDetails.cached_tokens } : {}),
115
+ ...(completionDetails?.reasoning_tokens !== undefined ? { reasoningOutputTokens: completionDetails.reasoning_tokens } : {}),
116
+ };
117
+ }