@bitkyc08/opencodex 2.27.0 → 2.28.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/gui/dist/assets/{index-7jlKgmJd.js → index-D2sP-biU.js} +11 -11
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +59 -0
- package/src/adapters/base.ts +2 -0
- package/src/adapters/google-antigravity-replay.ts +16 -8
- package/src/adapters/google.ts +21 -4
- package/src/adapters/openai-chat.ts +151 -54
- package/src/adapters/openai-responses.ts +37 -0
- package/src/cli/index.ts +19 -6
- package/src/codex/account-usability.ts +3 -0
- package/src/codex/auth-api.ts +22 -5
- package/src/codex/auth-context.ts +55 -2
- package/src/codex/catalog/metadata.ts +17 -3
- package/src/codex/catalog/native-models.ts +22 -14
- package/src/codex/catalog/sync.ts +57 -11
- package/src/codex/convergence.ts +61 -13
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/quota.ts +28 -3
- package/src/codex/routing.ts +14 -8
- package/src/generated/compatibility-version.json +51 -39
- package/src/lib/destination-policy.ts +47 -0
- package/src/lib/shadow-call.ts +15 -0
- package/src/oauth/index.ts +33 -5
- package/src/oauth/store.ts +11 -5
- package/src/providers/fastwire.ts +39 -8
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +74 -5
- package/src/providers/service-tier.ts +16 -8
- package/src/responses/parser.ts +3 -9
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/router.ts +7 -0
- package/src/routing/capability.ts +26 -9
- package/src/routing/compatibility/behavior.ts +41 -3
- package/src/server/chat-native.ts +11 -2
- package/src/server/index.ts +54 -8
- package/src/server/management/agent-settings-routes.ts +16 -2
- package/src/server/request-log.ts +31 -0
- package/src/server/responses/compact.ts +54 -7
- package/src/server/responses/core.ts +246 -39
- package/src/server/responses/responses-field-backfill.ts +88 -6
- package/src/server/responses/terminal-guard.ts +10 -0
- package/src/server/responses-tool-search-repair.ts +217 -0
- package/src/server/system-env.ts +74 -5
- package/src/usage/log.ts +4 -0
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
} from "../types";
|
|
8
8
|
import { MODEL_ADAPTER_OVERRIDE_ALLOWED } from "../types";
|
|
9
9
|
import { sanitizeLogMetadataString } from "../lib/redact";
|
|
10
|
-
import type { InboundWire, ModelWireDefault } from "./registry";
|
|
10
|
+
import type { InboundWire, ModelWireDefault, ProviderAuthKind } from "./registry";
|
|
11
11
|
|
|
12
12
|
const SERVICE_TIER_ADAPTERS = new Set(["openai-chat", "openai-responses"]);
|
|
13
13
|
const FAST_WIRE_ADAPTERS: Readonly<Record<FastWire["kind"], ReadonlySet<string>>> = {
|
|
@@ -31,6 +31,7 @@ export type FastPolicyAuthTransport =
|
|
|
31
31
|
|
|
32
32
|
export interface FastPolicyAuthority {
|
|
33
33
|
readonly providerAdapter: string;
|
|
34
|
+
readonly providerAuthMode?: ProviderAuthKind;
|
|
34
35
|
readonly fastWireDeclaration: FastWire | null | undefined;
|
|
35
36
|
readonly modelWireOverrideAllowed: boolean;
|
|
36
37
|
readonly authTransport: FastPolicyAuthTransport;
|
|
@@ -114,21 +115,33 @@ function registryDefaultForModel(
|
|
|
114
115
|
defaults: Readonly<Record<string, ModelWireDefault>>,
|
|
115
116
|
modelId: string,
|
|
116
117
|
inbound: InboundWire,
|
|
117
|
-
|
|
118
|
+
authMode: ProviderAuthKind | undefined,
|
|
119
|
+
): { adapter: string; forwardCallerServiceTier?: boolean } | undefined {
|
|
118
120
|
const normalizedModelId = modelId.trim().toLowerCase();
|
|
119
121
|
if (!Object.hasOwn(defaults, normalizedModelId)) return undefined;
|
|
120
122
|
const declared = defaults[normalizedModelId];
|
|
121
123
|
if (declared === undefined) return undefined;
|
|
122
|
-
if (typeof declared !== "string"
|
|
124
|
+
if (typeof declared !== "string") {
|
|
125
|
+
if (!declared.inbound.includes(inbound)) return undefined;
|
|
126
|
+
if (declared.authModes && (authMode === undefined || !declared.authModes.includes(authMode))) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
123
130
|
const wire = typeof declared === "string" ? declared : declared.wire;
|
|
124
|
-
|
|
131
|
+
if (!MODEL_ADAPTER_OVERRIDE_ALLOWED.has(wire)) return undefined;
|
|
132
|
+
return {
|
|
133
|
+
adapter: wire,
|
|
134
|
+
...(typeof declared !== "string" && declared.forwardCallerServiceTier !== undefined
|
|
135
|
+
? { forwardCallerServiceTier: declared.forwardCallerServiceTier }
|
|
136
|
+
: {}),
|
|
137
|
+
};
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
function resolvePolicyAdapter(
|
|
128
141
|
authority: FastPolicyAuthority,
|
|
129
142
|
modelId: string,
|
|
130
143
|
inbound: InboundWire,
|
|
131
|
-
): { adapter: string; hardPinned: boolean } {
|
|
144
|
+
): { adapter: string; hardPinned: boolean; forwardCallerServiceTier?: boolean } {
|
|
132
145
|
// Hard pins and configured overrides deliberately use the same exact-key semantics as
|
|
133
146
|
// resolveWireProtocolOverride(). Registry defaults alone normalize ids at their boundary.
|
|
134
147
|
const hardPin = Object.hasOwn(authority.hardPins, modelId)
|
|
@@ -143,8 +156,21 @@ function resolvePolicyAdapter(
|
|
|
143
156
|
return { adapter: configured, hardPinned: false };
|
|
144
157
|
}
|
|
145
158
|
if (MODEL_ADAPTER_OVERRIDE_ALLOWED.has(authority.providerAdapter)) {
|
|
146
|
-
const registryDefault = registryDefaultForModel(
|
|
147
|
-
|
|
159
|
+
const registryDefault = registryDefaultForModel(
|
|
160
|
+
authority.registryWireDefaults,
|
|
161
|
+
modelId,
|
|
162
|
+
inbound,
|
|
163
|
+
authority.providerAuthMode,
|
|
164
|
+
);
|
|
165
|
+
if (registryDefault !== undefined) {
|
|
166
|
+
return {
|
|
167
|
+
adapter: registryDefault.adapter,
|
|
168
|
+
hardPinned: false,
|
|
169
|
+
...(registryDefault.forwardCallerServiceTier !== undefined
|
|
170
|
+
? { forwardCallerServiceTier: registryDefault.forwardCallerServiceTier }
|
|
171
|
+
: {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
148
174
|
}
|
|
149
175
|
}
|
|
150
176
|
return { adapter: authority.providerAdapter, hardPinned: false };
|
|
@@ -155,7 +181,11 @@ export function resolveFastPolicy(
|
|
|
155
181
|
modelId: string,
|
|
156
182
|
inbound: InboundWire = "responses",
|
|
157
183
|
): ResolvedFastPolicy {
|
|
158
|
-
const { adapter, hardPinned } = resolvePolicyAdapter(
|
|
184
|
+
const { adapter, hardPinned, forwardCallerServiceTier } = resolvePolicyAdapter(
|
|
185
|
+
authority,
|
|
186
|
+
modelId,
|
|
187
|
+
inbound,
|
|
188
|
+
);
|
|
159
189
|
const exactCapability = exactModelValue(authority.capability.models, modelId);
|
|
160
190
|
const capability = authority.capability.provider === false
|
|
161
191
|
? false
|
|
@@ -173,6 +203,7 @@ export function resolveFastPolicy(
|
|
|
173
203
|
// tier still needs the final wire's forwarding permission.
|
|
174
204
|
const forwardCallerTier = capability !== false
|
|
175
205
|
&& callerWireAvailable
|
|
206
|
+
&& forwardCallerServiceTier !== false
|
|
176
207
|
&& (adapter !== "openai-chat" || authority.capability.chatServiceTier === true);
|
|
177
208
|
|
|
178
209
|
let eligibility: ResolvedFastPolicy["eligibility"];
|
package/src/providers/quota.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { getAccountCredential, getAccountSet, getCredential } from "../oauth/sto
|
|
|
13
13
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
14
14
|
import { apiKeyPoolEntryId } from "./api-keys";
|
|
15
15
|
import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
|
|
16
|
-
import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry";
|
|
16
|
+
import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry";
|
|
17
17
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
18
18
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
|
|
19
19
|
import {
|
|
@@ -2084,7 +2084,14 @@ async function maybeFetchProviderQuota(
|
|
|
2084
2084
|
&& isCanonicalCommandCodeBaseUrl(provider.baseUrl)) {
|
|
2085
2085
|
return fetchCommandCodeQuota(name, provider);
|
|
2086
2086
|
}
|
|
2087
|
-
|
|
2087
|
+
// Identify OpenCode Go by where it routes, not by what the row is called. Multi-account
|
|
2088
|
+
// setups keep the same destination under names like `opencode-go-2` (#1924), and those rows
|
|
2089
|
+
// silently had no quota panel and no `ocx provider quota --json` report while the literal
|
|
2090
|
+
// name was the gate. `registryEntryForProviderDestination` is the existing predicate for
|
|
2091
|
+
// exactly this question: normalized endpoint + adapter + key auth, so a canonical URL behind
|
|
2092
|
+
// a different adapter is still not OpenCode Go. The defensive URL check inside
|
|
2093
|
+
// `fetchOpenCodeGoQuota` stays — sending a key anywhere must not depend on this gate.
|
|
2094
|
+
if ((provider.authMode ?? "key") === "key" && registryEntryForProviderDestination(provider)?.id === "opencode-go") {
|
|
2088
2095
|
return fetchOpenCodeGoQuota(name, provider);
|
|
2089
2096
|
}
|
|
2090
2097
|
if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) {
|
|
@@ -31,9 +31,15 @@ export type InboundWire = "responses" | "chat" | "anthropic";
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* A per-model wire default: a bare string applies to every inbound, while the object
|
|
34
|
-
* form
|
|
34
|
+
* form may scope the default to listed inbound protocols and authentication modes.
|
|
35
35
|
*/
|
|
36
|
-
export type ModelWireDefault = string | {
|
|
36
|
+
export type ModelWireDefault = string | {
|
|
37
|
+
wire: string;
|
|
38
|
+
inbound: readonly InboundWire[];
|
|
39
|
+
authModes?: readonly ProviderAuthKind[];
|
|
40
|
+
/** Whether this registry-selected route may relay a caller-owned service_tier. */
|
|
41
|
+
forwardCallerServiceTier?: boolean;
|
|
42
|
+
};
|
|
37
43
|
|
|
38
44
|
export interface ResponsesTerminalRepairPolicy {
|
|
39
45
|
/** Quiet time after a structurally complete output graph before synthesizing completion. */
|
|
@@ -1017,6 +1023,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1017
1023
|
// grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung.
|
|
1018
1024
|
models: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
|
|
1019
1025
|
defaultModel: "grok-4.5",
|
|
1026
|
+
// The current Grok CLI catalog declares both subscription models as native Responses
|
|
1027
|
+
// backends. Keep API-key and translated Chat/Anthropic callers on their existing wire;
|
|
1028
|
+
// Codex Responses traffic can relay xAI's SSE as it arrives instead of waiting for the
|
|
1029
|
+
// Chat Completions compatibility stream to flush at the end of a reasoning turn.
|
|
1030
|
+
modelWireDefaults: {
|
|
1031
|
+
"grok-4.6": {
|
|
1032
|
+
wire: "openai-responses",
|
|
1033
|
+
inbound: ["responses"],
|
|
1034
|
+
authModes: ["oauth"],
|
|
1035
|
+
forwardCallerServiceTier: false,
|
|
1036
|
+
},
|
|
1037
|
+
"grok-4.5": {
|
|
1038
|
+
wire: "openai-responses",
|
|
1039
|
+
inbound: ["responses"],
|
|
1040
|
+
authModes: ["oauth"],
|
|
1041
|
+
forwardCallerServiceTier: false,
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1020
1044
|
// Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat
|
|
1021
1045
|
// models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves
|
|
1022
1046
|
// inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to
|
|
@@ -1083,6 +1107,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1083
1107
|
adapter: "anthropic",
|
|
1084
1108
|
baseUrl: "https://api.anthropic.com",
|
|
1085
1109
|
authKind: "oauth",
|
|
1110
|
+
allowBaseUrlOverride: true,
|
|
1086
1111
|
featured: true,
|
|
1087
1112
|
oauthId: "anthropic",
|
|
1088
1113
|
jawcodeBundle: "anthropic",
|
|
@@ -1496,7 +1521,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1496
1521
|
// 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API
|
|
1497
1522
|
// evidence from ai.google.dev does not establish Vertex publisher availability.
|
|
1498
1523
|
{ id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
|
|
1499
|
-
{ id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
|
|
1524
|
+
{ id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
|
|
1500
1525
|
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
|
|
1501
1526
|
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
1502
1527
|
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
@@ -2435,6 +2460,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
2435
2460
|
note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. The same Zen gateway can also short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
|
|
2436
2461
|
dashboardUrl: "https://opencode.ai",
|
|
2437
2462
|
staticHeaders: {
|
|
2463
|
+
// Zen answers a bare runtime User-Agent (Bun/x.y.z) more aggressively than a client
|
|
2464
|
+
// that identifies itself, which is what the 429 in #2067 traced to. The value is
|
|
2465
|
+
// deliberately unversioned: a pinned "opencode-cli/<version>" is a claim about an
|
|
2466
|
+
// install we do not have and goes stale on the vendor's schedule, not ours.
|
|
2467
|
+
// Corroboration, not authority: OmniRoute — an independent open-source broker against
|
|
2468
|
+
// the same Zen upstream — defaults to exactly this pair (userAgent "opencode", client
|
|
2469
|
+
// "desktop") in open-sse/executors/opencode.ts, and got there by RETREATING from its
|
|
2470
|
+
// own earlier "opencode-cli/1.0.0" pin. An operator can still override either value
|
|
2471
|
+
// through the provider headers API; user headers win case-insensitively at route time.
|
|
2472
|
+
"User-Agent": "opencode",
|
|
2438
2473
|
"x-opencode-client": "desktop",
|
|
2439
2474
|
},
|
|
2440
2475
|
modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
|
|
@@ -2591,6 +2626,36 @@ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | un
|
|
|
2591
2626
|
return PROVIDER_REGISTRY.find(entry => entry.id === id);
|
|
2592
2627
|
}
|
|
2593
2628
|
|
|
2629
|
+
/**
|
|
2630
|
+
* Merge a registry row's `staticHeaders` beneath a provider's own headers.
|
|
2631
|
+
*
|
|
2632
|
+
* The field is documented as "merged into every upstream request for this provider", but that
|
|
2633
|
+
* was only ever true for a freshly seeded config: `providerConfigSeed` copies the block once
|
|
2634
|
+
* (`derive.ts`), `enrichProviderFromCatalog` fills it only when the whole block is absent, and
|
|
2635
|
+
* nothing merged it at request time. So an install that predates a header — or that saved any
|
|
2636
|
+
* header of its own — never received the new one, which is exactly what #2067 would have
|
|
2637
|
+
* shipped for every existing opencode-free user.
|
|
2638
|
+
*
|
|
2639
|
+
* The comparison is case-insensitive on purpose. HTTP header names are case-insensitive, but a
|
|
2640
|
+
* plain object spread is not: merging a registry `User-Agent` over a user's `user-agent`
|
|
2641
|
+
* produces two entries that `Headers` serializes as one comma-joined value
|
|
2642
|
+
* ("opencode, custom-agent"), which is a corrupted request rather than an override. The user's
|
|
2643
|
+
* spelling and value both win; the registry only fills names the user has not spoken for.
|
|
2644
|
+
*/
|
|
2645
|
+
export function mergeRegistryStaticHeaders(
|
|
2646
|
+
staticHeaders: Record<string, string> | undefined,
|
|
2647
|
+
userHeaders: Record<string, string> | undefined,
|
|
2648
|
+
): Record<string, string> | undefined {
|
|
2649
|
+
if (!staticHeaders) return userHeaders;
|
|
2650
|
+
if (!userHeaders) return { ...staticHeaders };
|
|
2651
|
+
const claimed = new Set(Object.keys(userHeaders).map(name => name.toLowerCase()));
|
|
2652
|
+
const merged: Record<string, string> = { ...userHeaders };
|
|
2653
|
+
for (const [name, value] of Object.entries(staticHeaders)) {
|
|
2654
|
+
if (!claimed.has(name.toLowerCase())) merged[name] = value;
|
|
2655
|
+
}
|
|
2656
|
+
return merged;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2594
2659
|
/** Whether this registry row's per-model service-tier evidence applies to one configured target. */
|
|
2595
2660
|
export function registryModelServiceTierCapabilityApplies(
|
|
2596
2661
|
entry: Pick<ProviderRegistryEntry, "modelServiceTierCapabilityBaseUrlGuard">,
|
|
@@ -2679,8 +2744,12 @@ export function providerModelWireDefault(
|
|
|
2679
2744
|
if (!entry?.modelWireDefaults || !providerMatchesRegistryTransport(id, provider)) return undefined;
|
|
2680
2745
|
const declared = entry.modelWireDefaults[modelId.trim().toLowerCase()];
|
|
2681
2746
|
if (declared === undefined) return undefined;
|
|
2682
|
-
// A bare string applies to every inbound; the object form
|
|
2683
|
-
if (typeof declared !== "string"
|
|
2747
|
+
// A bare string applies to every inbound/auth mode; the object form may narrow either.
|
|
2748
|
+
if (typeof declared !== "string") {
|
|
2749
|
+
if (!declared.inbound.includes(inbound)) return undefined;
|
|
2750
|
+
const authMode = provider.authMode ?? entry.authKind;
|
|
2751
|
+
if (declared.authModes && !declared.authModes.includes(authMode)) return undefined;
|
|
2752
|
+
}
|
|
2684
2753
|
const wire = typeof declared === "string" ? declared : declared.wire;
|
|
2685
2754
|
return wire !== undefined && allowedWires.has(wire) ? wire : undefined;
|
|
2686
2755
|
}
|
|
@@ -45,7 +45,16 @@ function cloneRegistryWireDefaults(
|
|
|
45
45
|
for (const [modelId, declaration] of Object.entries(defaults)) {
|
|
46
46
|
clone[modelId.trim().toLowerCase()] = typeof declaration === "string"
|
|
47
47
|
? declaration
|
|
48
|
-
: Object.freeze({
|
|
48
|
+
: Object.freeze({
|
|
49
|
+
wire: declaration.wire,
|
|
50
|
+
inbound: Object.freeze([...declaration.inbound]),
|
|
51
|
+
...(declaration.authModes
|
|
52
|
+
? { authModes: Object.freeze([...declaration.authModes]) }
|
|
53
|
+
: {}),
|
|
54
|
+
...(declaration.forwardCallerServiceTier !== undefined
|
|
55
|
+
? { forwardCallerServiceTier: declaration.forwardCallerServiceTier }
|
|
56
|
+
: {}),
|
|
57
|
+
});
|
|
49
58
|
}
|
|
50
59
|
return Object.freeze(clone);
|
|
51
60
|
}
|
|
@@ -68,6 +77,7 @@ function buildFastPolicyAuthority(
|
|
|
68
77
|
const providerCapability = capabilityProvider.supportsServiceTier ?? registry?.supportsServiceTier;
|
|
69
78
|
const authority: FastPolicyAuthority = Object.freeze({
|
|
70
79
|
providerAdapter: provider.adapter,
|
|
80
|
+
providerAuthMode: provider.authMode ?? registry?.authKind ?? "key",
|
|
71
81
|
fastWireDeclaration: cloneFastWire(
|
|
72
82
|
provider.fastWire !== undefined ? provider.fastWire : registry?.fastWire,
|
|
73
83
|
{ freeze: true },
|
|
@@ -241,13 +251,11 @@ export function serviceTierSupportFromPolicy(
|
|
|
241
251
|
): boolean | undefined {
|
|
242
252
|
if (policy.eligibility === "eligible") return true;
|
|
243
253
|
if (policy.eligibility === "unclassified") {
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
// unknown, as does every unclassified Responses-wire route.
|
|
250
|
-
if (policy.adapter === "openai-chat" && !policy.forwardCallerTier) return false;
|
|
254
|
+
// An unclassified route that cannot forward a caller tier has definitive negative
|
|
255
|
+
// evidence even when its adapter can normally serialize service_tier. This covers both
|
|
256
|
+
// Chat routes without chatServiceTier and a registry default that explicitly closes a
|
|
257
|
+
// subscription gateway. Generic unclassified Responses routes still project unknown.
|
|
258
|
+
if (!policy.forwardCallerTier) return false;
|
|
251
259
|
return undefined;
|
|
252
260
|
}
|
|
253
261
|
return false;
|
package/src/responses/parser.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { previousResponseReplayPrefixLength } from "./state";
|
|
|
20
20
|
import { decodeReasoningEnvelope } from "./reasoning-envelope";
|
|
21
21
|
import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
|
|
22
22
|
import { extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
|
|
23
|
+
import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat";
|
|
23
24
|
|
|
24
25
|
function isObj(v: unknown): v is Record<string, unknown> {
|
|
25
26
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -214,15 +215,8 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
|
|
|
214
215
|
// Expose as a function so chat models can call it; the bridge relays it as a tool_search_call.
|
|
215
216
|
out.push({
|
|
216
217
|
name: "tool_search",
|
|
217
|
-
description: (t
|
|
218
|
-
parameters: (
|
|
219
|
-
type: "object",
|
|
220
|
-
properties: {
|
|
221
|
-
query: { type: "string", description: "Search query for tools to load." },
|
|
222
|
-
limit: { type: "number", description: "Maximum number of tools to return." },
|
|
223
|
-
},
|
|
224
|
-
required: ["query"],
|
|
225
|
-
}) as Record<string, unknown>,
|
|
218
|
+
description: toolSearchDescription(t),
|
|
219
|
+
parameters: normalizeParameters(toolSearchParameters(t)),
|
|
226
220
|
toolSearch: true,
|
|
227
221
|
});
|
|
228
222
|
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
export const TOOL_SEARCH_FUNCTION_NAME = "tool_search";
|
|
2
|
+
export const TOOL_SEARCH_DEFAULT_DESCRIPTION = "Search for additional tools to load for the next turn.";
|
|
3
|
+
const TOOL_SEARCH_WIRE_ALIAS_PREFIX = "opencodex_tool_search";
|
|
4
|
+
|
|
5
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function toolSearchDescription(tool: unknown): string {
|
|
10
|
+
return isPlainObject(tool) && typeof tool.description === "string"
|
|
11
|
+
? tool.description
|
|
12
|
+
: TOOL_SEARCH_DEFAULT_DESCRIPTION;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function toolSearchParameters(tool: unknown): Record<string, unknown> {
|
|
16
|
+
if (isPlainObject(tool) && isPlainObject(tool.parameters)) return tool.parameters;
|
|
17
|
+
// Fresh per parse/build: downstream normalizers are allowed to clone or extend schemas, and a
|
|
18
|
+
// shared mutable default would couple otherwise unrelated requests.
|
|
19
|
+
return {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
query: { type: "string", description: "Search query for tools to load." },
|
|
23
|
+
limit: { type: "number", description: "Maximum number of tools to return." },
|
|
24
|
+
},
|
|
25
|
+
required: ["query"],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectToolDeclarationNames(tools: unknown[], names: Set<string>, namespace?: string): void {
|
|
30
|
+
for (const tool of tools) {
|
|
31
|
+
if (!isPlainObject(tool) || tool.type === "tool_search") continue;
|
|
32
|
+
if (tool.type === "function" && isPlainObject(tool.function) && typeof tool.function.name === "string") {
|
|
33
|
+
names.add(tool.function.name);
|
|
34
|
+
}
|
|
35
|
+
if (typeof tool.name === "string") {
|
|
36
|
+
names.add(tool.name);
|
|
37
|
+
if (namespace) names.add(`${namespace}__${tool.name}`);
|
|
38
|
+
}
|
|
39
|
+
if (tool.type === "namespace" && Array.isArray(tool.tools)) {
|
|
40
|
+
collectToolDeclarationNames(tool.tools, names, typeof tool.name === "string" ? tool.name : undefined);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function declaredToolNames(body: Record<string, unknown>): Set<string> {
|
|
46
|
+
const names = new Set<string>();
|
|
47
|
+
if (Array.isArray(body.tools)) collectToolDeclarationNames(body.tools, names);
|
|
48
|
+
if (Array.isArray(body.input)) {
|
|
49
|
+
for (const item of body.input) {
|
|
50
|
+
if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
|
|
51
|
+
collectToolDeclarationNames(item.tools, names);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return names;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasToolSearchDeclaration(tools: unknown[]): boolean {
|
|
59
|
+
return tools.some(tool => isPlainObject(tool) && tool.type === "tool_search");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function chooseToolSearchWireName(usedNames: ReadonlySet<string>): string {
|
|
63
|
+
if (!usedNames.has(TOOL_SEARCH_FUNCTION_NAME)) return TOOL_SEARCH_FUNCTION_NAME;
|
|
64
|
+
if (!usedNames.has(TOOL_SEARCH_WIRE_ALIAS_PREFIX)) return TOOL_SEARCH_WIRE_ALIAS_PREFIX;
|
|
65
|
+
for (let suffix = 2; ; suffix++) {
|
|
66
|
+
const candidate = `${TOOL_SEARCH_WIRE_ALIAS_PREFIX}_${suffix}`;
|
|
67
|
+
if (!usedNames.has(candidate)) return candidate;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rewriteToolList(tools: unknown[], wireName: string): { tools: unknown[]; changed: boolean } {
|
|
72
|
+
let changed = false;
|
|
73
|
+
const rewritten = tools.map(tool => {
|
|
74
|
+
if (!isPlainObject(tool) || tool.type !== "tool_search") return tool;
|
|
75
|
+
const {
|
|
76
|
+
execution: _execution,
|
|
77
|
+
defer_loading: _deferLoading,
|
|
78
|
+
...rest
|
|
79
|
+
} = tool;
|
|
80
|
+
changed = true;
|
|
81
|
+
return {
|
|
82
|
+
...rest,
|
|
83
|
+
type: "function",
|
|
84
|
+
name: wireName,
|
|
85
|
+
description: toolSearchDescription(tool),
|
|
86
|
+
parameters: toolSearchParameters(tool),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
return changed ? { tools: rewritten, changed: true } : { tools, changed: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rewriteToolChoice(choice: unknown, wireName: string): unknown {
|
|
93
|
+
if (!isPlainObject(choice)) return choice;
|
|
94
|
+
if (choice.type === "tool_search") {
|
|
95
|
+
return { type: "function", name: wireName };
|
|
96
|
+
}
|
|
97
|
+
if (choice.type !== "allowed_tools" || !Array.isArray(choice.tools)) return choice;
|
|
98
|
+
let changed = false;
|
|
99
|
+
const tools = choice.tools.map(tool => {
|
|
100
|
+
if (!isPlainObject(tool) || tool.type !== "tool_search") return tool;
|
|
101
|
+
changed = true;
|
|
102
|
+
return { type: "function", name: wireName };
|
|
103
|
+
});
|
|
104
|
+
return changed ? { ...choice, tools } : choice;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toolChoiceAllowsPrivateSearch(choice: unknown): boolean {
|
|
108
|
+
if (choice === undefined || choice === null || choice === "auto" || choice === "required") return true;
|
|
109
|
+
if (choice === "none") return false;
|
|
110
|
+
if (!isPlainObject(choice)) return true;
|
|
111
|
+
if (choice.type === "tool_search") return true;
|
|
112
|
+
if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
|
|
113
|
+
return choice.tools.some(tool => isPlainObject(tool) && tool.type === "tool_search");
|
|
114
|
+
}
|
|
115
|
+
// A forced ordinary function named `tool_search` is distinct from the private tool kind.
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function upstreamToolSearchItemId(id: unknown): unknown {
|
|
120
|
+
if (typeof id !== "string") return id;
|
|
121
|
+
return id.startsWith("tsc_") ? `fc_${id.slice(4)}` : id;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toolSearchArgumentsText(value: unknown): string {
|
|
125
|
+
if (typeof value === "string") return value;
|
|
126
|
+
return JSON.stringify(isPlainObject(value) ? value : {});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toolSearchOutputText(value: Record<string, unknown>): string {
|
|
130
|
+
const payload: Record<string, unknown> = {
|
|
131
|
+
tools: Array.isArray(value.tools) ? value.tools : [],
|
|
132
|
+
};
|
|
133
|
+
if (typeof value.status === "string") payload.status = value.status;
|
|
134
|
+
return JSON.stringify(payload);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function rewriteHistoryItem(item: Record<string, unknown>, wireName: string): Record<string, unknown> {
|
|
138
|
+
if (item.type === "tool_search_call") {
|
|
139
|
+
const {
|
|
140
|
+
execution: _execution,
|
|
141
|
+
arguments: argumentsValue,
|
|
142
|
+
id,
|
|
143
|
+
...rest
|
|
144
|
+
} = item;
|
|
145
|
+
return {
|
|
146
|
+
...rest,
|
|
147
|
+
type: "function_call",
|
|
148
|
+
...(id === undefined ? {} : { id: upstreamToolSearchItemId(id) }),
|
|
149
|
+
name: wireName,
|
|
150
|
+
arguments: toolSearchArgumentsText(argumentsValue),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (item.type === "tool_search_output") {
|
|
154
|
+
const {
|
|
155
|
+
execution: _execution,
|
|
156
|
+
id: _id,
|
|
157
|
+
tools: _tools,
|
|
158
|
+
status: _status,
|
|
159
|
+
...rest
|
|
160
|
+
} = item;
|
|
161
|
+
return {
|
|
162
|
+
...rest,
|
|
163
|
+
type: "function_call_output",
|
|
164
|
+
output: toolSearchOutputText(item),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return item;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Third-party Responses gateways generally implement the public function-tool schema, not Codex's
|
|
172
|
+
* private client-executed `tool_search` declaration. Lower only request catalogs sent to a
|
|
173
|
+
* noncanonical upstream; the caller-facing response is restored separately.
|
|
174
|
+
*/
|
|
175
|
+
export function rewriteRoutedToolSearchForUpstream(body: unknown): {
|
|
176
|
+
body: unknown;
|
|
177
|
+
names: Set<string>;
|
|
178
|
+
} {
|
|
179
|
+
const names = new Set<string>();
|
|
180
|
+
if (!isPlainObject(body)) return { body, names };
|
|
181
|
+
|
|
182
|
+
const topLevelSearch = Array.isArray(body.tools) && hasToolSearchDeclaration(body.tools);
|
|
183
|
+
const inputItems = Array.isArray(body.input) ? body.input : [];
|
|
184
|
+
const additionalSearch = inputItems.some(item =>
|
|
185
|
+
isPlainObject(item)
|
|
186
|
+
&& item.type === "additional_tools"
|
|
187
|
+
&& Array.isArray(item.tools)
|
|
188
|
+
&& hasToolSearchDeclaration(item.tools));
|
|
189
|
+
const historySearch = inputItems.some(item =>
|
|
190
|
+
isPlainObject(item)
|
|
191
|
+
&& (item.type === "tool_search_call" || item.type === "tool_search_output"));
|
|
192
|
+
if (!topLevelSearch && !additionalSearch && !historySearch) return { body, names };
|
|
193
|
+
|
|
194
|
+
const wireName = chooseToolSearchWireName(declaredToolNames(body));
|
|
195
|
+
const declarationChanged = topLevelSearch || additionalSearch;
|
|
196
|
+
|
|
197
|
+
let tools = body.tools;
|
|
198
|
+
if (Array.isArray(tools)) {
|
|
199
|
+
const result = rewriteToolList(tools, wireName);
|
|
200
|
+
tools = result.tools;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let input = body.input;
|
|
204
|
+
let historyLowered = false;
|
|
205
|
+
if (Array.isArray(input)) {
|
|
206
|
+
input = input.map(item => {
|
|
207
|
+
if (!isPlainObject(item)) return item;
|
|
208
|
+
if (item.type === "additional_tools" && Array.isArray(item.tools)) {
|
|
209
|
+
const result = rewriteToolList(item.tools, wireName);
|
|
210
|
+
return result.changed ? { ...item, tools: result.tools } : item;
|
|
211
|
+
}
|
|
212
|
+
const rewritten = rewriteHistoryItem(item, wireName);
|
|
213
|
+
if (rewritten !== item) historyLowered = true;
|
|
214
|
+
return rewritten;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A turn can replay `tool_search_call` history WITHOUT re-declaring the tool — Codex normally
|
|
219
|
+
// sends the declaration, but a history-only body is legal. The history is lowered to
|
|
220
|
+
// `function_call` either way, so restoration has to be armed on that too: leaving `names`
|
|
221
|
+
// empty there would hand the client a public `function_call` for what it issued as a private
|
|
222
|
+
// search call, and the round trip would silently stop matching.
|
|
223
|
+
if ((declarationChanged || historyLowered) && toolChoiceAllowsPrivateSearch(body.tool_choice)) {
|
|
224
|
+
names.add(wireName);
|
|
225
|
+
}
|
|
226
|
+
const toolChoice = declarationChanged ? rewriteToolChoice(body.tool_choice, wireName) : body.tool_choice;
|
|
227
|
+
return {
|
|
228
|
+
body: {
|
|
229
|
+
...body,
|
|
230
|
+
...(tools !== body.tools ? { tools } : {}),
|
|
231
|
+
...(input !== body.input ? { input } : {}),
|
|
232
|
+
...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}),
|
|
233
|
+
},
|
|
234
|
+
names,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function toolSearchItemId(id: unknown): unknown {
|
|
239
|
+
if (typeof id !== "string") return id;
|
|
240
|
+
return id.startsWith("fc_") ? `tsc_${id.slice(3)}` : id;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function toolSearchArguments(value: unknown): Record<string, unknown> {
|
|
244
|
+
if (isPlainObject(value)) return value;
|
|
245
|
+
if (typeof value !== "string" || value.length === 0) return {};
|
|
246
|
+
try {
|
|
247
|
+
const parsed: unknown = JSON.parse(value);
|
|
248
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
249
|
+
} catch {
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function restoreRoutedToolSearchCalls(
|
|
255
|
+
value: unknown,
|
|
256
|
+
names: ReadonlySet<string>,
|
|
257
|
+
): { value: unknown; changed: boolean } {
|
|
258
|
+
if (Array.isArray(value)) {
|
|
259
|
+
let changed = false;
|
|
260
|
+
const restored = value.map(entry => {
|
|
261
|
+
const result = restoreRoutedToolSearchCalls(entry, names);
|
|
262
|
+
changed ||= result.changed;
|
|
263
|
+
return result.value;
|
|
264
|
+
});
|
|
265
|
+
return changed ? { value: restored, changed: true } : { value, changed: false };
|
|
266
|
+
}
|
|
267
|
+
if (!isPlainObject(value)) return { value, changed: false };
|
|
268
|
+
|
|
269
|
+
let changed = false;
|
|
270
|
+
const restored: Record<string, unknown> = {};
|
|
271
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
272
|
+
const result = restoreRoutedToolSearchCalls(entry, names);
|
|
273
|
+
restored[key] = result.value;
|
|
274
|
+
changed ||= result.changed;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (value.type === "function_call" && typeof value.name === "string" && names.has(value.name)) {
|
|
278
|
+
restored.type = "tool_search_call";
|
|
279
|
+
restored.id = toolSearchItemId(value.id);
|
|
280
|
+
restored.execution = "client";
|
|
281
|
+
restored.arguments = toolSearchArguments(value.arguments);
|
|
282
|
+
delete restored.name;
|
|
283
|
+
changed = true;
|
|
284
|
+
}
|
|
285
|
+
return changed ? { value: restored, changed: true } : { value, changed: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function restoreRoutedToolSearchCallsInJson(
|
|
289
|
+
text: string,
|
|
290
|
+
names: ReadonlySet<string>,
|
|
291
|
+
): string {
|
|
292
|
+
if (names.size === 0) return text;
|
|
293
|
+
let payload: unknown;
|
|
294
|
+
try {
|
|
295
|
+
payload = JSON.parse(text);
|
|
296
|
+
} catch {
|
|
297
|
+
return text;
|
|
298
|
+
}
|
|
299
|
+
const restored = restoreRoutedToolSearchCalls(payload, names);
|
|
300
|
+
return restored.changed ? JSON.stringify(restored.value) : text;
|
|
301
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { assertProviderDestinationAllowed } from "./lib/destination-policy";
|
|
|
14
14
|
import { redactSecretString, redactUrlForLog } from "./lib/redact";
|
|
15
15
|
import {
|
|
16
16
|
PROVIDER_REGISTRY,
|
|
17
|
+
mergeRegistryStaticHeaders,
|
|
17
18
|
providerCodexAccountMode,
|
|
18
19
|
registryModelServiceTierCapabilityApplies,
|
|
19
20
|
} from "./providers/registry";
|
|
@@ -296,6 +297,11 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
296
297
|
? mergePositiveNumberCaps(registryEntry.modelContextWindows, provider.modelContextWindows)
|
|
297
298
|
: mergeRecordFill(registryEntry.modelContextWindows, provider.modelContextWindows);
|
|
298
299
|
const modelInputModalities = mergeRecordFill(registryEntry.modelInputModalities, provider.modelInputModalities);
|
|
300
|
+
// Registry static headers are documented as applying to every upstream request, so they are
|
|
301
|
+
// filled at resolve time rather than only at seed time: a config written before a header
|
|
302
|
+
// existed, or one carrying any header of its own, would otherwise never receive it. User
|
|
303
|
+
// headers win, matched case-insensitively so an override replaces rather than duplicates.
|
|
304
|
+
const headers = mergeRegistryStaticHeaders(registryEntry.staticHeaders, provider.headers);
|
|
299
305
|
const modelMaxInputTokens = providerName === OPENAI_API_PROVIDER_ID
|
|
300
306
|
? mergePositiveNumberCaps(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens)
|
|
301
307
|
: mergeRecordFill(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens);
|
|
@@ -372,6 +378,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
372
378
|
authMode: canonicalAuthMode,
|
|
373
379
|
apiKey: resolvedApiKey,
|
|
374
380
|
...(staticModelCatalog ? { liveModels: false } : {}),
|
|
381
|
+
...(headers ? { headers } : {}),
|
|
375
382
|
// Backfill the Google wire mode + Vertex project/location from the registry when the user
|
|
376
383
|
// config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes
|
|
377
384
|
// through the correct branch (CCA/Vertex) instead of falling back to AI Studio.
|