@bitkyc08/opencodex 2.14.1 → 2.14.2

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 (44) hide show
  1. package/gui/dist/assets/{index-DWhX3yMp.css → index-DUCH59lJ.css} +1 -1
  2. package/gui/dist/assets/index-DUyQeU1j.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/command-code.ts +15 -4
  6. package/src/adapters/cursor/request-builder.ts +54 -10
  7. package/src/adapters/cursor/tool-definitions.ts +24 -0
  8. package/src/adapters/kiro.ts +10 -1
  9. package/src/adapters/openai-chat.ts +5 -3
  10. package/src/adapters/openai-responses.ts +109 -0
  11. package/src/adapters/tool-catalog-nudge.ts +26 -4
  12. package/src/bridge.ts +50 -3
  13. package/src/cli/init.ts +4 -17
  14. package/src/codex/catalog/effort.ts +2 -1
  15. package/src/codex/catalog/metadata.ts +62 -12
  16. package/src/codex/catalog/native-models.ts +27 -0
  17. package/src/codex/catalog/parsing.ts +17 -2
  18. package/src/codex/catalog/provider-fetch.ts +47 -5
  19. package/src/codex/catalog/sync.ts +21 -7
  20. package/src/codex/catalog.ts +1 -1
  21. package/src/config.ts +79 -4
  22. package/src/generated/compatibility-version.json +48 -36
  23. package/src/lib/app-owned-memory-stores.ts +22 -0
  24. package/src/lib/tool-argument-integers.ts +158 -0
  25. package/src/oauth/nous.ts +58 -9
  26. package/src/providers/base-url-choices.ts +10 -0
  27. package/src/providers/command-code-efforts.ts +18 -0
  28. package/src/providers/model-rename-migration.ts +202 -0
  29. package/src/providers/model-rename-startup.ts +28 -0
  30. package/src/providers/openai-tier-startup.ts +31 -2
  31. package/src/providers/quota.ts +9 -2
  32. package/src/providers/registry.ts +12 -5
  33. package/src/responses/spill-store.ts +5 -1
  34. package/src/responses/state.ts +50 -2
  35. package/src/server/index.ts +2 -1
  36. package/src/server/management/api-key-usage.ts +31 -5
  37. package/src/server/management/logs-usage-routes.ts +48 -10
  38. package/src/server/management/provider-routes.ts +2 -1
  39. package/src/server/management/usage-summary-cache.ts +7 -1
  40. package/src/server/responses/collaboration.ts +12 -2
  41. package/src/server/responses/core.ts +33 -16
  42. package/src/server/startup-health-cache.ts +12 -0
  43. package/src/usage/log.ts +430 -12
  44. package/gui/dist/assets/index-DuaUVm_d.js +0 -76
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DuaUVm_d.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DWhX3yMp.css">
19
+ <script type="module" crossorigin src="/assets/index-DUyQeU1j.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-DUCH59lJ.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.14.1",
3
+ "version": "2.14.2",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -428,10 +428,21 @@ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string
428
428
  const canonicalId = canonicalCommandCodeModelId(modelId);
429
429
  const supported = commandCodeReasoningEfforts(canonicalId) ?? configuredReasoningEfforts(provider, canonicalId);
430
430
  if (!supported) return undefined;
431
- // Command Code's official profiles describe xhigh and ultra as the CLI labels that map to
432
- // the wire value `max`; preserve that mapping without advertising a synthetic tier.
433
- const wire = (requested === "xhigh" || requested === "ultra") && supported.includes("max") ? "max" : requested;
434
- return supported.includes(wire) ? wire : undefined;
431
+ // Only remap xhigh/ultra→max for models whose official profile documents that
432
+ // aliasing (deepseek v4, glm-5.2). Muse Spark's upstream accepts xhigh as a
433
+ // distinct wire value and rejects ultra, so it must not be collapsed.
434
+ let wire = requested;
435
+ const lower = canonicalId.toLowerCase();
436
+ const needsAlias =
437
+ lower === "deepseek/deepseek-v4-pro" ||
438
+ lower === "deepseek/deepseek-v4-flash" ||
439
+ lower === "zai-org/glm-5.2";
440
+ if (requested === "xhigh" && !supported.includes("xhigh") && supported.includes("max")) {
441
+ wire = "max";
442
+ } else if (requested === "ultra" && needsAlias && supported.includes("max")) {
443
+ wire = "max";
444
+ }
445
+ return (supported as readonly string[]).includes(wire) ? wire : undefined;
435
446
  }
436
447
 
437
448
  export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderAdapter {
@@ -21,6 +21,8 @@ import {
21
21
  cursorToolsForActivePrompt,
22
22
  isCursorStructuredEditToolName,
23
23
  isBareCodexShellBridgeTool,
24
+ isCursorExecutionPathTool,
25
+ isCursorWaitTool,
24
26
  } from "./tool-definitions";
25
27
  import { lookupCursorThreadConversation } from "./thread-continuity";
26
28
 
@@ -39,21 +41,25 @@ function explicitlySelectedNames(choice: OcxToolChoice | undefined): Set<string>
39
41
  }
40
42
 
41
43
  function toolPriority(tool: OcxTool, selectedNames: ReadonlySet<string>): number {
42
- // Shell bridge and apply_patch outrank unrelated allowed_tools entries so a large
43
- // selected filler cannot starve the Codex execution path during truncation (#399).
44
+ // Execution path (bare or opencodex-responses `exec` / `exec_command` / `shell_command`)
45
+ // outranks filler so a crowded catalog cannot drop the Codex shell bridge (#399).
46
+ if (isCursorExecutionPathTool(tool)) return 0;
44
47
  if (isBareCodexShellBridgeTool(tool)) return 0;
45
- if (!tool.namespace && tool.name === "apply_patch") return 1;
48
+ // `wait` only resumes a yielded exec cell. Keep it with the execution path, but after
49
+ // `exec` itself so a large wait schema cannot starve the tool that creates the cell.
50
+ if (isCursorWaitTool(tool)) return 1;
51
+ if (!tool.namespace && tool.name === "apply_patch") return 2;
46
52
  // Structured edit tools convert to apply_patch on the return path, so they must survive the
47
53
  // same byte/count truncation as the freeform tool they stand in for (#1017).
48
- if (!tool.namespace && isCursorStructuredEditToolName(tool.name)) return 1;
49
- if (cursorToolChoiceAliases(tool).some(name => selectedNames.has(name))) return 2;
50
- if (tool.loadedFromToolSearch) return 3;
51
- if (!tool.namespace) return 4;
52
- return 5;
54
+ if (!tool.namespace && isCursorStructuredEditToolName(tool.name)) return 2;
55
+ if (cursorToolChoiceAliases(tool).some(name => selectedNames.has(name))) return 3;
56
+ if (tool.loadedFromToolSearch) return 4;
57
+ if (!tool.namespace) return 5;
58
+ return 6;
53
59
  }
54
60
 
55
61
  function isPinnedCursorTool(tool: OcxTool, selectedNames: ReadonlySet<string>): boolean {
56
- return toolPriority(tool, selectedNames) <= 2;
62
+ return toolPriority(tool, selectedNames) <= 3;
57
63
  }
58
64
 
59
65
  /**
@@ -96,7 +102,7 @@ export function applyCursorToolBudget(
96
102
  return true;
97
103
  };
98
104
 
99
- // Phase 1: selected tools + shell bridge + apply_patch (priority <= 2).
105
+ // Phase 1: selected tools + execution path + apply_patch (priority <= 3).
100
106
  // Pins are admitted before filler so a crowded catalog cannot drop the Codex execution path (#399).
101
107
  for (const candidate of candidates) {
102
108
  if (!isPinnedCursorTool(candidate.tool, selectedNames)) continue;
@@ -108,6 +114,44 @@ export function applyCursorToolBudget(
108
114
  tryKeep(candidate.tool);
109
115
  }
110
116
 
117
+ const evictNonExecutionPath = (needBytes: number): void => {
118
+ for (let i = kept.length - 1; i >= 0; i--) {
119
+ const occupant = kept[i];
120
+ if (!occupant || isCursorExecutionPathTool(occupant)) continue;
121
+ kept.splice(i, 1);
122
+ keptSet.delete(occupant);
123
+ keptBytes -= cursorMcpToolEncodedSize(occupant, toolChoice);
124
+ if (kept.length < CURSOR_TOOL_COUNT_LIMIT && keptBytes + needBytes <= CURSOR_TOOL_BYTES_LIMIT) {
125
+ return;
126
+ }
127
+ }
128
+ };
129
+
130
+ // Force-admit at least one execution-path tool when one was eligible. Priority-0
131
+ // admission can still fail if the tool itself is larger than leftover room after
132
+ // earlier same-priority pins; evict wait/patch/filler rather than ship wait-only.
133
+ for (const tool of eligible) {
134
+ if (!isCursorExecutionPathTool(tool) || keptSet.has(tool)) continue;
135
+ const need = cursorMcpToolEncodedSize(tool, toolChoice);
136
+ if (need > CURSOR_TOOL_BYTES_LIMIT) continue;
137
+ evictNonExecutionPath(need);
138
+ tryKeep(tool);
139
+ if (keptSet.has(tool)) break;
140
+ }
141
+
142
+ const eligibleHasExecutionPath = eligible.some(isCursorExecutionPathTool);
143
+ const keptHasExecutionPath = eligible.some(tool => keptSet.has(tool) && isCursorExecutionPathTool(tool));
144
+ // Never advertise `wait` after dropping the tool that creates the exec cell.
145
+ if (eligibleHasExecutionPath && !keptHasExecutionPath) {
146
+ for (const tool of eligible) {
147
+ if (!isCursorWaitTool(tool) || !keptSet.has(tool)) continue;
148
+ keptSet.delete(tool);
149
+ const index = kept.indexOf(tool);
150
+ if (index >= 0) kept.splice(index, 1);
151
+ keptBytes -= cursorMcpToolEncodedSize(tool, toolChoice);
152
+ }
153
+ }
154
+
111
155
  return {
112
156
  tools: eligible.filter(tool => keptSet.has(tool)),
113
157
  // Synthetic tools are pinned in phase 1 and never reported as omitted; the note counts only
@@ -7,6 +7,9 @@ import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from
7
7
  export const OCX_RESPONSES_TOOL_PROVIDER = "opencodex-responses";
8
8
  export const CODEX_EXEC_COMMAND_TOOL = "exec_command";
9
9
  export const CODEX_SHELL_COMMAND_TOOL = "shell_command";
10
+ /** Codex Desktop unified-exec client tool. Companion of `wait`; not an `exec_command` schema alias. */
11
+ export const CODEX_UNIFIED_EXEC_TOOL = "exec";
12
+ export const CODEX_WAIT_TOOL = "wait";
10
13
  export const CODEX_APPLY_PATCH_TOOL = "apply_patch";
11
14
  export const CURSOR_EDIT_FILE_TOOL = "edit_file";
12
15
  export const CURSOR_MULTI_EDIT_TOOL = "multi_edit";
@@ -167,6 +170,27 @@ export function isBareCodexShellBridgeTool(tool: Pick<OcxTool, "namespace" | "na
167
170
  return !tool.namespace && isCodexShellBridgeToolName(tool.name);
168
171
  }
169
172
 
173
+ function isCursorResponsesProvider(namespace: string | undefined): boolean {
174
+ return !namespace || namespace === OCX_RESPONSES_TOOL_PROVIDER;
175
+ }
176
+
177
+ const CURSOR_EXECUTION_PATH_TOOL_NAMES = [
178
+ CODEX_UNIFIED_EXEC_TOOL,
179
+ CODEX_EXEC_COMMAND_TOOL,
180
+ CODEX_SHELL_COMMAND_TOOL,
181
+ ] as const;
182
+
183
+ /** True for the Codex execution path that must survive Cursor transport truncation. */
184
+ export function isCursorExecutionPathTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
185
+ return isCursorResponsesProvider(tool.namespace)
186
+ && (CURSOR_EXECUTION_PATH_TOOL_NAMES as readonly string[]).includes(tool.name);
187
+ }
188
+
189
+ /** `wait` only resumes a yielded exec cell; it is unusable without an execution-path tool. */
190
+ export function isCursorWaitTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
191
+ return isCursorResponsesProvider(tool.namespace) && tool.name === CODEX_WAIT_TOOL;
192
+ }
193
+
170
194
  /** @deprecated Prefer isBareCodexShellBridgeTool; kept for older call sites/tests. */
171
195
  function isBareCodexExecCommandTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
172
196
  return isBareCodexShellBridgeTool(tool);
@@ -455,7 +455,16 @@ export function buildKiroPayload(
455
455
  const boundedAddition = boundedInjectedInstruction(addition, injectedChars);
456
456
  if (boundedAddition) systemParts.push(boundedAddition);
457
457
  }
458
- const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames(kiroToolWireNames(kiroTools));
458
+ // Kiro renames tools to satisfy its wire constraints, so resolve neighbor names through the
459
+ // registry's existing aliases; a bare-name comparison would forbid tools this turn actually
460
+ // advertises. Read the recorded mapping instead of calling `alias()`, which would REGISTER a
461
+ // name for a tool that was never advertised and pollute the collision domain.
462
+ const advertisedAlias = new Map<string, string>();
463
+ for (const [alias, wireName] of registry.nameMap) advertisedAlias.set(wireName, alias);
464
+ const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames(
465
+ kiroToolWireNames(kiroTools),
466
+ name => advertisedAlias.get(name) ?? name,
467
+ );
459
468
  const boundedNudge = toolCatalogNudge ? boundedInjectedInstruction(toolCatalogNudge, injectedChars) : undefined;
460
469
  if (boundedNudge) systemParts.push(boundedNudge);
461
470
  if (completionMode !== "disabled") {
@@ -673,16 +673,18 @@ const VOLCENGINE_ARK_HOSTNAMES = new Set([
673
673
  "ark.ap-southeast.volces.com",
674
674
  ]);
675
675
 
676
- function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
676
+ function isVolcengineArkPaygChatTarget(provider: OcxProviderConfig): boolean {
677
677
  try {
678
- return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
678
+ const url = new URL(provider.baseUrl);
679
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
680
+ return VOLCENGINE_ARK_HOSTNAMES.has(url.hostname) && pathname === "/api/v3";
679
681
  } catch {
680
682
  return false;
681
683
  }
682
684
  }
683
685
 
684
686
  function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
685
- return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
687
+ return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : "";
686
688
  }
687
689
 
688
690
  function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
@@ -400,6 +400,112 @@ function normalizeToolSchemas(body: unknown): unknown {
400
400
  return normalizedBody;
401
401
  }
402
402
 
403
+ function activateDeferredTool(tool: Record<string, unknown>): Record<string, unknown> {
404
+ const { defer_loading: _, ...activeTool } = tool;
405
+ if (tool.type !== "namespace" || !Array.isArray(tool.tools)) return activeTool;
406
+ return {
407
+ ...activeTool,
408
+ tools: tool.tools.map(inner => isPlainObject(inner) ? activateDeferredTool(inner) : inner),
409
+ };
410
+ }
411
+
412
+ function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unknown[] {
413
+ const merged = [...declaredTools];
414
+ let changed = false;
415
+
416
+ for (const candidate of loadedTools) {
417
+ if (!isPlainObject(candidate) || typeof candidate.name !== "string") continue;
418
+ const loaded = activateDeferredTool(candidate);
419
+ if (loaded.type === "namespace" && Array.isArray(loaded.tools)) {
420
+ const namespaceIndex = merged.findIndex(tool =>
421
+ isPlainObject(tool) && tool.type === "namespace" && tool.name === loaded.name
422
+ );
423
+ if (namespaceIndex < 0) {
424
+ merged.push(loaded);
425
+ changed = true;
426
+ continue;
427
+ }
428
+
429
+ const namespace = merged[namespaceIndex];
430
+ if (!isPlainObject(namespace)) continue;
431
+ const namespaceTools = Array.isArray(namespace.tools) ? namespace.tools : [];
432
+ const nextNamespaceTools = [...namespaceTools];
433
+ let namespaceChanged = "defer_loading" in namespace;
434
+ for (const tool of loaded.tools) {
435
+ if (!isPlainObject(tool) || typeof tool.name !== "string") continue;
436
+ const declaredIndex = nextNamespaceTools.findIndex(declared =>
437
+ isPlainObject(declared) && declared.name === tool.name
438
+ );
439
+ if (declaredIndex < 0) {
440
+ nextNamespaceTools.push(tool);
441
+ namespaceChanged = true;
442
+ continue;
443
+ }
444
+ const declared = nextNamespaceTools[declaredIndex];
445
+ if (isPlainObject(declared) && "defer_loading" in declared) {
446
+ nextNamespaceTools[declaredIndex] = activateDeferredTool(declared);
447
+ namespaceChanged = true;
448
+ }
449
+ }
450
+ if (!namespaceChanged) continue;
451
+ const { defer_loading: _, ...activeNamespace } = namespace;
452
+ merged[namespaceIndex] = { ...activeNamespace, tools: nextNamespaceTools };
453
+ changed = true;
454
+ continue;
455
+ }
456
+
457
+ const declaredIndex = merged.findIndex(tool =>
458
+ isPlainObject(tool) && tool.type !== "namespace" && tool.name === loaded.name
459
+ );
460
+ if (declaredIndex < 0) {
461
+ merged.push(loaded);
462
+ changed = true;
463
+ } else {
464
+ const declared = merged[declaredIndex];
465
+ if (isPlainObject(declared) && "defer_loading" in declared) {
466
+ merged[declaredIndex] = activateDeferredTool(declared);
467
+ changed = true;
468
+ }
469
+ }
470
+ }
471
+
472
+ return changed ? merged : declaredTools;
473
+ }
474
+
475
+ /**
476
+ * Client-executed tool search only changes Codex's parsed tool context. Routed passthrough keeps
477
+ * serializing the raw request, so activate those returned definitions for upstreams that do not
478
+ * implement the native deferred-loading handshake themselves.
479
+ */
480
+ function promoteClientLoadedTools(body: unknown): unknown {
481
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
482
+
483
+ const loadedTools = body.input.flatMap(item =>
484
+ isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools)
485
+ ? item.tools
486
+ : []
487
+ );
488
+ if (loadedTools.length === 0) return body;
489
+
490
+ if (Array.isArray(body.tools)) {
491
+ const tools = mergeLoadedTools(body.tools, loadedTools);
492
+ return tools === body.tools ? body : { ...body, tools };
493
+ }
494
+
495
+ const additionalToolsIndex = body.input.findIndex(item =>
496
+ isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)
497
+ );
498
+ if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) };
499
+
500
+ const additionalTools = body.input[additionalToolsIndex];
501
+ if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body;
502
+ const tools = mergeLoadedTools(additionalTools.tools, loadedTools);
503
+ if (tools === additionalTools.tools) return body;
504
+ const input = [...body.input];
505
+ input[additionalToolsIndex] = { ...additionalTools, tools };
506
+ return { ...body, input };
507
+ }
508
+
403
509
  const MAX_RESPONSES_CALL_ID_LENGTH = 64;
404
510
  const REPAIRED_CALL_ID_PREFIX = "call_ocx_";
405
511
  const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length;
@@ -1298,6 +1404,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1298
1404
  if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
1299
1405
  outBody = buildRoutedCompactionBody(outBody);
1300
1406
  }
1407
+ if (!isCanonicalOpenAiForwardProvider(provider)) {
1408
+ outBody = promoteClientLoadedTools(outBody);
1409
+ }
1301
1410
  if (provider.authMode !== "forward") {
1302
1411
  outBody = rewriteRoutedCustomToolsForUpstream(outBody).body;
1303
1412
  }
@@ -6,7 +6,16 @@ import {
6
6
  type OcxProviderConfig,
7
7
  } from "../types";
8
8
 
9
- const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS", "apply_patch"] as const;
9
+ // Tool names that exist only in OTHER agent harnesses (Claude Code and friends). Naming one
10
+ // here tells a routed model not to call it unless this turn's catalog really lists it.
11
+ //
12
+ // `apply_patch` is deliberately absent: it is Codex's own first-class edit tool, not a
13
+ // neighbor's. Under Codex code mode it is reachable as a nested `tools.apply_patch(...)`
14
+ // helper declared inside the `exec` tool description rather than as a top-level wire tool,
15
+ // so a flat catalog check cannot see it and forbidding it pushed routed models into
16
+ // `python3` heredoc edits. The sibling list in `./cursor/tool-definitions.ts` never
17
+ // included it either.
18
+ const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const;
10
19
 
11
20
  function quoteNames(names: readonly string[]): string {
12
21
  return names.map(name => `\`${name}\``).join(", ");
@@ -31,12 +40,21 @@ export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick<OcxProvider
31
40
  }
32
41
  }
33
42
 
34
- export function buildNonOpenAIToolCatalogNudgeFromNames(wireNames: readonly string[] | undefined): string | undefined {
43
+ export function buildNonOpenAIToolCatalogNudgeFromNames(
44
+ wireNames: readonly string[] | undefined,
45
+ toWireName: (name: string) => string = name => name,
46
+ ): string | undefined {
35
47
  const names = uniqueNames(wireNames ?? []);
36
48
  if (names.length === 0) return undefined;
37
49
 
38
50
  const advertised = new Set(names);
39
- const unavailableNeighborNames = NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertised.has(name));
51
+ // Compare in the catalog's own coordinate system. `advertised` holds WIRE names, so a
52
+ // provider that rewrites them (Claude OAuth `custom_`, Anthropic compat `cx_`) would never
53
+ // match a bare neighbor name and would forbid tools the turn actually advertises — the
54
+ // catalog would list `custom_apply_patch` while the same sentence banned `apply_patch`.
55
+ const unavailableNeighborNames = NEIGHBOR_AGENT_TOOL_NAMES.filter(
56
+ name => !advertised.has(name) && !advertised.has(toWireName(name)),
57
+ );
40
58
 
41
59
  return [
42
60
  "Tool contract: use the current tool catalog as ground truth.",
@@ -58,5 +76,9 @@ export function buildNonOpenAIToolCatalogNudgeForTools(
58
76
  const visibleNames = tools
59
77
  ?.filter(toolChoiceToolPredicate(toolChoice))
60
78
  .map(toWireName);
61
- return buildNonOpenAIToolCatalogNudgeFromNames(visibleNames);
79
+ // Neighbor names are bare and un-namespaced, so probe the same transform with a bare tool.
80
+ return buildNonOpenAIToolCatalogNudgeFromNames(
81
+ visibleNames,
82
+ name => toWireName({ name }),
83
+ );
62
84
  }
package/src/bridge.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  OcxReasoningReplayScopeRef,
6
6
  OcxUsage,
7
7
  } from "./types";
8
+ import { coerceIntegerToolArguments } from "./lib/tool-argument-integers";
8
9
  import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
9
10
  import { encodeCompactionSummary } from "./responses/compaction";
10
11
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
@@ -186,6 +187,10 @@ export function bridgeToResponsesSSE(
186
187
  * from this callback instead of re-parsing the bridged SSE.
187
188
  */
188
189
  onUsage?: (usage: OcxUsage | undefined) => void;
190
+ /** Request-visible tool names. When present, an upstream call outside this set fails closed. */
191
+ declaredToolNames?: ReadonlySet<string>;
192
+ /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
193
+ toolParameterSchemas?: ReadonlyMap<string, Record<string, unknown>>;
189
194
  translatorBudget?: TranslatorBudget;
190
195
  /**
191
196
  * Conversation identity for the reasoning replay cache (issue #950).
@@ -579,7 +584,13 @@ export function bridgeToResponsesSSE(
579
584
  // Empty input (no-arg tools like computer_use get_app_state / list_apps) must serialize as
580
585
  // "{}", never "" — Codex echoes the call back as a function_call next turn, and JSON.parse("")
581
586
  // would 400 the whole session ("invalid JSON arguments"), poisoning all later turns.
582
- const argsStr = currentToolCall.args || "{}";
587
+ // #1611: Grok serializes integer arguments through a float, so `120000.0`
588
+ // reaches Codex and is REJECTED before the tool runs. Repair integral floats
589
+ // against the declared schema; a non-integral value stays an error.
590
+ const argsStr = coerceIntegerToolArguments(
591
+ currentToolCall.args || "{}",
592
+ options?.toolParameterSchemas?.get(currentToolCall.name),
593
+ );
583
594
  // Finalize streamed function-call arguments so Codex commits the call (incl. MCP / computer_use).
584
595
  if (!currentToolCall.freeform && !currentToolCall.toolSearch) {
585
596
  emit("response.function_call_arguments.done", {
@@ -974,6 +985,23 @@ export function bridgeToResponsesSSE(
974
985
  if (currentToolCall) closeCurrentToolCall();
975
986
  const mapped = toolNsMap?.get(event.name);
976
987
  const realName = mapped?.name ?? event.name;
988
+ if (options?.declaredToolNames && !options.declaredToolNames.has(event.name)) {
989
+ const failure = responseError(
990
+ 502,
991
+ "upstream_error",
992
+ `routed provider emitted undeclared client tool "${event.name}"; only request-declared tools may be called`,
993
+ );
994
+ emit("response.failed", {
995
+ response: {
996
+ ...responseSnapshot("failed", finishedItems),
997
+ error: failure,
998
+ last_error: failure,
999
+ },
1000
+ });
1001
+ reportTerminal("failed");
1002
+ terminalEvent = true;
1003
+ break;
1004
+ }
977
1005
  const ns = mapped?.namespace;
978
1006
  const toolSearch = toolSearchToolNames?.has(realName) ?? false;
979
1007
  const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
@@ -1359,6 +1387,10 @@ function buildResponseJSONWithBudget(
1359
1387
  options?: {
1360
1388
  hideThinkingSummary?: boolean;
1361
1389
  toolNsMap?: Map<string, { namespace: string; name: string }>;
1390
+ /** Request-visible tool names. When present, an upstream call outside this set fails closed. */
1391
+ declaredToolNames?: ReadonlySet<string>;
1392
+ /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
1393
+ toolParameterSchemas?: ReadonlyMap<string, Record<string, unknown>>;
1362
1394
  freeformToolNames?: Set<string>;
1363
1395
  toolSearchToolNames?: Set<string>;
1364
1396
  /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */
@@ -1527,11 +1559,17 @@ function buildResponseJSONWithBudget(
1527
1559
  const ns = mapped?.namespace;
1528
1560
  const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
1529
1561
  const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
1562
+ // #1611: same integral-float repair as the streaming path. Keyed by the wire name
1563
+ // the request declared, which is the pre-namespace-mapping `currentToolCallName`.
1564
+ const coercedArgs = coerceIntegerToolArguments(
1565
+ currentToolCallArgs,
1566
+ options?.toolParameterSchemas?.get(currentToolCallName),
1567
+ );
1530
1568
  if (toolSearch) {
1531
1569
  pushOutput({
1532
1570
  type: "tool_search_call", id: `tsc_${uuid()}`,
1533
1571
  call_id: currentToolCallId, execution: "client",
1534
- arguments: parseArgsObj(currentToolCallArgs), status,
1572
+ arguments: parseArgsObj(coercedArgs), status,
1535
1573
  });
1536
1574
  } else if (freeform) {
1537
1575
  pushOutput({
@@ -1543,7 +1581,7 @@ function buildResponseJSONWithBudget(
1543
1581
  pushOutput({
1544
1582
  type: "function_call", id: `fc_${uuid()}`,
1545
1583
  call_id: currentToolCallId, name: realName,
1546
- arguments: currentToolCallArgs || "{}", status,
1584
+ arguments: coercedArgs || "{}", status,
1547
1585
  ...(ns ? { namespace: ns } : {}),
1548
1586
  });
1549
1587
  }
@@ -1641,6 +1679,15 @@ function buildResponseJSONWithBudget(
1641
1679
  rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope);
1642
1680
  }
1643
1681
  flushToolCall();
1682
+ if (options?.declaredToolNames && !options.declaredToolNames.has(e.name)) {
1683
+ errorEvent = {
1684
+ type: "error",
1685
+ message: `routed provider emitted undeclared client tool "${e.name}"; only request-declared tools may be called`,
1686
+ status: 502,
1687
+ errorType: "upstream_error",
1688
+ };
1689
+ break;
1690
+ }
1644
1691
  currentToolCallId = e.id;
1645
1692
  budget?.openCall(e.id);
1646
1693
  currentToolCallName = e.name;
package/src/cli/init.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as readline from "node:readline";
2
- import { constants as fsConstants, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { injectCodexConfig } from "../codex/inject";
4
- import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, saveConfig } from "../config";
4
+ import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, preserveOpenAiTierRollbackSnapshot, saveConfig } from "../config";
5
5
  import { enrichProviderFromCatalog } from "../oauth/key-providers";
6
6
  import { deriveInitProviders } from "../providers/derive";
7
7
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -80,21 +80,8 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()):
80
80
  unlinkSync(backup);
81
81
  return;
82
82
  }
83
- // Publish the preserved snapshot with a no-replace copy (COPYFILE_EXCL) so a
84
- // destination collision (frozen/rolled-back clock, pre-created file) can never
85
- // silently overwrite another rollback snapshot; retry with a sequence suffix.
86
- for (let attempt = 0; attempt < 16; attempt++) {
87
- const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`;
88
- try {
89
- copyFileSync(backup, preserved, fsConstants.COPYFILE_EXCL);
90
- } catch (error) {
91
- if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
92
- throw error;
93
- }
94
- unlinkSync(backup);
95
- console.warn(`⚠️ Kept your pre-migration config rollback snapshot at ${preserved}`);
96
- return;
97
- }
83
+ const preserved = preserveOpenAiTierRollbackSnapshot(configPath);
84
+ console.warn(`⚠️ Kept your pre-migration config rollback snapshot at ${preserved}`);
98
85
  } catch { /* cleanup is best-effort; never block init on backup housekeeping */ }
99
86
  }
100
87
 
@@ -34,6 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
34
34
  import { readCatalog, readCodexCatalogPath } from "./parsing";
35
35
  import type { CatalogModel, RawEntry } from "./parsing";
36
36
  import { UPSTREAM_NATIVE_ENTRIES } from "./metadata";
37
+ import { nativeOpenAiCapabilitySourceSlug } from "./native-models";
37
38
  import { loadBundledCodexCatalog } from "./bundled";
38
39
  import type { BundledCatalogDeps, ReadonlyRawCatalog } from "./bundled";
39
40
  import { deriveEntry } from "./sync";
@@ -180,7 +181,7 @@ export function applyReasoningLevels(
180
181
  }
181
182
 
182
183
  export function isGpt56NativeSlug(slug: string): boolean {
183
- return !slug.includes("/") && slug.startsWith("gpt-5.6-");
184
+ return !slug.includes("/") && nativeOpenAiCapabilitySourceSlug(slug).startsWith("gpt-5.6-");
184
185
  }
185
186
 
186
187
  export function ensureGpt56ReasoningLevels(entry: RawEntry): void {