@bitkyc08/opencodex 2.30.0-preview.20260821 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/gui/dist/assets/index-DkcRs1fL.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/cursor-errors.ts +65 -6
- package/src/adapters/cursor/discovery.ts +7 -2
- package/src/adapters/cursor/effort-map.ts +6 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/live-models.ts +21 -26
- package/src/adapters/cursor/live-transport.ts +213 -3
- package/src/adapters/cursor/native-exec-common.ts +17 -0
- package/src/adapters/cursor/native-exec.ts +9 -4
- package/src/adapters/cursor/protobuf-events.ts +5 -1
- package/src/adapters/cursor/protobuf-request.ts +11 -4
- package/src/adapters/cursor/tool-definitions.ts +20 -0
- package/src/adapters/cursor/transport.ts +10 -0
- package/src/adapters/cursor.ts +23 -5
- package/src/adapters/google.ts +16 -3
- package/src/adapters/openai-responses.ts +66 -20
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/cli/agent.ts +2 -1
- package/src/cli/dispatch.ts +2 -2
- package/src/cli/doctor.ts +89 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/registry.ts +7 -2
- package/src/codex/auth-context.ts +41 -2
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-fetch.ts +20 -5
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/inject-coordination.ts +39 -6
- package/src/codex/transition-state.ts +12 -12
- package/src/generated/compatibility-version.json +74 -50
- package/src/lib/errors.ts +8 -2
- package/src/oauth/cursor.ts +21 -0
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +3 -0
- package/src/providers/fastwire.ts +12 -1
- package/src/providers/openai-sidecar.ts +1 -0
- package/src/providers/registry.ts +25 -0
- package/src/providers/service-tier.ts +22 -7
- package/src/responses/custom-tool-compat.ts +24 -8
- package/src/responses/namespace-tool-compat.ts +2 -3
- package/src/router.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/chat-native.ts +20 -0
- package/src/server/management/agent-settings-routes.ts +16 -5
- package/src/server/management/config-routes.ts +25 -5
- package/src/server/management/vision-sidecar-options.ts +54 -19
- package/src/server/responses/compact.ts +1 -2
- package/src/server/responses/core.ts +54 -13
- package/src/service.ts +122 -14
- package/src/types/config.ts +9 -3
- package/src/types/provider.ts +6 -0
- package/src/usage/cost.ts +52 -38
- package/src/usage/expected-prices.ts +79 -9
- package/src/vision/backends.ts +97 -0
- package/src/vision/eligibility.ts +43 -22
- package/src/vision/index.ts +73 -5
- package/src/vision/routed-describe.ts +175 -0
- package/gui/dist/assets/index-eBA05kYB.js +0 -102
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
recordScreenExec,
|
|
51
51
|
type CursorNativeToolDeps,
|
|
52
52
|
} from "./native-exec-tools";
|
|
53
|
-
import { clientBytes, execBytes } from "./native-exec-common";
|
|
53
|
+
import { clientBytes, execBytes, execStreamCloseBytes, execThrowBytes } from "./native-exec-common";
|
|
54
54
|
import type { McpToolDefinition } from "./gen/agent_pb";
|
|
55
55
|
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
|
|
56
56
|
|
|
@@ -603,10 +603,15 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
|
|
|
603
603
|
}))];
|
|
604
604
|
}
|
|
605
605
|
// Unknown exec case — Cursor added a new native exec type that our protobuf definition does not
|
|
606
|
-
// include yet.
|
|
607
|
-
//
|
|
606
|
+
// include yet. T05 (senpi contract): reply with ExecClientThrow + stream-close so the server
|
|
607
|
+
// unblocks with a known failure. Previously this returned an empty reply (silence), which is
|
|
608
|
+
// the stall class senpi explicitly refused (#116 was about throwing into failAndClear and
|
|
609
|
+
// killing the whole connection; a typed in-band throw does not do that).
|
|
608
610
|
debugProviderDiagnostic("cursor", "unknown-exec-case", { execCase: execCase ?? "unknown", execId: execMsg.execId });
|
|
609
|
-
return [
|
|
611
|
+
return [
|
|
612
|
+
execThrowBytes(execMsg, "Unknown exec message variant; this client does not implement it."),
|
|
613
|
+
execStreamCloseBytes(execMsg),
|
|
614
|
+
];
|
|
610
615
|
}
|
|
611
616
|
|
|
612
617
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isCodexShellBridgeToolName,
|
|
12
12
|
isCursorStructuredEditToolName,
|
|
13
13
|
normalizeCursorWireName,
|
|
14
|
+
normalizeCursorTextToolMarkers,
|
|
14
15
|
OCX_RESPONSES_TOOL_PROVIDER,
|
|
15
16
|
resolveShellBridgeAliasKey,
|
|
16
17
|
responsesToolNameFromCursorWire,
|
|
@@ -1243,7 +1244,10 @@ export function mapCursorProtobufServerMessage(
|
|
|
1243
1244
|
const update = serverMessage.message.value.message;
|
|
1244
1245
|
switch (update.case) {
|
|
1245
1246
|
case "textDelta":
|
|
1246
|
-
|
|
1247
|
+
// #2305: fold Cursor display aliases inside textual pseudo tool-call markers back to
|
|
1248
|
+
// the advertised wire name before any client sees the text. Real frames are already
|
|
1249
|
+
// normalized structurally (mcpWireNameFromArgs above).
|
|
1250
|
+
return update.value.text ? [{ type: "text", text: normalizeCursorTextToolMarkers(update.value.text) }] : [];
|
|
1247
1251
|
case "thinkingDelta":
|
|
1248
1252
|
return update.value.text ? [{ type: "thinking", thinking: update.value.text }] : [];
|
|
1249
1253
|
case "toolCallStarted": {
|
|
@@ -188,10 +188,12 @@ function assistantRootText(
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
// Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata),
|
|
191
|
-
// so prior history
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
191
|
+
// so prior history must be replayed here or a ResumeAction has nothing model-visible to continue from.
|
|
192
|
+
// The active user message is excluded because it travels in the action. When the continuation cannot
|
|
193
|
+
// rely on native MCP turn state, tool results stay assistant-role text with a [Tool Result] /
|
|
194
|
+
// [Tool Error] marker so Cursor does not wrap them as `<user_query>` (#1992). Native resume models
|
|
195
|
+
// already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto
|
|
196
|
+
// few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID.
|
|
195
197
|
function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken): {
|
|
196
198
|
ids: Uint8Array[];
|
|
197
199
|
byteLength: number;
|
|
@@ -212,6 +214,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
const externalModel = isCursorExternalWireModel(request.modelId);
|
|
217
|
+
const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId);
|
|
215
218
|
const lastRawIsToolResult = messages.at(-1)?.role === "toolResult";
|
|
216
219
|
const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages);
|
|
217
220
|
|
|
@@ -243,6 +246,10 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
243
246
|
}
|
|
244
247
|
// Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
|
|
245
248
|
} else if (message.role === "toolResult") {
|
|
249
|
+
// Native resume models already receive the paired MCP result through turns[]. Replaying
|
|
250
|
+
// the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto
|
|
251
|
+
// to echo that envelope as chat instead of continuing from the structured result.
|
|
252
|
+
if (!echoToolResultInRoot) continue;
|
|
246
253
|
// #1920: the prefix must reflect the NORMALIZED error state (an empty
|
|
247
254
|
// node_repl result is an error even when the runtime said isError=false).
|
|
248
255
|
const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]";
|
|
@@ -336,6 +336,26 @@ export function normalizeCursorWireName(name: string): string {
|
|
|
336
336
|
return name.startsWith(CURSOR_MCP_DISPLAY_PREFIX) ? name.slice(CURSOR_MCP_DISPLAY_PREFIX.length) : name;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* #2305: some models emit a TEXTUAL pseudo tool call ("[TOOL_CALL]name[ARGS]{...}")
|
|
341
|
+
* instead of a real frame, using Cursor's display alias as the name. Text-mode clients
|
|
342
|
+
* (Pi) parse that text and then cannot dispatch the undeclared display name. Rewrite the
|
|
343
|
+
* display alias to the advertised wire name ONLY inside the marker pair — prose that
|
|
344
|
+
* merely mentions the alias stays untouched, and the scope guard is the exact
|
|
345
|
+
* `mcp_${OCX_RESPONSES_TOOL_PROVIDER}_` prefix, never generic `mcp_`.
|
|
346
|
+
* Known limit (recorded in devlog 230): a marker split across two streaming deltas is
|
|
347
|
+
* not rewritten; tail-buffering is deferred until a live trace shows split markers.
|
|
348
|
+
*/
|
|
349
|
+
const CURSOR_TEXT_TOOL_MARKER = new RegExp(
|
|
350
|
+
String.raw`\[TOOL_CALL\](${CURSOR_MCP_DISPLAY_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\[\]]+)\[ARGS\]`,
|
|
351
|
+
"g",
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
export function normalizeCursorTextToolMarkers(text: string): string {
|
|
355
|
+
if (!text.includes(CURSOR_MCP_DISPLAY_PREFIX)) return text;
|
|
356
|
+
return text.replace(CURSOR_TEXT_TOOL_MARKER, (_match, name: string) => `[TOOL_CALL]${normalizeCursorWireName(name)}[ARGS]`);
|
|
357
|
+
}
|
|
358
|
+
|
|
339
359
|
export function responsesToolNameFromCursorWire(name: string, cursorToolNameMap?: ReadonlyMap<string, string>): string {
|
|
340
360
|
const normalized = normalizeCursorWireName(name);
|
|
341
361
|
if (!cursorToolNameMap) return normalized;
|
|
@@ -29,6 +29,16 @@ export interface CursorTransportFactoryInput {
|
|
|
29
29
|
firstFrameTimeoutMs?: number;
|
|
30
30
|
/** Grace (ms) between close() and the force-destroy fallback after a first-frame timeout. Defaults to 1s. */
|
|
31
31
|
timeoutDestroyGraceMs?: number;
|
|
32
|
+
/**
|
|
33
|
+
* T04 watchdog: maximum inbound decoded-frame silence (ms) after the first frame before the
|
|
34
|
+
* turn is failed. Defaults to 30s.
|
|
35
|
+
*/
|
|
36
|
+
streamSilenceFailMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* T04 watchdog: maximum heartbeat/checkpoint-only traffic (ms) without turn progress before
|
|
39
|
+
* the turn is failed. Defaults to 90s.
|
|
40
|
+
*/
|
|
41
|
+
streamHeartbeatOnlyFailMs?: number;
|
|
32
42
|
/**
|
|
33
43
|
* Grace window (ms) before a drained client-tool turn is finalized, so a sibling tool call
|
|
34
44
|
* announced in a later receive chunk can revoke a premature finalize. Defaults to 50ms.
|
package/src/adapters/cursor.ts
CHANGED
|
@@ -3,8 +3,8 @@ import type { AdapterEvent, OcxProviderConfig } from "../types";
|
|
|
3
3
|
import type { ProviderAdapter } from "./base";
|
|
4
4
|
import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
|
|
5
5
|
import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
|
|
6
|
-
import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors";
|
|
7
|
-
import { cursorCheckpointModelAffinityId, isCursorExternalWireModel } from "./cursor/discovery";
|
|
6
|
+
import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
|
|
7
|
+
import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
|
|
8
8
|
import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
|
|
9
9
|
import { mapCursorServerMessage } from "./cursor/message-mapper";
|
|
10
10
|
import {
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
invalidateCursorCheckpoint,
|
|
26
26
|
} from "./cursor/checkpoint-store";
|
|
27
27
|
import { debugProviderDiagnostic } from "../lib/debug";
|
|
28
|
+
import { estimateTokens } from "../lib/token-estimate";
|
|
28
29
|
import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
|
|
29
30
|
import { runCursorTurnWithRetry } from "./cursor/transport-retry";
|
|
30
31
|
import {
|
|
@@ -53,16 +54,29 @@ export interface CursorAdapterDeps {
|
|
|
53
54
|
rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function safeCursorTransportError(err: unknown): string {
|
|
57
|
+
function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext): string {
|
|
57
58
|
if (err instanceof CursorTransportDisabledError) return CURSOR_TRANSPORT_DISABLED_MESSAGE;
|
|
58
59
|
if (err instanceof CursorMissingCredentialError) {
|
|
59
60
|
return "Cursor live transport is enabled, but no Cursor access token is configured. Set provider.apiKey or OPENCODEX_CURSOR_TEST_TOKEN.";
|
|
60
61
|
}
|
|
61
62
|
const message = err instanceof Error ? err.message : typeof err === "string" ? err : undefined;
|
|
62
|
-
if (message) return safeCursorErrorMessage(message);
|
|
63
|
+
if (message) return safeCursorErrorMessage(message, sizeContext);
|
|
63
64
|
return "Cursor upstream error: transport failed before completion.";
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Size prior for bare resource_exhausted classification (devlog 260): a rough input
|
|
69
|
+
* estimate over the outgoing text vs the model's context window. Only used to keep
|
|
70
|
+
* SMALL requests on the 429 class — unknown/large stays on the overflow mapping.
|
|
71
|
+
*/
|
|
72
|
+
function cursorRequestSizeContext(request: { modelId: string; system: string[]; messages: { content: string }[] }): CursorSizeContext {
|
|
73
|
+
const text = [...request.system, ...request.messages.map(message => message.content)].join("\n");
|
|
74
|
+
return {
|
|
75
|
+
estimatedInputTokens: estimateTokens(text, request.modelId),
|
|
76
|
+
contextWindow: inferCursorContextWindow(request.modelId),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAdapterDeps = {}): ProviderAdapter {
|
|
67
81
|
return {
|
|
68
82
|
name: "cursor",
|
|
@@ -88,6 +102,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
88
102
|
emit({ type: "error", message: "Cursor turn was aborted before start." });
|
|
89
103
|
return;
|
|
90
104
|
}
|
|
105
|
+
// Captured after createCursorRequest so the catch block can apply the bare-RE
|
|
106
|
+
// size prior (devlog 260) even though `request` is scoped inside the try.
|
|
107
|
+
let requestSizeContext: CursorSizeContext | undefined;
|
|
91
108
|
try {
|
|
92
109
|
const makeTransport = deps.createTransport ?? createLiveCursorTransport;
|
|
93
110
|
const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget);
|
|
@@ -110,6 +127,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
110
127
|
const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef;
|
|
111
128
|
const previousConversationId = _parsed._cursorConversationId;
|
|
112
129
|
let request = createCursorRequest(_parsed);
|
|
130
|
+
requestSizeContext = cursorRequestSizeContext(request);
|
|
113
131
|
// The builder may derive a stable provider id from the client thread when Responses state
|
|
114
132
|
// is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn,
|
|
115
133
|
// and isolated helper/compaction turns must never inherit or donate the parent's usage state.
|
|
@@ -292,7 +310,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
292
310
|
type: "error",
|
|
293
311
|
message: isTranslatorBudgetExceededError(err)
|
|
294
312
|
? "upstream translation buffer exceeded the safe limit"
|
|
295
|
-
: safeCursorTransportError(err),
|
|
313
|
+
: safeCursorTransportError(err, requestSizeContext),
|
|
296
314
|
...(isTranslatorBudgetExceededError(err)
|
|
297
315
|
? { status: 502, errorType: "upstream_error", code: "translation_buffer_limit" }
|
|
298
316
|
: {}),
|
package/src/adapters/google.ts
CHANGED
|
@@ -413,6 +413,7 @@ interface GoogleResponsePart {
|
|
|
413
413
|
thought?: boolean;
|
|
414
414
|
thoughtSignature?: string;
|
|
415
415
|
thought_signature?: string;
|
|
416
|
+
extra_content?: { google?: { thought_signature?: unknown } };
|
|
416
417
|
functionCall?: unknown;
|
|
417
418
|
}
|
|
418
419
|
|
|
@@ -421,6 +422,18 @@ interface GoogleFunctionCall {
|
|
|
421
422
|
args?: unknown;
|
|
422
423
|
}
|
|
423
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Read a Gemini/Antigravity thought signature from a response part. Antigravity can place it
|
|
427
|
+
* either directly on the part (`thoughtSignature` / `thought_signature`) or inside the same
|
|
428
|
+
* nested `extra_content.google.thought_signature` shape used on the Responses wire.
|
|
429
|
+
*/
|
|
430
|
+
function googlePartThoughtSignature(part: GoogleResponsePart): string | undefined {
|
|
431
|
+
const direct = part.thoughtSignature ?? part.thought_signature;
|
|
432
|
+
if (typeof direct === "string" && direct.length > 0) return direct;
|
|
433
|
+
const nested = part.extra_content?.google?.thought_signature;
|
|
434
|
+
return typeof nested === "string" && nested.length > 0 ? nested : undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
424
437
|
/**
|
|
425
438
|
* Carry a Gemini thought signature with the exact function-call part that produced it. Google
|
|
426
439
|
* validates the signature against that specific part, so it must ride the individual tool call
|
|
@@ -430,7 +443,7 @@ function googleToolCallMetadataFromPart(
|
|
|
430
443
|
part: GoogleResponsePart,
|
|
431
444
|
fallbackSignature?: string,
|
|
432
445
|
): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined {
|
|
433
|
-
const signature = part
|
|
446
|
+
const signature = googlePartThoughtSignature(part) ?? fallbackSignature;
|
|
434
447
|
if (!isLikelyRealThoughtSignature(signature)) return undefined;
|
|
435
448
|
return { providerMetadata: { google: { thoughtSignature: signature } } };
|
|
436
449
|
}
|
|
@@ -960,7 +973,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
960
973
|
}
|
|
961
974
|
if (parts) {
|
|
962
975
|
for (const part of parts) {
|
|
963
|
-
const sig = part
|
|
976
|
+
const sig = googlePartThoughtSignature(part);
|
|
964
977
|
if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
|
|
965
978
|
pendingStreamThoughtSig = sig;
|
|
966
979
|
}
|
|
@@ -1224,7 +1237,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
1224
1237
|
}
|
|
1225
1238
|
let pendingThoughtSig: string | undefined;
|
|
1226
1239
|
for (const part of parts) {
|
|
1227
|
-
const sig = part
|
|
1240
|
+
const sig = googlePartThoughtSignature(part);
|
|
1228
1241
|
if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
|
|
1229
1242
|
pendingThoughtSig = sig;
|
|
1230
1243
|
}
|
|
@@ -19,6 +19,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co
|
|
|
19
19
|
import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
|
|
20
20
|
import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
|
|
21
21
|
import { openaiResponsesUrl } from "./openai-responses-url";
|
|
22
|
+
import { normalizeXaiResponsesWebSearch } from "./xai-web-search";
|
|
22
23
|
import {
|
|
23
24
|
createAdapterTierMetadata,
|
|
24
25
|
} from "../providers/fastwire";
|
|
@@ -1503,17 +1504,55 @@ function stripUnsupportedHostedTools(body: unknown): unknown {
|
|
|
1503
1504
|
* provider capability metadata; an unclassified upstream keeps the fields.
|
|
1504
1505
|
*/
|
|
1505
1506
|
const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const;
|
|
1506
|
-
|
|
1507
|
-
|
|
1507
|
+
|
|
1508
|
+
function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): {
|
|
1509
|
+
tools: unknown[];
|
|
1510
|
+
changed: boolean;
|
|
1511
|
+
} {
|
|
1508
1512
|
let changed = false;
|
|
1509
|
-
const
|
|
1510
|
-
if (!isPlainObject(
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
+
const stripped = tools.map(tool => {
|
|
1514
|
+
if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) {
|
|
1515
|
+
return tool;
|
|
1516
|
+
}
|
|
1517
|
+
if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool;
|
|
1518
|
+
const { external_web_access: _access, search_context_size: _size, ...rest } = tool;
|
|
1513
1519
|
changed = true;
|
|
1514
1520
|
return rest;
|
|
1515
1521
|
});
|
|
1516
|
-
return changed ?
|
|
1522
|
+
return { tools: changed ? stripped : tools, changed };
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
|
|
1526
|
+
if (!isPlainObject(body)) return body;
|
|
1527
|
+
|
|
1528
|
+
let next: Record<string, unknown> = body;
|
|
1529
|
+
let changed = false;
|
|
1530
|
+
if (Array.isArray(body.tools)) {
|
|
1531
|
+
const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools);
|
|
1532
|
+
if (stripped.changed) {
|
|
1533
|
+
next = { ...next, tools: stripped.tools };
|
|
1534
|
+
changed = true;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
if (Array.isArray(body.input)) {
|
|
1539
|
+
let inputChanged = false;
|
|
1540
|
+
const input = body.input.map(item => {
|
|
1541
|
+
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) {
|
|
1542
|
+
return item;
|
|
1543
|
+
}
|
|
1544
|
+
const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools);
|
|
1545
|
+
if (!stripped.changed) return item;
|
|
1546
|
+
inputChanged = true;
|
|
1547
|
+
return { ...item, tools: stripped.tools };
|
|
1548
|
+
});
|
|
1549
|
+
if (inputChanged) {
|
|
1550
|
+
next = { ...next, input };
|
|
1551
|
+
changed = true;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
return changed ? next : body;
|
|
1517
1556
|
}
|
|
1518
1557
|
|
|
1519
1558
|
/** Replace every `input_image` part under a routed-compaction body with a short marker. */
|
|
@@ -1692,17 +1731,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
1692
1731
|
// that already recorded a single-query web_search_call replays it every turn, and
|
|
1693
1732
|
// a strict parser rejects the whole request over it (#930).
|
|
1694
1733
|
outBody = backfillWebSearchQueries(outBody);
|
|
1695
|
-
// Same predicate as the routedCompaction gate in handleResponses(): an
|
|
1696
|
-
// authMode check would let a noncanonical custom forward provider skip this
|
|
1697
|
-
// rewrite while the server still routes it as a summarizer turn (#422).
|
|
1698
|
-
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
|
|
1699
|
-
outBody = buildRoutedCompactionBody(outBody);
|
|
1700
|
-
}
|
|
1701
1734
|
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
1702
1735
|
outBody = promoteClientLoadedTools(outBody);
|
|
1703
1736
|
}
|
|
1704
1737
|
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
1705
|
-
const rewritten = rewriteRoutedCustomToolsForUpstream(
|
|
1738
|
+
const rewritten = rewriteRoutedCustomToolsForUpstream(
|
|
1739
|
+
outBody,
|
|
1740
|
+
provider.supportsResponsesCustomTools,
|
|
1741
|
+
);
|
|
1706
1742
|
outBody = rewritten.body;
|
|
1707
1743
|
convertedRoutedCustomToolNames = rewritten.names;
|
|
1708
1744
|
}
|
|
@@ -1712,12 +1748,6 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
1712
1748
|
const rewritten = rewriteRoutedToolSearchForUpstream(outBody);
|
|
1713
1749
|
outBody = rewritten.body;
|
|
1714
1750
|
convertedRoutedToolSearchNames = rewritten.names;
|
|
1715
|
-
// xAI rejects these OpenAI web_search extensions with HTTP 400. Keep them
|
|
1716
|
-
// for OpenAI API-key traffic and unclassified gateways; only an explicit
|
|
1717
|
-
// provider capability denial activates the compatibility transform.
|
|
1718
|
-
if (provider.supportsOpenAiWebSearchToolFields === false) {
|
|
1719
|
-
outBody = stripOpenAiOnlyWebSearchFields(outBody);
|
|
1720
|
-
}
|
|
1721
1751
|
}
|
|
1722
1752
|
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
1723
1753
|
// Codex 0.147 emits private namespace tool groups, while public/third-party Responses
|
|
@@ -1726,9 +1756,25 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
1726
1756
|
const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody);
|
|
1727
1757
|
outBody = rewritten.body;
|
|
1728
1758
|
convertedRoutedNamespaceToolAliases = rewritten.aliases;
|
|
1759
|
+
// Preserve xAI's cached-only fail-closed semantics and image-search mapping before the
|
|
1760
|
+
// generic capability fallback removes the private OpenAI fields.
|
|
1761
|
+
outBody = normalizeXaiResponsesWebSearch(outBody, provider);
|
|
1762
|
+
// xAI and explicitly classified compatible gateways reject these OpenAI web_search
|
|
1763
|
+
// extensions. Keep them for OpenAI API-key traffic and unclassified gateways.
|
|
1764
|
+
if (provider.supportsOpenAiWebSearchToolFields === false) {
|
|
1765
|
+
outBody = stripOpenAiOnlyWebSearchFields(outBody);
|
|
1766
|
+
}
|
|
1729
1767
|
// Last, so promoted namespace children are also cleared of Codex-private fields.
|
|
1730
1768
|
outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
|
|
1731
1769
|
}
|
|
1770
|
+
// Same predicate as the routedCompaction gate in handleResponses(): an authMode check would
|
|
1771
|
+
// let a noncanonical custom forward provider skip this rewrite while the server still routes
|
|
1772
|
+
// it as a summarizer turn (#422). The compaction body build removes the tool surface and must
|
|
1773
|
+
// therefore be the last routed transform: anything before it may depend on the declarations;
|
|
1774
|
+
// anything after it cannot.
|
|
1775
|
+
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
|
|
1776
|
+
outBody = buildRoutedCompactionBody(outBody);
|
|
1777
|
+
}
|
|
1732
1778
|
const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;
|
|
1733
1779
|
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(
|
|
1734
1780
|
outBody,
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
const CODEX_WEB_SEARCH_TOOL = "web_search";
|
|
4
|
+
const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
|
|
5
|
+
const XAI_API_HOST = "api.x.ai";
|
|
6
|
+
|
|
7
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isCodexWebSearchToolType(value: unknown): boolean {
|
|
12
|
+
return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Match only xAI's documented public API, not arbitrary Responses-compatible gateways. */
|
|
16
|
+
function isXaiPublicApi(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(provider.baseUrl);
|
|
19
|
+
return url.protocol === "https:"
|
|
20
|
+
&& url.hostname.toLowerCase() === XAI_API_HOST
|
|
21
|
+
&& (url.port === "" || url.port === "443");
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ToolGroupRewrite = {
|
|
28
|
+
tools: unknown[];
|
|
29
|
+
changed: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Translate Codex-private hosted-search fields to xAI's public Responses schema.
|
|
34
|
+
*
|
|
35
|
+
* xAI web search is live-only. A Codex cached/index-only declaration carries
|
|
36
|
+
* `external_web_access: false`; dropping that flag while keeping the tool would silently widen
|
|
37
|
+
* network access, so the whole tool is omitted instead. `true` maps to xAI's ordinary live
|
|
38
|
+
* `{type:"web_search"}` declaration. Requests that omit the private flag are already public-API
|
|
39
|
+
* shaped and retain their live-search behavior.
|
|
40
|
+
*/
|
|
41
|
+
function normalizeToolGroup(tools: unknown[]): ToolGroupRewrite {
|
|
42
|
+
const normalized: unknown[] = [];
|
|
43
|
+
let changed = false;
|
|
44
|
+
|
|
45
|
+
for (const tool of tools) {
|
|
46
|
+
if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) {
|
|
47
|
+
normalized.push(tool);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hasExternalAccess = Object.hasOwn(tool, "external_web_access");
|
|
52
|
+
if (hasExternalAccess && tool.external_web_access !== true) {
|
|
53
|
+
// xAI has no cached/index-only equivalent. Fail closed instead of turning it into live search.
|
|
54
|
+
changed = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const searchContentTypes = Array.isArray(tool.search_content_types)
|
|
59
|
+
? tool.search_content_types
|
|
60
|
+
: undefined;
|
|
61
|
+
const enableImageSearch = searchContentTypes?.includes("image") === true;
|
|
62
|
+
const next: Record<string, unknown> = { ...tool, type: CODEX_WEB_SEARCH_TOOL };
|
|
63
|
+
delete next.external_web_access;
|
|
64
|
+
delete next.search_context_size;
|
|
65
|
+
delete next.search_content_types;
|
|
66
|
+
delete next.user_location;
|
|
67
|
+
if (enableImageSearch && !Object.hasOwn(next, "enable_image_search")) {
|
|
68
|
+
next.enable_image_search = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const toolChanged = Object.keys(next).length !== Object.keys(tool).length
|
|
72
|
+
|| Object.entries(next).some(([key, value]) => tool[key] !== value);
|
|
73
|
+
changed ||= toolChanged;
|
|
74
|
+
normalized.push(toolChanged ? next : tool);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { tools: changed ? normalized : tools, changed };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasWebSearchTool(body: Record<string, unknown>): boolean {
|
|
81
|
+
if (Array.isArray(body.tools) && body.tools.some(tool =>
|
|
82
|
+
isPlainObject(tool) && isCodexWebSearchToolType(tool.type)
|
|
83
|
+
)) return true;
|
|
84
|
+
return Array.isArray(body.input) && body.input.some(item =>
|
|
85
|
+
isPlainObject(item)
|
|
86
|
+
&& item.type === "additional_tools"
|
|
87
|
+
&& Array.isArray(item.tools)
|
|
88
|
+
&& item.tools.some(tool => isPlainObject(tool) && isCodexWebSearchToolType(tool.type))
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hasAnyDeclaredTool(body: Record<string, unknown>): boolean {
|
|
93
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) return true;
|
|
94
|
+
return Array.isArray(body.input) && body.input.some(item =>
|
|
95
|
+
isPlainObject(item)
|
|
96
|
+
&& item.type === "additional_tools"
|
|
97
|
+
&& Array.isArray(item.tools)
|
|
98
|
+
&& item.tools.length > 0
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Remove selectors that would still force a cached-only tool omitted above. */
|
|
103
|
+
function normalizeToolChoice(body: Record<string, unknown>): Record<string, unknown> {
|
|
104
|
+
const choice = body.tool_choice;
|
|
105
|
+
if (choice === undefined) return body;
|
|
106
|
+
const hasSearch = hasWebSearchTool(body);
|
|
107
|
+
|
|
108
|
+
if (isPlainObject(choice) && isCodexWebSearchToolType(choice.type)) {
|
|
109
|
+
if (!hasSearch) return { ...body, tool_choice: "none" };
|
|
110
|
+
return choice.type === CODEX_WEB_SEARCH_TOOL
|
|
111
|
+
? body
|
|
112
|
+
: { ...body, tool_choice: { ...choice, type: CODEX_WEB_SEARCH_TOOL } };
|
|
113
|
+
}
|
|
114
|
+
if (isPlainObject(choice) && choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
|
|
115
|
+
let changed = false;
|
|
116
|
+
const tools: unknown[] = [];
|
|
117
|
+
for (const tool of choice.tools) {
|
|
118
|
+
if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) {
|
|
119
|
+
tools.push(tool);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!hasSearch) {
|
|
123
|
+
changed = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (tool.type === CODEX_WEB_SEARCH_PREVIEW_TOOL) {
|
|
127
|
+
tools.push({ ...tool, type: CODEX_WEB_SEARCH_TOOL });
|
|
128
|
+
changed = true;
|
|
129
|
+
} else {
|
|
130
|
+
tools.push(tool);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!changed) return body;
|
|
134
|
+
return {
|
|
135
|
+
...body,
|
|
136
|
+
tool_choice: tools.length > 0 ? { ...choice, tools } : "none",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (choice === "required" && !hasAnyDeclaredTool(body)) {
|
|
140
|
+
return { ...body, tool_choice: "none" };
|
|
141
|
+
}
|
|
142
|
+
return body;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
|
|
147
|
+
* providers or mutating the caller-owned request body.
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeXaiResponsesWebSearch(
|
|
150
|
+
body: unknown,
|
|
151
|
+
provider: Pick<OcxProviderConfig, "baseUrl">,
|
|
152
|
+
): unknown {
|
|
153
|
+
if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body;
|
|
154
|
+
|
|
155
|
+
let next: Record<string, unknown> = body;
|
|
156
|
+
if (Array.isArray(body.tools)) {
|
|
157
|
+
const rewritten = normalizeToolGroup(body.tools);
|
|
158
|
+
if (rewritten.changed) {
|
|
159
|
+
next = { ...next };
|
|
160
|
+
if (rewritten.tools.length > 0) next.tools = rewritten.tools;
|
|
161
|
+
else delete next.tools;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (Array.isArray(next.input)) {
|
|
166
|
+
let inputChanged = false;
|
|
167
|
+
const input: unknown[] = [];
|
|
168
|
+
for (const item of next.input) {
|
|
169
|
+
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) {
|
|
170
|
+
input.push(item);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const rewritten = normalizeToolGroup(item.tools);
|
|
174
|
+
if (!rewritten.changed) {
|
|
175
|
+
input.push(item);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
inputChanged = true;
|
|
179
|
+
if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools });
|
|
180
|
+
}
|
|
181
|
+
if (inputChanged) next = { ...next, input };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return normalizeToolChoice(next);
|
|
185
|
+
}
|
package/src/cli/agent.ts
CHANGED
|
@@ -27,7 +27,8 @@ const USAGE = `Usage:
|
|
|
27
27
|
ocx agent effort <status|set> [--main <level|->] [--subagent <level|->] [--json]
|
|
28
28
|
ocx agent subagents <status|set|clear> [model,model...] [--json]
|
|
29
29
|
ocx agent fallback <status|set|clear> [model,model...] [--poll-ms <5000-600000>] [--json]
|
|
30
|
-
ocx agent sidecar <status|web|vision> [--list] [--model <id|->]
|
|
30
|
+
ocx agent sidecar <status|web|vision> [--list] [--model <id|->]
|
|
31
|
+
[--backend web:<openai|anthropic|xai|gemini|exa|-> vision:<openai|anthropic|routed|->]
|
|
31
32
|
[--reasoning <level>] [--max-descriptions <n>] [--json]`;
|
|
32
33
|
|
|
33
34
|
function clearable(value: string | undefined): string | null | undefined {
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -172,9 +172,9 @@ const commandRunners: Record<string, CommandRunner> = {
|
|
|
172
172
|
},
|
|
173
173
|
doctor: async deps => {
|
|
174
174
|
const doctorArgs = deps.args.slice(1);
|
|
175
|
-
const { runDoctor } = await import("./doctor");
|
|
175
|
+
const { RECOVER_ZERO_BYTE_COORDINATOR_FLAG, runDoctor } = await import("./doctor");
|
|
176
176
|
await runDoctor(doctorArgs);
|
|
177
|
-
if (!doctorArgs.includes("--fix-codex-runtime")) {
|
|
177
|
+
if (!doctorArgs.includes("--fix-codex-runtime") && !doctorArgs.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) {
|
|
178
178
|
console.log("");
|
|
179
179
|
const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor");
|
|
180
180
|
printCodexLogGuardDoctor();
|