@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-D_t6sCWs.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -29,6 +29,8 @@ export interface CodexAccountCredentialRecord {
29
29
  credential?: CodexAccountCredentials;
30
30
  generation: number;
31
31
  refreshGrantFingerprint?: string;
32
+ /** Private non-secret publication identity, stable across same-account token refresh. */
33
+ quotaHistoryIdentity?: string;
32
34
  deletedAt?: number;
33
35
  replacedAt?: number;
34
36
  lastCodexValidatedAt?: number;
@@ -380,6 +380,8 @@ export interface OcxConfig {
380
380
  privacy?: OcxPrivacyConfig;
381
381
  /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */
382
382
  emptyCompletionRetry?: boolean;
383
+ /** Suppress allowlisted client-facing Codex transport hints; provider enforcement is unchanged. */
384
+ dropCodexSafetyBuffering?: boolean;
383
385
  /**
384
386
  * Whether a login may open a browser on the machine running the proxy.
385
387
  *
@@ -637,11 +639,18 @@ export interface OcxConfig {
637
639
  * - "v2": force ALL models to v2 surface (override upstream pins)
638
640
  */
639
641
  multiAgentMode?: "v1" | "default" | "v2";
642
+ /**
643
+ * Which revision of the sub-agent surface advisory this install has answered.
644
+ * Absent means it has answered none. Written by the dashboard, never by a mode change.
645
+ */
646
+ multiAgentSurfaceAdvisoryVersion?: number;
640
647
  /**
641
648
  * When `multiAgentMode` is `"v2"`, keep ChatGPT-native catalog rows on v1.
642
649
  * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92).
643
650
  */
644
651
  keepNativeChatGptOnV1?: boolean;
652
+ /** Experimental plaintext delivery for native v2 collaboration messages; disabled unless true. */
653
+ plaintextV2AgentMessages?: boolean;
645
654
  /** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */
646
655
  agentTaskRecovery?: {
647
656
  enabled?: boolean;
@@ -822,15 +831,6 @@ export interface OcxConfig {
822
831
  * selector map remains visible for compatibility with hand-written configurations.
823
832
  */
824
833
  codexAccountPickerEnabled?: boolean;
825
- /**
826
- * Show the GPT-5.3-Codex-Spark 5-hour and weekly windows on Codex quota surfaces. Default false.
827
- *
828
- * Spark is a single-model window that reads 0% for most operators, and on a multi-account
829
- * pool it doubles the bar count for information almost nobody acts on. Hidden by default and
830
- * revealed by an explicit `true`; a malformed value reads as hidden rather than rejecting the
831
- * whole config.
832
- */
833
- showCodexSparkQuota?: boolean;
834
834
  /**
835
835
  * Opt-in auto-redemption of a main-account Codex reset credit shortly before it expires
836
836
  * (#822). Default off. `leadTimeMinutes` (1–60, default 10) is how long before
@@ -866,7 +866,7 @@ export interface OcxConfig {
866
866
  /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */
867
867
  autoSwitchThreshold?: number;
868
868
  /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */
869
- accountPoolStrategy?: OcxAccountPoolRotationStrategy;
869
+ accountPoolStrategy?: OcxAccountPoolRotationStrategy | "reset-first";
870
870
  /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */
871
871
  accountPoolStickyLimit?: number;
872
872
  /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */
@@ -967,8 +967,9 @@ export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max"
967
967
  * advertises no effort control (`reasoningEfforts: []`) empties the combo's picker.
968
968
  * `adaptive` excludes those empty ladders from the published intersection, keeping the
969
969
  * control usable for a mixed-capability group. Unknown (`undefined`) ladders stay
970
- * wildcards in both modes. Dispatch is unchanged: each concrete target still resolves
971
- * its own effort at request time.
970
+ * wildcards in both modes. An explicit empty ladder removes unsupported effort controls
971
+ * in either mode; adaptive dispatch also removes them before sending to an unknown target,
972
+ * while each known target still resolves its own effort.
972
973
  */
973
974
  export type OcxComboReasoningEffortMode = "strict" | "adaptive";
974
975
 
@@ -227,6 +227,14 @@ export type TierDecision =
227
227
  * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429
228
228
  * retries are allowed; OAuth/forward credentials and local runtimes are never replayed.
229
229
  */
230
+ /** Explicit per-model operator declarations; absent axes keep legacy behavior. */
231
+ export interface ModelCapabilities {
232
+ inputModalities?: Array<"text" | "image" | "audio" | "video">;
233
+ /** Requested tier only; does not imply an upstream window or activate an unverified wire. */
234
+ contextTier?: "default" | "long_context";
235
+ video?: { processing?: "static" | "agentic" };
236
+ }
237
+
230
238
  export interface OcxProviderConfig {
231
239
  /** Optional short provider namespace used only at request/catalog presentation time. */
232
240
  alias?: string;
@@ -478,6 +486,7 @@ export interface OcxProviderConfig {
478
486
  modelContextWindows?: Record<string, number>;
479
487
  /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */
480
488
  modelInputModalities?: Record<string, string[]>;
489
+ modelCapabilities?: Record<string, ModelCapabilities>;
481
490
  /** Model-specific max input token limits. Values cap auto_compact_token_limit. */
482
491
  modelMaxInputTokens?: Record<string, number>;
483
492
  /**
@@ -501,6 +510,28 @@ export interface OcxProviderConfig {
501
510
  * all-zero entry means "not billable here" and falls through to the catalogs.
502
511
  */
503
512
  modelCosts?: Record<string, ProviderCostOverlay>;
513
+ /**
514
+ * Provider-wide auto-review (approval) model for routed models of this provider.
515
+ *
516
+ * The value is a catalog selector: either a bare model id of this provider
517
+ * (for example `deepseek-v4-flash`) or a full public slug (for example
518
+ * `opencode-go/deepseek-v4-flash`). During catalog synchronization the
519
+ * selector is resolved against the final catalog and stamped as
520
+ * `auto_review_model_override` on each routed row of this provider that has
521
+ * no per-model override. The root Codex `auto_review_model` remains the
522
+ * fallback for every row without a provider stamp. Null or blank clears the
523
+ * provider-wide stamp; see `autoReviewModelOverrides` for per-model targets.
524
+ */
525
+ autoReviewModel?: string;
526
+ /**
527
+ * Per-model auto-review (approval) overrides for routed models of this
528
+ * provider. Keys are exact upstream model ids under this provider (either
529
+ * spelling of a slash-containing id is accepted). Each value is a catalog
530
+ * selector with the same meaning as `autoReviewModel`; an entry wins over
531
+ * the provider-wide value for its model. Null or blank entries remove the
532
+ * model from the map while preserving other entries.
533
+ */
534
+ autoReviewModelOverrides?: Record<string, string>;
504
535
  headers?: Record<string, string>;
505
536
  /** Default provider-routing preferences for models sent through the canonical OpenRouter API. */
506
537
  openRouterRouting?: OpenRouterProviderRouting;
@@ -773,6 +804,12 @@ export interface OcxProviderConfig {
773
804
  * out explicitly (e.g. MiniMax, where low effort disables thinking).
774
805
  */
775
806
  requiresReasoningPlaceholderModels?: string[];
807
+ /**
808
+ * Default to displaying provider-authored summaries when Responses summary is omitted.
809
+ * Explicit wire summary:"none" wins; false disables a seeded provider default.
810
+ * Raw reasoning is never relabeled as a summary.
811
+ */
812
+ showThinkingSummary?: boolean;
776
813
  /**
777
814
  * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only,
778
815
  * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays
@@ -79,6 +79,8 @@ export interface OcxParsedRequest {
79
79
  * prepareOpaqueBlobRecovery after an authoritative rejection; consumers strip replayed blobs.
80
80
  */
81
81
  _stripReasoningEncryptedContent?: boolean;
82
+ /** Final-route opt-in: emit v2 collaboration message arguments as plaintext on ChatGPT. */
83
+ _plaintextV2AgentMessages?: boolean;
82
84
  /**
83
85
  * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation.
84
86
  * When absent (single-operator local proxy), derivation stays local-scoped.
@@ -47,16 +47,17 @@ export function dottedToolName(namespace: string | undefined, name: string): str
47
47
  *
48
48
  * Codex's code-mode shell tool is declared as `exec` (a freeform custom tool whose own
49
49
  * description mentions the nested `await tools.exec_command(...)` helper). Some routed providers
50
- * echo that helper name as the tool-call name, emitting `exec_command`, `write_stdin`, or
51
- * `apply_patch` instead of the declared `exec`. Accept these nested helper names only when the
52
- * request catalog actually declares `exec` and does not itself declare the emitted name (an MCP
53
- * server may legitimately advertise one under its own namespace).
50
+ * echo that helper name as the tool-call name, emitting `exec_command`, `write_stdin`,
51
+ * `apply_patch`, or `view_image` instead of the declared `exec`. Accept these nested helper names
52
+ * only when the request catalog actually declares `exec` and does not itself declare the emitted
53
+ * name (an MCP server may legitimately advertise one under its own namespace).
54
54
  */
55
55
  const LEGACY_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const;
56
56
  const CODE_MODE_HELPER_TOOL_NAMES = [
57
57
  ...LEGACY_SHELL_BRIDGE_TOOL_NAMES,
58
58
  "write_stdin",
59
59
  "apply_patch",
60
+ "view_image",
60
61
  ] as const;
61
62
 
62
63
  /**
@@ -71,7 +72,7 @@ export const CODE_MODE_EXEC_TOOL_NAME = "exec";
71
72
  *
72
73
  * Rewrites invented `default.<name>` prefixes back to a declared bare tool when that bare tool
73
74
  * is declared and neither `default.<name>` nor `default__<name>` was explicitly declared (#4176).
74
- * Also normalizes legacy helper names (`exec_command`, `shell_command`, `apply_patch`) to
75
+ * Also normalizes legacy helper names (`exec_command`, `shell_command`, `apply_patch`, `view_image`) to
75
76
  * `exec` when code-mode `exec` is declared in the request catalog.
76
77
  *
77
78
  * @param name - The tool name emitted on the wire by the provider.
@@ -98,6 +99,17 @@ export function normalizeDeclaredToolName(
98
99
  && !declared.has("default__" + bare)
99
100
  ) {
100
101
  candidate = bare;
102
+ } else if (
103
+ // Code mode never declares bare helper names; a provider that invents `default.`
104
+ // for one still means the nested helper. Strip the prefix so the helper list
105
+ // below can rewrite it to `exec` (#4412).
106
+ bare.length > 0
107
+ && declared.has(CODE_MODE_EXEC_TOOL_NAME)
108
+ && (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(bare)
109
+ && !declared.has("default." + bare)
110
+ && !declared.has("default__" + bare)
111
+ ) {
112
+ candidate = bare;
101
113
  }
102
114
  }
103
115
  if (!declared.has(CODE_MODE_EXEC_TOOL_NAME)) return candidate;
package/src/types.ts CHANGED
@@ -111,6 +111,7 @@ export type {
111
111
  TierObservationContext,
112
112
  TierDecision,
113
113
  OcxProviderConfig,
114
+ ModelCapabilities,
114
115
  } from "./types/provider";
115
116
 
116
117
  export { PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS } from "./types/provider";
@@ -70,6 +70,29 @@ const KIMI_K27_CODE: Cost4 = { input: 0.95, output: 4, cacheRead: 0.19, cacheWri
70
70
  const KIMI_K27_CODE_HIGHSPEED: Cost4 = { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 1.9 };
71
71
  const KIMI_K26: Cost4 = { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0.95 };
72
72
  const KIMI_K25: Cost4 = { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0.6 };
73
+ /*
74
+ * Z.AI GLM list prices (USD / 1M tokens), verified 2026-09-13 against
75
+ * https://docs.z.ai/guides/overview/pricing. Neither z.ai nor bigmodel.cn
76
+ * publishes a cache-write rate — both list cache storage as limited-time free,
77
+ * an open-beta promotion the vendor may change or end — so cacheWrite is 0 as a
78
+ * 2026-09-13 snapshot, not a guaranteed rate; re-check the pricing page before
79
+ * relying on it long-term. glm-4.5-flash and glm-4.7-flash are officially
80
+ * "Free" and deliberately get no rows: a zero-cost overlay is inert in the
81
+ * resolver, which requires a nonzero tuple. glm-5-turbo / glm-5v-turbo are
82
+ * published only in CNY on bigmodel.cn and stay unregistered — the same hold
83
+ * the xiaomi CNY rows took in devlog/_fin/260720_toks_speed_price_columns/003.
84
+ * glm-4.5 (0.6/2.2/0.11), glm-4.5-air (0.2/1.1/0.03) and glm-4.5v (0.6/1.8/0.11)
85
+ * are verified on the same page but no registered provider exposes them, so
86
+ * they have no constants here.
87
+ */
88
+ const GLM_46: Cost4 = { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 };
89
+ const GLM_46V: Cost4 = { input: 0.3, output: 0.9, cacheRead: 0.05, cacheWrite: 0 };
90
+ const GLM_47: Cost4 = { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 };
91
+ const GLM_5: Cost4 = { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 };
92
+ const GLM_51: Cost4 = { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 };
93
+ const GLM_52: Cost4 = { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 };
94
+ const GLM_53: Cost4 = { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 };
95
+ const GLM_53_FLASH: Cost4 = { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 };
73
96
  const QWEN38_MAX: Cost4 = { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 };
74
97
  // Anthropic official list prices (USD / 1M tokens). Cache write uses the published 5-minute rate.
75
98
  const CLAUDE_SONNET_46: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
@@ -104,6 +127,13 @@ const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-deta
104
127
  // Kimi official tables publish input/output/cache-hit only; cacheWrite is mapped to the
105
128
  // cache-miss input price (Kimi auto-caches with no separate write billing). 2026-07-20 re-verified.
106
129
  const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing (official table; cacheWrite derived = input, Kimi auto-cache has no write billing)";
130
+ // Z.AI publishes one USD table for the international surface; the Coding Plan
131
+ // subscription and the domestic bigmodel.cn endpoints bill differently
132
+ // (subscription quota / CNY tiers), so every GLM row below is verified-derived:
133
+ // the numbers are the verified z.ai list prices shown as estimates.
134
+ const ZAI_PRICING = "https://docs.z.ai/guides/overview/pricing (official USD table, 2026-09-13; cacheWrite=0 — cache storage is limited-time free on both z.ai and bigmodel.cn)";
135
+ const ZAI_CODING_PLAN_NOTE = "z.ai list price shown as estimate; GLM Coding Plan is subscription-billed";
136
+ const BIGMODEL_NOTE = "z.ai international list price shown as estimate; domestic bigmodel.cn billing is CNY tiered (docs.bigmodel.cn/cn/guide/start/pricing)";
107
137
  // 260804: Qwen3.8-Max shipped as a stable model and Qwen published a per-token rate, which
108
138
  // is the exit condition the previous Routeway reseller overlay named. Two caveats are
109
139
  // deliberately in the source string rather than dropped: the figure comes from Qwen's own
@@ -113,6 +143,31 @@ const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing (official table; cac
113
143
  // under a vendor-price label would be a wrong value wearing a verified badge.
114
144
  const QWEN38_MAX_PRICING = "https://qwen.ai/blog?id=qwen3.8 (Qwen release announcement; no Model Studio billing row yet; cache rates unpublished -> 0)";
115
145
 
146
+ /*
147
+ * Cognition/Devin list prices (USD / 1M tokens), verified 2026-09-13 against the
148
+ * official "AI Models" page — its embedded modelCostData table publishes
149
+ * input / cache-read / cache-write / output per model uid. Self-serve extra
150
+ * usage and enterprise ACU conversion both bill at these list rates, so the
151
+ * tuples are the vendor's own published numbers; every row still stays
152
+ * verified-derived because the surface itself is subscription/ACU, not a
153
+ * per-token API.
154
+ * Time-boxed promos are NOT baked in: SWE-2 shows $0 self-serve through
155
+ * 2026-10-08 and 75%-off enterprise through 2026-12-31, and the doc states the
156
+ * list rate is what applies afterward, so the list rate is the durable catalog
157
+ * value. swe-1-7 keeps its list rate for the same reason even though the
158
+ * self-serve column currently shows 0. gemini-3-8-flash is absent from the
159
+ * table entirely, so its row derives from Google's published rate instead.
160
+ */
161
+ const DEVIN_SWE_2: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 };
162
+ const DEVIN_SWE_17: Cost4 = { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 };
163
+ const DEVIN_SWE_17_LIGHTNING: Cost4 = { input: 2.5, output: 12.5, cacheRead: 1, cacheWrite: 0 };
164
+ const DEVIN_SONNET_5: Cost4 = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
165
+ const DEVIN_KIMI_K3: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 };
166
+ const DEVIN_KIMI_K27: Cost4 = { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 };
167
+ const DEVIN_GROK: Cost4 = { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 };
168
+ const DEVIN_PRICING = "https://docs.devin.ai/desktop/models (official modelCostData table, 2026-09-13; list rates for self-serve overage / enterprise ACU conversion on a subscription surface)";
169
+ const DEVIN_SWE2_NOTE = "list rate; $0 self-serve through 2026-10-08 and 75%-off enterprise through 2026-12-31 are time-boxed promos, not baked in";
170
+
116
171
  export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
117
172
  { provider: "openai-apikey", modelId: "gpt-6-astra", cost4: GPT6_ASTRA, source: ASTRA_API_PRICING, verifiedAt: "2026-09-05", status: "verified" },
118
173
  // Display estimates use API prices for both login and API-key routes, including cache writes.
@@ -236,6 +291,78 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
236
291
  { provider: "alibaba-token-plan-intl", modelId: "qwen3.8-max", cost4: QWEN38_MAX, source: QWEN38_MAX_PRICING, verifiedAt: "2026-08-04", status: "verified" },
237
292
  // Cursor Auto router — Cursor's published fixed token price (verified).
238
293
  { provider: "cursor", modelId: "auto", cost4: { input: 1.25, output: 6, cacheRead: 0.25, cacheWrite: 1.25 }, source: "https://docs.cursor.com/account/pricing + https://cursor.com/blog/aug-2025-pricing", verifiedAt: "2026-07-20", status: "verified" },
294
+ // Z.AI GLM family — the zai bundle's rows are all-zero upstream, and the four
295
+ // provider surfaces below resolve overlays by exact provider id, so each one
296
+ // needs its own rows (same pattern as kimi/moonshot/kimi-code). All rows are
297
+ // verified-derived: the tuples are the verified z.ai USD list prices, while
298
+ // the Coding Plan rows are subscription products and zhipu-bigmodel is the
299
+ // domestic CNY-tiered PAYG — see ZAI_CODING_PLAN_NOTE / BIGMODEL_NOTE.
300
+ // zai (api.z.ai Coding Plan) exposes: glm-5.3, glm-5.3[1m], glm-5.3-flash,
301
+ // glm-5.2, glm-5.2[1m], glm-5.1, glm-5, glm-4.6.
302
+ { provider: "zai", modelId: "glm-5.3", cost4: GLM_53, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
303
+ { provider: "zai", modelId: "glm-5.3[1m]", cost4: GLM_53, source: `derived: glm-5.3 1M-context compat notation; ${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
304
+ { provider: "zai", modelId: "glm-5.3-flash", cost4: GLM_53_FLASH, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
305
+ { provider: "zai", modelId: "glm-5.2", cost4: GLM_52, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
306
+ { provider: "zai", modelId: "glm-5.2[1m]", cost4: GLM_52, source: `derived: glm-5.2 1M-context compat notation; ${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
307
+ { provider: "zai", modelId: "glm-5.1", cost4: GLM_51, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
308
+ { provider: "zai", modelId: "glm-5", cost4: GLM_5, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
309
+ { provider: "zai", modelId: "glm-4.6", cost4: GLM_46, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
310
+ // zhipu-bigmodel (open.bigmodel.cn PAYG) exposes: glm-4.6, glm-4.7,
311
+ // glm-4.7-flash (officially free — no row), glm-5, glm-5.1, glm-5.2, glm-5.3,
312
+ // glm-4.6v.
313
+ { provider: "zhipu-bigmodel", modelId: "glm-4.6", cost4: GLM_46, source: `${BIGMODEL_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
314
+ { provider: "zhipu-bigmodel", modelId: "glm-4.6v", cost4: GLM_46V, source: `${BIGMODEL_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
315
+ { provider: "zhipu-bigmodel", modelId: "glm-4.7", cost4: GLM_47, source: `${BIGMODEL_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
316
+ { provider: "zhipu-bigmodel", modelId: "glm-5", cost4: GLM_5, source: `${BIGMODEL_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
317
+ { provider: "zhipu-bigmodel", modelId: "glm-5.1", cost4: GLM_51, source: `${BIGMODEL_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
318
+ { provider: "zhipu-bigmodel", modelId: "glm-5.2", cost4: GLM_52, source: `${BIGMODEL_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
319
+ { provider: "zhipu-bigmodel", modelId: "glm-5.3", cost4: GLM_53, source: `${BIGMODEL_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
320
+ // zhipu-bigmodel-coding exposes the same roster as the zai Coding Plan row.
321
+ { provider: "zhipu-bigmodel-coding", modelId: "glm-5.3", cost4: GLM_53, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
322
+ { provider: "zhipu-bigmodel-coding", modelId: "glm-5.3[1m]", cost4: GLM_53, source: `derived: glm-5.3 1M-context compat notation; ${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
323
+ { provider: "zhipu-bigmodel-coding", modelId: "glm-5.3-flash", cost4: GLM_53_FLASH, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
324
+ { provider: "zhipu-bigmodel-coding", modelId: "glm-5.2", cost4: GLM_52, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
325
+ { provider: "zhipu-bigmodel-coding", modelId: "glm-5.2[1m]", cost4: GLM_52, source: `derived: glm-5.2 1M-context compat notation; ${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
326
+ { provider: "zhipu-bigmodel-coding", modelId: "glm-5.1", cost4: GLM_51, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
327
+ { provider: "zhipu-bigmodel-coding", modelId: "glm-5", cost4: GLM_5, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
328
+ { provider: "zhipu-bigmodel-coding", modelId: "glm-4.6", cost4: GLM_46, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
329
+ // zhipu-bigmodel-responses exposes glm-5.3, glm-5.3-flash, glm-5-turbo; the
330
+ // turbo id is CNY-only upstream and stays unregistered (see the GLM_* note).
331
+ { provider: "zhipu-bigmodel-responses", modelId: "glm-5.3", cost4: GLM_53, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
332
+ { provider: "zhipu-bigmodel-responses", modelId: "glm-5.3-flash", cost4: GLM_53_FLASH, source: `${ZAI_CODING_PLAN_NOTE}; ${ZAI_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
333
+ // Cognition/Devin — the two OAuth surfaces resolve by exact provider id, so
334
+ // each carries the roster its liveModels discovery can surface. swe-2 and
335
+ // swe-1-6 are listed on both even though each static seed names only one
336
+ // side: the live catalog is authoritative and drifts between them.
337
+ // gpt-5-6-sol uses the enterprise list column — the same table's self-serve
338
+ // column shows a discounted 1.2/6, and the doc calls the list rate the
339
+ // billing rate for overage. glm-5-2 likewise takes the nonzero list column.
340
+ { provider: "devin-cli", modelId: "swe-2", cost4: DEVIN_SWE_2, source: `${DEVIN_SWE2_NOTE}; ${DEVIN_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
341
+ { provider: "devin-cli", modelId: "swe-1-7", cost4: DEVIN_SWE_17, source: `list rate; self-serve column currently shows 0 (unannounced promo); ${DEVIN_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
342
+ { provider: "devin-cli", modelId: "swe-1-7-lightning", cost4: DEVIN_SWE_17_LIGHTNING, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
343
+ { provider: "devin-cli", modelId: "swe-1-6", cost4: DEVIN_SWE_17, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
344
+ { provider: "devin-cli", modelId: "gpt-5-6-sol", cost4: GPT56_SOL, source: `enterprise list column (self-serve shows discounted 1.2/6); ${DEVIN_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
345
+ { provider: "devin-cli", modelId: "gpt-6-astra", cost4: GPT6_ASTRA, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
346
+ { provider: "devin-cli", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_46, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
347
+ { provider: "devin-cli", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
348
+ { provider: "devin-cli", modelId: "claude-sonnet-5", cost4: DEVIN_SONNET_5, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
349
+ { provider: "devin-cli", modelId: "glm-5-3", cost4: GLM_53, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
350
+ { provider: "devin-cli", modelId: "kimi-k3", cost4: DEVIN_KIMI_K3, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
351
+ { provider: "devin-cli", modelId: "gemini-3-8-flash", cost4: GEMINI_38_FLASH, source: `derived: absent from Devin's modelCostData table; Google published promotional rate through 2026-12-31 shown as estimate ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
352
+ { provider: "devin-cli", modelId: "grok-4-6", cost4: DEVIN_GROK, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
353
+ { provider: "devin", modelId: "swe-2", cost4: DEVIN_SWE_2, source: `${DEVIN_SWE2_NOTE}; ${DEVIN_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
354
+ { provider: "devin", modelId: "swe-1-7", cost4: DEVIN_SWE_17, source: `list rate; self-serve column currently shows 0 (unannounced promo); ${DEVIN_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
355
+ { provider: "devin", modelId: "swe-1-7-lightning", cost4: DEVIN_SWE_17_LIGHTNING, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
356
+ { provider: "devin", modelId: "swe-1-6", cost4: DEVIN_SWE_17, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
357
+ { provider: "devin", modelId: "gpt-5-6-sol", cost4: GPT56_SOL, source: `enterprise list column (self-serve shows discounted 1.2/6); ${DEVIN_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
358
+ { provider: "devin", modelId: "gpt-5-6-luna", cost4: GPT56_LUNA, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
359
+ { provider: "devin", modelId: "gpt-5-6-terra", cost4: GPT56_TERRA, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
360
+ { provider: "devin", modelId: "claude-opus-4-8", cost4: CLAUDE_OPUS_46, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
361
+ { provider: "devin", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
362
+ { provider: "devin", modelId: "claude-sonnet-5", cost4: DEVIN_SONNET_5, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
363
+ { provider: "devin", modelId: "glm-5-2", cost4: GLM_52, source: `enterprise list column (self-serve shows an unannounced 0 promo); ${DEVIN_PRICING}`, verifiedAt: "2026-09-13", status: "verified-derived" },
364
+ { provider: "devin", modelId: "kimi-k2-7", cost4: DEVIN_KIMI_K27, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
365
+ { provider: "devin", modelId: "grok-4-5", cost4: DEVIN_GROK, source: DEVIN_PRICING, verifiedAt: "2026-09-13", status: "verified-derived" },
239
366
  ];
240
367
 
241
368
  /**
package/src/usage/log.ts CHANGED
@@ -10,6 +10,7 @@ import type { AttemptTierOutcome, OcxUsage } from "../types";
10
10
  import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
11
11
  import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
12
12
  import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility";
13
+ import type { CodexWsStageRecord } from "../server/responses/codex-ws-wire";
13
14
 
14
15
  export interface PersistedClaudeCompatibilityLog {
15
16
  decision: "shadow";
@@ -70,8 +71,10 @@ export type AttemptRecoveryKind =
70
71
  | "anthropic-oauth-429"
71
72
  | "oauth-account-429"
72
73
  | "image-413"
74
+ | "console-go-upload-retry"
73
75
  | "opaque-blob-rejection"
74
- | "empty-completion";
76
+ | "empty-completion"
77
+ | "reasoning-effort-downgrade";
75
78
 
76
79
  /** Request-time upstream credential class, never a credential or account identifier. */
77
80
  export type UsageCredentialSource = "grok-oauth" | "xai-api-key";
@@ -118,6 +121,14 @@ export interface PersistedUsageAttempt {
118
121
  reasoningWireValue?: string | number | boolean;
119
122
  /** Adapter-produced tier fact for this physical attempt; absent on pre-B0 rows. */
120
123
  tierOutcome?: AttemptTierOutcome;
124
+ /**
125
+ * #4191: content-free stage record of a Codex WS upstream exchange that
126
+ * served this attempt (frame size, counters, close code, versions). Absent
127
+ * on HTTP-transport attempts and pre-instrumentation rows. Numbers,
128
+ * booleans, and semver strings only — never reason text, headers, or
129
+ * account identifiers.
130
+ */
131
+ codexWsStage?: CodexWsStageRecord;
121
132
  }
122
133
 
123
134
  export interface PersistedUsageEntry {
@@ -309,8 +320,10 @@ const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([
309
320
  "anthropic-oauth-429",
310
321
  "oauth-account-429",
311
322
  "image-413",
323
+ "console-go-upload-retry",
312
324
  "opaque-blob-rejection",
313
325
  "empty-completion",
326
+ "reasoning-effort-downgrade",
314
327
  ]);
315
328
  const USAGE_STATUSES = new Set<UsageStatus>([
316
329
  "reported",
@@ -436,6 +449,9 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
436
449
  const tierOutcome = "tierOutcome" in attempt
437
450
  ? normalizeAttemptTierOutcome(attempt.tierOutcome)
438
451
  : undefined;
452
+ const codexWsStage = "codexWsStage" in attempt
453
+ ? normalizeCodexWsStageRecord(attempt.codexWsStage)
454
+ : undefined;
439
455
  const recoveryKinds = Array.isArray(attempt.recoveryKinds)
440
456
  ? [...new Set(attempt.recoveryKinds.filter(
441
457
  (value): value is AttemptRecoveryKind => typeof value === "string"
@@ -455,6 +471,7 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
455
471
  durationMs: attempt.durationMs,
456
472
  // Absent by default; only the literal `true` marker survives the round trip.
457
473
  ...(attempt.streamAborted === true ? { streamAborted: true } : {}),
474
+ ...(attempt.locallyAnswered === true ? { locallyAnswered: true } : {}),
458
475
  ...(isNonNegativeFiniteNumber(attempt.firstOutputMs)
459
476
  ? { firstOutputMs: attempt.firstOutputMs }
460
477
  : {}),
@@ -490,6 +507,46 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
490
507
  : { reasoningWireValue: attempt.reasoningWireValue }
491
508
  : {}),
492
509
  ...(tierOutcome ? { tierOutcome } : {}),
510
+ ...(codexWsStage ? { codexWsStage } : {}),
511
+ };
512
+ }
513
+
514
+ /**
515
+ * #4191: a persisted stage record is trusted only when every field matches the
516
+ * exchange's own shapes. Anything else — a hand-edited number as a string, an
517
+ * injected free-form field — drops the whole record rather than passing
518
+ * attacker text into the DTO.
519
+ */
520
+ function normalizeCodexWsStageRecord(value: unknown): CodexWsStageRecord | undefined {
521
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
522
+ const stage = value as Record<string, unknown>;
523
+ for (const key of ["upstreamFrames", "controlFrames", "relayedEvents", "pings", "pongs"] as const) {
524
+ if (!isNonNegativeFiniteNumber(stage[key])) return undefined;
525
+ }
526
+ if (!(stage.requestBytes === null || isNonNegativeFiniteNumber(stage.requestBytes))) return undefined;
527
+ if (!(stage.firstFrameMs === null || isNonNegativeFiniteNumber(stage.firstFrameMs))) return undefined;
528
+ if (!(stage.elapsedMs === null || isNonNegativeFiniteNumber(stage.elapsedMs))) return undefined;
529
+ if (!(stage.closeCode === null || (typeof stage.closeCode === "number"
530
+ && Number.isInteger(stage.closeCode) && stage.closeCode >= 1000 && stage.closeCode <= 4999))) {
531
+ return undefined;
532
+ }
533
+ if (typeof stage.sent !== "boolean" || typeof stage.reused !== "boolean") return undefined;
534
+ if (typeof stage.ocxVersion !== "string" || !stage.ocxVersion || stage.ocxVersion.length > 32) return undefined;
535
+ if (typeof stage.bunVersion !== "string" || !stage.bunVersion || stage.bunVersion.length > 32) return undefined;
536
+ return {
537
+ requestBytes: stage.requestBytes as number | null,
538
+ sent: stage.sent,
539
+ upstreamFrames: stage.upstreamFrames as number,
540
+ controlFrames: stage.controlFrames as number,
541
+ relayedEvents: stage.relayedEvents as number,
542
+ firstFrameMs: stage.firstFrameMs as number | null,
543
+ elapsedMs: stage.elapsedMs as number | null,
544
+ pings: stage.pings as number,
545
+ pongs: stage.pongs as number,
546
+ closeCode: stage.closeCode as number | null,
547
+ reused: stage.reused,
548
+ ocxVersion: stage.ocxVersion,
549
+ bunVersion: stage.bunVersion,
493
550
  };
494
551
  }
495
552
 
@@ -77,9 +77,12 @@ type EnrichedProviderCache = Map<string, OcxProviderConfig>;
77
77
  * not a text-only model and must not be widened to image through the vision sidecar.
78
78
  */
79
79
  export function isModelVisionSidecarConsumer(
80
- provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities">,
80
+ provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities" | "modelCapabilities">,
81
81
  modelId: string,
82
82
  ): boolean {
83
+ const declared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
84
+ ? provider.modelCapabilities?.[modelId]?.inputModalities : undefined;
85
+ if (declared !== undefined) return declared.includes("text") && !declared.includes("image");
83
86
  if (modelInList(provider.noVisionModels, modelId)) return true;
84
87
  const modalities = modelRecordValue(provider.modelInputModalities, modelId);
85
88
  return Array.isArray(modalities) && modalities.includes("text") && !modalities.includes("image");
@@ -151,10 +154,18 @@ function modelAcceptsImageInputWithCache(
151
154
  candidate: VisionCandidateModel,
152
155
  cache: EnrichedProviderCache,
153
156
  ): boolean | undefined {
154
- if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false;
155
157
  if (candidate.native === true || (candidate.provider === "openai" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(candidate.id))) {
158
+ const nativeProvider = enrichedProviderForVision(config, candidate.provider, cache);
159
+ if (nativeProvider && isModelVisionSidecarConsumer({
160
+ noVisionModels: nativeProvider.noVisionModels, modelInputModalities: nativeProvider.modelInputModalities,
161
+ }, candidate.id)) return false;
156
162
  return advertisesImageInput(nativeInputModalities(candidate.id)) ?? true;
157
163
  }
164
+ if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false;
165
+ const provider = enrichedProviderForVision(config, candidate.provider, cache);
166
+ const declared = Object.hasOwn(provider?.modelCapabilities ?? {}, candidate.id)
167
+ ? provider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined;
168
+ if (declared !== undefined) return declared.includes("image");
158
169
  const fromRow = advertisesImageInput(candidate.inputModalities);
159
170
  if (fromRow !== undefined) return fromRow;
160
171
  return metadataImageInput(candidate.provider, candidate.id);
@@ -3,6 +3,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx
3
3
  import { namespacedToolName, toolChoiceToolPredicate } from "../types";
4
4
  import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata";
5
5
  import type { AttemptRecoveryKind } from "../usage/log";
6
+ import { isTruncatedStopReason } from "../responses/truncated-stop-reason";
6
7
  import { bridgeToResponsesSSE } from "../bridge";
7
8
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
8
9
  import { runAnthropicWebSearch } from "./anthropic-executor";
@@ -230,6 +231,24 @@ function forcedAnswerNudge(): OcxMessage {
230
231
  };
231
232
  }
232
233
 
234
+ /**
235
+ * Transient developer-role nudge for the ONE recovery pass after a forced answer came back empty.
236
+ * The recovery also removes every tool, so the model has nothing to call and can only return text;
237
+ * this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge}
238
+ * it is iteration-local and never touches the persisted `messages`.
239
+ */
240
+ function forcedAnswerRetryNudge(): OcxMessage {
241
+ return {
242
+ role: "developer",
243
+ content:
244
+ "Your previous response contained no usable answer. Web search has finished for this turn and " +
245
+ "no tools are available for this response. Answer the user's question now in assistant text, " +
246
+ "using the web search results already gathered above. If those results are insufficient, say " +
247
+ "what is missing instead of returning an empty response.",
248
+ timestamp: Date.now(),
249
+ };
250
+ }
251
+
233
252
  function jsonError(status: number, message: string): Response {
234
253
  return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), {
235
254
  status,
@@ -370,7 +389,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
370
389
  const signal = internalAbort.signal;
371
390
 
372
391
  // Hard iteration bound (termination safety net); forceAnswer normally ends the loop sooner.
373
- const HARD_CAP = maxSearches + 2;
392
+ // One iteration beyond the forced answer is reserved for its empty-answer recovery below.
393
+ const HARD_CAP = maxSearches + 3;
394
+ let emptyAnswerRetries = 0;
374
395
  const connectTimeoutMs = deps.connectTimeoutMs ?? 200_000;
375
396
  const routedModelStallTimeoutMs = deps.routedModelStallTimeoutMs ?? 200_000;
376
397
 
@@ -407,12 +428,19 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
407
428
  // ignores what the search found, which reads to the user as "the search did nothing". Nudge it
408
429
  // (iteration-locally — never mutate the shared `messages`) to actually use the gathered results.
409
430
  // Only when a REAL search ran (executedSearchCount, not empty-query/limit/repeat placeholders).
410
- const iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
431
+ let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
411
432
  ? [...messages, forcedAnswerNudge()]
412
433
  : messages;
434
+ // #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the
435
+ // model nothing to call, and the extra developer turn asks it for the text it just failed to
436
+ // produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry
437
+ // cannot repeat the same empty or tool-shaped response.
438
+ const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0;
439
+ if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()];
413
440
  const iterParsed: OcxParsedRequest = {
414
441
  ...parsed, stream: true,
415
- context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools },
442
+ ...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}),
443
+ context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : forceAnswer ? toolsNoWebSearch : allTools },
416
444
  };
417
445
  // One cumulative header deadline spans every pool-key 429 rotation in this model iteration.
418
446
  // clear() stops only its timer after final headers; the direct turn signal remains attached to
@@ -847,9 +875,34 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
847
875
  // An unterminated call flushes AFTER the terminal event, so find
848
876
  // the terminal rather than assuming it is last (#1001).
849
877
  const terminalEvent = split.passthrough.find(event => event.type === "done");
878
+ if (terminalEvent?.type === "done" && !split.hasMalformedToolCall
879
+ && isTruncatedStopReason(terminalEvent.stopReason)) {
880
+ // A provider refusal or truncation is authoritative, even without text.
881
+ // Preserve it once; neither an empty-answer retry nor a generic 502 applies.
882
+ yield* replay(split.passthrough.slice(split.streamedPassthroughCount));
883
+ return;
884
+ }
850
885
  if (terminalEvent?.type === "done"
851
886
  && (split.hasMalformedToolCall
852
887
  || (!split.hasRealToolCall && !hasVisibleAssistantText(split.passthrough)))) {
888
+ // #1001 fixed the silent success by failing here. A malformed call still fails: it
889
+ // reports a protocol problem, and replaying it would only re-ask an unwell upstream.
890
+ // Silence is different — it is recoverable, so retry exactly once with the results
891
+ // already gathered before failing the turn.
892
+ console.warn("[web-search-loop] unusable forced answer", JSON.stringify({
893
+ model: parsed.modelId,
894
+ recoveryAttempt: emptyAnswerRetries,
895
+ searchCalls: split.calls.length,
896
+ malformed: split.hasMalformedToolCall,
897
+ stopReason: terminalEvent.stopReason,
898
+ eventTypes: [...new Set(split.passthrough.map(event => event.type))],
899
+ }));
900
+ if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) {
901
+ emptyAnswerRetries++;
902
+ console.warn("[web-search-loop] empty forced answer — retrying once without tools");
903
+ yield { type: "heartbeat" };
904
+ continue;
905
+ }
853
906
  throw new LoopError(502, "forced-answer pass produced no usable assistant output");
854
907
  }
855
908
  }