@bitkyc08/opencodex 2.14.0 → 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.
- package/README.md +55 -0
- package/gui/dist/assets/index-DUCH59lJ.css +1 -0
- package/gui/dist/assets/index-DUyQeU1j.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +46 -6
- package/src/adapters/cursor/request-builder.ts +54 -10
- package/src/adapters/cursor/tool-definitions.ts +24 -0
- package/src/adapters/kiro.ts +10 -1
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +7 -4
- package/src/adapters/openai-responses-url.ts +14 -0
- package/src/adapters/openai-responses.ts +111 -2
- package/src/adapters/tool-catalog-nudge.ts +26 -4
- package/src/bridge.ts +50 -3
- package/src/cli/init.ts +4 -17
- package/src/codex/auth-api.ts +2 -74
- package/src/codex/catalog/effort.ts +2 -1
- package/src/codex/catalog/metadata.ts +62 -12
- package/src/codex/catalog/native-models.ts +27 -0
- package/src/codex/catalog/parsing.ts +27 -8
- package/src/codex/catalog/provider-fetch.ts +47 -5
- package/src/codex/catalog/sync.ts +31 -8
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +14 -3
- package/src/codex/model-cache.ts +7 -1
- package/src/codex/native-main-claim.ts +13 -2
- package/src/config.ts +79 -4
- package/src/generated/compatibility-version.json +74 -46
- package/src/lab/ledger/store.ts +0 -18
- package/src/lab/subject/installation-salt.ts +13 -2
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/tool-argument-integers.ts +158 -0
- package/src/oauth/nous.ts +58 -9
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/command-code-efforts.ts +18 -0
- package/src/providers/model-rename-migration.ts +202 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +17 -10
- package/src/responses/spill-store.ts +5 -1
- package/src/responses/state.ts +50 -2
- package/src/router.ts +12 -1
- package/src/server/index.ts +3 -2
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/config-routes.ts +51 -16
- package/src/server/management/logs-usage-routes.ts +48 -10
- package/src/server/management/provider-routes.ts +2 -1
- package/src/server/management/usage-summary-cache.ts +7 -1
- package/src/server/responses/collaboration.ts +12 -2
- package/src/server/responses/core.ts +33 -17
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/ws-upstream.ts +199 -0
- package/src/server/startup-health-cache.ts +12 -0
- package/src/usage/log.ts +430 -12
- package/src/vision/index.ts +25 -4
- package/src/vision/timeout-bounds.ts +9 -0
- package/gui/dist/assets/index-BNVYzdn0.css +0 -1
- package/gui/dist/assets/index-Co12XTT-.js +0 -76
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -200,6 +200,8 @@ const MAX_RECENT_COMMIT_LENGTH = 512;
|
|
|
200
200
|
const MAX_GIT_STATUS_LENGTH = 2048;
|
|
201
201
|
/** Keep collected workspace/git metadata fresh for this long (ms) so repeated requests reuse it. */
|
|
202
202
|
const WORKSPACE_METADATA_TTL_MS = 30_000;
|
|
203
|
+
/** Hard cap on cached workspace metadata entries to prevent unbounded growth across distinct cwds. */
|
|
204
|
+
export const MAX_WORKSPACE_METADATA_ENTRIES = 128;
|
|
203
205
|
|
|
204
206
|
/** Derive a bounded project slug from the working directory for the `x-project-slug` header. */
|
|
205
207
|
function projectSlug(cwd: string): string {
|
|
@@ -214,7 +216,32 @@ interface GitWorkspaceInfo {
|
|
|
214
216
|
recentCommits: string[];
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
const workspaceMetadataCache = new Map<string, { collectedAt: number; value: GitWorkspaceInfo }>();
|
|
219
|
+
export const workspaceMetadataCache = new Map<string, { collectedAt: number; value: GitWorkspaceInfo }>();
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Evict expired entries first, then the oldest live entry if at capacity.
|
|
223
|
+
* Called before inserting a new key so the cache never exceeds the cap.
|
|
224
|
+
*/
|
|
225
|
+
export function pruneWorkspaceMetadataCache(now: number): void {
|
|
226
|
+
// Pass 1: remove expired entries.
|
|
227
|
+
for (const [key, entry] of workspaceMetadataCache) {
|
|
228
|
+
if (now - entry.collectedAt >= WORKSPACE_METADATA_TTL_MS) {
|
|
229
|
+
workspaceMetadataCache.delete(key);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Pass 2: if still at capacity, evict the oldest live entry.
|
|
233
|
+
if (workspaceMetadataCache.size >= MAX_WORKSPACE_METADATA_ENTRIES) {
|
|
234
|
+
let oldestKey: string | null = null;
|
|
235
|
+
let oldestAt = Infinity;
|
|
236
|
+
for (const [key, entry] of workspaceMetadataCache) {
|
|
237
|
+
if (entry.collectedAt < oldestAt) {
|
|
238
|
+
oldestAt = entry.collectedAt;
|
|
239
|
+
oldestKey = key;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (oldestKey !== null) workspaceMetadataCache.delete(oldestKey);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
218
245
|
|
|
219
246
|
const execFile = promisify(execFileCallback);
|
|
220
247
|
|
|
@@ -246,7 +273,9 @@ async function gitWorkspaceInfo(cwd: string | undefined): Promise<GitWorkspaceIn
|
|
|
246
273
|
.map(commit => commit.slice(0, MAX_RECENT_COMMIT_LENGTH)),
|
|
247
274
|
}
|
|
248
275
|
: fallback;
|
|
249
|
-
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
if (!workspaceMetadataCache.has(cwd)) pruneWorkspaceMetadataCache(now);
|
|
278
|
+
workspaceMetadataCache.set(cwd, { collectedAt: now, value });
|
|
250
279
|
return value;
|
|
251
280
|
}
|
|
252
281
|
|
|
@@ -399,10 +428,21 @@ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string
|
|
|
399
428
|
const canonicalId = canonicalCommandCodeModelId(modelId);
|
|
400
429
|
const supported = commandCodeReasoningEfforts(canonicalId) ?? configuredReasoningEfforts(provider, canonicalId);
|
|
401
430
|
if (!supported) return undefined;
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
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;
|
|
406
446
|
}
|
|
407
447
|
|
|
408
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
|
-
//
|
|
43
|
-
//
|
|
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
|
-
|
|
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
|
|
49
|
-
if (cursorToolChoiceAliases(tool).some(name => selectedNames.has(name))) return
|
|
50
|
-
if (tool.loadedFromToolSearch) return
|
|
51
|
-
if (!tool.namespace) return
|
|
52
|
-
return
|
|
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) <=
|
|
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 +
|
|
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);
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -455,7 +455,16 @@ export function buildKiroPayload(
|
|
|
455
455
|
const boundedAddition = boundedInjectedInstruction(addition, injectedChars);
|
|
456
456
|
if (boundedAddition) systemParts.push(boundedAddition);
|
|
457
457
|
}
|
|
458
|
-
|
|
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") {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
2
|
+
const TRAILING_CHAT_COMPLETIONS = /\/chat\/completions\/?$/;
|
|
3
|
+
|
|
4
|
+
/** Build the openai-chat send URL from a configured baseUrl.
|
|
5
|
+
* Accepts /v1, /v1/, /v1/chat/completions, and /v1/chat/completions/.
|
|
6
|
+
*/
|
|
7
|
+
export function openaiChatCompletionsUrl(baseUrl: string): string {
|
|
8
|
+
const trimmed = baseUrl.trim().replace(TRAILING_SLASHES, "");
|
|
9
|
+
const withoutEndpoint = trimmed.replace(TRAILING_CHAT_COMPLETIONS, "");
|
|
10
|
+
return `${withoutEndpoint}/chat/completions`;
|
|
11
|
+
}
|
|
@@ -12,6 +12,7 @@ import { identifyRoutedModel } from "./identity";
|
|
|
12
12
|
import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
|
|
13
13
|
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
|
|
14
14
|
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
|
|
15
|
+
import { openaiChatCompletionsUrl } from "./openai-chat-url";
|
|
15
16
|
import {
|
|
16
17
|
isTranslatorBudgetExceededError,
|
|
17
18
|
retainTranslatedEventBatch,
|
|
@@ -672,16 +673,18 @@ const VOLCENGINE_ARK_HOSTNAMES = new Set([
|
|
|
672
673
|
"ark.ap-southeast.volces.com",
|
|
673
674
|
]);
|
|
674
675
|
|
|
675
|
-
function
|
|
676
|
+
function isVolcengineArkPaygChatTarget(provider: OcxProviderConfig): boolean {
|
|
676
677
|
try {
|
|
677
|
-
|
|
678
|
+
const url = new URL(provider.baseUrl);
|
|
679
|
+
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
680
|
+
return VOLCENGINE_ARK_HOSTNAMES.has(url.hostname) && pathname === "/api/v3";
|
|
678
681
|
} catch {
|
|
679
682
|
return false;
|
|
680
683
|
}
|
|
681
684
|
}
|
|
682
685
|
|
|
683
686
|
function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
|
|
684
|
-
return
|
|
687
|
+
return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : "";
|
|
685
688
|
}
|
|
686
689
|
|
|
687
690
|
function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
|
|
@@ -975,7 +978,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
975
978
|
}
|
|
976
979
|
if (parsed.stream) body.stream_options = { include_usage: true };
|
|
977
980
|
|
|
978
|
-
const url =
|
|
981
|
+
const url = openaiChatCompletionsUrl(provider.baseUrl);
|
|
979
982
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
980
983
|
if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
981
984
|
if (provider.headers) Object.assign(headers, provider.headers);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
2
|
+
const TRAILING_RESPONSES = /\/responses\/?$/;
|
|
3
|
+
const TRAILING_V1 = /\/v1\/?$/;
|
|
4
|
+
|
|
5
|
+
/** Build the default key-auth openai-responses send URL.
|
|
6
|
+
* Accepts /v1, /v1/, /v1/responses, and /v1/responses/.
|
|
7
|
+
* Custom `responsesPath` stays on the adapter; this helper is only the legacy /v1/responses branch.
|
|
8
|
+
*/
|
|
9
|
+
export function openaiResponsesUrl(baseUrl: string): string {
|
|
10
|
+
const trimmed = baseUrl.trim().replace(TRAILING_SLASHES, "");
|
|
11
|
+
const withoutEndpoint = trimmed.replace(TRAILING_RESPONSES, "");
|
|
12
|
+
const withoutV1 = withoutEndpoint.replace(TRAILING_V1, "");
|
|
13
|
+
return `${withoutV1}/v1/responses`;
|
|
14
|
+
}
|
|
@@ -11,6 +11,7 @@ import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
|
|
|
11
11
|
import { modelRecordValue } from "../reasoning-effort";
|
|
12
12
|
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
13
13
|
import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat";
|
|
14
|
+
import { openaiResponsesUrl } from "./openai-responses-url";
|
|
14
15
|
|
|
15
16
|
// Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
|
|
16
17
|
// Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
|
|
@@ -399,6 +400,112 @@ function normalizeToolSchemas(body: unknown): unknown {
|
|
|
399
400
|
return normalizedBody;
|
|
400
401
|
}
|
|
401
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
|
+
|
|
402
509
|
const MAX_RESPONSES_CALL_ID_LENGTH = 64;
|
|
403
510
|
const REPAIRED_CALL_ID_PREFIX = "call_ocx_";
|
|
404
511
|
const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length;
|
|
@@ -1244,8 +1351,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
1244
1351
|
}
|
|
1245
1352
|
} else {
|
|
1246
1353
|
if (provider.responsesPath === undefined) {
|
|
1247
|
-
|
|
1248
|
-
url = `${base}/v1/responses`;
|
|
1354
|
+
url = openaiResponsesUrl(provider.baseUrl);
|
|
1249
1355
|
} else {
|
|
1250
1356
|
const base = provider.baseUrl.replace(/\/$/, "");
|
|
1251
1357
|
url = `${base}${provider.responsesPath}`;
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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;
|