@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -192,6 +192,35 @@ async function resolveWireModelUid(
192
192
  */
193
193
  export const resolveWireModelUidForTests = resolveWireModelUid;
194
194
 
195
+ const positiveTokenCount = (value: unknown): number | undefined =>
196
+ typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
197
+
198
+ /**
199
+ * Read a per-model token count for the exact UID selected for this turn.
200
+ *
201
+ * Tries the selected UID and then its collapsed base id, preferring the
202
+ * canonical spelling and accepting dotted or case-folded saved hints — the same
203
+ * normalization the inference request applies to the model id. Where several
204
+ * spellings match one id, the smallest wins: a ceiling stated twice is
205
+ * satisfied by the lower statement.
206
+ */
207
+ function devinModelTokenHint(
208
+ record: Record<string, number> | undefined,
209
+ modelUid: string,
210
+ ): number | undefined {
211
+ if (!record) return undefined;
212
+ for (const id of [modelUid, collapseDevinModelUid(modelUid)]) {
213
+ const exact = Object.hasOwn(record, id) ? positiveTokenCount(record[id]) : undefined;
214
+ if (exact !== undefined) return exact;
215
+ const matches = Object.entries(record)
216
+ .filter(([key]) => normalizeDevinModelId(key).toLowerCase() === id.toLowerCase())
217
+ .map(([, value]) => positiveTokenCount(value))
218
+ .filter((value): value is number => value !== undefined);
219
+ if (matches.length > 0) return Math.min(...matches);
220
+ }
221
+ return undefined;
222
+ }
223
+
195
224
  /**
196
225
  * Resolve the INPUT ceiling for the exact UID selected for this turn. Catalog
197
226
  * ClientModelConfig #18 and CompletionConfiguration #3 both carry input tokens;
@@ -204,33 +233,50 @@ function resolveDevinMaxInputTokens(
204
233
  modelUid: string,
205
234
  liveWindow?: number,
206
235
  ): number | undefined {
207
- const positive = (value: unknown): number | undefined =>
208
- typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
209
- const baseId = collapseDevinModelUid(modelUid);
210
- const configured = (record: Record<string, number> | undefined): number | undefined => {
211
- if (!record) return undefined;
212
- for (const id of [modelUid, baseId]) {
213
- // Prefer the canonical spelling; retain dotted/case-folded saved hints,
214
- // matching the model-id normalization used for the inference request.
215
- const exact = Object.hasOwn(record, id) ? positive(record[id]) : undefined;
216
- if (exact !== undefined) return exact;
217
- const matches = Object.entries(record)
218
- .filter(([key]) => normalizeDevinModelId(key).toLowerCase() === id.toLowerCase())
219
- .map(([, value]) => positive(value))
220
- .filter((value): value is number => value !== undefined);
221
- if (matches.length > 0) return Math.min(...matches);
222
- }
223
- return undefined;
224
- };
225
- const contextHint = configured(provider.modelContextWindows) ?? positive(provider.contextWindow);
226
- const inputHint = configured(provider.modelMaxInputTokens);
227
- const ceilings = [positive(liveWindow), contextHint, inputHint]
236
+ const contextHint = devinModelTokenHint(provider.modelContextWindows, modelUid)
237
+ ?? positiveTokenCount(provider.contextWindow);
238
+ const inputHint = devinModelTokenHint(provider.modelMaxInputTokens, modelUid);
239
+ const ceilings = [positiveTokenCount(liveWindow), contextHint, inputHint]
228
240
  .filter((value): value is number => value !== undefined);
229
241
  return ceilings.length > 0 ? Math.min(...ceilings) : undefined;
230
242
  }
231
243
 
232
- /** Pure test seam; runtime uses the same resolver immediately before dispatch. */
244
+ /**
245
+ * Resolve the OUTPUT ceiling for this turn, highest authority first:
246
+ *
247
+ * 1. the caller's explicit `max_output_tokens`, forwarded unchanged — an
248
+ * explicit cap is a request, so a small one is never widened into a
249
+ * configured larger one;
250
+ * 2. the configured per-model cap (`modelMaxOutputTokens`), read through the
251
+ * same UID-aware hint lookup the input ceiling uses;
252
+ * 3. the provider-wide `defaultMaxOutputTokens`;
253
+ * 4. undefined, which leaves the cloud-direct encoder's own 8192 fallback in
254
+ * place for a provider that configured nothing.
255
+ *
256
+ * This is NOT the history ceiling, and the two must not collapse into one
257
+ * number. CompletionConfiguration #2 is the output cap and #3 is the context
258
+ * window, so feeding a context window into this resolver would ask Cognition to
259
+ * generate a whole window's worth of output. Nothing here reads
260
+ * `contextWindow` or `modelContextWindows` for that reason.
261
+ *
262
+ * Step 1 keeps the caller's raw value rather than `positiveTokenCount`: the
263
+ * inbound parser owns what a caller may send, and re-filtering here would
264
+ * silently promote a rejected value to a configured cap the caller never asked
265
+ * for.
266
+ */
267
+ function resolveDevinMaxOutputTokens(
268
+ provider: OcxProviderConfig,
269
+ modelUid: string,
270
+ requested: number | undefined,
271
+ ): number | undefined {
272
+ if (typeof requested === "number") return requested;
273
+ return devinModelTokenHint(provider.modelMaxOutputTokens, modelUid)
274
+ ?? positiveTokenCount(provider.defaultMaxOutputTokens);
275
+ }
276
+
277
+ /** Pure test seams; runtime uses the same resolvers immediately before dispatch. */
233
278
  export const resolveDevinMaxInputTokensForTests = resolveDevinMaxInputTokens;
279
+ export const resolveDevinMaxOutputTokensForTests = resolveDevinMaxOutputTokens;
234
280
 
235
281
  export class DevinMissingCredentialError extends Error {
236
282
  constructor() {
@@ -285,6 +331,9 @@ function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): st
285
331
  for (const part of content) {
286
332
  if (part.type === "text" && part.text) {
287
333
  out.push({ type: "text", text: part.text });
334
+ } else if (part.type === "document") {
335
+ // No Devin document field; the marker keeps the turn from disappearing entirely.
336
+ out.push({ type: "text", text: part.text });
288
337
  } else if (part.type === "image") {
289
338
  const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/);
290
339
  if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! });
@@ -590,6 +639,9 @@ export function createDevinAdapter(
590
639
  const maxInputTokens = resolveDevinMaxInputTokens(
591
640
  provider, modelUid, catalog?.byUid.get(modelUid)?.contextWindow,
592
641
  );
642
+ const maxOutputTokens = resolveDevinMaxOutputTokens(
643
+ provider, modelUid, parsed.options.maxOutputTokens,
644
+ );
593
645
  // The reset-retry wrapper waits out a 429 that states its own recovery
594
646
  // delay ("limit will reset in 35 seconds") and replays the identical
595
647
  // request — but only while zero events have been yielded, so a
@@ -606,7 +658,7 @@ export function createDevinAdapter(
606
658
  // input hint used to force every model through the 128k default.
607
659
  completionOpts: {
608
660
  ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
609
- ...(typeof parsed.options.maxOutputTokens === "number" ? { maxOutputTokens: parsed.options.maxOutputTokens } : {}),
661
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
610
662
  ...(typeof parsed.options.temperature === "number" ? { temperature: parsed.options.temperature } : {}),
611
663
  ...(typeof parsed.options.topP === "number" ? { topP: parsed.options.topP } : {}),
612
664
  },
@@ -45,8 +45,11 @@ function firstUserText(parsed: OcxParsedRequest): string | undefined {
45
45
  for (const msg of parsed.context.messages) {
46
46
  if (msg.role !== "user") continue;
47
47
  if (typeof msg.content === "string") return msg.content;
48
- const first = (msg.content as OcxContentPart[]).find(p => p.type === "text" && typeof p.text === "string");
49
- if (first && first.type === "text") return first.text;
48
+ // A document part carries text too: ignoring it left a document-only opening turn with no
49
+ // anchor, which silently downgrades the deterministic session id to a random one.
50
+ const first = (msg.content as OcxContentPart[])
51
+ .find(p => (p.type === "text" || p.type === "document") && typeof p.text === "string");
52
+ if (first && (first.type === "text" || first.type === "document")) return first.text;
50
53
  }
51
54
  return undefined;
52
55
  }
@@ -66,7 +66,13 @@ function classifyGoogle(label: string, status: number | undefined, enumStatus: s
66
66
  if (status === 401 || enumStatus === "UNAUTHENTICATED" || lower.includes("unauthenticated") || lower.includes("invalid authentication") || lower.includes("expired")) {
67
67
  return `${label} authentication failed`;
68
68
  }
69
- if (status === 403 || enumStatus === "PERMISSION_DENIED" || lower.includes("permission_denied") || lower.includes("permission denied") || lower.includes("access denied")) {
69
+ // Keep Google's explicit enum in the normalized text. Responses/combo handling receives
70
+ // only this string, so dropping it would let location wording override the authoritative
71
+ // permission reason during downstream classification.
72
+ if (enumStatus === "PERMISSION_DENIED") {
73
+ return `${label} access denied (PERMISSION_DENIED)`;
74
+ }
75
+ if (status === 403 || lower.includes("permission_denied") || lower.includes("permission denied") || lower.includes("access denied")) {
70
76
  return `${label} access denied`;
71
77
  }
72
78
  // Google rejects unsupported geographic / datacenter locations with HTTP 400
@@ -15,6 +15,7 @@ import type {
15
15
  OcxUsage,
16
16
  } from "../types";
17
17
  import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types";
18
+ import type { OcxTool } from "../types";
18
19
  import { contentPartsToText, parseDataUrl } from "./image";
19
20
  import { getVertexAccessToken } from "../lib/gcp-adc";
20
21
  import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
@@ -346,6 +347,13 @@ function messagesToGeminiFormat(
346
347
  parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[video: ${p.videoUrl}]` });
347
348
  continue;
348
349
  }
350
+ if (p.type === "document") {
351
+ // Gemini takes document bytes through the same inline_data part as images and
352
+ // video. The marker on the part is the fallback for wires without one, not this
353
+ // wire's best effort (#5212).
354
+ parts.push({ inline_data: { mime_type: p.mediaType, data: p.data } });
355
+ continue;
356
+ }
349
357
  // Drop empty/malformed text instead of emitting `{ text: "" }` or a bare `{}` part.
350
358
  const textPart = geminiTextPart(p.text);
351
359
  if (textPart) parts.push(textPart);
@@ -465,9 +473,7 @@ function messagesToGeminiFormat(
465
473
 
466
474
  function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
467
475
  if (!parsed.context.tools?.length) return undefined;
468
- const tools = isAllowedToolChoice(parsed.options.toolChoice)
469
- ? parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools))
470
- : parsed.context.tools;
476
+ const tools = advertisedGeminiTools(parsed);
471
477
  if (tools.length === 0) return undefined;
472
478
  return [{
473
479
  functionDeclarations: tools.map(t => ({
@@ -478,19 +484,37 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
478
484
  }];
479
485
  }
480
486
 
487
+ /** The declarations this request actually advertises, after any allowed-tools filter. */
488
+ function advertisedGeminiTools(parsed: OcxParsedRequest): readonly OcxTool[] {
489
+ const declared = parsed.context.tools ?? [];
490
+ return isAllowedToolChoice(parsed.options.toolChoice)
491
+ ? declared.filter(toolChoiceToolPredicate(parsed.options.toolChoice, declared))
492
+ : declared;
493
+ }
494
+
481
495
  /**
482
496
  * Client tool_choice enforcement on the wire. The catalog nudge states the same contract in
483
497
  * prose, but without functionCallingConfig the model is free to ignore it. "auto" stays absent
484
498
  * so the common case is byte-identical. The allowedTools variant already filters the
485
499
  * declarations in toolsToGeminiFormat; only its "required" half needs a wire mode.
500
+ *
501
+ * A caller that declares strict tools is asking for its argument schemas to be enforced, and
502
+ * Gemini expresses that as VALIDATED. The mode existed and was plumbed end to end, but was only
503
+ * ever reachable by matching a model name, so a strict declaration arrived as an ordinary
504
+ * unvalidated AUTO turn and the response looked the same either way (#5210). VALIDATED replaces
505
+ * AUTO only: ANY and NONE are stronger constraints the caller asked for explicitly, and
506
+ * overwriting either of them would lose the choice this function exists to enforce.
486
507
  */
487
508
  function toolChoiceToGeminiToolConfig(parsed: OcxParsedRequest): Record<string, unknown> | undefined {
488
509
  const choice = parsed.options.toolChoice;
489
- if (!choice || choice === "auto") return undefined;
510
+ const validated = advertisedGeminiTools(parsed).some(t => t.strict === true)
511
+ ? { functionCallingConfig: { mode: "VALIDATED" } }
512
+ : undefined;
513
+ if (!choice || choice === "auto") return validated;
490
514
  if (choice === "none") return { functionCallingConfig: { mode: "NONE" } };
491
515
  if (choice === "required") return { functionCallingConfig: { mode: "ANY" } };
492
516
  if (isAllowedToolChoice(choice)) {
493
- return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : undefined;
517
+ return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : validated;
494
518
  }
495
519
  return {
496
520
  functionCallingConfig: {
@@ -18,6 +18,9 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string }
18
18
  */
19
19
  export function contentPartsToText(content: string | OcxContentPart[]): string {
20
20
  if (typeof content === "string") return content;
21
- const text = content.map(p => p.type === "text" ? p.text : p.type === "image" ? "[image]" : "[video]").join("");
21
+ // A document carries its own marker, so this wire states the attachment instead of
22
+ // mislabelling it as a video.
23
+ const text = content.map(p =>
24
+ p.type === "text" || p.type === "document" ? p.text : p.type === "image" ? "[image]" : "[video]").join("");
22
25
  return text || "[image]";
23
26
  }
@@ -1,31 +1,45 @@
1
1
  import type { ProviderAdapter } from "./base";
2
2
  import { untranslatedInputMediaMessage, untranslatedResponsesInputMedia } from "../responses/input-media";
3
+ import { unrepresentableDeclaration } from "./declaration-carrier";
4
+ import type { AdapterWire } from "./registry";
5
+ import type { OcxParsedRequest } from "../types";
3
6
 
4
7
  /**
5
8
  * Refuse unrepresentable input at the final translated-adapter boundary. The registry
6
9
  * applies this after wire resolution; Responses passthrough (including Azure) opts
7
10
  * out because it uses the original body rather than the lossy normalized content.
11
+ *
12
+ * Two of the three checks are wire-scoped, and that is the point. A constraint the normalized
13
+ * request CAN carry — a caller restriction on a tool, an attached document's bytes — still has
14
+ * to reach a wire that can express it. Leaving that to each adapter means an adapter that never
15
+ * learned about the carrier rebuilds without it and answers normally, so the allowlist in
16
+ * `declaration-carrier.ts` is default-deny and this is the one place every registered adapter
17
+ * passes through.
8
18
  */
9
- export function withInputMediaGuard<T extends ProviderAdapter>(adapter: T): T {
19
+ export function withInputMediaGuard<T extends ProviderAdapter>(adapter: T, wire: AdapterWire): T {
20
+ const refusal = (parsed: OcxParsedRequest): string | undefined => {
21
+ const kind = untranslatedResponsesInputMedia(parsed._rawBody);
22
+ return kind ? untranslatedInputMediaMessage(kind) : unrepresentableDeclaration(parsed, wire);
23
+ };
10
24
  const build = adapter.buildRequest.bind(adapter);
11
25
  adapter.buildRequest = (parsed, incoming) => {
12
- const kind = untranslatedResponsesInputMedia(parsed._rawBody);
13
- if (kind) throw new Error(untranslatedInputMediaMessage(kind));
26
+ const message = refusal(parsed);
27
+ if (message) throw new Error(message);
14
28
  return build(parsed, incoming);
15
29
  };
16
30
 
17
31
  const runTurn = adapter.runTurn?.bind(adapter);
18
32
  if (runTurn) {
19
33
  adapter.runTurn = async (parsed, incoming, emit) => {
20
- const kind = untranslatedResponsesInputMedia(parsed._rawBody);
21
- if (kind) {
34
+ const message = refusal(parsed);
35
+ if (message) {
22
36
  emit({
23
37
  type: "error",
24
38
  status: 400,
25
39
  errorType: "invalid_request_error",
26
40
  code: "unsupported_input_modality",
27
41
  retryable: false,
28
- message: untranslatedInputMediaMessage(kind),
42
+ message,
29
43
  });
30
44
  return;
31
45
  }
@@ -37,9 +51,7 @@ export function withInputMediaGuard<T extends ProviderAdapter>(adapter: T): T {
37
51
  if (localTerminal) {
38
52
  // This hook is outside the builder's error catch. Decline its success shortcut;
39
53
  // the ordinary buildRequest path then returns the established client-safe 400.
40
- adapter.localTerminal = parsed => untranslatedResponsesInputMedia(parsed._rawBody)
41
- ? undefined
42
- : localTerminal(parsed);
54
+ adapter.localTerminal = parsed => refusal(parsed) ? undefined : localTerminal(parsed);
43
55
  }
44
56
  return adapter;
45
57
  }
@@ -12,14 +12,15 @@ import type { KiroHistoryEntry } from "./wire";
12
12
 
13
13
  export function userContentText(content: string | OcxContentPart[]): string {
14
14
  if (typeof content === "string") return content;
15
- return content.map(p => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n");
15
+ // A document carries its own marker: dropping it built an empty user turn that Kiro rejects.
16
+ return content.map(p => (p.type === "text" || p.type === "document" ? p.text : "")).filter(Boolean).join("\n");
16
17
  }
17
18
 
18
19
  export function usageContentText(content: string | OcxContentPart[]): string {
19
20
  if (typeof content === "string") return content;
20
21
  return content
21
22
  .map(p => {
22
- if (p.type === "text") return p.text;
23
+ if (p.type === "text" || p.type === "document") return p.text;
23
24
  if (p.type === "image") return `[image:${p.detail ?? "auto"}]`;
24
25
  return "";
25
26
  })
@@ -9,7 +9,7 @@ function contentText(content: string | OcxContentPart[]): string {
9
9
  if (typeof content === "string") return content;
10
10
  return content
11
11
  .map(part => {
12
- if (part.type === "text") return part.text;
12
+ if (part.type === "text" || part.type === "document") return part.text;
13
13
  if (part.type === "image") return `[image:${part.detail ?? "auto"}]`;
14
14
  return "";
15
15
  })
@@ -262,6 +262,12 @@ function contentToNative(
262
262
  text += part.text;
263
263
  continue;
264
264
  }
265
+ // No Ollama document carrier: keep the marker rather than falling through to the image
266
+ // branch below, which would read a nonexistent imageUrl.
267
+ if (part.type === "document") {
268
+ text += part.text;
269
+ continue;
270
+ }
265
271
  // Ollama's native /api/chat message shape carries `images: string[]` and has no video
266
272
  // counterpart, so a video part is refused rather than silently dropped or mis-sent as an image.
267
273
  if (part.type === "video") throw new Error(`ollama-native cannot send video content in ${label}`);
@@ -0,0 +1,61 @@
1
+ import type { OcxProviderConfig } from "../../types";
2
+
3
+ /**
4
+ * The role a `developer` message carries on the Chat wire, decided in one place for both the
5
+ * translated adapter and the native passthrough.
6
+ *
7
+ * `developer` belongs to the Chat Completions role set, but not every OpenAI-compatible gateway
8
+ * accepts it: one that does not answers `400 role 'developer' is not allowed` and the turn never
9
+ * starts. #5213 removed a hostname test that decided the role, which was right — a gateway
10
+ * proxying OpenAI accepts the role and the hostname cannot say so.
11
+ *
12
+ * `foldDeveloperRoleToSystem` is tri-state and only two of its states say anything about the
13
+ * destination: `true` records an upstream known to reject the role, `false` one known to accept
14
+ * it, and absent means nobody has recorded either. Returning `undefined` for the absent state
15
+ * instead of a role keeps the recorded fact separate from the default a route applies to
16
+ * silence, which is what lets the native route honour the first without inheriting the second.
17
+ *
18
+ * This decides the role and only the role. Which slot the message occupies is the caller's
19
+ * decision and must not change with this value.
20
+ */
21
+ export function explicitChatDeveloperWireRole(
22
+ provider: OcxProviderConfig,
23
+ ): "developer" | "system" | undefined {
24
+ if (provider.foldDeveloperRoleToSystem === undefined) return undefined;
25
+ return provider.foldDeveloperRoleToSystem ? "system" : "developer";
26
+ }
27
+
28
+ /**
29
+ * The translated route's role: the recorded one, or `system` when nothing is recorded.
30
+ *
31
+ * The unrecorded state folds because the two mistakes are not symmetrical. Sending `developer`
32
+ * to a destination that rejects it fails the request outside this repository, where no test here
33
+ * can reach it; sending `system` to one that would have accepted `developer` costs the role name
34
+ * and nothing else.
35
+ */
36
+ export function translatedChatDeveloperWireRole(provider: OcxProviderConfig): "developer" | "system" {
37
+ return explicitChatDeveloperWireRole(provider) ?? "system";
38
+ }
39
+
40
+ /**
41
+ * Apply a recorded role to a caller-supplied `messages` array, leaving every other field of
42
+ * every message, and the order of all of them, exactly as they arrived.
43
+ *
44
+ * The native route forwards the caller's messages verbatim, so an operator who recorded that a
45
+ * destination rejects the role still sent `developer` there and the turn failed upstream with a
46
+ * 400. Only an explicit record changes anything here: with the key unset, or set to the role the
47
+ * message already carries, the same array reference is returned and the wire is byte-identical
48
+ * to the one the caller sent.
49
+ */
50
+ export function applyExplicitChatDeveloperRole(messages: unknown, provider: OcxProviderConfig): unknown {
51
+ const role = explicitChatDeveloperWireRole(provider);
52
+ if (role === undefined || role === "developer" || !Array.isArray(messages)) return messages;
53
+ let rewritten = false;
54
+ const applied = messages.map(message => {
55
+ if (typeof message !== "object" || message === null || Array.isArray(message)) return message;
56
+ if ((message as { role?: unknown }).role !== "developer") return message;
57
+ rewritten = true;
58
+ return { ...(message as Record<string, unknown>), role };
59
+ });
60
+ return rewritten ? applied : messages;
61
+ }
@@ -1,13 +1,14 @@
1
1
  import { isNativeOpenAIChatTarget, stripBracketedModelSuffix } from "./wire";
2
2
  import { reasoningDetailSegmentForWire } from "./response-events";
3
+ import { translatedChatDeveloperWireRole } from "./developer-role";
3
4
  import { isVolcengineArkPaygChatTarget } from "./tool-schema";
4
5
  import { contentPartsToText } from "../image";
5
6
  import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation";
6
7
  import { identifyRoutedModel } from "../identity";
7
8
  import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge";
8
- import { registryEntryForProviderDestination } from "../../providers/registry";
9
9
  import { peekReasoningForCall } from "../../responses/reasoning-replay-cache";
10
- import type { OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types";
10
+ import { inlineDocumentDataUrl } from "../../responses/inline-document";
11
+ import type { OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types";
11
12
  import { modelInList, namespacedToolName } from "../../types";
12
13
 
13
14
  /**
@@ -21,13 +22,6 @@ import { modelInList, namespacedToolName } from "../../types";
21
22
  */
22
23
  const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has no video mapping]";
23
24
 
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
25
  /**
32
26
  * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
33
27
  * content is text-only on every chat provider, so these ride in a follow-up user message instead of
@@ -121,21 +115,26 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
121
115
  };
122
116
 
123
117
  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";
118
+ // Which role a developer message carries, and why the unrecorded state folds, is stated once
119
+ // in ./developer-role.ts and read from there by the native passthrough as well. Either way the
120
+ // message keeps the slot it arrived in — only the role changes, never the position.
121
+ const developerWireRole = translatedChatDeveloperWireRole(provider);
122
+ // A developer message keeps the slot it arrived in. Hoisting its text into the leading
123
+ // system block moved a mid-conversation instruction ahead of every turn it was written to
124
+ // follow, and the caller saw an ordinary answer either way (#5213). The Claude inbound mints
125
+ // chronological developer items for exactly this reason (#4161), so the two halves of the
126
+ // route were working against each other on every host but api.openai.com. Placement is now
127
+ // uniform; which ROLE that slot carries is decided separately below.
128
+ //
129
+ // One destination already had the chronological behaviour, keyed to a model and a registry
130
+ // id, because hoisting a newly appended reminder rewrites the reusable prompt prefix. That is
131
+ // a property of prompt-prefix caching rather than of that destination, and it is now what
132
+ // every destination gets.
128
133
  const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
129
134
  ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
130
135
  : 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
136
  const systemParts = [
137
137
  ...(context.systemPrompt ?? []),
138
- ...developerSystemParts,
139
138
  ...(toolCatalogNudge ? [toolCatalogNudge] : []),
140
139
  ];
141
140
  if (systemParts.length > 0) {
@@ -152,20 +151,23 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
152
151
  case "developer": {
153
152
  const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
154
153
  const hasImages = parts?.some(p => p.type === "image") ?? false;
154
+ // A document has a structured counterpart on this wire, so it needs the parts array for
155
+ // the same reason an image does: flattening it to a string would drop the bytes.
156
+ const hasStructured = hasImages || (parts?.some(p => p.type === "document") ?? false);
155
157
  let chatMsg: Record<string, unknown>;
156
- if (msg.role === "developer" && !hasImages) {
157
- if (!nativeOpenAI && !chronologicalSystem) break;
158
+ if (msg.role === "developer" && !hasStructured) {
158
159
  const text = typeof msg.content === "string"
159
160
  ? msg.content
160
161
  : 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.
162
+ // A non-text timeline part (video, for example) serializes to nothing here. The
163
+ // generic user path drops such a message, and emitting a content-free system
164
+ // message instead is rejected by some upstreams. Native OpenAI keeps its existing
165
+ // empty-developer wire, which is a separate question from placement.
164
166
  if (!nativeOpenAI && text.length === 0) break;
165
- chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text };
167
+ chatMsg = { role: developerWireRole, content: text };
166
168
  } else if (typeof msg.content === "string") {
167
169
  chatMsg = { role: "user", content: msg.content };
168
- } else if (!hasImages) {
170
+ } else if (!hasStructured) {
169
171
  // A video part has no `text`, so joining it produced "" and the whole message
170
172
  // was dropped: a video-only or text-plus-video turn vanished silently. OpenAI's
171
173
  // Chat Completions wire has no video content part, so state the omission
@@ -183,13 +185,30 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
183
185
  if (p.type === "image") {
184
186
  return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } };
185
187
  }
188
+ // Chat Completions carries an attached document as a file part with inline bytes,
189
+ // the direct counterpart of the Anthropic document block the caller sent.
190
+ if (p.type === "document") {
191
+ return {
192
+ type: "file",
193
+ file: {
194
+ file_data: inlineDocumentDataUrl(p),
195
+ ...(p.filename !== undefined ? { filename: p.filename } : {}),
196
+ },
197
+ };
198
+ }
186
199
  // Previously this produced { type: "text", text: undefined } for a video
187
200
  // part — a malformed part, worse than a drop because it can fail upstream
188
201
  // schema validation.
189
202
  if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER };
190
203
  return { type: "text", text: (p as OcxTextContent).text };
191
204
  });
192
- chatMsg = { role: "user", content: chatParts };
205
+ // A developer message with images keeps the user-compatible shape it has always had on
206
+ // this wire. One carrying only a document has no such precedent, and demoting it would
207
+ // undo the role this adapter just finished preserving.
208
+ chatMsg = {
209
+ role: msg.role === "developer" && !hasImages ? developerWireRole : "user",
210
+ content: chatParts,
211
+ };
193
212
  }
194
213
  if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
195
214
  else out.push(chatMsg);
@@ -0,0 +1,32 @@
1
+ import type { OcxProviderConfig } from "../../types";
2
+
3
+ /**
4
+ * The `parallel_tool_calls` value for a translated Chat request, or `undefined` to omit the key.
5
+ *
6
+ * A provider has three states here, not two, and the third is the default for every provider that
7
+ * never configured the knob. While the call site only branched on the two configured states, a
8
+ * caller's own explicit `parallel_tool_calls: false` reached the parser, was carried on
9
+ * `options.parallelToolCalls`, and was then dropped on the way to the wire — the upstream stayed
10
+ * free to emit concurrent calls and the response looked entirely normal (#5211).
11
+ *
12
+ * Only an explicit request-side `false` is forwarded in the unset state. `true` is already the
13
+ * upstream default, so emitting it would introduce the knob to strict OpenAI-compatible hosts
14
+ * that have never had to accept it, which is the reason the configured opt-out below omits the
15
+ * key rather than sending `false`.
16
+ */
17
+ export function chatParallelToolCallsWireValue(
18
+ provider: OcxProviderConfig,
19
+ requested: boolean | undefined,
20
+ ): boolean | undefined {
21
+ if (provider.parallelToolCalls === false) {
22
+ // NIM documents the Boolean defaulting to false and kimi rejects true; pin the wire bit so
23
+ // Codex cannot opt in via request.options. Other opted-out providers omit the field, but a
24
+ // self-hosted gateway that DOES honor it and keeps emitting parallel calls without it can opt
25
+ // in via pinParallelToolCallsFalse.
26
+ const pinned = provider.baseUrl === "https://integrate.api.nvidia.com/v1"
27
+ || provider.pinParallelToolCallsFalse === true;
28
+ return pinned ? false : undefined;
29
+ }
30
+ if (provider.parallelToolCalls === true) return requested !== false;
31
+ return requested === false ? false : undefined;
32
+ }