@yansigit/opencodex 2.31.3 → 2.33.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 (176) hide show
  1. package/README.md +2 -2
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-DKLr4LTE.js +102 -0
  4. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +6 -5
  7. package/src/adapters/anthropic.ts +25 -13
  8. package/src/adapters/azure.ts +20 -4
  9. package/src/adapters/base.ts +5 -1
  10. package/src/adapters/command-code.ts +42 -10
  11. package/src/adapters/cursor/live-models.ts +8 -0
  12. package/src/adapters/cursor/live-transport.ts +1 -1
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/protobuf-events.ts +158 -7
  15. package/src/adapters/cursor/protobuf-request.ts +33 -15
  16. package/src/adapters/cursor/request-builder.ts +4 -3
  17. package/src/adapters/cursor/tool-definitions.ts +27 -1
  18. package/src/adapters/cursor/types.ts +4 -3
  19. package/src/adapters/cursor.ts +9 -0
  20. package/src/adapters/google-antigravity-replay.ts +2 -2
  21. package/src/adapters/google-antigravity-wire.ts +7 -0
  22. package/src/adapters/google-errors.ts +6 -2
  23. package/src/adapters/google-http.ts +30 -7
  24. package/src/adapters/google-truncation.ts +5 -0
  25. package/src/adapters/google-wire-compiler.ts +38 -6
  26. package/src/adapters/google.ts +154 -33
  27. package/src/adapters/kiro-tools.ts +20 -9
  28. package/src/adapters/kiro.ts +0 -3
  29. package/src/adapters/openai-chat.ts +9 -0
  30. package/src/adapters/openai-responses.ts +4 -1
  31. package/src/adapters/tool-catalog-nudge.ts +1 -1
  32. package/src/adapters/xai-web-search.ts +7 -2
  33. package/src/bridge.ts +133 -24
  34. package/src/claude/context-windows.ts +16 -9
  35. package/src/cli/dispatch.ts +50 -2
  36. package/src/cli/doctor.ts +26 -13
  37. package/src/cli/help.ts +4 -3
  38. package/src/cli/index.ts +20 -6
  39. package/src/cli/models.ts +13 -3
  40. package/src/cli/observe.ts +20 -5
  41. package/src/cli/provider.ts +8 -1
  42. package/src/cli/registry.ts +7 -5
  43. package/src/cli/status.ts +25 -1
  44. package/src/cli/system-restart-client.ts +1 -1
  45. package/src/cli/usage-report.ts +134 -0
  46. package/src/codex/app-server-processes.ts +3 -1
  47. package/src/codex/auth-api.ts +4 -2
  48. package/src/codex/autostart-health.ts +16 -0
  49. package/src/codex/catalog/aggregation.ts +12 -0
  50. package/src/codex/catalog/effort.ts +42 -12
  51. package/src/codex/catalog/metadata.ts +27 -1
  52. package/src/codex/catalog/model-metadata.ts +593 -0
  53. package/src/codex/catalog/parsing.ts +71 -27
  54. package/src/codex/catalog/provider-fetch.ts +189 -33
  55. package/src/codex/catalog/sync.ts +6 -5
  56. package/src/codex/convergence.ts +5 -0
  57. package/src/codex/desktop-app-restart.ts +342 -0
  58. package/src/codex/history-job.ts +32 -3
  59. package/src/codex/history-manifest.ts +112 -0
  60. package/src/codex/history-migration-guardian.ts +5 -5
  61. package/src/codex/history-provider.ts +825 -247
  62. package/src/codex/history-worker.ts +8 -5
  63. package/src/codex/inject.ts +49 -21
  64. package/src/codex/injected-marker.ts +1 -1
  65. package/src/codex/internal/history-writer.ts +4 -3
  66. package/src/codex/native-profile-startup.ts +157 -27
  67. package/src/codex/native-residue.ts +26 -33
  68. package/src/codex/shim.ts +56 -3
  69. package/src/combos/failover.ts +27 -0
  70. package/src/compatibility/index.ts +26 -0
  71. package/src/compatibility/manifest.ts +253 -0
  72. package/src/compatibility/openai-responses.ts +81 -0
  73. package/src/config/atomic-write.ts +219 -0
  74. package/src/config/paths.ts +40 -0
  75. package/src/config/process-state.ts +308 -0
  76. package/src/config/provider-validation.ts +214 -0
  77. package/src/config.ts +153 -814
  78. package/src/generated/compatibility-version.json +232 -148
  79. package/src/images/loop.ts +37 -6
  80. package/src/images/plan.ts +5 -4
  81. package/src/integrations/ownership-policy.ts +141 -0
  82. package/src/integrations/ownership.ts +10 -0
  83. package/src/integrations/state.ts +44 -5
  84. package/src/integrations/writer.ts +6 -0
  85. package/src/lib/azure-identity.ts +154 -0
  86. package/src/lib/bounded-body.ts +14 -2
  87. package/src/lib/debug.ts +42 -0
  88. package/src/lib/errors.ts +14 -0
  89. package/src/lib/process-control.ts +2 -1
  90. package/src/lib/provider-outbound.ts +45 -33
  91. package/src/lib/provider-tls-profile.ts +309 -0
  92. package/src/lib/proxy-env.ts +49 -0
  93. package/src/lib/redact.ts +10 -1
  94. package/src/lib/state-store-registrations.ts +2 -0
  95. package/src/lib/tool-argument-integers.ts +56 -5
  96. package/src/oauth/antigravity-routing.ts +282 -236
  97. package/src/oauth/callback-server.ts +22 -2
  98. package/src/oauth/command-code.ts +5 -16
  99. package/src/oauth/google-antigravity.ts +42 -5
  100. package/src/oauth/health.ts +1 -1
  101. package/src/oauth/index.ts +15 -3
  102. package/src/oauth/kimi.ts +9 -1
  103. package/src/oauth/open-browser-choice.ts +26 -0
  104. package/src/oauth/store.ts +6 -0
  105. package/src/providers/antigravity-quota.ts +3 -1
  106. package/src/providers/api-keys.ts +2 -1
  107. package/src/providers/auto-compact-budget.ts +65 -0
  108. package/src/providers/derive.ts +4 -0
  109. package/src/providers/key-failover.ts +5 -1
  110. package/src/providers/openai-tiers.ts +5 -0
  111. package/src/providers/provider-id-rewrite.ts +1 -0
  112. package/src/providers/quota.ts +59 -13
  113. package/src/providers/registry.ts +4 -2
  114. package/src/providers/request-pacing.ts +33 -6
  115. package/src/providers/xai-transport.ts +21 -0
  116. package/src/reasoning-effort.ts +19 -2
  117. package/src/responses/apply-patch-envelope.ts +63 -0
  118. package/src/responses/custom-tool-compat.ts +132 -38
  119. package/src/responses/google-provider-options.ts +36 -0
  120. package/src/responses/namespace-tool-compat.ts +84 -4
  121. package/src/responses/parser.ts +14 -2
  122. package/src/responses/provider-opaque-metadata.ts +3 -3
  123. package/src/responses/reasoning-replay-cache.ts +81 -3
  124. package/src/responses/schema.ts +37 -0
  125. package/src/responses/state.ts +94 -4
  126. package/src/router.ts +8 -2
  127. package/src/server/auth-cors.ts +37 -7
  128. package/src/server/images.ts +19 -35
  129. package/src/server/index.ts +102 -21
  130. package/src/server/local-management-read-client.ts +1 -1
  131. package/src/server/local-provider-reload-client.ts +1 -1
  132. package/src/server/management/agent-settings-routes.ts +206 -16
  133. package/src/server/management/combo-routes.ts +6 -0
  134. package/src/server/management/config-routes.ts +35 -6
  135. package/src/server/management/context.ts +1 -1
  136. package/src/server/management/logs-usage-routes.ts +27 -6
  137. package/src/server/management/model-routes.ts +8 -4
  138. package/src/server/management/model-rows.ts +4 -0
  139. package/src/server/management/native-integration-routes.ts +2 -1
  140. package/src/server/management/oauth-account-routes.ts +25 -4
  141. package/src/server/management/provider-capability-config.ts +1 -1
  142. package/src/server/management/provider-routes.ts +113 -15
  143. package/src/server/management/routing-profile-routes.ts +3 -0
  144. package/src/server/management/system-restart.ts +1 -1
  145. package/src/server/port-reclaim.ts +1 -1
  146. package/src/server/proxy-liveness.ts +2 -1
  147. package/src/server/request-log-conversation.ts +30 -0
  148. package/src/server/request-log.ts +21 -0
  149. package/src/server/responses/agent-task-recovery.ts +1 -1
  150. package/src/server/responses/codex-auth-error.ts +55 -0
  151. package/src/server/responses/combo-stream-preflight.ts +171 -0
  152. package/src/server/responses/compact.ts +36 -22
  153. package/src/server/responses/core.ts +584 -247
  154. package/src/server/responses/empty-completion-guard.ts +35 -6
  155. package/src/server/responses/fetch-helpers.ts +20 -102
  156. package/src/server/responses/v2-native-parent-override.ts +59 -0
  157. package/src/server/responses/ws-upstream.ts +75 -2
  158. package/src/server/responses-custom-tool-repair.ts +41 -5
  159. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  160. package/src/service.ts +9 -5
  161. package/src/types/config.ts +16 -1
  162. package/src/types/provider.ts +16 -0
  163. package/src/types/request.ts +34 -1
  164. package/src/types/tools.ts +114 -11
  165. package/src/types.ts +7 -1
  166. package/src/update/index.ts +5 -4
  167. package/src/update/job.ts +3 -1
  168. package/src/update/transactional-install.mjs +8 -1
  169. package/src/usage/log.ts +16 -8
  170. package/src/usage/summary.ts +201 -8
  171. package/src/vision/describe.ts +18 -13
  172. package/src/web-search/executor.ts +10 -3
  173. package/src/web-search/gemini-executor.ts +6 -4
  174. package/src/web-search/loop.ts +42 -6
  175. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
  176. package/gui/dist/assets/index-Cxt5fZMP.js +0 -102
@@ -33,6 +33,8 @@ interface Waiter {
33
33
  modelId?: string;
34
34
  providerIntervalMs: number;
35
35
  modelIntervalMs: number;
36
+ providerJitterMs: number;
37
+ modelJitterMs: number;
36
38
  queuedAt: number;
37
39
  signal?: AbortSignal;
38
40
  resolve: () => void;
@@ -54,6 +56,7 @@ export interface RequestPacingRuntime {
54
56
  setTimer: (callback: () => void, delayMs: number) => unknown;
55
57
  clearTimer: (handle: unknown) => void;
56
58
  enqueueMicrotask: (callback: () => void) => void;
59
+ random: () => number;
57
60
  }
58
61
 
59
62
  export interface ProviderRequestPacingStatus {
@@ -71,6 +74,7 @@ const defaultRuntime: RequestPacingRuntime = {
71
74
  setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
72
75
  clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
73
76
  enqueueMicrotask: queueMicrotask,
77
+ random: Math.random,
74
78
  };
75
79
  let runtime = defaultRuntime;
76
80
  let lastReconciledGeneration = 0;
@@ -90,6 +94,19 @@ function normalizedInterval(rule: RequestPacingRule | undefined): number {
90
94
  return Math.max(rpmInterval, fixedInterval);
91
95
  }
92
96
 
97
+ function normalizedJitter(rule: RequestPacingRule | undefined): number {
98
+ return rule?.jitterMs !== undefined && Number.isInteger(rule.jitterMs)
99
+ ? Math.min(60_000, Math.max(0, rule.jitterMs))
100
+ : 0;
101
+ }
102
+
103
+ function jitterDelay(jitterMs: number): number {
104
+ if (jitterMs <= 0) return 0;
105
+ const sample = runtime.random();
106
+ if (!Number.isFinite(sample)) return 0;
107
+ return Math.min(jitterMs, Math.floor(Math.max(0, sample) * jitterMs));
108
+ }
109
+
93
110
  export function requestPacingIntervalMs(provider: OcxProviderConfig, modelId?: string): number {
94
111
  const policy = provider.requestPacing;
95
112
  if (!policy?.enabled) return 0;
@@ -100,12 +117,17 @@ export function requestPacingIntervalMs(provider: OcxProviderConfig, modelId?: s
100
117
  function requestPacingIntervals(provider: OcxProviderConfig, modelId?: string): {
101
118
  providerIntervalMs: number;
102
119
  modelIntervalMs: number;
120
+ providerJitterMs: number;
121
+ modelJitterMs: number;
103
122
  } {
104
123
  const policy = provider.requestPacing;
105
- if (!policy?.enabled) return { providerIntervalMs: 0, modelIntervalMs: 0 };
124
+ if (!policy?.enabled) return { providerIntervalMs: 0, modelIntervalMs: 0, providerJitterMs: 0, modelJitterMs: 0 };
125
+ const model = modelId ? policy.models?.[modelId] : undefined;
106
126
  return {
107
127
  providerIntervalMs: normalizedInterval(policy),
108
- modelIntervalMs: modelId ? normalizedInterval(policy.models?.[modelId]) : 0,
128
+ modelIntervalMs: normalizedInterval(model),
129
+ providerJitterMs: normalizedJitter(policy),
130
+ modelJitterMs: normalizedJitter(model),
109
131
  };
110
132
  }
111
133
 
@@ -186,9 +208,9 @@ function runQueue(providerName: string, state: ProviderPacer): void {
186
208
  const startedAt = runtime.now();
187
209
  state.lastStartedAt = startedAt;
188
210
  state.lastModelId = waiter.modelId;
189
- state.providerNextStartAt = startedAt + waiter.providerIntervalMs;
190
- if (waiter.modelId && waiter.modelIntervalMs > 0) {
191
- state.modelNextStartAt.set(waiter.modelId, startedAt + waiter.modelIntervalMs);
211
+ state.providerNextStartAt = startedAt + waiter.providerIntervalMs + jitterDelay(waiter.providerJitterMs);
212
+ if (waiter.modelId && (waiter.modelIntervalMs > 0 || waiter.modelJitterMs > 0)) {
213
+ state.modelNextStartAt.set(waiter.modelId, startedAt + waiter.modelIntervalMs + jitterDelay(waiter.modelJitterMs));
192
214
  }
193
215
  waiter.resolve();
194
216
  runtime.enqueueMicrotask(() => runQueue(providerName, state));
@@ -201,7 +223,12 @@ export async function waitForProviderRequestSlot(
201
223
  signal?: AbortSignal,
202
224
  ): Promise<void> {
203
225
  const intervals = requestPacingIntervals(provider, modelId);
204
- if (Math.max(intervals.providerIntervalMs, intervals.modelIntervalMs) <= 0) return;
226
+ if (Math.max(
227
+ intervals.providerIntervalMs,
228
+ intervals.modelIntervalMs,
229
+ intervals.providerJitterMs,
230
+ intervals.modelJitterMs,
231
+ ) <= 0) return;
205
232
  if (signal?.aborted) throw abortReason(signal);
206
233
 
207
234
  const state = pacers.get(providerName) ?? {
@@ -4,6 +4,27 @@ import { resolveGithubCopilotTransport } from "./github-copilot-transport";
4
4
 
5
5
  export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
6
6
 
7
+ /** The two hosts that serve xAI's Responses API: the public API and the Grok CLI proxy. */
8
+ const XAI_RESPONSES_HOSTS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
9
+
10
+ /**
11
+ * True when this provider's Responses traffic terminates at xAI itself.
12
+ *
13
+ * Probed 2026-08-22 one field per request: the two hosts accept and refuse exactly the same
14
+ * web_search fields, so they are one dialect rather than two. Matching is exact-host over
15
+ * https, which keeps lookalikes (`api.x.ai.evil.test`) and nonstandard ports out.
16
+ */
17
+ export function isXaiResponsesDestination(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
18
+ try {
19
+ const url = new URL(provider.baseUrl);
20
+ return url.protocol === "https:"
21
+ && XAI_RESPONSES_HOSTS.has(url.hostname.toLowerCase())
22
+ && (url.port === "" || url.port === "443");
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
7
28
  export const XAI_GROK_COMPATIBILITY = {
8
29
  version: "0.2.93",
9
30
  userAgent: "opencodex-grok/0.2.93",
@@ -14,6 +14,16 @@ export const CODEX_REASONING_LEVELS: { effort: string; description: string }[] =
14
14
  const CODEX_REASONING_ORDER = CODEX_REASONING_LEVELS.map(l => l.effort);
15
15
  const CODEX_REASONING_SET = new Set(CODEX_REASONING_ORDER);
16
16
 
17
+ /**
18
+ * Sentinel wire value in reasoningEffortMap / modelReasoningEffortMap to explicitly
19
+ * omit the reasoning_effort field from the upstream wire request (issue #2356).
20
+ */
21
+ export const REASONING_EFFORT_OMIT_SENTINEL = "__omit__";
22
+
23
+ export function isReasoningEffortOmitted(wireEffort: string | undefined): boolean {
24
+ return wireEffort === REASONING_EFFORT_OMIT_SENTINEL;
25
+ }
26
+
17
27
  /** True when `effort` is a member of the Codex reasoning ladder (low..ultra). */
18
28
  export function isCodexReasoningEffort(effort: string): boolean {
19
29
  return CODEX_REASONING_SET.has(effort);
@@ -170,7 +180,10 @@ export function mapReasoningEffort(provider: OcxProviderConfig, modelId: string,
170
180
  const boundary = requested === "ultra" ? "max" : requested;
171
181
 
172
182
  const wireMap = reasoningEffortMapFor(provider, modelId);
173
- if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, boundary)) return wireMap[boundary];
183
+ if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, boundary)) {
184
+ const mapped = wireMap[boundary];
185
+ return mapped === REASONING_EFFORT_OMIT_SENTINEL ? undefined : mapped;
186
+ }
174
187
 
175
188
  const supported = configuredReasoningEfforts(provider, modelId);
176
189
  const codexEffort = supported !== undefined ? clampToSupportedCodexEffort(boundary, supported) : requestToCodexEffort(boundary);
@@ -178,6 +191,10 @@ export function mapReasoningEffort(provider: OcxProviderConfig, modelId: string,
178
191
 
179
192
  // Belt for the odd config where the supported ladder is ultra-only and the clamp lands on it.
180
193
  const wire = codexEffort === "ultra" ? "max" : codexEffort;
181
- if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, wire)) return wireMap[wire];
194
+ if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, wire)) {
195
+ const mapped = wireMap[wire];
196
+ return mapped === REASONING_EFFORT_OMIT_SENTINEL ? undefined : mapped;
197
+ }
198
+ if (wire === REASONING_EFFORT_OMIT_SENTINEL) return undefined;
182
199
  return wire;
183
200
  }
@@ -0,0 +1,63 @@
1
+ // Representation repair for top-level Codex apply_patch custom-tool payloads.
2
+ //
3
+ // Some routed models decorate the first and last lines as
4
+ // `*** Begin Patch ***` / `*** End Patch ***`. Codex rejects those otherwise
5
+ // valid custom-tool payloads. Repair is deliberately limited to a complete,
6
+ // structurally recognizable top-level patch: arbitrary `exec` JavaScript is
7
+ // caller-authored executable input and must remain byte-identical.
8
+ //
9
+ // This is the same intent boundary as `src/lib/tool-argument-integers.ts`:
10
+ // repair the one faithful reading, leave genuine patch content alone.
11
+
12
+ const PATCH_BEGIN = "*** Begin Patch";
13
+ const PATCH_END = "*** End Patch";
14
+ const TOP_LEVEL_PATCH_ENVELOPE = /^(\*\*\* Begin Patch(?: \*\*\*)?)(\r?\n)([\s\S]*)(\r?\n)(\*\*\* End Patch(?: \*\*\*)?)(\r?\n)?$/;
15
+ const PATCH_OPERATION_LINE = /^\*\*\* (?:Add|Update|Delete) File: .+$/m;
16
+
17
+ /** Unwrap the `{input:string}` function-call wrapper used for freeform tools. */
18
+ export function unwrapFreeformToolInput(argumentsText: unknown): string {
19
+ if (typeof argumentsText !== "string") return "";
20
+ try {
21
+ const parsed: unknown = JSON.parse(argumentsText);
22
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
23
+ const input = (parsed as { input?: unknown }).input;
24
+ if (typeof input === "string") return input;
25
+ }
26
+ } catch {
27
+ // The string is the freeform body, not nested JSON.
28
+ }
29
+ return argumentsText;
30
+ }
31
+
32
+ /**
33
+ * Strip trailing `***` only from the outer lines of one complete patch.
34
+ * Internal patch content, incomplete envelopes, and non-patch text are exact
35
+ * pass-throughs.
36
+ */
37
+ export function normalizeApplyPatchDelimiters(text: string): string {
38
+ const match = TOP_LEVEL_PATCH_ENVELOPE.exec(text);
39
+ if (!match) return text;
40
+ const [, begin, beginBreak, body, endBreak, end, trailingBreak = ""] = match;
41
+ if (!PATCH_OPERATION_LINE.test(body)) return text;
42
+ if (begin === PATCH_BEGIN && end === PATCH_END) return text;
43
+ return `${PATCH_BEGIN}${beginBreak}${body}${endBreak}${PATCH_END}${trailingBreak}`;
44
+ }
45
+
46
+ /**
47
+ * Repair freeform input before Codex sees it.
48
+ *
49
+ * Only a bare or reserved-`functions` `apply_patch` payload may receive delimiter
50
+ * repair. Remote namespaces own their grammar; those bodies and every other
51
+ * freeform input are unwrapped and left byte-exact.
52
+ */
53
+ export function repairFreeformToolInput(
54
+ argumentsText: unknown,
55
+ toolName = "",
56
+ namespace?: string,
57
+ ): string {
58
+ const unwrapped = unwrapFreeformToolInput(argumentsText);
59
+ const ownsApplyPatchGrammar = namespace === undefined || namespace === "functions";
60
+ return ownsApplyPatchGrammar && toolName === "apply_patch"
61
+ ? normalizeApplyPatchDelimiters(unwrapped)
62
+ : unwrapped;
63
+ }
@@ -1,4 +1,9 @@
1
1
  import { namespacedToolName } from "../types";
2
+ import {
3
+ normalizeApplyPatchDelimiters,
4
+ repairFreeformToolInput,
5
+ unwrapFreeformToolInput,
6
+ } from "./apply-patch-envelope";
2
7
  import { collectResponsesToolGroups } from "./tool-groups";
3
8
 
4
9
  const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]);
@@ -15,19 +20,42 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
20
  return !!value && typeof value === "object" && !Array.isArray(value);
16
21
  }
17
22
 
18
- function customToolInput(argumentsText: unknown): string {
19
- if (typeof argumentsText !== "string") return "";
20
- try {
21
- const parsed = JSON.parse(argumentsText) as unknown;
22
- if (isPlainObject(parsed) && typeof parsed.input === "string") return parsed.input;
23
- } catch { /* malformed arguments stay visible to the client */ }
24
- return argumentsText;
25
- }
26
-
27
23
  function customToolWireName(namespace: string | undefined, name: string): string {
28
24
  return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name);
29
25
  }
30
26
 
27
+ function toolChoiceAllowsRoutedCustomTool(
28
+ body: unknown,
29
+ wireName: string,
30
+ candidateNames: ReadonlySet<string>,
31
+ ): boolean {
32
+ if (!isPlainObject(body)) return true;
33
+ const choice = body.tool_choice;
34
+ if (choice === undefined || choice === null || choice === "auto" || choice === "required") {
35
+ return true;
36
+ }
37
+ if (choice === "none") return false;
38
+ if (!isPlainObject(choice)) return true;
39
+
40
+ const selectorAllows = (selector: unknown): boolean => {
41
+ if (!isPlainObject(selector) || typeof selector.name !== "string") return false;
42
+ if (selector.type !== "custom") return false;
43
+ if (typeof selector.namespace === "string") {
44
+ return customToolWireName(selector.namespace, selector.name) === wireName;
45
+ }
46
+ if (selector.name === wireName) return true;
47
+ const suffix = `__${selector.name}`;
48
+ const candidates = [...candidateNames].filter(name => name.endsWith(suffix));
49
+ return candidates.length === 1 && candidates[0] === wireName;
50
+ };
51
+
52
+ if (choice.type === "function" || choice.type === "custom") return selectorAllows(choice);
53
+ if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
54
+ return choice.tools.some(selectorAllows);
55
+ }
56
+ return false;
57
+ }
58
+
31
59
  /** Final upstream identity of a call, including a namespace restored by an earlier rewrite. */
32
60
  export function routedCustomToolWireName(value: unknown): string | undefined {
33
61
  if (!isPlainObject(value) || typeof value.name !== "string") return undefined;
@@ -38,12 +66,13 @@ export function routedCustomToolWireName(value: unknown): string | undefined {
38
66
  }
39
67
 
40
68
  /**
41
- * Names of converted custom declarations after namespace lowering. Restoration uses these exact
42
- * wire identities so same-named function and custom children in different namespaces stay distinct.
69
+ * Names of custom declarations after namespace lowering. The selection flag separates converted
70
+ * names from native passthrough names while keeping same-named function and custom children distinct.
43
71
  */
44
72
  function collectRoutedCustomToolWireNames(
45
73
  body: unknown,
46
74
  supportsResponsesCustomTools?: boolean,
75
+ passthrough = false,
47
76
  ): Set<string> {
48
77
  const names = new Set<string>();
49
78
  const groups = collectResponsesToolGroups(body);
@@ -64,7 +93,7 @@ function collectRoutedCustomToolWireNames(
64
93
  if (
65
94
  tool.type === "custom"
66
95
  && typeof tool.name === "string"
67
- && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools)
96
+ && routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) === passthrough
68
97
  ) {
69
98
  names.add(tool.name);
70
99
  continue;
@@ -77,7 +106,8 @@ function collectRoutedCustomToolWireNames(
77
106
  isPlainObject(child)
78
107
  && child.type === "custom"
79
108
  && typeof child.name === "string"
80
- && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools)
109
+ && routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) === passthrough
110
+ && (!passthrough || tool.name === BUILTIN_FUNCTIONS_NAMESPACE)
81
111
  && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name))
82
112
  ) names.add(customToolWireName(tool.name, child.name));
83
113
  }
@@ -85,7 +115,6 @@ function collectRoutedCustomToolWireNames(
85
115
  }
86
116
  return names;
87
117
  }
88
-
89
118
  export function customToolItemId(id: unknown): unknown {
90
119
  if (typeof id !== "string") return id;
91
120
  return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id;
@@ -203,64 +232,129 @@ export function rewriteRoutedCustomToolsForUpstream(
203
232
  ): {
204
233
  body: unknown;
205
234
  names: Set<string>;
235
+ repairNames: Set<string>;
206
236
  } {
207
237
  const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools);
208
238
  const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools);
209
- if (conversionNames.size === 0) return { body, names };
239
+ const repairNames = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools, true);
240
+ for (const name of repairNames) {
241
+ if (!toolChoiceAllowsRoutedCustomTool(body, name, repairNames)) repairNames.delete(name);
242
+ }
243
+ if (conversionNames.size === 0) return { body, names, repairNames };
210
244
  const callIds = new Set<string>();
211
245
  collectConvertedCallIds(body, conversionNames, callIds);
212
- return { body: rewriteForUpstream(body, conversionNames, callIds), names };
246
+ return { body: rewriteForUpstream(body, conversionNames, callIds), names, repairNames };
213
247
  }
214
248
 
215
249
  export function restoreRoutedCustomCalls(
216
250
  value: unknown,
217
251
  names: ReadonlySet<string>,
252
+ repairNames: ReadonlySet<string> = new Set(),
218
253
  ): { value: unknown; changed: boolean } {
219
- if (Array.isArray(value)) {
254
+ if (!isPlainObject(value)) return { value, changed: false };
255
+
256
+ const restoreItem = (item: unknown): { value: unknown; changed: boolean } => {
257
+ if (!isPlainObject(item)) return { value: item, changed: false };
258
+ const wireName = routedCustomToolWireName(item);
259
+ if (
260
+ item.type === "function_call"
261
+ && typeof item.name === "string"
262
+ && wireName !== undefined
263
+ && names.has(wireName)
264
+ ) {
265
+ const restored: Record<string, unknown> = {
266
+ ...item,
267
+ type: "custom_tool_call",
268
+ id: customToolItemId(item.id),
269
+ input: repairFreeformToolInput(
270
+ item.arguments,
271
+ item.name,
272
+ typeof item.namespace === "string" ? item.namespace : undefined,
273
+ ),
274
+ };
275
+ delete restored.arguments;
276
+ return { value: restored, changed: true };
277
+ }
278
+ if (
279
+ item.type === "custom_tool_call"
280
+ && typeof item.name === "string"
281
+ && wireName !== undefined
282
+ && repairNames.has(wireName)
283
+ && typeof item.input === "string"
284
+ ) {
285
+ const input = normalizeApplyPatchDelimiters(item.input);
286
+ if (input !== item.input) return { value: { ...item, input }, changed: true };
287
+ }
288
+ return { value: item, changed: false };
289
+ };
290
+
291
+ const restoreOutput = (output: unknown): { value: unknown; changed: boolean } => {
292
+ if (!Array.isArray(output)) return { value: output, changed: false };
220
293
  let changed = false;
221
- const restored = value.map(entry => {
222
- const result = restoreRoutedCustomCalls(entry, names);
294
+ const restored = output.map(item => {
295
+ const result = restoreItem(item);
223
296
  changed ||= result.changed;
224
297
  return result.value;
225
298
  });
226
- return changed ? { value: restored, changed: true } : { value, changed: false };
227
- }
228
- if (!isPlainObject(value)) return { value, changed: false };
299
+ return changed ? { value: restored, changed: true } : { value: output, changed: false };
300
+ };
229
301
 
230
302
  let changed = false;
231
- const restored: Record<string, unknown> = {};
232
- for (const [key, entry] of Object.entries(value)) {
233
- const result = restoreRoutedCustomCalls(entry, names);
234
- restored[key] = result.value;
235
- changed ||= result.changed;
303
+ const restored: Record<string, unknown> = { ...value };
304
+ const output = restoreOutput(value.output);
305
+ if (output.changed) {
306
+ restored.output = output.value;
307
+ changed = true;
236
308
  }
237
309
 
238
- const wireName = routedCustomToolWireName(value);
239
- if (value.type === "function_call" && wireName !== undefined && names.has(wireName)) {
240
- restored.type = "custom_tool_call";
241
- restored.id = customToolItemId(value.id);
242
- restored.input = customToolInput(value.arguments);
243
- delete restored.arguments;
244
- changed = true;
310
+ if (
311
+ (value.type === "response.output_item.added" || value.type === "response.output_item.done")
312
+ && isPlainObject(value.item)
313
+ ) {
314
+ const item = restoreItem(value.item);
315
+ if (item.changed) {
316
+ restored.item = item.value;
317
+ changed = true;
318
+ }
319
+ }
320
+
321
+ if (
322
+ typeof value.type === "string"
323
+ && value.type.startsWith("response.")
324
+ && isPlainObject(value.response)
325
+ ) {
326
+ const response = restoreRoutedCustomCalls(value.response, names, repairNames);
327
+ if (response.changed) {
328
+ restored.response = response.value;
329
+ changed = true;
330
+ }
245
331
  }
332
+
246
333
  return changed ? { value: restored, changed: true } : { value, changed: false };
247
334
  }
248
335
 
249
336
  export function restoreRoutedCustomCallsInJson(
250
337
  text: string,
251
338
  names: ReadonlySet<string>,
339
+ repairNames: ReadonlySet<string> = new Set(),
252
340
  ): string {
253
- if (names.size === 0) return text;
341
+ if (names.size === 0 && repairNames.size === 0) return text;
254
342
  let payload: unknown;
255
343
  try {
256
344
  payload = JSON.parse(text);
257
345
  } catch {
258
346
  return text;
259
347
  }
260
- const restored = restoreRoutedCustomCalls(payload, names);
348
+ const restored = restoreRoutedCustomCalls(payload, names, repairNames);
261
349
  return restored.changed ? JSON.stringify(restored.value) : text;
262
350
  }
263
351
 
264
- export function unwrapRoutedCustomToolArguments(argumentsText: unknown): string {
265
- return customToolInput(argumentsText);
352
+ export function unwrapRoutedCustomToolArguments(
353
+ argumentsText: unknown,
354
+ toolName = "",
355
+ namespace?: string,
356
+ ): string {
357
+ return toolName
358
+ ? repairFreeformToolInput(argumentsText, toolName, namespace)
359
+ : unwrapFreeformToolInput(argumentsText);
266
360
  }
@@ -0,0 +1,36 @@
1
+ import { effectiveGoogleMode } from "../providers/registry";
2
+ import type { OcxParsedRequest, OcxProviderConfig } from "../types";
3
+
4
+ const AI_STUDIO_CACHED_CONTENT = /^cachedContents\/[^/?#\s]+$/;
5
+ const VERTEX_CACHED_CONTENT = /^projects\/[^/?#\s]+\/locations\/[^/?#\s]+\/cachedContents\/[^/?#\s]+$/;
6
+
7
+ export function googleProviderOptionsRouteError(
8
+ parsed: Pick<OcxParsedRequest, "options">,
9
+ route: {
10
+ providerName: string;
11
+ provider: OcxProviderConfig;
12
+ adapterName: string;
13
+ },
14
+ ): string | undefined {
15
+ if (!parsed.options.providerOptions?.google) return undefined;
16
+ const mode = effectiveGoogleMode(route.providerName, route.provider);
17
+ if (mode === "cloud-code-assist") {
18
+ return "provider_options.google is not supported on Google Cloud Code Assist routes";
19
+ }
20
+ if (route.adapterName !== "google" || (mode !== "ai-studio" && mode !== "vertex")) {
21
+ return "provider_options.google is supported only on Google AI Studio or Vertex routes";
22
+ }
23
+ if (mode === "ai-studio") {
24
+ const cachedContent = parsed.options.providerOptions.google.cachedContent;
25
+ if (cachedContent && VERTEX_CACHED_CONTENT.test(cachedContent)) {
26
+ return "provider_options.google.cached_content must use cachedContents/{id} on Google AI Studio routes";
27
+ }
28
+ }
29
+ if (mode === "vertex") {
30
+ const cachedContent = parsed.options.providerOptions.google.cachedContent;
31
+ if (cachedContent && AI_STUDIO_CACHED_CONTENT.test(cachedContent)) {
32
+ return "provider_options.google.cached_content must use projects/{project}/locations/{location}/cachedContents/{id} on Vertex routes";
33
+ }
34
+ }
35
+ return undefined;
36
+ }
@@ -4,6 +4,14 @@ import { collectResponsesToolGroups } from "./tool-groups";
4
4
  export interface RoutedNamespaceToolIdentity {
5
5
  namespace: string;
6
6
  name: string;
7
+ /**
8
+ * The kind the tool was DECLARED as. Restoration and `tool_choice` matching both
9
+ * need it: a wire name identifies which tool, not which kind of call may carry it,
10
+ * so without this a tool declared `function` could be selected by a `custom`
11
+ * selector and come back as a `custom_tool_call` — the same name/kind mismatch
12
+ * that motivated narrowing the alias map in the first place.
13
+ */
14
+ kind: "function" | "custom";
7
15
  }
8
16
 
9
17
  export type RoutedNamespaceToolAliases = ReadonlyMap<string, RoutedNamespaceToolIdentity>;
@@ -138,7 +146,10 @@ function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan {
138
146
  addSelector(selectors, `${parsed.namespace}.${childName}`, wireName);
139
147
  addSelector(selectors, childName, wireName);
140
148
  if (parsed.namespace !== BUILTIN_FUNCTIONS_NAMESPACE) {
141
- aliases.set(wireName, { namespace: parsed.namespace, name: childName });
149
+ // A child declared `custom` stays custom; everything else lowers to a
150
+ // function, which is how `buildTools` flattens it upstream.
151
+ const kind = child.type === "custom" ? "custom" : "function";
152
+ aliases.set(wireName, { namespace: parsed.namespace, name: childName, kind });
142
153
  }
143
154
  }
144
155
  }
@@ -206,18 +217,34 @@ function rewriteToolList(
206
217
  * the failure this layer exists to prevent, and this layer's own response restoration is what put
207
218
  * the key on the item.
208
219
  */
220
+ /**
221
+ * A selector's `namespace` is either absent — meaning "unqualified, resolve the bare
222
+ * name" — or a string naming the group. A present-but-non-string value is neither, and
223
+ * a malformed selector must not authorize anything: not through the unqualified
224
+ * fallback, and not by happening to carry an already-flattened wire name, which would
225
+ * otherwise match the alias map exactly and arm it anyway.
226
+ */
227
+ function hasMalformedNamespace(value: Record<string, unknown>): boolean {
228
+ return "namespace" in value && typeof value.namespace !== "string";
229
+ }
230
+
209
231
  function rewriteNamedSelector(
210
232
  value: unknown,
211
233
  plan: NamespaceRewritePlan,
212
234
  bareFallback: boolean,
213
235
  ): unknown {
214
236
  if (!isPlainObject(value) || typeof value.name !== "string") return value;
215
- if (typeof value.namespace !== "string") {
237
+ // Malformed: hand it back untouched so no rewrite occurs. Authorization rejects it
238
+ // separately — returning it unchanged is not by itself enough, because the name it
239
+ // carries may already BE a wire name.
240
+ if (hasMalformedNamespace(value)) return value;
241
+ const namespace = value.namespace;
242
+ if (typeof namespace !== "string") {
216
243
  if (!bareFallback) return value;
217
244
  const wireName = plan.selectors.get(value.name) ?? undefined;
218
245
  return wireName === undefined || wireName === value.name ? value : { ...value, name: wireName };
219
246
  }
220
- const { namespace, ...rest } = value;
247
+ const { namespace: _dropped, ...rest } = value;
221
248
  const wireName = plan.identities.get(loweredIdentity(namespace, value.name))
222
249
  ?? loweredWireName(namespace, value.name);
223
250
  return { ...rest, name: wireName };
@@ -239,6 +266,59 @@ function rewriteToolChoice(value: unknown, plan: NamespaceRewritePlan): unknown
239
266
  return changed ? { ...value, tools } : value;
240
267
  }
241
268
 
269
+ /**
270
+ * Keep response restoration inside the caller's per-turn tool authorization boundary. The
271
+ * upstream sees every flattened declaration even when `tool_choice` narrows the tools it may call,
272
+ * so its output cannot be trusted merely because a wire name appeared in that catalog.
273
+ */
274
+ function authorizedAliases(
275
+ aliases: Map<string, RoutedNamespaceToolIdentity>,
276
+ toolChoice: unknown,
277
+ ): Map<string, RoutedNamespaceToolIdentity> {
278
+ if (toolChoice === undefined || toolChoice === "auto" || toolChoice === "required") return aliases;
279
+ if (toolChoice === "none" || !isPlainObject(toolChoice)) return new Map();
280
+
281
+ // name -> the kind the selector claimed. A selector authorizes a tool only when it
282
+ // names it AND agrees about what kind of tool it is.
283
+ let authorized: Map<string, "function" | "custom">;
284
+ if (
285
+ (toolChoice.type === "function" || toolChoice.type === "custom")
286
+ && typeof toolChoice.name === "string"
287
+ ) {
288
+ // A malformed namespace makes the whole selector untrustworthy, even when its
289
+ // name is already a flattened wire name that would match the alias map exactly.
290
+ if (hasMalformedNamespace(toolChoice)) return new Map();
291
+ authorized = new Map([[toolChoice.name, toolChoice.type]]);
292
+ } else if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) {
293
+ authorized = new Map();
294
+ for (const tool of toolChoice.tools) {
295
+ // Match the top-level branch above: only a function/custom selector can
296
+ // authorize a client namespace call. `allowed_tools` entries are typed
297
+ // `{type: string}` by the schema, so the accepted set is open-ended and
298
+ // an allowlist is the only closure that also covers kinds added later.
299
+ // Without this, `{type: "file_search", name: "<wire-name>"}` keeps the
300
+ // alias, and an upstream `function_call` carrying that name is restored
301
+ // into a namespace call the caller never permitted.
302
+ if (!isPlainObject(tool)) continue;
303
+ if (tool.type !== "function" && tool.type !== "custom") continue;
304
+ if (typeof tool.name !== "string") continue;
305
+ if (hasMalformedNamespace(tool)) continue;
306
+ authorized.set(tool.name, tool.type);
307
+ }
308
+ } else {
309
+ // An explicit selector for another tool kind does not authorize a client namespace call.
310
+ return new Map();
311
+ }
312
+
313
+ // The kind must agree too. A wire name says WHICH tool, not what kind of call may
314
+ // carry it, so a `custom` selector naming a tool declared `function` is the same
315
+ // name/kind mismatch as a `file_search` selector naming it — narrower, but the
316
+ // same class, and `allowed_tools[].type` accepts any string so both are reachable.
317
+ return new Map(
318
+ [...aliases].filter(([wireName, identity]) => authorized.get(wireName) === identity.kind),
319
+ );
320
+ }
321
+
242
322
  function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Set<string>): unknown {
243
323
  if (!isPlainObject(item)) return item;
244
324
  if (item.type === "additional_tools" && Array.isArray(item.tools)) {
@@ -292,7 +372,7 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
292
372
  ...(input !== body.input ? { input } : {}),
293
373
  ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}),
294
374
  },
295
- aliases: plan.aliases,
375
+ aliases: authorizedAliases(plan.aliases, toolChoice),
296
376
  };
297
377
  }
298
378