@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.
- package/README.md +5 -5
- package/gui/dist/assets/index-DyWYnr-t.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +3 -3
- package/src/adapters/cursor/cursor-errors.ts +65 -6
- package/src/adapters/cursor/discovery.ts +29 -2
- package/src/adapters/cursor/effort-map.ts +6 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +21 -26
- package/src/adapters/cursor/live-transport.ts +239 -8
- package/src/adapters/cursor/native-exec-common.ts +17 -0
- package/src/adapters/cursor/native-exec.ts +9 -4
- package/src/adapters/cursor/protobuf-events.ts +5 -1
- package/src/adapters/cursor/protobuf-request.ts +46 -9
- package/src/adapters/cursor/request-builder.ts +29 -14
- package/src/adapters/cursor/tool-definitions.ts +20 -0
- package/src/adapters/cursor/transport.ts +10 -0
- package/src/adapters/cursor/types.ts +8 -1
- package/src/adapters/cursor.ts +23 -5
- package/src/adapters/google.ts +16 -3
- package/src/adapters/openai-responses.ts +66 -20
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/cli/agent.ts +2 -1
- package/src/cli/dispatch.ts +2 -2
- package/src/cli/doctor.ts +89 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/registry.ts +7 -2
- package/src/codex/auth-context.ts +41 -2
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-fetch.ts +20 -5
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/features.ts +58 -0
- package/src/codex/inject-coordination.ts +39 -6
- package/src/codex/transition-state.ts +12 -12
- package/src/generated/compatibility-version.json +86 -58
- package/src/lib/bun-stream-caps.ts +7 -4
- package/src/lib/errors.ts +8 -2
- package/src/oauth/cursor.ts +21 -0
- package/src/providers/command-code-efforts.ts +7 -0
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +3 -0
- package/src/providers/fastwire.ts +12 -1
- package/src/providers/openai-sidecar.ts +1 -0
- package/src/providers/quota.ts +98 -25
- package/src/providers/registry.ts +115 -10
- package/src/providers/service-tier.ts +22 -7
- package/src/responses/custom-tool-compat.ts +24 -8
- package/src/responses/namespace-tool-compat.ts +2 -3
- package/src/router.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/chat-native.ts +20 -0
- package/src/server/management/agent-settings-routes.ts +16 -5
- package/src/server/management/config-routes.ts +25 -5
- package/src/server/management/vision-sidecar-options.ts +54 -19
- package/src/server/responses/compact.ts +1 -2
- package/src/server/responses/core.ts +54 -13
- package/src/service.ts +122 -14
- package/src/types/config.ts +9 -3
- package/src/types/provider.ts +6 -0
- package/src/usage/cost.ts +52 -38
- package/src/usage/expected-prices.ts +79 -9
- package/src/vision/backends.ts +97 -0
- package/src/vision/eligibility.ts +43 -22
- package/src/vision/index.ts +73 -5
- package/src/vision/routed-describe.ts +175 -0
- 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
|
|
272
|
-
// call items carrying a private `namespace
|
|
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
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
// a backend the
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
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".
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2332
|
+
poolAffinityKey,
|
|
2299
2333
|
config,
|
|
2300
2334
|
Date.now(),
|
|
2301
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
3473
|
+
threadId: authCtx.affinityKey,
|
|
3433
3474
|
fixedAccount: authCtx.fixedAccount,
|
|
3434
3475
|
modelId: route.modelId,
|
|
3435
3476
|
probeLeaseId: codexProbeLeaseId(authCtx),
|