@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.
- package/README.md +1 -1
- package/gui/dist/assets/index-DkcRs1fL.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/cursor-errors.ts +65 -6
- package/src/adapters/cursor/discovery.ts +7 -2
- package/src/adapters/cursor/effort-map.ts +6 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/live-models.ts +21 -26
- package/src/adapters/cursor/live-transport.ts +213 -3
- 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 +11 -4
- package/src/adapters/cursor/tool-definitions.ts +20 -0
- package/src/adapters/cursor/transport.ts +10 -0
- 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/inject-coordination.ts +39 -6
- package/src/codex/transition-state.ts +12 -12
- package/src/generated/compatibility-version.json +74 -50
- package/src/lib/errors.ts +8 -2
- package/src/oauth/cursor.ts +21 -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/registry.ts +25 -0
- 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-eBA05kYB.js +0 -102
package/src/oauth/cursor.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/providers/derive.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
@@ -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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
&& !
|
|
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
|
-
&& !
|
|
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(
|
|
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
|
-
&& !
|
|
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(
|
|
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
|
|
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. */
|