@bitkyc08/opencodex 2.29.0 → 2.31.0-preview.20260822

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 (68) hide show
  1. package/README.md +5 -5
  2. package/gui/dist/assets/index-DyWYnr-t.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +3 -3
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +29 -2
  7. package/src/adapters/cursor/effort-map.ts +6 -0
  8. package/src/adapters/cursor/h2-pool.ts +123 -0
  9. package/src/adapters/cursor/images.ts +704 -0
  10. package/src/adapters/cursor/live-models.ts +21 -26
  11. package/src/adapters/cursor/live-transport.ts +239 -8
  12. package/src/adapters/cursor/native-exec-common.ts +17 -0
  13. package/src/adapters/cursor/native-exec.ts +9 -4
  14. package/src/adapters/cursor/protobuf-events.ts +5 -1
  15. package/src/adapters/cursor/protobuf-request.ts +46 -9
  16. package/src/adapters/cursor/request-builder.ts +29 -14
  17. package/src/adapters/cursor/tool-definitions.ts +20 -0
  18. package/src/adapters/cursor/transport.ts +10 -0
  19. package/src/adapters/cursor/types.ts +8 -1
  20. package/src/adapters/cursor.ts +23 -5
  21. package/src/adapters/google.ts +16 -3
  22. package/src/adapters/openai-responses.ts +66 -20
  23. package/src/adapters/xai-web-search.ts +185 -0
  24. package/src/cli/agent.ts +2 -1
  25. package/src/cli/dispatch.ts +2 -2
  26. package/src/cli/doctor.ts +89 -0
  27. package/src/cli/help.ts +2 -0
  28. package/src/cli/registry.ts +7 -2
  29. package/src/codex/auth-context.ts +41 -2
  30. package/src/codex/catalog/effort.ts +1 -1
  31. package/src/codex/catalog/parsing.ts +2 -0
  32. package/src/codex/catalog/provider-fetch.ts +20 -5
  33. package/src/codex/coordinator-doctor.ts +332 -0
  34. package/src/codex/features.ts +58 -0
  35. package/src/codex/inject-coordination.ts +39 -6
  36. package/src/codex/transition-state.ts +12 -12
  37. package/src/generated/compatibility-version.json +86 -58
  38. package/src/lib/bun-stream-caps.ts +7 -4
  39. package/src/lib/errors.ts +8 -2
  40. package/src/oauth/cursor.ts +21 -0
  41. package/src/providers/command-code-efforts.ts +7 -0
  42. package/src/providers/cursor-pool.ts +72 -0
  43. package/src/providers/derive.ts +3 -0
  44. package/src/providers/fastwire.ts +12 -1
  45. package/src/providers/openai-sidecar.ts +1 -0
  46. package/src/providers/quota.ts +98 -25
  47. package/src/providers/registry.ts +115 -10
  48. package/src/providers/service-tier.ts +22 -7
  49. package/src/responses/custom-tool-compat.ts +24 -8
  50. package/src/responses/namespace-tool-compat.ts +2 -3
  51. package/src/router.ts +3 -0
  52. package/src/server/chat-completions.ts +4 -0
  53. package/src/server/chat-native.ts +20 -0
  54. package/src/server/management/agent-settings-routes.ts +16 -5
  55. package/src/server/management/config-routes.ts +25 -5
  56. package/src/server/management/vision-sidecar-options.ts +54 -19
  57. package/src/server/responses/compact.ts +1 -2
  58. package/src/server/responses/core.ts +54 -13
  59. package/src/service.ts +122 -14
  60. package/src/types/config.ts +9 -3
  61. package/src/types/provider.ts +6 -0
  62. package/src/usage/cost.ts +52 -38
  63. package/src/usage/expected-prices.ts +79 -9
  64. package/src/vision/backends.ts +97 -0
  65. package/src/vision/eligibility.ts +43 -22
  66. package/src/vision/index.ts +73 -5
  67. package/src/vision/routed-describe.ts +175 -0
  68. package/gui/dist/assets/index-BNESwCzn.js +0 -102
@@ -268,9 +268,8 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
268
268
  const groups = collectResponsesToolGroups(body);
269
269
  const plan = buildRewritePlan(groups);
270
270
 
271
- // Deliberately not gated on the plan being non-empty: a turn whose catalog is gone still replays
272
- // call items carrying a private `namespace`, and the routed compaction turn strips the whole tool
273
- // surface before this runs.
271
+ // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still
272
+ // replay call items carrying a private `namespace`.
274
273
  const emitted = new Set<string>();
275
274
  const tools = Array.isArray(body.tools) ? rewriteToolList(body.tools, plan, emitted) : body.tools;
276
275
 
package/src/router.ts CHANGED
@@ -366,6 +366,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
366
366
  && registryEntry.supportsOpenAiWebSearchToolFields !== undefined
367
367
  ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields }
368
368
  : {}),
369
+ ...(provider.supportsResponsesCustomTools === undefined && registryEntry.supportsResponsesCustomTools !== undefined
370
+ ? { supportsResponsesCustomTools: registryEntry.supportsResponsesCustomTools }
371
+ : {}),
369
372
  ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined
370
373
  ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent }
371
374
  : {}),
@@ -258,6 +258,10 @@ async function handleChatCompletionsWithBudget(
258
258
  abortSignal: req.signal,
259
259
  // Body is Responses-shaped by now, but the client spoke Chat Completions.
260
260
  inboundWire: "chat",
261
+ // Terminal vision-describe marker (roadmap 180): the bridge rebuilds
262
+ // headers from the FORWARD_HEADERS allowlist, which would drop the raw
263
+ // header — so the fact is detected here and carried as an option flag.
264
+ ...(req.headers.get("x-opencodex-vision-describe") === "1" ? { visionDescribeTerminal: true } : {}),
261
265
  translatorBudget,
262
266
  ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
263
267
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }),
@@ -11,6 +11,7 @@ import type { AdmissionLease } from "../lib/admission";
11
11
  import { readBoundedResponseBody } from "../lib/bounded-body";
12
12
  import { redactSecretString } from "../lib/redact";
13
13
  import { resolveClientRetryAfter } from "../lib/retry-after";
14
+ import { isModelTextOnly } from "../vision";
14
15
  import {
15
16
  applyUpstreamRecoveryInit,
16
17
  fetchWithResetRetry,
@@ -61,6 +62,12 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo
61
62
  if (rawBody.store === true || rawBody.background === true) return false;
62
63
  if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return false;
63
64
  if (rawBody.compaction_trigger !== undefined) return false;
65
+ // Vision sidecar coverage (roadmap 180): a text-only routed model with an
66
+ // image-bearing body must go through the Responses pipeline, whose plan
67
+ // site describes or strips the image. The native fast path has no vision
68
+ // handling, so letting it keep such a request forwards raw pixels to a
69
+ // model the operator declared blind.
70
+ if (isModelTextOnly(provider, route.modelId) && chatBodyCarriesImage(rawBody)) return false;
64
71
  if (Array.isArray(rawBody.tools)) {
65
72
  for (const tool of rawBody.tools) {
66
73
  if (!isRec(tool)) continue;
@@ -72,6 +79,19 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo
72
79
  return true;
73
80
  }
74
81
 
82
+ /** Any messages[].content[] part of type image_url. */
83
+ function chatBodyCarriesImage(rawBody: Rec): boolean {
84
+ const messages = rawBody.messages;
85
+ if (!Array.isArray(messages)) return false;
86
+ for (const message of messages) {
87
+ if (!isRec(message) || !Array.isArray(message.content)) continue;
88
+ for (const part of message.content) {
89
+ if (isRec(part) && part.type === "image_url") return true;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
75
95
  function chatCompletionJson(value: unknown): Rec | null {
76
96
  if (!isRec(value) || !Array.isArray(value.choices) || value.choices.length === 0) return null;
77
97
  return value;
@@ -1073,13 +1073,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1073
1073
  const section = body[field];
1074
1074
  if (section === undefined || section === null) continue;
1075
1075
  if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400);
1076
- // The widened union applies to the WEB-SEARCH override only (roadmap 060).
1077
- // Vision keeps its two-backend contract accepting a wider id there would
1078
- // persist a backend the vision resolver reads as unset, silently activating
1079
- // a backend the operator never chose (review F1).
1076
+ // Both overrides now speak their full unions (roadmap 060 web, 170
1077
+ // vision revised). Vision's third arm is "routed" (loopback through the
1078
+ // proxy's own router), never exa: exa is not an LLM, and accepting an
1079
+ // unknown literal would persist a backend the vision resolver reads as
1080
+ // unset (review F1's failure mode).
1080
1081
  const allowedBackends = field === "webSearchSidecar"
1081
1082
  ? ["openai", "anthropic", "xai", "gemini", "exa"]
1082
- : ["openai", "anthropic"];
1083
+ : ["openai", "anthropic", "routed"];
1083
1084
  if (section.backend !== undefined && section.backend !== null
1084
1085
  && !allowedBackends.includes(section.backend as string)) {
1085
1086
  return jsonResponse({ error: `${field}.backend must be ${allowedBackends.join(", ")}, or null` }, 400);
@@ -1094,8 +1095,18 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1094
1095
  const requested = section.model;
1095
1096
  const candidates = await visionCandidateRows(config);
1096
1097
  const hint = section.backend === "anthropic" || section.backend === "openai"
1098
+ || section.backend === "routed"
1097
1099
  ? section.backend
1098
1100
  : config.claudeCode?.visionSidecar?.backend;
1101
+ // Same coherence rule as /api/sidecar-settings (roadmap 170 r2).
1102
+ const effectiveBackend = hint ?? "openai";
1103
+ const namespaced = requested.includes("/");
1104
+ if (namespaced && effectiveBackend !== "routed") {
1105
+ return jsonResponse({ error: `visionSidecar.model "${requested}" is provider-namespaced; it requires backend "routed"` }, 400);
1106
+ }
1107
+ if (!namespaced && effectiveBackend === "routed") {
1108
+ return jsonResponse({ error: `visionSidecar.backend "routed" requires a provider-namespaced model ("provider/model"); got "${requested}"` }, 400);
1109
+ }
1099
1110
  if (visionDescriberIsProvablyBlind(config, requested, candidates, hint)) {
1100
1111
  return jsonResponse(visionDescriberRejection("visionSidecar.model", requested, config, candidates), 400);
1101
1112
  }
@@ -113,8 +113,14 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{
113
113
  // Match the runtime's one selected Anthropic executor for both backend fallback
114
114
  // and catalog reachability; resolving it once prevents the two projections drifting.
115
115
  const anthropicSidecar = findAnthropicVisionProvider(config);
116
- const backend = resolveVisionBackend(vs.backend, anthropicSidecar);
117
- const model = resolveEffectiveVisionModel(config, backend);
116
+ // The routed backend reports its own namespaced model verbatim: it is the
117
+ // dispatched value, and collapsing it through the legacy resolver would
118
+ // display a describer the runtime is not using (roadmap 190).
119
+ const routedActive = vs.backend === "routed" && !!vs.model && vs.model.includes("/");
120
+ const backend = routedActive ? "routed" as const : resolveVisionBackend(vs.backend, anthropicSidecar);
121
+ const model = routedActive && vs.model
122
+ ? vs.model
123
+ : resolveEffectiveVisionModel(config, backend === "routed" ? resolveVisionBackend(undefined, anthropicSidecar) : backend);
118
124
  const reasoning = normalizeVisionReasoningForModel(model, vs.reasoning) ?? "low";
119
125
  const models = await visionModelOptionsFor(config, anthropicSidecar);
120
126
  // Display-only grandfather: a persisted id stays selectable, but the write gate
@@ -592,8 +598,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
592
598
  return jsonResponse({ error: "webSearch.streamRoutedModelOutput must be a boolean" }, 400);
593
599
  }
594
600
  if (body.vision && body.vision.backend !== undefined
595
- && body.vision.backend !== null && body.vision.backend !== "openai" && body.vision.backend !== "anthropic") {
596
- return jsonResponse({ error: "vision.backend must be openai, anthropic, or null" }, 400);
601
+ && body.vision.backend !== null && body.vision.backend !== "openai" && body.vision.backend !== "anthropic"
602
+ && body.vision.backend !== "routed") {
603
+ return jsonResponse({ error: "vision.backend must be openai, anthropic, routed, or null" }, 400);
597
604
  }
598
605
  if (body.vision && body.vision.maxDescriptionsPerTurn !== undefined
599
606
  && (typeof body.vision.maxDescriptionsPerTurn !== "number"
@@ -621,8 +628,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
621
628
  const requested = body.vision.model;
622
629
  const candidates = await visionCandidateRows(config);
623
630
  const hint = body.vision.backend === "anthropic" || body.vision.backend === "openai"
631
+ || body.vision.backend === "routed"
624
632
  ? body.vision.backend
625
633
  : config.visionSidecar?.backend;
634
+ // Coherence (roadmap 170 r2): the forward/OAuth executors POST the model
635
+ // string VERBATIM, so a namespaced id on those backends persists a wire
636
+ // id they cannot run; and "routed" without a namespace cannot route.
637
+ const effectiveBackend = hint ?? "openai";
638
+ const namespaced = requested.includes("/");
639
+ if (namespaced && effectiveBackend !== "routed") {
640
+ return jsonResponse({ error: `vision.model "${requested}" is provider-namespaced; it requires vision.backend "routed"` }, 400);
641
+ }
642
+ if (!namespaced && effectiveBackend === "routed") {
643
+ return jsonResponse({ error: `vision.backend "routed" requires a provider-namespaced vision.model ("provider/model"); got "${requested}"` }, 400);
644
+ }
626
645
  if (visionDescriberIsProvablyBlind(config, requested, candidates, hint)) {
627
646
  return jsonResponse(visionDescriberRejection("vision.model", requested, config, candidates), 400);
628
647
  }
@@ -736,7 +755,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
736
755
  else config.visionSidecar.model = body.vision.model;
737
756
  }
738
757
  if (body.vision.backend === null) delete config.visionSidecar.backend;
739
- else if (body.vision.backend === "openai" || body.vision.backend === "anthropic") {
758
+ else if (body.vision.backend === "openai" || body.vision.backend === "anthropic"
759
+ || body.vision.backend === "routed") {
740
760
  config.visionSidecar.backend = body.vision.backend;
741
761
  }
742
762
  if (typeof body.vision.maxDescriptionsPerTurn === "number") {
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { OcxConfig } from "../../types";
11
11
  import { findAnthropicVisionProvider, type AnthropicVisionProvider } from "../../vision";
12
+ import { VISION_BACKENDS } from "../../vision/backends";
12
13
  import {
13
14
  modelAcceptsImageInput,
14
15
  visionEligibleModelOptions,
@@ -16,30 +17,44 @@ import {
16
17
  type VisionModelOption,
17
18
  type VisionSidecarBackend,
18
19
  } from "../../vision/eligibility";
19
- import { listOpenAiForwardSidecarCandidates } from "../../providers/openai-sidecar";
20
20
  import { pickerVisibleSidecarCandidates } from "../../sidecar/candidates";
21
21
  import { resolveSidecarAuth } from "../../sidecar/auth";
22
22
 
23
23
  /**
24
- * Backends whose executor could actually run: openai forward, anthropic OAuth.
24
+ * Backends whose executor could actually run (#2188 roadmap 170): openai
25
+ * forward, anthropic OAuth, xai OAuth, Antigravity OAuth — resolved by the
26
+ * VISION_BACKENDS descriptor table so this module and the options path cannot
27
+ * drift on what "active" means.
25
28
  *
26
29
  * `anthropicSidecar` is REQUIRED rather than defaulted. `findAnthropicVisionProvider`
27
30
  * reads the OAuth account store from disk, and a default argument made every helper
28
31
  * in this chain re-resolve it whenever a caller passed an explicit `undefined` —
29
32
  * which is exactly the no-executor case. Passing it in keeps one read per request.
33
+ * The descriptor table re-derives the anthropic flag from the shared auth
34
+ * module; asserting the caller's resolution stays consistent with it is the
35
+ * job of the shared module, not this file.
30
36
  */
31
37
  export function enabledVisionBackends(
32
38
  config: OcxConfig,
33
39
  anthropicSidecar: AnthropicVisionProvider | undefined,
34
40
  ): VisionSidecarBackend[] {
35
- const backends: VisionSidecarBackend[] = [];
36
- // The OpenAI describer needs a CANONICAL ChatGPT forward provider, not merely a
37
- // provider keyed "openai" same predicate the runtime sidecar resolver uses.
38
- if (listOpenAiForwardSidecarCandidates(config).length > 0) backends.push("openai");
39
- if (anthropicSidecar) backends.push("anthropic");
40
- // Neither side resolvable (fresh install, no login): fall back to both so the
41
- // picker is populated rather than empty, matching the permissive-unknown rule.
42
- return backends.length > 0 ? backends : ["openai", "anthropic"];
41
+ const auth = resolveSidecarAuth(config);
42
+ // Preserve the caller's resolution for the anthropic side: the descriptor
43
+ // reads the shared auth module, but a caller that already resolved "no
44
+ // executor" must not see anthropic options it cannot dispatch. The filter
45
+ // applies to the ACTIVE set only — the fresh-install fallback below stays
46
+ // both universal sides, exactly the pre-widening behavior (test 6 pins it).
47
+ const active = VISION_BACKENDS
48
+ .filter(descriptor => descriptor.isActive(auth, config))
49
+ .map(descriptor => descriptor.backend)
50
+ .filter(backend => backend !== "anthropic" || anthropicSidecar !== undefined);
51
+ // "routed" is active by construction, so the fresh-install fallback keys on
52
+ // the UNIVERSAL sides: when neither resolves, both are offered so the picker
53
+ // stays populated (permissive-unknown rule; test 6 pins it).
54
+ if (!active.includes("openai") && !active.includes("anthropic")) {
55
+ return ["openai", "anthropic", ...active];
56
+ }
57
+ return active;
43
58
  }
44
59
 
45
60
  /**
@@ -93,10 +108,16 @@ export async function visionModelOptionsFor(
93
108
  * When no catalog row matches, the caller's `backend` is only a HINT, never the
94
109
  * authority. Trusting it let a client launder a known-blind OpenAI model past the
95
110
  * gate by claiming `backend: "anthropic"`, since the id is absent from the
96
- * Anthropic table and absence reads as "unknown". Both families are therefore
97
- * consulted and any positive text-only verdict wins. That is safe precisely
98
- * because the two vendor tables share no bare model id, so they can never
99
- * disagree about one.
111
+ * Anthropic table and absence reads as "unknown".
112
+ *
113
+ * A NAMESPACED id ("provider/model", the routed-backend option shape) names
114
+ * its provider outright, so that provider's config row and metadata family
115
+ * are probed directly. A BARE id probes ALL configured provider families and
116
+ * any positive text-only verdict wins (roadmap 170: a bare `grok-4` is
117
+ * provably text-only in the xai vendor table and must not slip through a
118
+ * two-family probe). That is safe precisely because the vendor tables share
119
+ * no bare model id (collision scan in roadmap 160: openai 48, anthropic 26,
120
+ * xai 32, google 43, zero overlaps), so they can never disagree about one.
100
121
  */
101
122
  export function visionDescriberIsProvablyBlind(
102
123
  config: OcxConfig,
@@ -109,11 +130,25 @@ export function visionDescriberIsProvablyBlind(
109
130
  if (candidates.some(candidate => candidate.id === requested
110
131
  && modelAcceptsImageInput(config, candidate) === false)) return true;
111
132
 
112
- const hinted: VisionSidecarBackend = backendHint === "anthropic" ? "anthropic" : "openai";
113
- const probed: VisionSidecarBackend[] = hinted === "anthropic"
114
- ? ["anthropic", "openai"]
115
- : ["openai", "anthropic"];
116
- return probed.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false);
133
+ // Namespaced routed id: the provider is named, probe it directly (config
134
+ // row enrichment + its metadata family both flow through the predicate).
135
+ const sep = requested.indexOf("/");
136
+ if (sep > 0) {
137
+ const provider = requested.slice(0, sep);
138
+ const id = requested.slice(sep + 1);
139
+ if (modelAcceptsImageInput(config, { provider, id }) === false) return true;
140
+ // A namespaced candidate row (value shape) may also carry the proof.
141
+ return candidates.some(candidate => candidate.provider === provider && candidate.id === id
142
+ && modelAcceptsImageInput(config, candidate) === false);
143
+ }
144
+
145
+ // Bare id: probe the base vendor families plus every configured provider —
146
+ // a positive text-only verdict from any source wins.
147
+ const families = new Set(["openai", "anthropic", "xai", "google-antigravity", ...Object.keys(config.providers ?? {})]);
148
+ const ordered = backendHint === "anthropic"
149
+ ? ["anthropic", ...[...families].filter(family => family !== "anthropic")]
150
+ : ["openai", ...[...families].filter(family => family !== "openai")];
151
+ return ordered.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false);
117
152
  }
118
153
 
119
154
  /** The 400 body both routes return, so the two errors cannot diverge either. */
@@ -461,7 +461,6 @@ export async function handleResponsesCompact(
461
461
  }
462
462
  compactHostAdmissionLease = null;
463
463
  };
464
- const compactThreadId = req.headers.get("x-codex-parent-thread-id");
465
464
  const connectMs = config.connectTimeoutMs ?? 200_000;
466
465
  // Takes its context explicitly: the alternate-account flow below records a rejection
467
466
  // against A while promoting B, then records B's own outcome. A closure over a single
@@ -478,7 +477,7 @@ export async function handleResponsesCompact(
478
477
  if (!usesCodexForwardPoolAuth(ctx, route.provider)) return;
479
478
  recordCodexUpstreamOutcome(config, ctx.accountId, outcome, {
480
479
  ...meta,
481
- threadId: compactThreadId,
480
+ threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined,
482
481
  fixedAccount: ctx.fixedAccount,
483
482
  modelId: selectedModelId,
484
483
  probeLeaseId: codexProbeLeaseId(ctx),
@@ -111,6 +111,7 @@ import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenA
111
111
  import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue";
112
112
  import {
113
113
  applyCodexAuthContextToProvider,
114
+ codexPoolAffinityKey,
114
115
  CodexAccountCooldownError,
115
116
  codexMainProfileDrainingResponse,
116
117
  cooldownErrorResponse,
@@ -139,6 +140,7 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-m
139
140
  import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
140
141
  import {
141
142
  computeQuotaCooldown,
143
+ codexQuotaScopeForModel,
142
144
  formatCodexProviderForLog,
143
145
  previewCodexAccountForRequest,
144
146
  recordCodexUpstreamOutcome,
@@ -328,11 +330,10 @@ export function adapterNeedsForcedContinuation(name: string): boolean {
328
330
  export function sidecarOutcomeRecorder(
329
331
  config: OcxConfig,
330
332
  authCtx: CodexAuthContext,
331
- threadId?: string | null,
332
333
  ): ((outcome: CodexUpstreamOutcome) => void) | undefined {
333
334
  return authCtx.kind === "pool" || authCtx.kind === "main-pool"
334
335
  ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
335
- threadId,
336
+ threadId: authCtx.affinityKey,
336
337
  fixedAccount: authCtx.fixedAccount,
337
338
  probeLeaseId: authCtx.probeLeaseId,
338
339
  probeQuotaScope: authCtx.probeQuotaScope,
@@ -946,7 +947,7 @@ async function retryCodexPoolOnAlternateAccount(
946
947
  const recordFirstOutcome = (): void => {
947
948
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
948
949
  ...quotaMeta,
949
- threadId: req.headers.get("x-codex-parent-thread-id"),
950
+ threadId: firstAuthCtx.affinityKey,
950
951
  modelId: route.modelId,
951
952
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
952
953
  probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
@@ -1081,7 +1082,6 @@ export function codexForwardTerminalOutcomeRecorder(
1081
1082
  provider: OcxProviderConfig,
1082
1083
  modelId?: string,
1083
1084
  logCtx?: RequestLogContext,
1084
- threadId?: string | null,
1085
1085
  ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
1086
1086
  if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
1087
1087
  return (status, httpStatusOverride) => {
@@ -1090,7 +1090,7 @@ export function codexForwardTerminalOutcomeRecorder(
1090
1090
  // request. Don't penalize account health; record success to clear any
1091
1091
  // prior soft-avoid so a healthy account isn't stuck avoided.
1092
1092
  recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
1093
- threadId,
1093
+ threadId: authCtx.affinityKey,
1094
1094
  fixedAccount: authCtx.fixedAccount,
1095
1095
  modelId,
1096
1096
  probeLeaseId: codexProbeLeaseId(authCtx),
@@ -1112,7 +1112,7 @@ export function codexForwardTerminalOutcomeRecorder(
1112
1112
  ? 200
1113
1113
  : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
1114
1114
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1115
- threadId,
1115
+ threadId: authCtx.affinityKey,
1116
1116
  fixedAccount: authCtx.fixedAccount,
1117
1117
  modelId,
1118
1118
  probeLeaseId: codexProbeLeaseId(authCtx),
@@ -1227,6 +1227,15 @@ export interface HandleResponsesOptions {
1227
1227
  onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
1228
1228
  /** Caller-owned for Chat/Claude replay; omitted only at genuine Responses ingress. */
1229
1229
  translatorBudget?: TranslatorBudget;
1230
+ /**
1231
+ * Terminal vision-describe marker (roadmap 180): true when the inbound
1232
+ * request IS the vision sidecar's own loopback describe call. The plan site
1233
+ * then STRIPS images instead of planning another describe — a depth cap of 1
1234
+ * that holds under predicate drift and combo re-resolution. The Chat surface
1235
+ * detects the raw `x-opencodex-vision-describe` header before its bridge
1236
+ * rebuilds headers and carries the fact through this flag.
1237
+ */
1238
+ visionDescribeTerminal?: boolean;
1230
1239
  }
1231
1240
 
1232
1241
 
@@ -2177,6 +2186,27 @@ async function handleResponsesInner(
2177
2186
  if (inboundClientThreadId) {
2178
2187
  parsed._clientThreadId = inboundClientThreadId;
2179
2188
  parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId };
2189
+ } else if (
2190
+ options.inboundWire === "anthropic"
2191
+ && options.promptCacheKeyIsSharedCohort !== true
2192
+ && typeof parsed.options.promptCacheKey === "string"
2193
+ && parsed.options.promptCacheKey.trim().length > 0
2194
+ ) {
2195
+ // Claude Code has no Codex parent-thread header, but its metadata.user_id is
2196
+ // translated into a stable per-session prompt_cache_key. Use it as the replay
2197
+ // thread identity so Gemini thought signatures are remembered by call_id for
2198
+ // Anthropic Messages clients too (#1735/#1926). Keep `_clientThreadId` unset so
2199
+ // existing provider session-id derivation (first-user-text fallback) is unchanged.
2200
+ // Normalize through anthropicSessionKeyFromParts so overlong keys are hashed and
2201
+ // trimming matches the affinity/session-key path exactly (no raw >128-char ids).
2202
+ const normalizedCacheKey = anthropicSessionKeyFromParts({
2203
+ promptCacheKey: parsed.options.promptCacheKey,
2204
+ // The enclosing branch already proves this is not the shared cohort.
2205
+ promptCacheKeyIsSharedCohort: false,
2206
+ });
2207
+ if (normalizedCacheKey) {
2208
+ parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey };
2209
+ }
2180
2210
  }
2181
2211
  } catch (err) {
2182
2212
  if (isTranslatorBudgetExceededError(err)) {
@@ -2278,6 +2308,7 @@ async function handleResponsesInner(
2278
2308
  let subagentFallbackPreviewAccountId: string | null | undefined;
2279
2309
  let subagentQuotaFailureModel = parsed.modelId;
2280
2310
  const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
2311
+ const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null;
2281
2312
 
2282
2313
  try {
2283
2314
  if (
@@ -2293,12 +2324,15 @@ async function handleResponsesInner(
2293
2324
  // Preview the preferred Codex account without acquiring a probe lease or refreshing
2294
2325
  // tokens — auth is resolved only after the final route is selected.
2295
2326
  if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) {
2296
- const threadId = req.headers.get("x-codex-parent-thread-id");
2327
+ // The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId),
2328
+ // so the preview must read the same scope slot — an undefined scope would map to the
2329
+ // "legacy" affinity bucket and never find a binding made under "shared" or a native
2330
+ // model scope, making the preview diverge from the account that actually authenticates.
2297
2331
  const previewAccountId = previewCodexAccountForRequest(
2298
- threadId,
2332
+ poolAffinityKey,
2299
2333
  config,
2300
2334
  Date.now(),
2301
- undefined,
2335
+ codexQuotaScopeForModel(route.modelId),
2302
2336
  previewSelectionOptions,
2303
2337
  );
2304
2338
  subagentFallbackPreviewAccountId = previewAccountId;
@@ -2725,7 +2759,15 @@ async function handleResponsesInner(
2725
2759
  // Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
2726
2760
  // attached image through the selected sidecar backend and replace it with text BEFORE the main
2727
2761
  // call, so the text-only model can reason about it.
2728
- const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar);
2762
+ // Terminal describe fence (roadmap 180): the sidecar's OWN loopback describe
2763
+ // call must never plan another describe. The flag arrives from the Chat
2764
+ // surface (whose bridge rebuilds headers) or as the raw header for native
2765
+ // Responses callers. Marked + text-only routed model → strip, depth cap 1.
2766
+ const visionDescribeTerminal = options.visionDescribeTerminal === true
2767
+ || req.headers.get("x-opencodex-vision-describe") === "1";
2768
+ const visionPlan = visionDescribeTerminal
2769
+ ? undefined
2770
+ : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar);
2729
2771
  const recordSidecarOutcome = openAiSidecar?.recordOutcome;
2730
2772
  if (visionPlan) {
2731
2773
  await describeImagesInPlace(
@@ -3005,7 +3047,7 @@ async function handleResponsesInner(
3005
3047
  }
3006
3048
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
3007
3049
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
3008
- threadId: req.headers.get("x-codex-parent-thread-id"),
3050
+ threadId: authCtx.affinityKey,
3009
3051
  fixedAccount: authCtx.fixedAccount,
3010
3052
  modelId: route.modelId,
3011
3053
  probeLeaseId: codexProbeLeaseId(authCtx),
@@ -3388,7 +3430,6 @@ async function handleResponsesInner(
3388
3430
  route.provider,
3389
3431
  route.modelId,
3390
3432
  logCtx,
3391
- req.headers.get("x-codex-parent-thread-id"),
3392
3433
  );
3393
3434
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
3394
3435
  // Capture quota from upstream response for multi-account tracking
@@ -3429,7 +3470,7 @@ async function handleResponsesInner(
3429
3470
  )) {
3430
3471
  recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
3431
3472
  ...quotaMeta,
3432
- threadId: req.headers.get("x-codex-parent-thread-id"),
3473
+ threadId: authCtx.affinityKey,
3433
3474
  fixedAccount: authCtx.fixedAccount,
3434
3475
  modelId: route.modelId,
3435
3476
  probeLeaseId: codexProbeLeaseId(authCtx),