@bitkyc08/opencodex 2.76.0 → 2.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -219,6 +219,8 @@ export interface OcxClaudeCodeConfig {
219
219
  * definition. Unset inherits the parent session effort.
220
220
  */
221
221
  subagentEffort?: "low" | "medium" | "high" | "xhigh" | "max";
222
+ /** Optional roster-style model forced on subagents at the next routed Claude Code launch. */
223
+ subagentModelForce?: string;
222
224
  /** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */
223
225
  webSearchSidecar?: { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa"; model?: string };
224
226
  /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */
@@ -750,6 +752,8 @@ export interface OcxConfig {
750
752
  modelPinnedEfforts?: Record<string, string>;
751
753
  compactionRouting?: {
752
754
  model: string;
755
+ /** Incoming model allowlist: exact selectors or provider/*; omitted means all models. */
756
+ sourceModels?: string[];
753
757
  reasoningEffort?: string;
754
758
  /** Compaction triggers this override covers; omission means `["manual"]`. */
755
759
  triggers?: ("manual" | "auto")[];
@@ -411,6 +411,22 @@ export interface OcxProviderConfig {
411
411
  * `ocxr1` envelopes are still stripped because no upstream can decrypt them.
412
412
  */
413
413
  preserveResponsesReasoningContent?: boolean;
414
+ /**
415
+ * Whether to preserve Codex-private `input[].id` on `store: false` requests.
416
+ * Disabled by default: ordinary Responses upstreams interpret input IDs as references
417
+ * to nonexistent stored items and return 404. Upstream launcher relays such as
418
+ * Codex Web GPT (`chatgpt-web/*`) require the current-turn user message ID for
419
+ * browser-session replay (#6220).
420
+ */
421
+ preserveResponsesInputItemIds?: boolean;
422
+ /**
423
+ * Whether to preserve ChatGPT-internal `internal_chat_message_metadata_passthrough`
424
+ * on outgoing input messages for noncanonical destinations. Disabled by default:
425
+ * public Responses gateways reject the private field as an unknown parameter.
426
+ * Upstream launcher relays such as Codex Web GPT require this metadata (or the item ID)
427
+ * to extract turn provenance (#6220).
428
+ */
429
+ preserveResponsesMessageMetadata?: boolean;
414
430
  /**
415
431
  * Treat this provider's `modelReasoningEfforts` as authoritative at the wire, not only in the
416
432
  * catalog. Adapters that ship their own per-model effort table (currently `command-code`)
@@ -652,6 +668,8 @@ export interface OcxProviderConfig {
652
668
  */
653
669
  autoReviewModelOverrides?: Record<string, string>;
654
670
  headers?: Record<string, string>;
671
+ /** Inbound client metadata headers to copy to this provider when the outbound field is otherwise unset. */
672
+ forwardClientHeaders?: string[];
655
673
  /** Default provider-routing preferences for models sent through the canonical OpenRouter API. */
656
674
  openRouterRouting?: OpenRouterProviderRouting;
657
675
  /** Exact model-id overrides for `openRouterRouting`. Each matching entry replaces the default. */
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, realpathSync } from "node:fs";
2
2
  import { win32 } from "node:path";
3
3
 
4
4
  const CMD_META = /([()%!^"`<>&|;, *?])/g;
@@ -38,11 +38,41 @@ export function resolveNpmCommand(
38
38
  ) {
39
39
  if (platform !== "win32") return "npm";
40
40
  const exists = deps.exists ?? existsSync;
41
+ const realpath = deps.realpath ?? realpathSync.native;
41
42
  const cwd = deps.cwd ?? process.cwd();
42
43
  const trustedRoots = [env.APPDATA, env.LOCALAPPDATA, env.ProgramFiles, env["ProgramFiles(x86)"],
43
44
  env.USERPROFILE && win32.join(env.USERPROFILE, "scoop", "shims")]
44
45
  .filter(root => typeof root === "string" && win32.isAbsolute(root));
45
46
  const trustedEntry = entry => trustedRoots.some(root => isInside(root, entry) && !isInside(root, cwd));
47
+ // Scoop installs Node's npm in the app tree, not in scoop/shims. Admit only
48
+ // the two Node apps' current npm directories, never arbitrary Scoop apps or
49
+ // a launch directory inside the installation itself.
50
+ const scoopNodeRoots = typeof env.USERPROFILE === "string" && win32.isAbsolute(env.USERPROFILE)
51
+ ? ["nodejs", "nodejs-lts"].map(app => win32.join(env.USERPROFILE, "scoop", "apps", app))
52
+ : [];
53
+ const trustedScoopNodeEntry = (entry, candidate) => {
54
+ const app = scoopNodeRoots.find(root => {
55
+ const current = win32.join(root, "current");
56
+ return isSamePath(current, entry) || isSamePath(win32.join(current, "bin"), entry);
57
+ });
58
+ if (!app) return null;
59
+ try {
60
+ const appPath = realpath(app);
61
+ const currentPath = realpath(win32.join(app, "current"));
62
+ const entryPath = realpath(entry);
63
+ const candidatePath = realpath(candidate);
64
+ const cwdPath = realpath(cwd);
65
+ const persistBin = win32.join(realpath(env.USERPROFILE), "scoop", "persist", win32.basename(app), "bin");
66
+ const insideInstall = isInside(currentPath, entryPath);
67
+ const persistedBin = isSamePath(entry, win32.join(app, "current", "bin"))
68
+ && isSamePath(entryPath, persistBin);
69
+ return !isSamePath(appPath, currentPath) && isInside(appPath, currentPath)
70
+ && (insideInstall || persistedBin) && isInside(entryPath, candidatePath)
71
+ && !isInside(currentPath, cwdPath) && !isInside(entryPath, cwdPath);
72
+ } catch {
73
+ return false;
74
+ }
75
+ };
46
76
  const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
47
77
  .split(";")
48
78
  .filter(Boolean);
@@ -54,10 +84,12 @@ export function resolveNpmCommand(
54
84
  for (const entry of pathEntries) {
55
85
  if (!win32.isAbsolute(entry)) continue;
56
86
  if (isSamePath(entry, cwd)) continue;
57
- if (isInside(cwd, entry) && !trustedEntry(entry)) continue;
58
87
  for (const extension of extensions) {
59
88
  const candidate = win32.join(entry, `npm${extension.toLowerCase()}`);
60
- if (exists(candidate)) return win32.resolve(candidate);
89
+ if (!exists(candidate)) continue;
90
+ const scoopTrust = trustedScoopNodeEntry(entry, candidate);
91
+ if (scoopTrust === false || (scoopTrust === null && isInside(cwd, entry) && !trustedEntry(entry))) continue;
92
+ return win32.resolve(candidate);
61
93
  }
62
94
  }
63
95
  return null;
@@ -16,7 +16,16 @@ import { spawnSync } from "node:child_process";
16
16
  * stop. Fail-open was wrong here: a listener that accepts connections but withholds
17
17
  * `/healthz`, or a probe that times out, is exactly the state where replacing package
18
18
  * files is most dangerous, and "we could not tell" is not evidence the proxy is gone.
19
- * Only a refused connection or a definitive non-OpenCodex answer earns `"dead"`.
19
+ * A refused connection or a definitive non-OpenCodex answer earns `"dead"`. So does one
20
+ * more observation when the dial is neither answered nor refused (a timeout, or a dropped
21
+ * SYN as with a listener bound to a tailnet address): the child tries one transient
22
+ * exclusive bind of the same host and port, and a successful bind means nothing holds the
23
+ * port at that instant. The fallback runs only for a literal IP address, because a name can
24
+ * resolve to different addresses for the dial and the bind. A failed bind (`EADDRINUSE`,
25
+ * `EADDRNOTAVAIL`, any error) stays
26
+ * `"unknown"`, a held-but-silent port included. The whole probe is bounded by the child's
27
+ * own `timeoutMs` plus a 1500 ms spawn ceiling, after which it is `"unknown"`. A successful
28
+ * bind is a statement about that moment, not a promise that the endpoint cannot restart.
20
29
  */
21
30
  export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 1500) {
22
31
  // An unusable port is not an ambiguous probe: there is nothing to ask.
@@ -38,6 +47,8 @@ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 150
38
47
  const script = [
39
48
  "const http = require('node:http');",
40
49
  "const [host, port, timeout] = process.argv.slice(1);",
50
+ "let settled = false;",
51
+ "const settle = word => { if (!settled) { settled = true; process.stdout.write(word); } };",
41
52
  "const req = http.get({ host, port: Number(port), path: '/healthz', timeout: Number(timeout) }, res => {",
42
53
  " let body = '';",
43
54
  " res.setEncoding('utf8');",
@@ -56,15 +67,34 @@ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 150
56
67
  " && typeof parsed.uptime === 'number'));",
57
68
  " // Only a clean 200 decides anything. Any other status means the endpoint is",
58
69
  " // answering but not telling us what it is, which is not evidence of absence.",
59
- " if (res.statusCode !== 200) process.stdout.write('UNKNOWN');",
60
- " else process.stdout.write(isOpencodex ? 'LIVE' : 'DEAD');",
61
- " } catch { process.stdout.write('UNKNOWN'); }",
70
+ " if (res.statusCode !== 200) settle('UNKNOWN');",
71
+ " else settle(isOpencodex ? 'LIVE' : 'DEAD');",
72
+ " } catch { settle('UNKNOWN'); }",
62
73
  " });",
63
74
  "});",
64
- "req.on('timeout', () => { process.stdout.write('UNKNOWN'); req.destroy(); });",
65
- "// ECONNREFUSED is the one error that proves nothing is listening. Everything else -",
66
- "// reset, unreachable host, TLS confusion - leaves the question open.",
67
- "req.on('error', err => process.stdout.write(err && err.code === 'ECONNREFUSED' ? 'DEAD' : 'UNKNOWN'));",
75
+ // A refused connection is the clean proof that nothing is listening. Everything else -
76
+ // reset, unreachable host, a silent drop - leaves the question open, with one more way
77
+ // to close it: a port nobody listens on can be bound, a held one cannot (EADDRINUSE).
78
+ // A proxy bound to a Tailscale address is the case that needs this: the stack drops
79
+ // the SYN instead of refusing it, so the dial only ever times out.
80
+ "let bindStarted = false;",
81
+ "const bindCheck = () => {",
82
+ " if (bindStarted || settled) return;",
83
+ " bindStarted = true;",
84
+ // A name can resolve to several addresses, and the dial and the bind resolve it on
85
+ // their own: a live proxy on one address would not stop a bind on another. Only a
86
+ // literal address names the same endpoint on both sides.
87
+ " if (require('node:net').isIP(host) === 0) { settle('UNKNOWN'); return; }",
88
+ " const server = require('node:net').createServer();",
89
+ " server.on('connection', socket => socket.destroy());",
90
+ " server.once('error', () => settle('UNKNOWN'));",
91
+ " server.listen({ host, port: Number(port), exclusive: true }, () => server.close(() => settle('DEAD')));",
92
+ "};",
93
+ "req.on('timeout', () => { req.destroy(); bindCheck(); });",
94
+ "req.on('error', err => {",
95
+ " if (err && err.code === 'ECONNREFUSED') settle('DEAD');",
96
+ " else bindCheck();",
97
+ "});",
68
98
  ].join("\n");
69
99
  try {
70
100
  const probe = spawnSync(
@@ -370,6 +370,15 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
370
370
  { provider: "google-antigravity", modelId: "claude-opus-4-6-thinking", cost4: CLAUDE_OPUS_46, source: `anthropic official Claude Opus 4.6 ${ANTHROPIC_PRICING}`, verifiedAt: "2026-07-23", status: "verified" },
371
371
  // Alias without the Antigravity "-thinking" suffix (if logs/UI ever surface it).
372
372
  { provider: "google-antigravity", modelId: "claude-opus-4-6", cost4: CLAUDE_OPUS_46, source: `anthropic official Claude Opus 4.6 ${ANTHROPIC_PRICING}`, verifiedAt: "2026-07-23", status: "verified" },
373
+ // Antigravity uses subscription quota; these are derived Anthropic reference estimates.
374
+ { provider: "google-antigravity", modelId: "claude-sonnet-5-5", cost4: CLAUDE_SONNET_55, source: `derived: Anthropic Claude Sonnet 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" },
375
+ { provider: "google-antigravity", modelId: "claude-sonnet-5-5-low", cost4: CLAUDE_SONNET_55, source: `derived: Anthropic Claude Sonnet 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" },
376
+ { provider: "google-antigravity", modelId: "claude-sonnet-5-5-medium", cost4: CLAUDE_SONNET_55, source: `derived: Anthropic Claude Sonnet 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" },
377
+ { provider: "google-antigravity", modelId: "claude-sonnet-5-5-high", cost4: CLAUDE_SONNET_55, source: `derived: Anthropic Claude Sonnet 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" },
378
+ { provider: "google-antigravity", modelId: "claude-opus-5-5", cost4: CLAUDE_OPUS_55, source: `derived: Anthropic Claude Opus 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" },
379
+ { provider: "google-antigravity", modelId: "claude-opus-5-5-low", cost4: CLAUDE_OPUS_55, source: `derived: Anthropic Claude Opus 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" },
380
+ { provider: "google-antigravity", modelId: "claude-opus-5-5-medium", cost4: CLAUDE_OPUS_55, source: `derived: Anthropic Claude Opus 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" },
381
+ { provider: "google-antigravity", modelId: "claude-opus-5-5-high", cost4: CLAUDE_OPUS_55, source: `derived: Anthropic Claude Opus 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" },
373
382
  { provider: "google-antigravity", modelId: "gpt-oss-120b-medium", cost4: { input: 0.03, output: 0.15, cacheRead: 0, cacheWrite: 0 }, source: "derived: gpt-oss-120b open-weights — OpenRouter advertised lowest https://openrouter.ai/openai/gpt-oss-120b/providers", verifiedAt: "2026-07-20", status: "verified-derived" },
374
383
  // Kimi / Moonshot — official price tables are now published (2026-07-20 re-check;
375
384
  // previously empty). kimi = Kimi Code OAuth surface, moonshot = CN key surface,
package/src/usage/log.ts CHANGED
@@ -347,6 +347,9 @@ export interface PersistedUsageEntry {
347
347
  durationMs: number;
348
348
  /** TTFT relative to the request start (WP4); unset for non-streaming/tool-only. */
349
349
  firstOutputMs?: number;
350
+ /** Request-relative generation window (#6309): first output item, last output delta. Written as a pair. */
351
+ genStartMs?: number;
352
+ lastOutputMs?: number;
350
353
  usageStatus: UsageStatus;
351
354
  usage?: OcxUsage;
352
355
  totalTokens?: number;
@@ -1035,6 +1038,10 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
1035
1038
  ...(isNonNegativeFiniteNumber(entry.firstOutputMs)
1036
1039
  ? { firstOutputMs: entry.firstOutputMs }
1037
1040
  : {}),
1041
+ ...(isNonNegativeFiniteNumber(entry.genStartMs) && isNonNegativeFiniteNumber(entry.lastOutputMs)
1042
+ && entry.lastOutputMs >= entry.genStartMs
1043
+ ? { genStartMs: entry.genStartMs, lastOutputMs: entry.lastOutputMs }
1044
+ : {}),
1038
1045
  usageStatus: entry.usageStatus,
1039
1046
  ...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}),
1040
1047
  ...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}),
@@ -10,7 +10,7 @@ import {
10
10
  type PersistedUsageEntry,
11
11
  type UsageStatus,
12
12
  } from "./log";
13
- import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext } from "./cost";
13
+ import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext, tokensPerSecond } from "./cost";
14
14
 
15
15
  /**
16
16
  * Canonical range members. The warm-up loop in the management usage route
@@ -73,6 +73,18 @@ export interface UsageSummaryTotals {
73
73
  cacheSynthesizedRequests?: number;
74
74
  /** Rows with no cache detail at all. Not a miss, and not a zero. */
75
75
  cacheUnknownRequests?: number;
76
+ /**
77
+ * Aggregated output throughput (#6309): output tokens and wall-clock milliseconds summed over
78
+ * the attempts whose usage was measured, plus how many attempts qualified. The rate is the
79
+ * token sum over the duration sum — a time-weighted average, not a mean of per-request rates —
80
+ * so one fast tiny request cannot outweigh many slow large ones. Attempts without both a
81
+ * positive token count and a positive duration contribute nothing.
82
+ */
83
+ throughputOutputTokens?: number;
84
+ throughputDurationMs?: number;
85
+ throughputSamples?: number;
86
+ /** Derived at finalize time: throughputOutputTokens / throughputDurationMs * 1000. */
87
+ throughputTokensPerSec?: number;
76
88
  }
77
89
 
78
90
  export interface UsageDay {
@@ -118,6 +130,10 @@ export interface UsageModel {
118
130
  totalTokens: number;
119
131
  inputTokens: number;
120
132
  outputTokens: number;
133
+ /** Aggregate output throughput for this row (#6309): summed measured tokens over summed duration. */
134
+ throughputTokensPerSec?: number;
135
+ /** Attempts behind the throughput aggregate; absent when nothing was measured. */
136
+ throughputSamples?: number;
121
137
  cachedInputTokens?: number;
122
138
  cacheReadInputTokens?: number;
123
139
  cacheCreationInputTokens?: number;
@@ -141,6 +157,12 @@ export interface UsageProvider {
141
157
  totalTokens: number;
142
158
  inputTokens?: number;
143
159
  outputTokens?: number;
160
+ /**
161
+ * Aggregate output throughput for this row (#6309): summed measured output tokens over summed
162
+ * wall-clock duration. Undefined when no attempt in the row reported both.
163
+ */
164
+ throughputTokensPerSec?: number;
165
+ throughputSamples?: number;
144
166
  cachedInputTokens?: number;
145
167
  cacheReadInputTokens?: number;
146
168
  cacheCreationInputTokens?: number;
@@ -420,6 +442,8 @@ export interface UsageAttribution {
420
442
  usageStatus: UsageStatus;
421
443
  usage?: PersistedUsageEntry["usage"];
422
444
  totalTokens?: number;
445
+ /** Wall-clock duration of the attempt, or of the row when it has no attempts. */
446
+ durationMs?: number;
423
447
  /** Attempt provenance when the row has one, else the entry's; never assumed observed. */
424
448
  cacheProvenance?: CacheTelemetryProvenance;
425
449
  }
@@ -465,6 +489,7 @@ export function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[
465
489
  usageStatus: entry.usageStatus,
466
490
  ...(entry.usage ? { usage: entry.usage } : {}),
467
491
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
492
+ durationMs: entry.durationMs,
468
493
  ...(entry.cacheProvenance ? { cacheProvenance: entry.cacheProvenance } : {}),
469
494
  }];
470
495
  }
@@ -482,6 +507,7 @@ export function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[
482
507
  usageStatus: attempt.usageStatus,
483
508
  ...(attempt.usage ? { usage: attempt.usage } : {}),
484
509
  ...(attempt.totalTokens !== undefined ? { totalTokens: attempt.totalTokens } : {}),
510
+ durationMs: attempt.durationMs,
485
511
  ...(cacheProvenance ? { cacheProvenance } : {}),
486
512
  };
487
513
  });
@@ -611,6 +637,30 @@ function finalizeCoverage(totals: UsageSummaryTotals): void {
611
637
  totals.coverageRatio = totals.requests === 0 ? 0 : totals.measuredRequests / totals.requests;
612
638
  }
613
639
 
640
+ /**
641
+ * Aggregate output throughput inputs (#6309) into the window totals. A measured attempt
642
+ * contributes its output tokens and duration only when both are positive, so a zero-token
643
+ * or zero-duration row cannot deflate or inflate the rate. Without attempts, the row-level
644
+ * usage and duration describe the request directly.
645
+ */
646
+ function addThroughputTotals(totals: UsageSummaryTotals, entry: PersistedUsageEntry): void {
647
+ const samples = entry.attempts?.length
648
+ ? entry.attempts
649
+ : [{ usage: entry.usage, durationMs: entry.durationMs }];
650
+ for (const sample of samples) {
651
+ if (!sample.usage || tokensPerSecond(sample.usage.outputTokens, sample.durationMs) === null) continue;
652
+ totals.throughputOutputTokens = (totals.throughputOutputTokens ?? 0) + sample.usage.outputTokens;
653
+ totals.throughputDurationMs = (totals.throughputDurationMs ?? 0) + sample.durationMs;
654
+ totals.throughputSamples = (totals.throughputSamples ?? 0) + 1;
655
+ }
656
+ }
657
+
658
+ /** Fold the raw throughput sums into the rate the dashboard and API expose. */
659
+ function finalizeThroughput(totals: UsageSummaryTotals): void {
660
+ if ((totals.throughputDurationMs ?? 0) <= 0) return;
661
+ totals.throughputTokensPerSec = tokensPerSecond(totals.throughputOutputTokens ?? 0, totals.throughputDurationMs!) ?? undefined;
662
+ }
663
+
614
664
  function addEstimatedCost(
615
665
  totals: UsageSummaryTotals,
616
666
  entry: Pick<PersistedUsageEntry, "usageStatus" | "usage" | "attempts">,
@@ -670,6 +720,9 @@ interface UsageModelAccumulator {
670
720
  cacheObserved: boolean;
671
721
  /** Input tokens from attributions with OBSERVED cache detail; the hit-rate denominator. */
672
722
  cacheObservedInputTokens: number;
723
+ throughputOutputTokens: number;
724
+ throughputDurationMs: number;
725
+ throughputSamples: number;
673
726
  estimatedCostUsd?: number;
674
727
  requestCounts: UsageRequestCounts;
675
728
  requestFacts?: Map<number, number>;
@@ -820,6 +873,9 @@ function mergeTotals(target: UsageSummaryTotals, source: UsageSummaryTotals): vo
820
873
  target.cacheObservedRequests = mergeOptionalTotal(target.cacheObservedRequests, source.cacheObservedRequests);
821
874
  target.cacheSynthesizedRequests = mergeOptionalTotal(target.cacheSynthesizedRequests, source.cacheSynthesizedRequests);
822
875
  target.cacheUnknownRequests = mergeOptionalTotal(target.cacheUnknownRequests, source.cacheUnknownRequests);
876
+ target.throughputOutputTokens = mergeOptionalTotal(target.throughputOutputTokens, source.throughputOutputTokens);
877
+ target.throughputDurationMs = mergeOptionalTotal(target.throughputDurationMs, source.throughputDurationMs);
878
+ target.throughputSamples = mergeOptionalTotal(target.throughputSamples, source.throughputSamples);
823
879
  }
824
880
 
825
881
  /** Sum an optional total. Absent on one side means "not computed there", so it contributes nothing. */
@@ -849,6 +905,9 @@ function blankModelAccumulator(
849
905
  cacheCreationInputTokens: 0,
850
906
  cacheObserved: false,
851
907
  cacheObservedInputTokens: 0,
908
+ throughputOutputTokens: 0,
909
+ throughputDurationMs: 0,
910
+ throughputSamples: 0,
852
911
  requestCounts: blankRequestCounts(),
853
912
  ...(mode === "exact" ? { requestFacts: new Map() } : {}),
854
913
  };
@@ -877,6 +936,9 @@ function mergeModelAccumulator(target: UsageModelAccumulator, source: UsageModel
877
936
  target.cacheCreationInputTokens += source.cacheCreationInputTokens;
878
937
  target.cacheObserved ||= source.cacheObserved;
879
938
  target.cacheObservedInputTokens += source.cacheObservedInputTokens;
939
+ target.throughputOutputTokens += source.throughputOutputTokens;
940
+ target.throughputDurationMs += source.throughputDurationMs;
941
+ target.throughputSamples += source.throughputSamples;
880
942
  if (source.estimatedCostUsd !== undefined) {
881
943
  target.estimatedCostUsd = (target.estimatedCostUsd ?? 0) + source.estimatedCostUsd;
882
944
  }
@@ -1081,6 +1143,12 @@ function buildUsageModels(
1081
1143
  totalTokens: model.summaryTotalTokens,
1082
1144
  inputTokens: model.inputTokens,
1083
1145
  outputTokens: model.outputTokens,
1146
+ ...(model.throughputDurationMs > 0
1147
+ ? {
1148
+ throughputTokensPerSec: tokensPerSecond(model.throughputOutputTokens, model.throughputDurationMs) ?? undefined,
1149
+ throughputSamples: model.throughputSamples,
1150
+ }
1151
+ : {}),
1084
1152
  cachedInputTokens: model.cacheReadInputTokens,
1085
1153
  cacheReadInputTokens: model.cacheReadInputTokens,
1086
1154
  cacheCreationInputTokens: model.cacheCreationInputTokens,
@@ -1120,6 +1188,12 @@ function buildUsageProviders(
1120
1188
  totalTokens: provider.summaryTotalTokens,
1121
1189
  inputTokens: provider.inputTokens,
1122
1190
  outputTokens: provider.outputTokens,
1191
+ ...(provider.throughputDurationMs > 0
1192
+ ? {
1193
+ throughputTokensPerSec: tokensPerSecond(provider.throughputOutputTokens, provider.throughputDurationMs) ?? undefined,
1194
+ throughputSamples: provider.throughputSamples,
1195
+ }
1196
+ : {}),
1123
1197
  cachedInputTokens: provider.cacheReadInputTokens,
1124
1198
  cacheReadInputTokens: provider.cacheReadInputTokens,
1125
1199
  cacheCreationInputTokens: provider.cacheCreationInputTokens,
@@ -1298,6 +1372,11 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1298
1372
  if (attribution.usage) {
1299
1373
  breakdown.inputTokens += attribution.usage.inputTokens;
1300
1374
  breakdown.outputTokens += attribution.usage.outputTokens;
1375
+ if (tokensPerSecond(attribution.usage.outputTokens, attribution.durationMs ?? 0) !== null) {
1376
+ breakdown.throughputOutputTokens += attribution.usage.outputTokens;
1377
+ breakdown.throughputDurationMs += attribution.durationMs!;
1378
+ breakdown.throughputSamples += 1;
1379
+ }
1301
1380
  const { read, creation, provenance } = cacheObservationFromUsage(
1302
1381
  attribution.usage,
1303
1382
  attribution.cacheProvenance,
@@ -1463,6 +1542,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1463
1542
  addTokens(partition.totals, entry);
1464
1543
  addSpendTotals(partition.totals, entry);
1465
1544
  addCacheProvenanceTotals(partition.totals, entry);
1545
+ addThroughputTotals(partition.totals, entry);
1466
1546
  addEstimatedCost(partition.totals, entry, costInfo);
1467
1547
 
1468
1548
  const requestKey = this.mode === "exact" ? this.requestKey(entry.requestId) : null;
@@ -1556,6 +1636,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1556
1636
  day.modelOverlaps.push(...partition.modelOverlaps.values());
1557
1637
  }
1558
1638
  finalizeCoverage(totals);
1639
+ finalizeThroughput(totals);
1559
1640
 
1560
1641
  const customDates = this.window ? new Set(customWindowDates(this.window)) : null;
1561
1642
  const dayCount = customDates?.size ?? (range === "all" ? dayCountForAllRange(oldestTimestamp, now) : fixedDays);
@@ -382,9 +382,6 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
382
382
  const messages: OcxMessage[] = [...parsed.context.messages];
383
383
  const loopT0 = Date.now();
384
384
  const allTools = parsed.context.tools ?? [];
385
- // For the forced-answer pass we drop the synthetic web_search tool so the model MUST answer from the
386
- // results already in `messages` (can't search again) — this guarantees a non-empty final answer.
387
- const toolsNoWebSearch = allTools.filter(t => !t.webSearch);
388
385
  let searchesExecuted = 0;
389
386
  let executedSearchCount = 0;
390
387
  // Queries whose search already failed this turn — repeats are short-circuited so a model that keeps
@@ -438,8 +435,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
438
435
  * restart) before the `on429` key rotation.
439
436
  */
440
437
  const prepareIterationEvents = async function* (forceAnswer: boolean): AsyncGenerator<AdapterEvent, IterationResponse> {
441
- // On the forced-answer pass the synthetic web_search tool is gone, so the model MUST answer
442
- // from the results already in `messages`. A weak model can still produce a thin answer that
438
+ // On the forced-answer pass the model is asked to answer from the results already in
439
+ // `messages`. A weak model can still produce a thin answer that
443
440
  // ignores what the search found, which reads to the user as "the search did nothing". Nudge it
444
441
  // (iteration-locally — never mutate the shared `messages`) to actually use the gathered results.
445
442
  // Only when a REAL search ran (executedSearchCount, not empty-query/limit/repeat placeholders).
@@ -455,7 +452,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
455
452
  const iterParsed: OcxParsedRequest = {
456
453
  ...parsed, stream: true,
457
454
  ...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}),
458
- context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : forceAnswer ? toolsNoWebSearch : allTools },
455
+ context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : allTools },
459
456
  };
460
457
  // One cumulative header deadline spans every pool-key 429 rotation in this model iteration.
461
458
  // clear() stops only its timer after final headers; the direct turn signal remains attached to
@@ -885,8 +882,13 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
885
882
 
886
883
  // Loop (search + re-ask) ONLY when the model's actionable output is purely web_search. A real
887
884
  // tool call (e.g. shell/apply_patch) means this turn is terminal for Codex — finalize so those
888
- // calls reach Codex. forceAnswer also finalizes.
889
- const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceAnswer;
885
+ // calls reach Codex.
886
+ // The budget check happens per query inside runSearchCall, so a
887
+ // forced pass that emits one more web_search call is served the
888
+ // limit-reached result instead of failing the turn (#6464: stripping
889
+ // the declaration makes some models emit the raw call as visible
890
+ // text).
891
+ const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall;
890
892
  if (!shouldLoop) {
891
893
  // #1001: a forced-answer pass that ends `done` must have produced
892
894
  // usable output — never a malformed tool call, and never silence.
@@ -959,6 +961,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
959
961
  return;
960
962
  }
961
963
  }
964
+ yield { type: "error", message: "web-search loop exceeded its iteration cap" };
962
965
  } finally {
963
966
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
964
967
  }
@@ -196,7 +196,6 @@ export async function* runTurnWebSearchLoop(
196
196
  const { parsed, plan, abortSignal } = deps;
197
197
  const messages: OcxMessage[] = [...parsed.context.messages];
198
198
  const allTools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
199
- const toolsNoWebSearch = (parsed.context.tools ?? []).filter(t => !t.webSearch);
200
199
  const failedQueries = new Set<string>();
201
200
  let searchesExecuted = 0;
202
201
  let executedSearchCount = 0;
@@ -469,9 +468,12 @@ export async function* runTurnWebSearchLoop(
469
468
  }
470
469
  const forceAnswer = searchesExecuted >= plan.maxSearches;
471
470
  // Loop only when the model's actionable output is purely web_search calls:
472
- // a real tool call belongs to Codex, and a budget-exhausted turn must
473
- // answer from what it already gathered.
474
- const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceAnswer;
471
+ // a real tool call belongs to Codex. The budget check happens per query
472
+ // inside runSearchCall, so a forced pass that emits one more web_search
473
+ // call is served the limit-reached result instead of failing the turn
474
+ // (#6464: stripping the declaration makes some models emit the raw call
475
+ // as visible text).
476
+ const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall;
475
477
  if (!shouldLoop) {
476
478
  // A forced-answer pass that ends `done` must have produced usable output —
477
479
  // never a malformed tool call, and never silence. A truncated/refusal
@@ -497,6 +499,7 @@ export async function* runTurnWebSearchLoop(
497
499
  eventTypes: [...new Set(split.passthrough.map(event => event.type))],
498
500
  }));
499
501
  if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) {
502
+ if (i + 1 === HARD_CAP) break;
500
503
  emptyAnswerRetries++;
501
504
  console.warn("[web-search-runturn] empty forced answer — retrying once without tools");
502
505
  yield { type: "heartbeat" };
@@ -540,6 +543,7 @@ export async function* runTurnWebSearchLoop(
540
543
  return;
541
544
  }
542
545
 
546
+ if (i + 1 === HARD_CAP) break;
543
547
  const nextForceAnswer = searchesExecuted >= plan.maxSearches;
544
548
  const iterParsed: OcxParsedRequest = {
545
549
  ...parsed,
@@ -548,15 +552,14 @@ export async function* runTurnWebSearchLoop(
548
552
  messages: nextForceAnswer && executedSearchCount > 0
549
553
  ? [...messages, forcedAnswerNudge()]
550
554
  : messages,
551
- tools: nextForceAnswer ? toolsNoWebSearch : allTools,
555
+ tools: allTools,
552
556
  },
553
557
  };
554
558
  currentParsed = iterParsed;
555
559
  source = deps.dispatch(iterParsed);
556
560
  }
557
561
 
558
- // Safety net: the hard cap should be unreachable (forceAnswer ends the loop
559
- // first), but never hang a client stream if an adapter misbehaves.
562
+ // Repeated over-budget calls terminate instead of leaving an unfinished stream.
560
563
  yield { type: "error", message: "web-search runTurn loop exceeded its iteration cap" };
561
564
  } catch (error) {
562
565
  if (!isTranslatorBudgetExceededError(error)) throw error;