@bitkyc08/opencodex 2.88.0 → 2.89.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 (205) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
  7. package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/coding-agent/protocol.ts +6 -0
  14. package/src/adapters/google-http.ts +3 -2
  15. package/src/adapters/google-vertex-endpoint.ts +67 -0
  16. package/src/adapters/google.ts +35 -29
  17. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  18. package/src/adapters/openai-responses/passthrough.ts +17 -12
  19. package/src/adapters/openai-responses/reasoning.ts +68 -1
  20. package/src/adapters/registry.ts +1 -1
  21. package/src/adapters/upstream-http-error.ts +3 -2
  22. package/src/adapters/zed.ts +7 -4
  23. package/src/bridge/internal.ts +11 -1
  24. package/src/bridge/response-json.ts +2 -2
  25. package/src/bridge/sse.ts +10 -4
  26. package/src/claude/inbound.ts +12 -26
  27. package/src/claude/outbound.ts +5 -0
  28. package/src/cli/account-api.ts +13 -1
  29. package/src/cli/account-extended.ts +17 -5
  30. package/src/cli/account-key-quota.ts +4 -1
  31. package/src/cli/account.ts +1 -1
  32. package/src/cli/capabilities-agents-routing.ts +45 -8
  33. package/src/cli/capabilities-base.ts +5 -5
  34. package/src/cli/capabilities-provider-models.ts +5 -4
  35. package/src/cli/combo-result.ts +8 -2
  36. package/src/cli/combo.ts +17 -0
  37. package/src/cli/help.ts +1 -1
  38. package/src/cli/message-command.ts +4 -1
  39. package/src/cli/message-remote-args.ts +69 -0
  40. package/src/cli/message-remote-command.ts +77 -0
  41. package/src/cli/provider-runtime.ts +69 -1
  42. package/src/cli/registry.ts +3 -3
  43. package/src/codex/account-pause.ts +6 -1
  44. package/src/codex/account-runtime-state.ts +5 -0
  45. package/src/codex/account-store.ts +30 -3
  46. package/src/codex/auth-api/account-list.ts +3 -0
  47. package/src/codex/auth-api/main-account-probe.ts +3 -2
  48. package/src/codex/auth-context.ts +9 -4
  49. package/src/codex/catalog/aggregation.ts +15 -0
  50. package/src/codex/catalog/auto-review.ts +2 -1
  51. package/src/codex/catalog/build-entries.ts +17 -7
  52. package/src/codex/catalog/derive-entry.ts +3 -1
  53. package/src/codex/catalog/metadata.ts +2 -0
  54. package/src/codex/catalog/model-visibility.ts +0 -2
  55. package/src/codex/catalog/ownership.ts +71 -0
  56. package/src/codex/catalog/parsing.ts +12 -3
  57. package/src/codex/catalog/retained-sync.ts +2 -1
  58. package/src/codex/catalog/routed-removal.ts +4 -4
  59. package/src/codex/convergence.ts +2 -1
  60. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  61. package/src/codex/low-quota-protection.ts +57 -31
  62. package/src/codex/main-account-cache.ts +77 -3
  63. package/src/codex/main-account.ts +6 -2
  64. package/src/codex/native-residue.ts +2 -3
  65. package/src/codex/quota.ts +26 -2
  66. package/src/combos/failover.ts +2 -1
  67. package/src/combos/jev-model-backend.ts +30 -7
  68. package/src/combos/jev-model-request.ts +20 -0
  69. package/src/combos/jev-quota-activation.ts +153 -0
  70. package/src/combos/jev-quota-config.ts +62 -0
  71. package/src/combos/jev-quota-route.ts +26 -0
  72. package/src/combos/jev-quota.ts +88 -0
  73. package/src/combos/jev.ts +25 -12
  74. package/src/combos/types.ts +27 -0
  75. package/src/compatibility/index.ts +3 -0
  76. package/src/compatibility/openai-responses.ts +8 -1
  77. package/src/compatibility/opengateway-responses.ts +61 -0
  78. package/src/config/account-auto-pause-revisions.ts +136 -0
  79. package/src/config/diagnostics.ts +13 -0
  80. package/src/config/live-reconcile.ts +9 -0
  81. package/src/config/persist-unlocked.ts +10 -1
  82. package/src/config/removed-opencode-free.ts +135 -0
  83. package/src/config/schema/account-auto-pause.ts +14 -0
  84. package/src/config/schema/config-schema.ts +15 -0
  85. package/src/config/schema/leaf-validators.ts +7 -0
  86. package/src/config.ts +10 -3
  87. package/src/generated/compatibility-version.json +344 -144
  88. package/src/images/loop.ts +32 -9
  89. package/src/lib/account-auto-pause-response.ts +16 -0
  90. package/src/lib/bun-runtime.ts +18 -1
  91. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  92. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  93. package/src/lib/decision-quota-sink.ts +76 -0
  94. package/src/lib/state-store-registrations.ts +25 -0
  95. package/src/link/ssh-argv.ts +3 -0
  96. package/src/messaging/envelope.ts +6 -3
  97. package/src/messaging/remote-auth.ts +83 -0
  98. package/src/messaging/remote-bridge.ts +270 -0
  99. package/src/messaging/remote-contract.ts +108 -0
  100. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  101. package/src/messaging/remote-enrollment.ts +244 -0
  102. package/src/messaging/remote-envelope.ts +40 -0
  103. package/src/messaging/remote-files.ts +117 -0
  104. package/src/messaging/remote-owner.ts +169 -0
  105. package/src/messaging/remote-ports.ts +56 -0
  106. package/src/messaging/remote-process.ts +131 -0
  107. package/src/messaging/remote-replies.ts +98 -0
  108. package/src/messaging/remote-rpc-admission.ts +32 -0
  109. package/src/messaging/remote-send.ts +72 -0
  110. package/src/messaging/remote-store.ts +111 -0
  111. package/src/messaging/remote-tunnels.ts +65 -0
  112. package/src/messaging/rpc.ts +13 -1
  113. package/src/oauth/account-auto-pause-policy.ts +66 -0
  114. package/src/oauth/account-auto-pause.ts +171 -0
  115. package/src/oauth/account-quota-rank.ts +2 -1
  116. package/src/oauth/anthropic-account-refusal.ts +6 -0
  117. package/src/oauth/anthropic-model-family.ts +8 -0
  118. package/src/oauth/anthropic-model-quota.ts +2 -6
  119. package/src/oauth/api-key-pool-preserve.ts +37 -0
  120. package/src/oauth/index.ts +5 -35
  121. package/src/oauth/store.ts +35 -8
  122. package/src/oauth/types.ts +2 -0
  123. package/src/plugins/upstream-hooks.ts +14 -1
  124. package/src/providers/api-key-pause.ts +32 -0
  125. package/src/providers/api-key-resolve.ts +10 -4
  126. package/src/providers/api-key-selection.ts +6 -1
  127. package/src/providers/api-keys.ts +8 -4
  128. package/src/providers/claude-cli-usage.ts +5 -0
  129. package/src/providers/key-failover.ts +11 -11
  130. package/src/providers/model-rename-fields.ts +1 -0
  131. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  132. package/src/providers/quota/account-cache.ts +15 -1
  133. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  134. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  135. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  136. package/src/providers/quota/antigravity.ts +39 -6
  137. package/src/providers/quota/report-cache.ts +14 -0
  138. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  139. package/src/providers/quota-decision-publication.ts +77 -0
  140. package/src/providers/quota-decision-snapshot.ts +145 -0
  141. package/src/providers/quota-routing-cache.ts +3 -0
  142. package/src/providers/quota-types.ts +6 -1
  143. package/src/providers/quota.ts +3 -2
  144. package/src/providers/registry/entries-extended.ts +8 -52
  145. package/src/providers/registry/model-seeds.ts +1 -1
  146. package/src/providers/registry.ts +1 -2
  147. package/src/providers/stale-vision-classification-migration.ts +1 -1
  148. package/src/responses/parser.ts +18 -10
  149. package/src/responses/web-search-replay.ts +138 -0
  150. package/src/routing/compatibility/behavior.ts +4 -1
  151. package/src/server/auth-cors.ts +7 -0
  152. package/src/server/background-lifecycle.ts +2 -1
  153. package/src/server/chat-native.ts +28 -13
  154. package/src/server/claude-messages.ts +2 -1
  155. package/src/server/index/optional-listeners.ts +6 -0
  156. package/src/server/management/combo-routes.ts +17 -2
  157. package/src/server/management/oauth-account-routes.ts +10 -1
  158. package/src/server/management/provider-completion-test.ts +289 -0
  159. package/src/server/management/provider-routes.ts +9 -1
  160. package/src/server/management/route-registry.ts +2 -0
  161. package/src/server/messages-native.ts +18 -4
  162. package/src/server/messages-shared.ts +4 -1
  163. package/src/server/request-log.ts +7 -2
  164. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  165. package/src/server/responses/adapter-dispatch.ts +77 -28
  166. package/src/server/responses/combo-session-recall.ts +25 -0
  167. package/src/server/responses/combo-stream-preflight.ts +3 -1
  168. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  169. package/src/server/responses/compact.ts +8 -5
  170. package/src/server/responses/context-overflow.ts +141 -2
  171. package/src/server/responses/core-codex-account.ts +7 -1
  172. package/src/server/responses/core-combo.ts +31 -5
  173. package/src/server/responses/core.ts +12 -9
  174. package/src/server/responses/empty-completion-guard.ts +2 -0
  175. package/src/server/responses/fetch-helpers.ts +35 -4
  176. package/src/server/responses/jev-model-invoke.ts +13 -14
  177. package/src/server/responses/native-steering-log.ts +2 -2
  178. package/src/server/responses/passthrough-delivery.ts +3 -2
  179. package/src/server/responses/passthrough-dispatch.ts +30 -33
  180. package/src/server/responses/passthrough-observations.ts +38 -0
  181. package/src/server/responses/request-compression.ts +98 -0
  182. package/src/server/responses/request-transport.ts +32 -3
  183. package/src/server/responses/run-turn-execution.ts +2 -0
  184. package/src/server/responses/sidecar-execution.ts +16 -3
  185. package/src/server/responses/terminal-guard.ts +2 -0
  186. package/src/server/startup-action-control.ts +2 -2
  187. package/src/server/startup-health-cache.ts +2 -2
  188. package/src/server/windows-tray-control.ts +2 -2
  189. package/src/service/definition.ts +9 -1
  190. package/src/types/account-auto-pause.ts +15 -0
  191. package/src/types/config.ts +23 -1
  192. package/src/types/provider.ts +9 -5
  193. package/src/types/request.ts +20 -2
  194. package/src/types.ts +1 -0
  195. package/src/usage/cost.ts +17 -1
  196. package/src/usage/expected-prices.ts +3 -1
  197. package/src/usage/jev-stats.ts +4 -0
  198. package/src/usage/log.ts +2 -0
  199. package/src/usage/summary.ts +7 -0
  200. package/src/web-search/loop.ts +37 -11
  201. package/src/web-search/progress-stream.ts +4 -1
  202. package/src/web-search/run-turn-loop.ts +9 -3
  203. package/src/web-search/sidecar-search-replay.ts +96 -0
  204. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  205. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
@@ -0,0 +1,77 @@
1
+ /** Producer projection: only measured subscription windows reach advisory snapshots. */
2
+ import type { ProviderQuota } from "./quota-types";
3
+ import type { StoredAccountQuota } from "../codex/quota-types";
4
+ import { resetAtToMs } from "../codex/quota-types";
5
+ import type { DecisionQuotaWindow } from "./quota-decision-snapshot";
6
+ import { decisionQuotaSinkActive, publishDecisionAccountQuota } from "../lib/decision-quota-sink";
7
+ const rawWindows = new WeakMap<ProviderQuota, readonly DecisionQuotaWindow[]>();
8
+ /** Producer retains raw advisory projection separately from unchanged display/hard policy. */
9
+ export function bindRawDecisionWindows(quota: ProviderQuota, windows: readonly DecisionQuotaWindow[]): void { if (decisionQuotaSinkActive()) rawWindows.set(quota, windows); }
10
+ /**
11
+ * Raw windows a binding producer attached to this observation, or undefined. Binding producers
12
+ * (Anthropic usage/header parsers) bind only while advisory evidence is active, so an observation
13
+ * parsed while inactive has none and must stay unknown rather than fall back to display fields,
14
+ * which drop invalid resets and clamp percentages.
15
+ */
16
+ export function boundDecisionWindows(quota: ProviderQuota): DecisionQuotaWindow[] | undefined {
17
+ const raw = rawWindows.get(quota);
18
+ return raw ? raw.map(row => ({ ...row })) : undefined;
19
+ }
20
+ /** Project a provider quota report onto decision windows, preferring raw windows bound by the producer over the display fields. */
21
+ export function providerDecisionWindows(quota: ProviderQuota): DecisionQuotaWindow[] {
22
+ const raw = rawWindows.get(quota);
23
+ if (raw) return raw.map(row => ({ ...row }));
24
+ const windows: DecisionQuotaWindow[] = [];
25
+ for (const [window, percent, reset] of [
26
+ ["5h", "fiveHourPercent", "fiveHourResetAt"], ["weekly", "weeklyPercent", "weeklyResetAt"], ["monthly", "monthlyPercent", "monthlyResetAt"],
27
+ ] as const) {
28
+ if (quota[percent] !== undefined) windows.push({ window, percent: quota[percent]!, observedAt: quota.updatedAt, ...(quota[reset] !== undefined ? { resetAt: quota[reset] } : {}) });
29
+ }
30
+ for (const row of Array.isArray(quota.customWindows) ? quota.customWindows : []) {
31
+ const window = typeof row?.label === "string" ? row.label.trim().toLowerCase() : "";
32
+ if (row?.scope === "model" && (window === "fable" || window === "opus" || window === "sonnet")) {
33
+ windows.push({ window, percent: row.percent, observedAt: row.passiveObservedAt ?? quota.updatedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) });
34
+ }
35
+ }
36
+ return windows;
37
+ }
38
+ /** Publish decision windows for one Anthropic pool account, bound to its credential generation; a null quota publishes no windows. */
39
+ export function publishAnthropicDecisionQuota(id: string, generation: string, quota: ProviderQuota | null, partial = false): void {
40
+ if (!decisionQuotaSinkActive()) return;
41
+ // A non-null observation without a raw binding was parsed while inactive: unknown, never display fallback.
42
+ const windows = quota ? boundDecisionWindows(quota) : [];
43
+ if (windows) publishDecisionAccountQuota("anthropic", id, generation, windows, partial);
44
+ }
45
+ type CodexObservation = Omit<StoredAccountQuota, "updatedAt">;
46
+ type CodexWindow = "short" | "weekly" | "monthly";
47
+ const rawCodexWindows = new WeakMap<CodexObservation, readonly DecisionQuotaWindow[]>();
48
+ /** Keep invalid resets and the producer clock apart from compatibility display/policy parsing. */
49
+ export function bindRawCodexDecisionResets(raw: CodexObservation, resets: Record<CodexWindow, unknown>, observedAt: number): void {
50
+ if (!decisionQuotaSinkActive()) return;
51
+ const windows = codexDecisionWindows(raw, observedAt).map(row => {
52
+ const value = resets[row.window === "5h" ? "short" : row.window as CodexWindow];
53
+ if (value === undefined || value === null) return row;
54
+ const numeric = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
55
+ return { ...row, resetAt: Number.isFinite(numeric) && numeric >= 0 ? resetAtToMs(numeric) : NaN };
56
+ });
57
+ rawCodexWindows.set(raw, windows);
58
+ }
59
+ /** Build decision windows from a raw Codex observation, stamping each with the producer clock rather than a merge-time clock. */
60
+ function codexDecisionWindows(raw: CodexObservation, observedAt: number): DecisionQuotaWindow[] {
61
+ const windows: DecisionQuotaWindow[] = [];
62
+ for (const kind of ["short", "weekly", "monthly"] as const) {
63
+ const percent = raw[`${kind}Percent`];
64
+ const reset = raw[`${kind}ResetAt`];
65
+ if (percent !== undefined) windows.push({ window: kind === "short" ? "5h" : kind, percent, observedAt,
66
+ ...(reset !== undefined ? { resetAt: resetAtToMs(reset) } : {}) });
67
+ }
68
+ return windows;
69
+ }
70
+ /** Publish decision windows for a Codex or main account from the raw observation, never from the display merge, so carried windows cannot acquire a fresh clock. */
71
+ export function publishCodexDecisionQuota(id: string, generation: number, raw: CodexObservation, observedAt: number, provider = "codex", partial = true): void {
72
+ if (!decisionQuotaSinkActive()) return;
73
+ // RAW observation, never the display merge: carried windows cannot acquire a fresh clock.
74
+ // Header/WHAM parsers bind reset validity only while active; an unbound observation stays unknown.
75
+ const windows = rawCodexWindows.get(raw);
76
+ if (windows) publishDecisionAccountQuota(provider, id, generation, windows, partial);
77
+ }
@@ -0,0 +1,145 @@
1
+ /** Loaded-only advisory evidence. No auth, disk, keychain, selectors, timers or probes. Imported only by src/combos/jev-quota-activation.ts; core producers reach it through src/lib/decision-quota-sink.ts while activation is registered. */
2
+ import { createHash } from "node:crypto";
3
+ import { getConfigDir } from "../config/paths";
4
+ import { captureConfigGeneration } from "../lib/state-store-sweeper";
5
+ import type { OcxProviderConfig } from "../types";
6
+
7
+ export interface DecisionQuotaWindow {
8
+ window: "5h" | "weekly" | "monthly" | "fable" | "opus" | "sonnet";
9
+ percent: number;
10
+ observedAt: number;
11
+ resetAt?: number;
12
+ }
13
+ export interface DecisionQuotaAccount {
14
+ id: string;
15
+ generation: string | number;
16
+ usable: boolean;
17
+ }
18
+ type Evidence = { generation: string | number; windows: readonly DecisionQuotaWindow[] };
19
+ type Roster = { root: string; accounts: readonly DecisionQuotaAccount[] };
20
+ const rosters = new Map<string, Roster>();
21
+ const accounts = new Map<string, Evidence>();
22
+ const unusable = new Map<string, string | number | true>();
23
+ const POOL_PROVIDERS = new Set(["anthropic", "codex", "codex-main"]);
24
+ const MAX_ROWS = 1024;
25
+ const MAX_WINDOWS = 64;
26
+ let keyEpoch = 0;
27
+ let writesForTests = 0;
28
+ /** Test-only: count of write entry calls, so signals-off tests can assert that nothing reached the snapshot. */
29
+ export function decisionQuotaSnapshotWriteCountForTests(): number { return writesForTests; }
30
+ const credentialVersions = new Map<string, string>();
31
+ /** Credential owner calls this after its normal read; JEV never invokes that owner. */
32
+ export function observeDecisionKeyCredential(reference: string, resolved: string | undefined): void {
33
+ writesForTests++;
34
+ const version = createHash("sha256").update(resolved ?? "").digest("hex");
35
+ const referenceId = createHash("sha256").update(reference).digest("hex");
36
+ const previous = credentialVersions.get(referenceId);
37
+ if (previous !== undefined && previous !== version) invalidateDecisionKeyQuotas();
38
+ if (credentialVersions.size >= 256 && !credentialVersions.has(referenceId)) {
39
+ credentialVersions.delete(credentialVersions.keys().next().value!);
40
+ invalidateDecisionKeyQuotas();
41
+ }
42
+ credentialVersions.set(referenceId, version);
43
+ }
44
+ const keys = new Map<string, { root: string; epoch: number; configGeneration: number; policy: string; windows: readonly DecisionQuotaWindow[] }>();
45
+ /** Compose the per-provider, per-account map key. */
46
+ const accountKey = (provider: string, id: string) => `${provider}\0${id}`;
47
+ // Private policy digest, never returned or serialized. Resolving the configured credential is
48
+ // the collector's responsibility; request reads only this already-published generation.
49
+ /** Digest the provider settings a key-based evidence row was published under; it is never returned or serialized. */
50
+ function keyPolicy(provider: OcxProviderConfig): string {
51
+ return createHash("sha256").update(JSON.stringify([
52
+ provider.adapter, provider.baseUrl, provider.authMode, provider.apiKey, provider.apiKeyPool, provider.headers,
53
+ provider.apiKey && credentialVersions.get(createHash("sha256").update(provider.apiKey).digest("hex")),
54
+ ])).digest("hex");
55
+ }
56
+ /** Drop all key-based decision evidence and advance the epoch so earlier publications cannot be read. */
57
+ export function invalidateDecisionKeyQuotas(): void { keyEpoch++; keys.clear(); }
58
+ /** Publish decision windows for a single-key provider; pooled, disabled or non-key providers are never published, and the table is bounded. */
59
+ export function publishDecisionKeyQuota(name: string, provider: OcxProviderConfig, windows: readonly DecisionQuotaWindow[]): void {
60
+ writesForTests++;
61
+ if (name.length > 1024 || provider.disabled || (provider.authMode ?? "key") !== "key" || (provider.apiKeyPool?.length ?? 0) > 1) return;
62
+ if (keys.size >= 256 && !keys.has(name)) keys.delete(keys.keys().next().value!);
63
+ keys.set(name, { root: getConfigDir(), epoch: keyEpoch, configGeneration: captureConfigGeneration(), policy: keyPolicy(provider), windows: copyWindows(windows) });
64
+ }
65
+ /** Read key-based evidence only when the config root, epoch, generation and provider policy still match what was published. */
66
+ export function readLoadedDecisionKeyQuota(name: string, provider: OcxProviderConfig): readonly DecisionQuotaWindow[] | undefined {
67
+ const row = keys.get(name);
68
+ if (!row || provider.disabled || (provider.authMode ?? "key") !== "key" || (provider.apiKeyPool?.length ?? 0) > 1
69
+ || row.root !== getConfigDir() || row.epoch !== keyEpoch || row.configGeneration !== captureConfigGeneration()
70
+ || row.policy !== keyPolicy(provider)) return undefined;
71
+ return row.windows.map(row => ({ ...row }));
72
+ }
73
+ /** Replace a pool provider's roster; an oversized pool makes the whole pool unknown rather than hiding usable rows. */
74
+ export function publishDecisionQuotaRoster(provider: string, rows: readonly DecisionQuotaAccount[]): void {
75
+ writesForTests++;
76
+ if (!POOL_PROVIDERS.has(provider)) return;
77
+ const old = rosters.get(provider);
78
+ if (old?.root !== getConfigDir()) {
79
+ clearDecisionAccountQuotas(provider);
80
+ for (const key of unusable.keys()) if (key.startsWith(`${provider}\0`)) unusable.delete(key);
81
+ }
82
+ // Overflow or oversized identities make the WHOLE pool unknown; never hide a usable row.
83
+ if (rows.length > MAX_ROWS || rows.some(row => row.id.length > 1024
84
+ || typeof row.generation === "string" && row.generation.length > 128)) {
85
+ clearDecisionAccountQuotas(provider);
86
+ rosters.delete(provider);
87
+ return;
88
+ }
89
+ const live = new Map(rows.map(row => [row.id, row]));
90
+ for (const row of old?.accounts ?? []) {
91
+ if (live.get(row.id)?.generation !== row.generation) {
92
+ const key = accountKey(provider, row.id);
93
+ accounts.delete(key);
94
+ if (!live.has(row.id) || unusable.get(key) !== true) unusable.delete(key);
95
+ }
96
+ }
97
+ rosters.set(provider, { root: getConfigDir(), accounts: rows.map(row => ({ id: row.id, generation: row.generation, usable: row.usable })) });
98
+ }
99
+ /** Withdraw a pool provider's roster and evidence so readers see it as unknown rather than stale or empty. */
100
+ export function withdrawDecisionQuotaRoster(provider: string): void {
101
+ clearDecisionAccountQuotas(provider);
102
+ rosters.delete(provider);
103
+ for (const key of unusable.keys()) if (key.startsWith(`${provider}\0`)) unusable.delete(key);
104
+ }
105
+ /** Publish windows for a roster account at the matching credential generation; a partial observation retains other windows of the same generation. */
106
+ export function publishDecisionAccountQuota(provider: string, id: string, generation: string | number, windows: readonly DecisionQuotaWindow[], partial = false): void {
107
+ writesForTests++;
108
+ const roster = rosters.get(provider);
109
+ if (roster?.root !== getConfigDir() || !roster.accounts.some(row => row.id === id && row.generation === generation)) return;
110
+ const key = accountKey(provider, id);
111
+ const previous = accounts.get(key);
112
+ const retained = partial && previous?.generation === generation ? previous.windows.filter(row => !windows.some(next => row.window === next.window)) : [];
113
+ accounts.set(key, { generation, windows: copyWindows([...retained, ...windows]) });
114
+ }
115
+ /** Read a pool's already-loaded roster with generation-matched windows; undefined when nothing is loaded for the current config root. */
116
+ export function readLoadedDecisionQuotaPool(provider: string): readonly { id: string; usable: boolean; windows?: readonly DecisionQuotaWindow[] }[] | undefined {
117
+ const roster = rosters.get(provider);
118
+ if (roster?.root !== getConfigDir()) return undefined;
119
+ return roster.accounts.map(row => {
120
+ const evidence = accounts.get(accountKey(provider, row.id));
121
+ const quarantine = unusable.get(accountKey(provider, row.id));
122
+ return { id: row.id, usable: row.usable && quarantine !== true && quarantine !== row.generation, ...(evidence?.generation === row.generation ? { windows: evidence.windows.map(window => ({ ...window })) } : {}) };
123
+ });
124
+ }
125
+ /** Mark a roster account usable or unusable, optionally scoped to one credential generation so an unrelated generation is left intact. */
126
+ export function setDecisionAccountUsable(provider: string, id: string, usable: boolean, generation?: string | number): void {
127
+ writesForTests++;
128
+ const roster = rosters.get(provider);
129
+ if (roster?.root !== getConfigDir()) return;
130
+ const row = roster.accounts.find(row => row.id === id && (generation === undefined || generation === row.generation));
131
+ if (!row) return;
132
+ const key = accountKey(provider, id);
133
+ if (usable) {
134
+ if (generation === undefined || unusable.get(key) !== true) unusable.delete(key);
135
+ } else if (generation === undefined || unusable.get(key) !== true) unusable.set(key, generation ?? true);
136
+ }
137
+ /** Drop published account windows for a provider or one account, or for every provider when none is given. */
138
+ export function clearDecisionAccountQuotas(provider?: string, id?: string): void {
139
+ for (const key of accounts.keys()) if (!provider || key.startsWith(`${provider}\0`) && (!id || key === accountKey(provider, id))) accounts.delete(key);
140
+ }
141
+ /** Copy only known window kinds into the bounded evidence table, so callers cannot retain a mutable reference. */
142
+ function copyWindows(windows: readonly DecisionQuotaWindow[]): DecisionQuotaWindow[] {
143
+ if (windows.length > MAX_WINDOWS) return [];
144
+ return windows.filter(row => ["5h", "weekly", "monthly", "fable", "opus", "sonnet"].includes(row.window)).map(row => ({ window: row.window, percent: row.percent, observedAt: row.observedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) }));
145
+ }
@@ -1,3 +1,4 @@
1
+ import { invalidateDecisionKeyQuotas } from "../lib/decision-quota-sink";
1
2
  import { createHash } from "node:crypto";
2
3
  import type { OcxProviderConfig } from "../types";
3
4
  import type { ProviderQuota, ProviderQuotaReport } from "./quota";
@@ -38,7 +39,9 @@ export function providerQuotaRoutingBinding(
38
39
  ])).digest("hex");
39
40
  }
40
41
 
42
+ /** Clear the routing quota cache together with key-based decision evidence, so one cannot outlive the other. */
41
43
  export function clearCachedProviderQuotas(): void {
44
+ invalidateDecisionKeyQuotas();
42
45
  quotaCache.clear();
43
46
  }
44
47
 
@@ -19,7 +19,12 @@ export interface ProviderQuotaWindow {
19
19
  label: string;
20
20
  percent: number;
21
21
  resetAt?: number;
22
- /** Exact CCA wire model measured by this window; display evidence only, not eligibility. */
22
+ /**
23
+ * Model this window is attributed to: the exact CCA wire model of a fallback measurement, or
24
+ * the canonical model of a quota-summary bucket dedicated to one model (Antigravity's Gemini 4
25
+ * Argon pool). Display evidence only, never eligibility: routing, exhaustion, reset ordering
26
+ * and reset notifications ignore every window that carries it.
27
+ */
23
28
  modelId?: string;
24
29
  /**
25
30
  * Set only when the PRODUCER proved this window covers one model family, structurally rather
@@ -31,7 +31,7 @@ import {
31
31
  isProviderQuotaReportCurrent,
32
32
  LAST_GOOD_MAX_AGE_MS,
33
33
  providerQuotaBeforePublishForTests,
34
- routingEvidence,
34
+ routingEvidence, publishCollectedDecisionQuota,
35
35
  setProviderQuotaReportCache,
36
36
  TERMINAL_QUOTA_FAILURE,
37
37
  type CodexAuthAccountsSnapshotPromise,
@@ -228,6 +228,7 @@ export function flushProviderQuotaObservationsForTests(): Promise<void> {
228
228
  return pendingProviderObservation;
229
229
  }
230
230
 
231
+ /** Collect provider quota reports, using the cache unless refresh is forced, and publish the secret-free decision evidence from the collected reports. */
231
232
  export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise<ProviderQuotaResponse> {
232
233
  // A passive read (a Remote Link Child's, #6959) shows the held reports and starts nothing.
233
234
  if (isPassiveRead()) return heldProviderQuotaReports(Object.keys(config.providers));
@@ -328,7 +329,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
328
329
  ) {
329
330
  const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
330
331
  setProviderQuotaReportCache({ key, ts: Date.now(), response: { ...response, reports } });
331
- replaceCachedProviderQuotas(reports, routingEvidence);
332
+ replaceCachedProviderQuotas(reports, routingEvidence); publishCollectedDecisionQuota(reports, config);
332
333
  notifyProviderQuotaSnapshot(reports, config);
333
334
  }
334
335
  return response;
@@ -54,7 +54,7 @@ import {
54
54
  DEEPSEEK_VISION_PREVIEW_MODEL,
55
55
  COMMAND_CODE_MIMO_CONTEXT_WINDOWS,
56
56
  COMMAND_CODE_MODEL_INPUT_MODALITIES,
57
- OPENCODE_FREE_DEEPSEEK_MODELS,
57
+ OPENCODE_ZEN_FREE_DEEPSEEK_MODELS,
58
58
  OPENCODE_ZEN_TEXT_ONLY_MODELS,
59
59
  OPENCODE_ZEN_IMAGE_MODELS,
60
60
  deepseekThinkingEffortsFor,
@@ -1089,20 +1089,20 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
1089
1089
  },
1090
1090
  {
1091
1091
  id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth",
1092
- // Same opencode.ai/zen/v1 gateway as `opencode-free` (keyed tier): DeepSeek thinking mode
1092
+ // Keyed opencode.ai/zen/v1 gateway: DeepSeek thinking mode
1093
1093
  // requires the assistant's original reasoning_content to be replayed on tool-call
1094
1094
  // continuations, or the gateway answers HTTP 400 (issues #950/#994). Mirror the DeepSeek
1095
1095
  // reasoning + thinking metadata so `opencode-zen/deepseek-v4-flash-free` — and the other
1096
1096
  // Zen DeepSeek thinking models — never serialize a bare tool-call turn.
1097
- note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured; OpenCode does not publish RPM). Zen may return generic 429s without Retry-After / X-RateLimit headers; when Retry-After is omitted, opencodex adds a synthetic backoff hint (upstream Retry-After still wins). Distinct from the keyless opencode-free desktop quota (~200 Big Pickle/free-model requests per 5 hours). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.",
1097
+ note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured; OpenCode does not publish RPM). Zen may return generic 429s without Retry-After / X-RateLimit headers; when Retry-After is omitted, opencodex adds a synthetic backoff hint (upstream Retry-After still wins). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.",
1098
1098
  modelReasoningEfforts: Object.fromEntries(
1099
- [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]),
1099
+ [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_ZEN_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]),
1100
1100
  ),
1101
1101
  modelReasoningEffortMap: Object.fromEntries(
1102
- [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekReasoningMapFor(id)]),
1102
+ [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_ZEN_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekReasoningMapFor(id)]),
1103
1103
  ),
1104
- preserveReasoningContentModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS],
1105
- // Same Zen gateway as opencode-free: the DeepSeek vision preview id
1104
+ preserveReasoningContentModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_ZEN_FREE_DEEPSEEK_MODELS],
1105
+ // The DeepSeek vision preview id
1106
1106
  // (merges into deepseek-v4-flash later).
1107
1107
  modelContextWindows: {
1108
1108
  [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576,
@@ -1114,7 +1114,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
1114
1114
  noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_GATEWAY_THINKING_MODELS],
1115
1115
  // Same DeepSeek routes as the Go preset above, behind the same vendor, so they carry
1116
1116
  // the same json_schema rejection (#1338 / #1415).
1117
- noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS],
1117
+ noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_ZEN_FREE_DEEPSEEK_MODELS],
1118
1118
  // Muse Spark on Zen can sit silent during prolonged reasoning and close without a protocol terminal.
1119
1119
  modelResponsesTerminalRepair: {
1120
1120
  "muse-spark-1.2-contributor-free": { graceMs: 5_000 },
@@ -1266,50 +1266,6 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
1266
1266
  models: ["glm-5.3-flash", "claude-sonnet-5-5", "gpt-6.1-sol", "deepseek-v4-flash"],
1267
1267
  note: "OpenAI-compatible relay for Claude, GPT, GLM and DeepSeek chat models. The seeded models were verified with chat, streaming and tool calls; deepseek-v4.1-flash is listed upstream but currently answers 503 and is left out. An ordinary key only reaches its own routing group's models; a Smart key reaches all seeded models. Image and video models are not served through this Chat preset. Upstream latency varies widely (a few seconds to about 30 s) with occasional transient 503s.",
1268
1268
  },
1269
- {
1270
- id: "opencode-free",
1271
- label: "OpenCode Free",
1272
- adapter: "openai-chat",
1273
- baseUrl: "https://opencode.ai/zen/v1",
1274
- authKind: "key",
1275
- keyOptional: true,
1276
- featured: true,
1277
- liveModels: true,
1278
- note: "No key needed, but OpenCode now gates this tier to its own client: Zen refuses any request that arrives without an x-opencode-session header (error type MissingSessionID, \"OpenCode's free tier can only be used in OpenCode\"). opencodex does not mint that header or claim an OpenCode client identity, because no upstream contract authorizes a third-party agent to present itself as OpenCode. Until OpenCode publishes a third-party integration path for the keyless tier, use the keyed opencode-zen provider instead (https://opencode.ai/auth). Quota figures for when the tier admitted a request: OpenCode advertises about 200 Big Pickle/free-model requests per 5 hours, and the same Zen gateway can short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
1279
- dashboardUrl: "https://opencode.ai",
1280
- staticHeaders: {
1281
- // Zen answers a bare runtime User-Agent (Bun/x.y.z) more aggressively than a client
1282
- // that identifies itself, which is what the 429 in #2067 traced to. The value is
1283
- // deliberately unversioned: a pinned "opencode-cli/<version>" is a claim about an
1284
- // install we do not have and goes stale on the vendor's schedule, not ours.
1285
- // Corroboration, not authority: OmniRoute — an independent open-source broker against
1286
- // the same Zen upstream — defaults to exactly this pair (userAgent "opencode", client
1287
- // "desktop") in open-sse/executors/opencode.ts, and got there by RETREATING from its
1288
- // own earlier "opencode-cli/1.0.0" pin. An operator can still override either value
1289
- // through the provider headers API; user headers win case-insensitively at route time.
1290
- "User-Agent": "opencode",
1291
- "x-opencode-client": "desktop",
1292
- },
1293
- modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
1294
- modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekReasoningMapFor(id)])),
1295
- preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS,
1296
- // The DeepSeek vision preview id is preemptive metadata for when Zen starts
1297
- // serving it (merges into v4-flash later).
1298
- modelContextWindows: {
1299
- [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576,
1300
- },
1301
- modelInputModalities: {
1302
- [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"],
1303
- ...Object.fromEntries(OPENCODE_ZEN_IMAGE_MODELS.map(id => [id, ["text", "image"] as string[]])),
1304
- },
1305
- // Same Zen roster behind the same base URL, so it carries the same measured
1306
- // text-only list rather than only its DeepSeek member (#1043).
1307
- noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS,
1308
- // Same reasoning: the free tier is the same Zen roster, so its DeepSeek members get
1309
- // the keyed tier's json_schema treatment and its reasoning contract rather than a
1310
- // narrower table that silently falls behind whenever the keyed one is updated.
1311
- noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS],
1312
- },
1313
1269
  // Xiaomi retires mimo-v2.5 and mimo-v2.5-pro on 2026-10-21 with no redirect
1314
1270
  // (https://mimo.mi.com/docs/en-US/updates/deprecate), so the first-party presets default to V2.6.
1315
1271
  // Saved defaults are not rewritten; V2.5 stays listed until it stops answering.
@@ -477,7 +477,7 @@ export const COMMAND_CODE_MODEL_INPUT_MODALITIES: Record<string, ["text"] | ["te
477
477
  ...Object.fromEntries(COMMAND_CODE_IMAGE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])),
478
478
  ...Object.fromEntries(COMMAND_CODE_TEXT_ONLY_MODELS.map(id => [id, ["text"] as ["text"]])),
479
479
  };
480
- export const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
480
+ export const OPENCODE_ZEN_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
481
481
  /*
482
482
  * Zen free models that reject `image_url` upstream (#1043, and the reproducible
483
483
  * half of #1024).
@@ -49,8 +49,7 @@ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | un
49
49
  * was only ever true for a freshly seeded config: `providerConfigSeed` copies the block once
50
50
  * (`derive.ts`), `enrichProviderFromCatalog` fills it only when the whole block is absent, and
51
51
  * nothing merged it at request time. So an install that predates a header — or that saved any
52
- * header of its own — never received the new one, which is exactly what #2067 would have
53
- * shipped for every existing opencode-free user.
52
+ * header of its own — never received the new one.
54
53
  *
55
54
  * The comparison is case-insensitive on purpose. HTTP header names are case-insensitive, but a
56
55
  * plain object spread is not: merging a registry `User-Agent` over a user's `user-agent`
@@ -39,7 +39,7 @@
39
39
  * Scope is deliberately one entry. The Go gateway's `deepseek-v4.1-flash` was declared text-only
40
40
  * from jawcode metadata and was measured natively multimodal on 2026-09-19 (see the note at that
41
41
  * entry in `registry/entries-core.ts`). Its sibling `deepseek-v4-flash` still rejects images
42
- * upstream, and the sibling Zen tiers (`opencode-zen`, `opencode-free`) could not be probed at
42
+ * upstream, and the keyed Zen tier (`opencode-zen`) could not be probed at
43
43
  * all — an unverified tier is not evidence, so neither is touched here.
44
44
  */
45
45
  import { PROVIDER_REGISTRY, providerMatchesRegistryTransport } from "./registry";
@@ -22,6 +22,7 @@ import { previousResponseReplayPrefixLength } from "./state";
22
22
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
23
23
  import { hasRoutedIdentity, nameRoutedIdentity } from "../adapters/identity";
24
24
  import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
25
+ import { rewriteReplayedWebSearchCells } from "./web-search-replay";
25
26
  import { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
26
27
  import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat";
27
28
 
@@ -232,6 +233,13 @@ export function parseRequest(
232
233
  // Built before the item loop: a custom_tool_call echoed back in `input` needs the
233
234
  // namespace from the request's own tool catalog to survive the round trip.
234
235
  const customToolNamespacesByName = customToolNamespaces(data.tools);
236
+ // Stash the hosted web_search config (if Codex enabled it) so the proxy can run searches via the
237
+ // sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path
238
+ // re-injects a synthetic function tool only when it will actually handle the call. The same
239
+ // declaration decides how replayed search cells are rebuilt below.
240
+ const webSearch = extractHostedWebSearch(data.tools as unknown[] | undefined);
241
+ // With search declared, replayed hosted cells become web_search call/output pairs (#7269).
242
+ const webSearchReplay = webSearch && Array.isArray(data.input) ? rewriteReplayedWebSearchCells(data.input) : undefined;
235
243
  const systemPrompt: string[] = [];
236
244
  // Responses reasoning siblings belong to the following assistant, including across call items.
237
245
  // Keep them off the message list until that assistant arrives; turn boundaries clear the array.
@@ -274,9 +282,12 @@ export function parseRequest(
274
282
  if (data.previous_response_id) continuationConversationMessageIndex = messages.length;
275
283
  messages.push({ role: "user", content: data.input, timestamp: now });
276
284
  } else if (data.input) {
277
- for (let inputIndex = 0; inputIndex < data.input.length; inputIndex++) {
278
- const item = data.input[inputIndex];
279
- if (inputIndex > 0 && isAnthropicProgressMirror(item, anthropicProgressEnvelope(data.input[inputIndex - 1]))) continue;
285
+ const inputItems = (webSearchReplay?.items ?? data.input) as typeof data.input;
286
+ for (let itemIndex = 0; itemIndex < inputItems.length; itemIndex++) {
287
+ const item = inputItems[itemIndex];
288
+ // Boundaries are measured on the caller's input, before any replayed-search rewrite.
289
+ const inputIndex = webSearchReplay?.sourceIndexes[itemIndex] ?? itemIndex;
290
+ if (itemIndex > 0 && isAnthropicProgressMirror(item, anthropicProgressEnvelope(inputItems[itemIndex - 1]))) continue;
280
291
  const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
281
292
  const itemRole = (item as { role?: string }).role;
282
293
  const externalTaskInput = effectiveType === "function_call_output" ? externalTaskInputContent(item) : undefined;
@@ -529,9 +540,9 @@ export function parseRequest(
529
540
  }
530
541
 
531
542
  if (effectiveType === "web_search_call") {
532
- // Replayed hosted web-search evidence has no paired result payload that routed providers can
533
- // consume. Keep it out of assistant-visible text: the old marker was useful as an internal
534
- // loop hint, but when no sidecar is available the model can echo it as a fake answer.
543
+ // Without a search declaration the cell stays out of routed history: a call to an undeclared
544
+ // tool, or assistant-visible marker text the model could echo as a fake answer, would be
545
+ // worse than the gap. With one, the cell was already rewritten into a web_search pair.
535
546
  pendingReasoning.length = 0;
536
547
  continue;
537
548
  }
@@ -689,10 +700,6 @@ export function parseRequest(
689
700
  if (data.service_tier !== undefined) options.serviceTier = data.service_tier;
690
701
  if (data.prompt_cache_key !== undefined) options.promptCacheKey = data.prompt_cache_key;
691
702
 
692
- // Stash the hosted web_search config (if Codex enabled it) so the proxy can run searches via the
693
- // gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path
694
- // re-injects a synthetic function tool only when it will actually handle the call.
695
- const webSearch = extractHostedWebSearch(data.tools as unknown[] | undefined);
696
703
  const imageGen = extractHostedImageGeneration([
697
704
  ...(data.tools as unknown[] ?? []),
698
705
  ...loadedToolSpecs,
@@ -718,6 +725,7 @@ export function parseRequest(
718
725
  ? { _continuationConversationMessageIndex: continuationConversationMessageIndex }
719
726
  : {}),
720
727
  ...(webSearch ? { _webSearch: webSearch } : {}),
728
+ ...(webSearchReplay ? { _replayedWebSearchCells: webSearchReplay.cells } : {}),
721
729
  ...(imageGen ? { _imageGeneration: imageGen } : {}),
722
730
  ...(textFormat ? { _structuredOutput: true } : {}),
723
731
  ...(compactionRequest ? { _compactionRequest: true } : {}),
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Parse-time rebuild of replayed hosted `web_search_call` cells for routed providers (#7269).
3
+ *
4
+ * Codex replays the cell it was shown, which carries the query and at most a source list, never the
5
+ * result text. Dropping the cell made a routed model forget it had searched: after a client tool
6
+ * call ended the turn, the next request carried no trace of the search, so the model searched and
7
+ * ran its tools again in a loop.
8
+ *
9
+ * When search is declared, the parser rewrites each cell into the `web_search` function call and
10
+ * output the routed model made, at the raw-input level, so the ordinary call/output handling owns
11
+ * turn batching and reasoning attachment. The call takes the cell's place; its output is deferred to
12
+ * the end of the contiguous run of call/output items that follows, so a cell inside a parallel
13
+ * client batch never splits it. The output is a fallback built from the cell alone; the web-search
14
+ * sidecar swaps in the result it recorded for the cell, and removes the pairs again when it does not
15
+ * run, because the synthetic tool is then undeclared (src/web-search/sidecar-search-replay.ts).
16
+ */
17
+ import { isObj } from "./parser-content";
18
+ import { WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
19
+ import type { OcxReplayedWebSearchCell } from "../types";
20
+
21
+ const MAX_FIELD_CHARS = 2048;
22
+ const MAX_LIST_ENTRIES = 20;
23
+ const CELL_ID = /^[A-Za-z0-9_-]{1,128}$/;
24
+
25
+ /** Call items of a batch; a call after the batch's first output starts the next assistant turn. */
26
+ const CALL_TYPES = new Set(["function_call", "custom_tool_call", "local_shell_call", "tool_search_call"]);
27
+ /** Output items of a batch. Anything that is neither a call nor an output ends the batch. */
28
+ const OUTPUT_TYPES = new Set(["function_call_output", "custom_tool_call_output", "local_shell_call_output", "tool_search_output"]);
29
+
30
+ function capped(value: string): string {
31
+ return value.length <= MAX_FIELD_CHARS ? value : `${value.slice(0, MAX_FIELD_CHARS)}…`;
32
+ }
33
+
34
+ function queriesOf(action: Record<string, unknown>): string[] {
35
+ const queries = Array.isArray(action.queries)
36
+ ? action.queries.filter((query): query is string => typeof query === "string" && query.trim() !== "")
37
+ : [];
38
+ if (queries.length === 0 && typeof action.query === "string" && action.query.trim() !== "") queries.push(action.query);
39
+ return queries.slice(0, MAX_LIST_ENTRIES).map(capped);
40
+ }
41
+
42
+ function sourcesOf(item: Record<string, unknown>, action: Record<string, unknown>): string[] {
43
+ const lines: string[] = [];
44
+ const seen = new Set<string>();
45
+ for (const list of [item.sources, action.sources]) {
46
+ if (!Array.isArray(list)) continue;
47
+ for (const source of list) {
48
+ if (lines.length >= MAX_LIST_ENTRIES) return lines;
49
+ if (!isObj(source) || typeof source.url !== "string" || seen.has(source.url)) continue;
50
+ seen.add(source.url);
51
+ lines.push(typeof source.title === "string" && source.title.trim() !== ""
52
+ ? `- ${capped(source.title)} <${capped(source.url)}>`
53
+ : `- <${capped(source.url)}>`);
54
+ }
55
+ }
56
+ return lines;
57
+ }
58
+
59
+ /** The call arguments and fallback result text for one replayed cell. */
60
+ export function replayedWebSearchContent(item: Record<string, unknown>): { arguments: Record<string, unknown>; result: string; failed: boolean } {
61
+ const action = isObj(item.action) ? item.action : {};
62
+ const failed = item.status === "failed";
63
+ const queries = queriesOf(action);
64
+ const lines: string[] = [];
65
+ if (action.type === "open_page" && typeof action.url === "string") {
66
+ lines.push(`Earlier page open: <${capped(action.url)}>.`);
67
+ } else if (action.type === "find_in_page" && typeof action.url === "string") {
68
+ lines.push(`Earlier find in page <${capped(action.url)}>${typeof action.pattern === "string" ? ` for "${capped(action.pattern)}"` : ""}.`);
69
+ } else {
70
+ lines.push(queries.length > 0 ? `Earlier web search for: ${queries.map(query => `"${query}"`).join(", ")}.` : "Earlier web search.");
71
+ }
72
+ if (failed) {
73
+ lines.push("This search failed and returned no results.");
74
+ } else {
75
+ lines.push("The result text of this search is not available in the replayed conversation history.");
76
+ const sources = sourcesOf(item, action);
77
+ if (sources.length > 0) lines.push("Sources it returned (titles and URLs only, untrusted):", ...sources);
78
+ }
79
+ return { arguments: queries.length > 1 ? { queries } : { query: queries[0] ?? "" }, result: lines.join("\n"), failed };
80
+ }
81
+
82
+ export interface RewrittenWebSearchInput {
83
+ items: unknown[];
84
+ /** Original input index per rewritten item, for boundaries measured on the caller's input. */
85
+ sourceIndexes: number[];
86
+ cells: OcxReplayedWebSearchCell[];
87
+ }
88
+
89
+ /** Rewrite replayed cells into web_search call/output pairs; undefined when the input has none. */
90
+ export function rewriteReplayedWebSearchCells(input: readonly unknown[]): RewrittenWebSearchInput | undefined {
91
+ if (!input.some(item => isObj(item) && item.type === "web_search_call")) return undefined;
92
+ // A synthetic call id must not collide with any client call id, nor with another cell's.
93
+ const taken = new Set(input.flatMap(item => isObj(item) && typeof item.call_id === "string" ? [item.call_id] : []));
94
+ const allocate = (base: string): string => {
95
+ let id = base;
96
+ for (let n = 1; taken.has(id); n++) id = `${base}_${n}`;
97
+ taken.add(id);
98
+ return id;
99
+ };
100
+ const items: unknown[] = [];
101
+ const sourceIndexes: number[] = [];
102
+ const cells: OcxReplayedWebSearchCell[] = [];
103
+ const seenCells = new Set<string>();
104
+ let pending: Array<{ item: unknown; sourceIndex: number }> = [];
105
+ // True once the current batch reached its outputs: the next call opens a new assistant turn.
106
+ let outputPhase = false;
107
+ const flush = () => {
108
+ for (const entry of pending) { items.push(entry.item); sourceIndexes.push(entry.sourceIndex); }
109
+ pending = [];
110
+ outputPhase = false;
111
+ };
112
+ input.forEach((item, sourceIndex) => {
113
+ const type = isObj(item) ? item.type : undefined;
114
+ if (isObj(item) && type === "web_search_call") {
115
+ const cellId = typeof item.id === "string" && CELL_ID.test(item.id) ? item.id : undefined;
116
+ // The same cell replayed twice is one search.
117
+ if (cellId && seenCells.has(cellId)) return;
118
+ if (cellId) seenCells.add(cellId);
119
+ if (outputPhase) flush();
120
+ const content = replayedWebSearchContent(item);
121
+ const callId = allocate(cellId ?? "ws_replay");
122
+ cells.push({ ...(cellId ? { cellId } : {}), callId, failed: content.failed });
123
+ items.push({ type: "function_call", call_id: callId, name: WEB_SEARCH_TOOL_NAME, arguments: JSON.stringify(content.arguments) });
124
+ sourceIndexes.push(sourceIndex);
125
+ pending.push({ item: { type: "function_call_output", call_id: callId, output: content.result }, sourceIndex });
126
+ return;
127
+ }
128
+ if (typeof type === "string" && OUTPUT_TYPES.has(type)) {
129
+ outputPhase = true;
130
+ } else if (!(typeof type === "string" && CALL_TYPES.has(type)) || outputPhase) {
131
+ flush();
132
+ }
133
+ items.push(item);
134
+ sourceIndexes.push(sourceIndex);
135
+ });
136
+ flush();
137
+ return { items, sourceIndexes, cells };
138
+ }