@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.
Files changed (45) hide show
  1. package/gui/dist/assets/{index-7jlKgmJd.js → index-D2sP-biU.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +59 -0
  5. package/src/adapters/base.ts +2 -0
  6. package/src/adapters/google-antigravity-replay.ts +16 -8
  7. package/src/adapters/google.ts +21 -4
  8. package/src/adapters/openai-chat.ts +151 -54
  9. package/src/adapters/openai-responses.ts +37 -0
  10. package/src/cli/index.ts +19 -6
  11. package/src/codex/account-usability.ts +3 -0
  12. package/src/codex/auth-api.ts +22 -5
  13. package/src/codex/auth-context.ts +55 -2
  14. package/src/codex/catalog/metadata.ts +17 -3
  15. package/src/codex/catalog/native-models.ts +22 -14
  16. package/src/codex/catalog/sync.ts +57 -11
  17. package/src/codex/convergence.ts +61 -13
  18. package/src/codex/model-entitlements.ts +353 -0
  19. package/src/codex/quota.ts +28 -3
  20. package/src/codex/routing.ts +14 -8
  21. package/src/generated/compatibility-version.json +51 -39
  22. package/src/lib/destination-policy.ts +47 -0
  23. package/src/lib/shadow-call.ts +15 -0
  24. package/src/oauth/index.ts +33 -5
  25. package/src/oauth/store.ts +11 -5
  26. package/src/providers/fastwire.ts +39 -8
  27. package/src/providers/quota.ts +9 -2
  28. package/src/providers/registry.ts +74 -5
  29. package/src/providers/service-tier.ts +16 -8
  30. package/src/responses/parser.ts +3 -9
  31. package/src/responses/tool-search-compat.ts +301 -0
  32. package/src/router.ts +7 -0
  33. package/src/routing/capability.ts +26 -9
  34. package/src/routing/compatibility/behavior.ts +41 -3
  35. package/src/server/chat-native.ts +11 -2
  36. package/src/server/index.ts +54 -8
  37. package/src/server/management/agent-settings-routes.ts +16 -2
  38. package/src/server/request-log.ts +31 -0
  39. package/src/server/responses/compact.ts +54 -7
  40. package/src/server/responses/core.ts +246 -39
  41. package/src/server/responses/responses-field-backfill.ts +88 -6
  42. package/src/server/responses/terminal-guard.ts +10 -0
  43. package/src/server/responses-tool-search-repair.ts +217 -0
  44. package/src/server/system-env.ts +74 -5
  45. 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
- ): string | undefined {
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" && !declared.inbound.includes(inbound)) return undefined;
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
- return MODEL_ADAPTER_OVERRIDE_ALLOWED.has(wire) ? wire : undefined;
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(authority.registryWireDefaults, modelId, inbound);
147
- if (registryDefault !== undefined) return { adapter: registryDefault, hardPinned: false };
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(authority, modelId, inbound);
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"];
@@ -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
- if ((provider.authMode ?? "key") === "key" && name === "opencode-go") {
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 applies only to the listed inbound protocols.
34
+ * form may scope the default to listed inbound protocols and authentication modes.
35
35
  */
36
- export type ModelWireDefault = string | { wire: string; inbound: readonly InboundWire[] };
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 only to the listed ones.
2683
- if (typeof declared !== "string" && !declared.inbound.includes(inbound)) return undefined;
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({ wire: declaration.wire, inbound: Object.freeze([...declaration.inbound]) });
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
- // B1 regression guard: an unclassified chat-wire route whose final adapter will not
245
- // forward any tier cannot serialize service_tier, so projecting "unknown" would let
246
- // require.serviceTier: "unsupported" routing stop matching groq/ollama-class providers
247
- // that main projected as false. Chat + no forwarding stays a definitive false; a
248
- // chat route with chatServiceTier: true (forwarding allowed) keeps the historical
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;
@@ -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.description as string) ?? "Search for additional tools to load for the next turn.",
218
- parameters: (isObj(t.parameters) ? t.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.