@bitkyc08/opencodex 2.29.0 → 2.31.0-preview.20260822
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 +5 -5
- package/gui/dist/assets/index-DyWYnr-t.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +3 -3
- package/src/adapters/cursor/cursor-errors.ts +65 -6
- package/src/adapters/cursor/discovery.ts +29 -2
- package/src/adapters/cursor/effort-map.ts +6 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +21 -26
- package/src/adapters/cursor/live-transport.ts +239 -8
- 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 +46 -9
- package/src/adapters/cursor/request-builder.ts +29 -14
- package/src/adapters/cursor/tool-definitions.ts +20 -0
- package/src/adapters/cursor/transport.ts +10 -0
- package/src/adapters/cursor/types.ts +8 -1
- 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/features.ts +58 -0
- package/src/codex/inject-coordination.ts +39 -6
- package/src/codex/transition-state.ts +12 -12
- package/src/generated/compatibility-version.json +86 -58
- package/src/lib/bun-stream-caps.ts +7 -4
- package/src/lib/errors.ts +8 -2
- package/src/oauth/cursor.ts +21 -0
- package/src/providers/command-code-efforts.ts +7 -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/quota.ts +98 -25
- package/src/providers/registry.ts +115 -10
- 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-BNESwCzn.js +0 -102
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type CursorCheckpointInvalidationReason,
|
|
32
32
|
type CursorCheckpointSnapshot,
|
|
33
33
|
} from "./checkpoint-store";
|
|
34
|
+
import { extractCursorImageUrls } from "./images";
|
|
34
35
|
|
|
35
36
|
/** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */
|
|
36
37
|
export const CURSOR_TOOL_COUNT_LIMIT = 330;
|
|
@@ -211,15 +212,8 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri
|
|
|
211
212
|
case "thinking":
|
|
212
213
|
return part.thinking;
|
|
213
214
|
case "image":
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
// populated by this adapter. The tool-result ENCODER does build real McpImageContent
|
|
217
|
-
// (see protobuf-request.ts), so the old "unsupported by Cursor adapter" wording is no
|
|
218
|
-
// longer true of the encoder — but note that nothing reaches Cursor today either way:
|
|
219
|
-
// every Cursor model is in noVisionModels (providers/registry.ts), so the vision sidecar
|
|
220
|
-
// describes or strips images before this adapter runs. Kept the same length to avoid
|
|
221
|
-
// shifting any byte-budgeted prompt path.
|
|
222
|
-
return `[image omitted from this Cursor text prompt: ${part.detail ?? "auto"}]`;
|
|
215
|
+
// Images ride UserMessage.selected_context (SelectedImage) instead of text.
|
|
216
|
+
return undefined;
|
|
223
217
|
case "toolCall":
|
|
224
218
|
// Cursor does not accept OpenAI Responses assistant tool-call parts as native history here.
|
|
225
219
|
// Rendering them as visible "[tool_call]" text leaks synthetic protocol markers back into
|
|
@@ -252,9 +246,19 @@ function requestMessage(message: OcxMessage): CursorRequestMessage | undefined {
|
|
|
252
246
|
switch (message.role) {
|
|
253
247
|
case "user":
|
|
254
248
|
case "developer":
|
|
255
|
-
|
|
249
|
+
{
|
|
250
|
+
const content = contentToText(message.content);
|
|
251
|
+
// Image-only turns survive as empty content; the encoder keeps them userMessageAction.
|
|
252
|
+
if (content.length === 0 && extractCursorImageUrls(message.content).length === 0) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
return { role: message.role, content };
|
|
256
|
+
}
|
|
256
257
|
case "assistant":
|
|
257
|
-
|
|
258
|
+
{
|
|
259
|
+
const content = contentToText(message.content);
|
|
260
|
+
return content.length > 0 ? { role: "assistant", content } : undefined;
|
|
261
|
+
}
|
|
258
262
|
case "toolResult":
|
|
259
263
|
return {
|
|
260
264
|
role: "tool",
|
|
@@ -263,6 +267,19 @@ function requestMessage(message: OcxMessage): CursorRequestMessage | undefined {
|
|
|
263
267
|
}
|
|
264
268
|
}
|
|
265
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Rebuild the text `messages` channel from prepared `rawMessages` so omission markers
|
|
272
|
+
* and JPEG-rewritten parts stay visible to activePromptText after image preparation.
|
|
273
|
+
*/
|
|
274
|
+
export function cursorRequestMessagesFromRaw(
|
|
275
|
+
messages: readonly OcxMessage[] | undefined,
|
|
276
|
+
): CursorRequestMessage[] {
|
|
277
|
+
if (!messages?.length) return [];
|
|
278
|
+
return messages
|
|
279
|
+
.map(requestMessage)
|
|
280
|
+
.filter((message): message is CursorRequestMessage => !!message);
|
|
281
|
+
}
|
|
282
|
+
|
|
266
283
|
export function generatedCursorConversationId(): string {
|
|
267
284
|
return `cursor_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
268
285
|
}
|
|
@@ -409,9 +426,7 @@ export function createCursorRequest(
|
|
|
409
426
|
parsed: OcxParsedRequest,
|
|
410
427
|
options: CreateCursorRequestOptions = {},
|
|
411
428
|
): CursorRunRequest {
|
|
412
|
-
const messages = parsed.context.messages
|
|
413
|
-
.map(requestMessage)
|
|
414
|
-
.filter((message): message is CursorRequestMessage => !!message && message.content.length > 0);
|
|
429
|
+
const messages = cursorRequestMessagesFromRaw(parsed.context.messages);
|
|
415
430
|
const activeText = [...messages].reverse().find(message => message.role === "user" || message.role === "developer")?.content ?? "";
|
|
416
431
|
const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice);
|
|
417
432
|
const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
|
|
@@ -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.
|
|
@@ -2,6 +2,7 @@ import type { OcxUsage } from "../../types";
|
|
|
2
2
|
import type { OcxMessage, OcxRequestOptions, OcxTool } from "../../types";
|
|
3
3
|
import type { CursorRoutingLevel } from "./discovery";
|
|
4
4
|
import type { CursorCheckpointInvalidationReason } from "./checkpoint-store";
|
|
5
|
+
import type { ResolvedCursorImage } from "./images";
|
|
5
6
|
|
|
6
7
|
export interface CursorRequestedModelParameter {
|
|
7
8
|
id: string;
|
|
@@ -17,7 +18,13 @@ export interface CursorRunRequest {
|
|
|
17
18
|
conversationId: string;
|
|
18
19
|
system: string[];
|
|
19
20
|
messages: CursorRequestMessage[];
|
|
20
|
-
rawMessages?: OcxMessage[];
|
|
21
|
+
rawMessages?: readonly OcxMessage[];
|
|
22
|
+
/**
|
|
23
|
+
* Images for the active user/developer turn. Encoded as SelectedImage blobIdWithData refs under
|
|
24
|
+
* UserMessage.selected_context (bytes live in the request-scoped KV store for getBlobArgs
|
|
25
|
+
* hydration). History stays text-only. data: URLs only in this slice.
|
|
26
|
+
*/
|
|
27
|
+
selectedImages?: readonly ResolvedCursorImage[];
|
|
21
28
|
tools?: OcxTool[];
|
|
22
29
|
toolChoice?: OcxRequestOptions["toolChoice"];
|
|
23
30
|
parallelToolCalls?: boolean;
|
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();
|