@bitkyc08/opencodex 2.30.0-preview.20260821 → 2.31.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 (61) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-DkcRs1fL.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +7 -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/live-models.ts +21 -26
  10. package/src/adapters/cursor/live-transport.ts +213 -3
  11. package/src/adapters/cursor/native-exec-common.ts +17 -0
  12. package/src/adapters/cursor/native-exec.ts +9 -4
  13. package/src/adapters/cursor/protobuf-events.ts +5 -1
  14. package/src/adapters/cursor/protobuf-request.ts +11 -4
  15. package/src/adapters/cursor/tool-definitions.ts +20 -0
  16. package/src/adapters/cursor/transport.ts +10 -0
  17. package/src/adapters/cursor.ts +23 -5
  18. package/src/adapters/google.ts +16 -3
  19. package/src/adapters/openai-responses.ts +66 -20
  20. package/src/adapters/xai-web-search.ts +185 -0
  21. package/src/cli/agent.ts +2 -1
  22. package/src/cli/dispatch.ts +2 -2
  23. package/src/cli/doctor.ts +89 -0
  24. package/src/cli/help.ts +2 -0
  25. package/src/cli/registry.ts +7 -2
  26. package/src/codex/auth-context.ts +41 -2
  27. package/src/codex/catalog/effort.ts +1 -1
  28. package/src/codex/catalog/parsing.ts +2 -0
  29. package/src/codex/catalog/provider-fetch.ts +20 -5
  30. package/src/codex/coordinator-doctor.ts +332 -0
  31. package/src/codex/inject-coordination.ts +39 -6
  32. package/src/codex/transition-state.ts +12 -12
  33. package/src/generated/compatibility-version.json +74 -50
  34. package/src/lib/errors.ts +8 -2
  35. package/src/oauth/cursor.ts +21 -0
  36. package/src/providers/cursor-pool.ts +72 -0
  37. package/src/providers/derive.ts +3 -0
  38. package/src/providers/fastwire.ts +12 -1
  39. package/src/providers/openai-sidecar.ts +1 -0
  40. package/src/providers/registry.ts +25 -0
  41. package/src/providers/service-tier.ts +22 -7
  42. package/src/responses/custom-tool-compat.ts +24 -8
  43. package/src/responses/namespace-tool-compat.ts +2 -3
  44. package/src/router.ts +3 -0
  45. package/src/server/chat-completions.ts +4 -0
  46. package/src/server/chat-native.ts +20 -0
  47. package/src/server/management/agent-settings-routes.ts +16 -5
  48. package/src/server/management/config-routes.ts +25 -5
  49. package/src/server/management/vision-sidecar-options.ts +54 -19
  50. package/src/server/responses/compact.ts +1 -2
  51. package/src/server/responses/core.ts +54 -13
  52. package/src/service.ts +122 -14
  53. package/src/types/config.ts +9 -3
  54. package/src/types/provider.ts +6 -0
  55. package/src/usage/cost.ts +52 -38
  56. package/src/usage/expected-prices.ts +79 -9
  57. package/src/vision/backends.ts +97 -0
  58. package/src/vision/eligibility.ts +43 -22
  59. package/src/vision/index.ts +73 -5
  60. package/src/vision/routed-describe.ts +175 -0
  61. package/gui/dist/assets/index-eBA05kYB.js +0 -102
@@ -101,6 +101,19 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
101
101
  });
102
102
  }
103
103
 
104
+ /** Terminal poll statuses (T07, senpi PR #905): the login is denied/expired — retrying cannot succeed. */
105
+ const POLL_TERMINAL_STATUSES = new Set([400, 401, 403, 410]);
106
+
107
+ export class CursorAuthTerminalError extends Error {
108
+ readonly status: number;
109
+
110
+ constructor(status: number) {
111
+ super(`Cursor login rejected by the auth server (HTTP ${status}); start a new login`);
112
+ this.name = "CursorAuthTerminalError";
113
+ this.status = status;
114
+ }
115
+ }
116
+
104
117
  /**
105
118
  * Poll cursor.com for login completion. 404 = still pending (back off), 200 = tokens.
106
119
  * `baseDelayMs` is injectable so tests can avoid the real 1s cadence; production uses the default.
@@ -135,9 +148,17 @@ export async function pollCursorAuth(
135
148
  return { accessToken: data.accessToken, refreshToken: data.refreshToken };
136
149
  }
137
150
 
151
+ // T07: a terminal auth status means the login attempt itself is dead (denied,
152
+ // expired, revoked). Fail on the FIRST such response instead of burning the
153
+ // 3-strike retry budget and masking the reason behind a generic error.
154
+ if (POLL_TERMINAL_STATUSES.has(response.status)) {
155
+ throw new CursorAuthTerminalError(response.status);
156
+ }
157
+
138
158
  throw new Error(`Cursor auth poll failed: ${response.status}`);
139
159
  } catch (err) {
140
160
  if (signal?.aborted) throw err instanceof Error ? err : new Error("Cursor login cancelled");
161
+ if (err instanceof CursorAuthTerminalError) throw err;
141
162
  consecutiveErrors++;
142
163
  if (consecutiveErrors >= 3) {
143
164
  throw new Error("Too many consecutive errors during Cursor auth polling");
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Weighted credential routing for Cursor accounts.
3
+ *
4
+ * Transfer from yelixir-dev/cursor-ai-proxy-bridge credentials.ts:
5
+ * weighted round-robin selection with per-credential auth-failure cooldown
6
+ * and one-retry failover on a different account before surfacing the error.
7
+ *
8
+ * OpenCodex already has JWT-based multi-account identification (src/oauth/cursor.ts)
9
+ * and Anthropic-specific 429 rotation; this module adds Cursor-aware weighted
10
+ * routing on top of those primitives.
11
+ */
12
+
13
+ export interface CursorCredential {
14
+ readonly id: string;
15
+ weight: number;
16
+ }
17
+
18
+ interface CredentialState {
19
+ readonly credential: CursorCredential;
20
+ currentWeight: number;
21
+ disabledUntil: number;
22
+ }
23
+
24
+ export class NoAvailableCursorCredentialError extends Error {
25
+ constructor(message = "No available Cursor credentials") { super(message); }
26
+ }
27
+
28
+ export class CursorCredentialRouter {
29
+ private states: CredentialState[] = [];
30
+ private readonly cooldownMs: number;
31
+
32
+ constructor(credentials: ReadonlyArray<CursorCredential>, cooldownMs = 300_000) {
33
+ this.cooldownMs = cooldownMs;
34
+ this.replace(credentials);
35
+ }
36
+
37
+ replace(credentials: ReadonlyArray<CursorCredential>): void {
38
+ this.states = credentials.map(c => ({
39
+ credential: { ...c, weight: Math.max(1, c.weight || 1) },
40
+ currentWeight: 0,
41
+ disabledUntil: 0,
42
+ }));
43
+ }
44
+
45
+ pick(excludeIds: ReadonlySet<string> = new Set()): CursorCredential {
46
+ const now = Date.now();
47
+ const candidates = this.states.filter(s =>
48
+ !excludeIds.has(s.credential.id) && s.disabledUntil <= now,
49
+ );
50
+ if (candidates.length === 0) throw new NoAvailableCursorCredentialError();
51
+ let selected: CredentialState | undefined;
52
+ let totalWeight = 0;
53
+ for (const state of candidates) {
54
+ state.currentWeight += state.credential.weight;
55
+ totalWeight += state.credential.weight;
56
+ if (!selected || state.currentWeight > selected.currentWeight) selected = state;
57
+ }
58
+ if (!selected) throw new NoAvailableCursorCredentialError();
59
+ selected.currentWeight -= totalWeight;
60
+ return { ...selected.credential };
61
+ }
62
+
63
+ disable(id: string): void {
64
+ const state = this.states.find(s => s.credential.id === id);
65
+ if (state) state.disabledUntil = Date.now() + this.cooldownMs;
66
+ }
67
+
68
+ get snapshot(): ReadonlyArray<{ id: string; disabled: boolean }> {
69
+ const now = Date.now();
70
+ return this.states.map(s => ({ id: s.credential.id, disabled: s.disabledUntil > now }));
71
+ }
72
+ }
@@ -483,6 +483,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
483
483
  if (prov.supportsOpenAiWebSearchToolFields === undefined && entry.supportsOpenAiWebSearchToolFields !== undefined) {
484
484
  prov.supportsOpenAiWebSearchToolFields = entry.supportsOpenAiWebSearchToolFields;
485
485
  }
486
+ if (prov.supportsResponsesCustomTools === undefined && entry.supportsResponsesCustomTools !== undefined) {
487
+ prov.supportsResponsesCustomTools = entry.supportsResponsesCustomTools;
488
+ }
486
489
  if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent;
487
490
  applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries);
488
491
  applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov));
@@ -33,6 +33,7 @@ export interface FastPolicyAuthority {
33
33
  readonly providerAdapter: string;
34
34
  readonly providerAuthMode?: ProviderAuthKind;
35
35
  readonly fastWireDeclaration: FastWire | null | undefined;
36
+ readonly fastTierDescription?: string;
36
37
  readonly modelWireOverrideAllowed: boolean;
37
38
  readonly authTransport: FastPolicyAuthTransport;
38
39
  readonly capability: {
@@ -55,6 +56,7 @@ export interface ResolvedFastPolicy {
55
56
  | "pin-unavailable";
56
57
  readonly adapter: string;
57
58
  readonly fastWire: FastWire | null;
59
+ readonly fastTierDescription?: string;
58
60
  readonly forwardCallerTier: boolean;
59
61
  }
60
62
 
@@ -222,7 +224,16 @@ export function resolveFastPolicy(
222
224
  else if (capability === undefined) eligibility = "unclassified";
223
225
  else eligibility = "eligible";
224
226
 
225
- return { capability, eligibility, adapter, fastWire, forwardCallerTier };
227
+ return {
228
+ capability,
229
+ eligibility,
230
+ adapter,
231
+ fastWire,
232
+ ...(authority.fastTierDescription !== undefined
233
+ ? { fastTierDescription: authority.fastTierDescription }
234
+ : {}),
235
+ forwardCallerTier,
236
+ };
226
237
  }
227
238
 
228
239
  export function canonicalFastTierMarker(callerTier: string | undefined): "priority" | undefined {
@@ -169,6 +169,7 @@ export async function resolveFirstUsableOpenAiSidecar(
169
169
  authContext.accountId,
170
170
  outcome,
171
171
  {
172
+ threadId: authContext.affinityKey,
172
173
  probeLeaseId: authContext.probeLeaseId,
173
174
  writerGeneration: authContext.writerGeneration,
174
175
  },
@@ -225,8 +225,22 @@ export interface ProviderRegistryEntry {
225
225
  supportsServiceTier?: boolean;
226
226
  /** Registry default for OpenAI extended hosted web_search field support. */
227
227
  supportsOpenAiWebSearchToolFields?: boolean;
228
+ /** Registry default for native Responses custom-tool support. */
229
+ supportsResponsesCustomTools?: boolean;
228
230
  /** Registry default for exact model service-tier capability; explicit config keys win. */
229
231
  modelSupportsServiceTier?: Record<string, boolean>;
232
+ /**
233
+ * Registry-only service-tier defaults for an OAuth preset's explicit API-key transport.
234
+ * Applied only when `allowKeyAuthOverride` is true and the captured effective auth transport
235
+ * is key-based. Explicit provider config still wins field-by-field, including `false`.
236
+ */
237
+ keyAuthServiceTier?: {
238
+ supportsServiceTier?: boolean;
239
+ modelSupportsServiceTier?: Record<string, boolean>;
240
+ chatServiceTier?: boolean;
241
+ };
242
+ /** Provider-specific copy for the Codex catalog's Fast tier. */
243
+ fastTierDescription?: string;
230
244
  /**
231
245
  * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence
232
246
  * without changing provider ownership, routing, authentication, or config validation.
@@ -1023,10 +1037,21 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1023
1037
  baseUrl: "https://api.x.ai/v1",
1024
1038
  authKind: "oauth",
1025
1039
  allowKeyAuthOverride: true,
1040
+ // Priority Processing is documented for xAI's public API-key Chat Completions and
1041
+ // Responses endpoints. OAuth is a separate Grok CLI subscription gateway and remains
1042
+ // unclassified; do not turn this into a provider-wide supportsServiceTier declaration.
1043
+ keyAuthServiceTier: {
1044
+ supportsServiceTier: true,
1045
+ chatServiceTier: true,
1046
+ },
1047
+ fastTierDescription: "Priority processing, 2x token price",
1026
1048
  featured: true,
1027
1049
  oauthId: "xai",
1028
1050
  jawcodeBundle: "xai",
1029
1051
  supportsOpenAiWebSearchToolFields: false,
1052
+ // Live A/B on 2026-08-20: xAI rejects native custom/custom_tool_call shapes while accepting
1053
+ // the otherwise-identical request after the custom tool is lowered to a function.
1054
+ supportsResponsesCustomTools: false,
1030
1055
  note: "Log in with your Grok account",
1031
1056
  // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling
1032
1057
  // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole
@@ -70,11 +70,22 @@ function buildFastPolicyAuthority(
70
70
  capabilityProvider: ServiceTierCapabilityProvider = provider,
71
71
  ): FastPolicyAuthority {
72
72
  const registry = registryTransportMatch ? getProviderRegistryEntry(providerName) : undefined;
73
+ const authTransport = resolveProviderAuthTransport(
74
+ provider.adapter,
75
+ provider.authMode ?? registry?.authKind ?? "key",
76
+ provider.apiKeyTransport,
77
+ );
78
+ const keyAuthDefaults = registry?.allowKeyAuthOverride === true
79
+ && (authTransport === "authorization_bearer" || authTransport === "x_api_key")
80
+ ? registry.keyAuthServiceTier
81
+ : undefined;
73
82
  const registryModelCapabilities = registry
74
83
  && registryModelServiceTierCapabilityApplies(registry, capabilityProvider)
75
84
  ? registry.modelSupportsServiceTier
76
85
  : undefined;
77
- const providerCapability = capabilityProvider.supportsServiceTier ?? registry?.supportsServiceTier;
86
+ const providerCapability = capabilityProvider.supportsServiceTier
87
+ ?? keyAuthDefaults?.supportsServiceTier
88
+ ?? registry?.supportsServiceTier;
78
89
  const authority: FastPolicyAuthority = Object.freeze({
79
90
  providerAdapter: provider.adapter,
80
91
  providerAuthMode: provider.authMode ?? registry?.authKind ?? "key",
@@ -82,19 +93,23 @@ function buildFastPolicyAuthority(
82
93
  provider.fastWire !== undefined ? provider.fastWire : registry?.fastWire,
83
94
  { freeze: true },
84
95
  ),
96
+ ...(registry?.fastTierDescription !== undefined
97
+ ? { fastTierDescription: registry.fastTierDescription }
98
+ : {}),
85
99
  modelWireOverrideAllowed: !isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig),
86
- authTransport: resolveProviderAuthTransport(
87
- provider.adapter,
88
- provider.authMode ?? registry?.authKind ?? "key",
89
- provider.apiKeyTransport,
90
- ),
100
+ authTransport,
91
101
  capability: Object.freeze({
92
102
  ...(providerCapability !== undefined ? { provider: providerCapability } : {}),
93
103
  models: Object.freeze({
94
104
  ...(registryModelCapabilities ?? {}),
105
+ ...(keyAuthDefaults?.modelSupportsServiceTier ?? {}),
95
106
  ...(capabilityProvider.modelSupportsServiceTier ?? {}),
96
107
  }),
97
- ...(provider.chatServiceTier !== undefined ? { chatServiceTier: provider.chatServiceTier } : {}),
108
+ ...(provider.chatServiceTier !== undefined
109
+ ? { chatServiceTier: provider.chatServiceTier }
110
+ : keyAuthDefaults?.chatServiceTier !== undefined
111
+ ? { chatServiceTier: keyAuthDefaults.chatServiceTier }
112
+ : {}),
98
113
  }),
99
114
  modelAdapters: Object.freeze({ ...(provider.modelAdapters ?? {}) }),
100
115
  hardPins: captureWireAdapterHardPins(providerName),
@@ -4,6 +4,13 @@ import { collectResponsesToolGroups } from "./tool-groups";
4
4
  const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]);
5
5
  const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
6
6
 
7
+ function routedCustomToolPassesThrough(
8
+ name: string,
9
+ supportsResponsesCustomTools: boolean | undefined,
10
+ ): boolean {
11
+ return supportsResponsesCustomTools !== false && ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(name);
12
+ }
13
+
7
14
  function isPlainObject(value: unknown): value is Record<string, unknown> {
8
15
  return !!value && typeof value === "object" && !Array.isArray(value);
9
16
  }
@@ -34,7 +41,10 @@ export function routedCustomToolWireName(value: unknown): string | undefined {
34
41
  * Names of converted custom declarations after namespace lowering. Restoration uses these exact
35
42
  * wire identities so same-named function and custom children in different namespaces stay distinct.
36
43
  */
37
- function collectRoutedCustomToolWireNames(body: unknown): Set<string> {
44
+ function collectRoutedCustomToolWireNames(
45
+ body: unknown,
46
+ supportsResponsesCustomTools?: boolean,
47
+ ): Set<string> {
38
48
  const names = new Set<string>();
39
49
  const groups = collectResponsesToolGroups(body);
40
50
  const bareWireNames = new Set<string>();
@@ -54,7 +64,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set<string> {
54
64
  if (
55
65
  tool.type === "custom"
56
66
  && typeof tool.name === "string"
57
- && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(tool.name)
67
+ && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools)
58
68
  ) {
59
69
  names.add(tool.name);
60
70
  continue;
@@ -67,7 +77,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set<string> {
67
77
  isPlainObject(child)
68
78
  && child.type === "custom"
69
79
  && typeof child.name === "string"
70
- && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(child.name)
80
+ && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools)
71
81
  && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name))
72
82
  ) names.add(customToolWireName(tool.name, child.name));
73
83
  }
@@ -81,7 +91,10 @@ export function customToolItemId(id: unknown): unknown {
81
91
  return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id;
82
92
  }
83
93
 
84
- export function collectRoutedCustomToolNames(body: unknown): Set<string> {
94
+ export function collectRoutedCustomToolNames(
95
+ body: unknown,
96
+ supportsResponsesCustomTools?: boolean,
97
+ ): Set<string> {
85
98
  const names = new Set<string>();
86
99
  const visit = (value: unknown): void => {
87
100
  if (Array.isArray(value)) {
@@ -92,7 +105,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set<string> {
92
105
  if (
93
106
  value.type === "custom"
94
107
  && typeof value.name === "string"
95
- && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name)
108
+ && !routedCustomToolPassesThrough(value.name, supportsResponsesCustomTools)
96
109
  ) {
97
110
  names.add(value.name);
98
111
  }
@@ -184,12 +197,15 @@ function rewriteForUpstream(
184
197
  return changed ? next : value;
185
198
  }
186
199
 
187
- export function rewriteRoutedCustomToolsForUpstream(body: unknown): {
200
+ export function rewriteRoutedCustomToolsForUpstream(
201
+ body: unknown,
202
+ supportsResponsesCustomTools?: boolean,
203
+ ): {
188
204
  body: unknown;
189
205
  names: Set<string>;
190
206
  } {
191
- const conversionNames = collectRoutedCustomToolNames(body);
192
- const names = collectRoutedCustomToolWireNames(body);
207
+ const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools);
208
+ const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools);
193
209
  if (conversionNames.size === 0) return { body, names };
194
210
  const callIds = new Set<string>();
195
211
  collectConvertedCallIds(body, conversionNames, callIds);
@@ -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. */