@bitkyc08/opencodex 2.7.13 → 2.7.18
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/gui/dist/assets/index-BUBsQALh.css +1 -0
- package/gui/dist/assets/index-DEbBFENM.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +63 -7
- package/src/adapters/anthropic-image-normalize.ts +383 -0
- package/src/adapters/anthropic.ts +7 -2
- package/src/adapters/base.ts +8 -0
- package/src/adapters/cursor/exec-policy.ts +10 -2
- package/src/adapters/cursor/live-transport.ts +19 -11
- package/src/adapters/cursor/native-exec-fs.ts +1 -1
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +1 -1
- package/src/adapters/cursor/protobuf-request.ts +7 -6
- package/src/adapters/cursor/tool-definitions.ts +3 -0
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/kiro-images.ts +94 -0
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro.ts +6 -2
- package/src/adapters/openai-chat.ts +102 -5
- package/src/adapters/openai-responses.ts +177 -2
- package/src/bridge.ts +25 -10
- package/src/cli/claude.ts +3 -0
- package/src/codex/catalog.ts +11 -0
- package/src/lib/upstream-retry.ts +6 -0
- package/src/providers/registry.ts +27 -2
- package/src/server/claude-messages.ts +11 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/management-api.ts +27 -0
- package/src/server/responses.ts +77 -24
- package/src/server/system-env.ts +7 -3
- package/src/types.ts +22 -4
- package/src/web-search/index.ts +8 -5
- package/src/web-search/loop.ts +11 -6
- package/gui/dist/assets/index-BNySqP9I.js +0 -40
- package/gui/dist/assets/index-Cq8maiJf.css +0 -1
|
@@ -15,14 +15,14 @@ import {
|
|
|
15
15
|
CreatePlanResultSchema,
|
|
16
16
|
CreatePlanSuccessSchema,
|
|
17
17
|
ExaFetchRequestResponseSchema,
|
|
18
|
-
|
|
18
|
+
ExaFetchRequestResponse_ApprovedSchema,
|
|
19
19
|
ExaSearchRequestResponseSchema,
|
|
20
|
-
|
|
20
|
+
ExaSearchRequestResponse_ApprovedSchema,
|
|
21
21
|
InteractionResponseSchema,
|
|
22
22
|
SwitchModeRequestResponseSchema,
|
|
23
23
|
SwitchModeRequestResponse_RejectedSchema,
|
|
24
24
|
WebSearchRequestResponseSchema,
|
|
25
|
-
|
|
25
|
+
WebSearchRequestResponse_ApprovedSchema,
|
|
26
26
|
type AgentServerMessage,
|
|
27
27
|
type ExecServerMessage,
|
|
28
28
|
type InteractionQuery,
|
|
@@ -181,8 +181,16 @@ export function planMcpArgsHandling(
|
|
|
181
181
|
* Codex as visible output so the user still sees it.
|
|
182
182
|
* - askQuestion: reject with a reason — the agent must proceed autonomously; there is no human to
|
|
183
183
|
* answer mid-turn. (Future: bridge to a Codex user-input request.)
|
|
184
|
-
* -
|
|
185
|
-
*
|
|
184
|
+
* - webSearch / exaSearch / exaFetch: APPROVE (empty approval). These are approve/reject
|
|
185
|
+
* permission gates, not client-run requests — the response schema has no result field, so
|
|
186
|
+
* approval delegates the search to Cursor's SERVER, which runs it and injects results into the
|
|
187
|
+
* model server-side (the answer then streams back as textDelta; the display-plane
|
|
188
|
+
* web_search_tool_call/exa_*_tool_call result frames are native, non-mcp, and safely dropped by
|
|
189
|
+
* the event mapper). Rejecting them (the old default) killed the model's web capability on the
|
|
190
|
+
* Cursor path. Tradeoff: approval consumes the user's Cursor web-search/Exa quota. The synthetic
|
|
191
|
+
* web_search sidecar (src/web-search) is an orthogonal proxy-side path used only when the client
|
|
192
|
+
* sends a hosted web_search tool; it does not cover Cursor-native web search.
|
|
193
|
+
* - switchMode: reject (deterministic default; no non-interactive mode switch).
|
|
186
194
|
* - setupVmEnvironment: the result schema has no error case — reply success so the agent is not
|
|
187
195
|
* left waiting; the command itself was never run locally.
|
|
188
196
|
* Pure (no I/O) for unit testing; `handleServerMessage` writes the frame and emits liveness.
|
|
@@ -240,10 +248,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
|
|
|
240
248
|
response: respond({
|
|
241
249
|
case: "webSearchRequestResponse",
|
|
242
250
|
value: create(WebSearchRequestResponseSchema, {
|
|
243
|
-
result: { case: "
|
|
251
|
+
result: { case: "approved", value: create(WebSearchRequestResponse_ApprovedSchema, {}) },
|
|
244
252
|
}),
|
|
245
253
|
}),
|
|
246
|
-
replyCase: "webSearchRequestResponse:
|
|
254
|
+
replyCase: "webSearchRequestResponse:approved",
|
|
247
255
|
};
|
|
248
256
|
}
|
|
249
257
|
if (q.case === "exaSearchRequestQuery") {
|
|
@@ -251,10 +259,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
|
|
|
251
259
|
response: respond({
|
|
252
260
|
case: "exaSearchRequestResponse",
|
|
253
261
|
value: create(ExaSearchRequestResponseSchema, {
|
|
254
|
-
result: { case: "
|
|
262
|
+
result: { case: "approved", value: create(ExaSearchRequestResponse_ApprovedSchema, {}) },
|
|
255
263
|
}),
|
|
256
264
|
}),
|
|
257
|
-
replyCase: "exaSearchRequestResponse:
|
|
265
|
+
replyCase: "exaSearchRequestResponse:approved",
|
|
258
266
|
};
|
|
259
267
|
}
|
|
260
268
|
if (q.case === "exaFetchRequestQuery") {
|
|
@@ -262,10 +270,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
|
|
|
262
270
|
response: respond({
|
|
263
271
|
case: "exaFetchRequestResponse",
|
|
264
272
|
value: create(ExaFetchRequestResponseSchema, {
|
|
265
|
-
result: { case: "
|
|
273
|
+
result: { case: "approved", value: create(ExaFetchRequestResponse_ApprovedSchema, {}) },
|
|
266
274
|
}),
|
|
267
275
|
}),
|
|
268
|
-
replyCase: "exaFetchRequestResponse:
|
|
276
|
+
replyCase: "exaFetchRequestResponse:approved",
|
|
269
277
|
};
|
|
270
278
|
}
|
|
271
279
|
if (q.case === "setupVmEnvironmentArgs") {
|
|
@@ -44,7 +44,7 @@ function codexNativeMutationRefusal(operation: "write" | "delete"): string {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const NATIVE_LOCAL_EXEC_DISABLED =
|
|
47
|
-
"Cursor native local filesystem execution is
|
|
47
|
+
"Cursor native local filesystem execution is not available for this request. Use the exec_command tool with equivalent shell commands (cat, head, ls, rg, grep) for file reads and searches, or apply_patch for file edits.";
|
|
48
48
|
|
|
49
49
|
export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
|
|
50
50
|
if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec");
|
|
@@ -7,7 +7,7 @@ export interface CursorNativeNetworkDeps {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const NATIVE_FETCH_DISABLED =
|
|
10
|
-
"Cursor native fetch execution is
|
|
10
|
+
"Cursor native fetch execution is not available for this request. Use the exec_command tool with curl or wget to make network requests instead.";
|
|
11
11
|
|
|
12
12
|
export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
|
|
13
13
|
if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec");
|
|
@@ -24,7 +24,7 @@ const backgroundShells = new Map<number, { child: ChildProcessWithoutNullStreams
|
|
|
24
24
|
let nextShellId = 1;
|
|
25
25
|
|
|
26
26
|
const NATIVE_SHELL_DISABLED =
|
|
27
|
-
"Cursor native shell execution is
|
|
27
|
+
"Cursor native shell execution is not available for this request. Use the exec_command tool to run shell commands instead.";
|
|
28
28
|
|
|
29
29
|
function rejectedShellResult(command: string, cwd: string, started: number) {
|
|
30
30
|
return create(ShellResultSchema, {
|
|
@@ -108,12 +108,13 @@ function rootPromptMessages(request: CursorRunRequest): Uint8Array[] {
|
|
|
108
108
|
} else if (message.role === "assistant") {
|
|
109
109
|
const text = assistantRootText(message).trim();
|
|
110
110
|
if (text.length > 0) entries.push(storeCursorBlob(jsonBlob({ role: "assistant", content: [{ type: "text", text }] })));
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
// Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
|
|
112
|
+
// rootPromptMessagesJson is the model-visible prompt, so a synthetic "[Tool Call]" marker in an
|
|
113
|
+
// assistant turn gets few-shot-mimicked: the model then emits later (esp. parallel/mixed) tool
|
|
114
|
+
// calls as inert text instead of real tool frames, halting multi-tool continuations. The paired
|
|
115
|
+
// tool result below ([Tool Result]/[Tool Error]) carries the call id/name/output Cursor needs to
|
|
116
|
+
// continue, and conversationTurns replays the native mcpToolCall step. Mirrors request-builder.ts
|
|
117
|
+
// contentPartToText() which returns undefined for toolCall for the same reason.
|
|
117
118
|
} else if (message.role === "toolResult") {
|
|
118
119
|
const prefix = message.isError ? "[Tool Error]" : "[Tool Result]";
|
|
119
120
|
const text = `${prefix}\n${toolResultToText(message)}`;
|
|
@@ -272,6 +272,9 @@ export function buildCursorToolGuidanceSystemNote(
|
|
|
272
272
|
? `Use ${discoveryTools} only for explicit discovery/resource tasks, not generic tool-count demos.`
|
|
273
273
|
: undefined,
|
|
274
274
|
"Do not count or report a tool call unless a tool result was actually returned.",
|
|
275
|
+
hasBareExec
|
|
276
|
+
? "If a built-in file read, directory listing, grep, or shell operation is rejected by the runtime, use \`exec_command\` with the equivalent shell command instead (e.g. \`cat\`, \`ls\`, \`rg\`, \`grep\`). For file edits, use \`apply_patch\` when available."
|
|
277
|
+
: undefined,
|
|
275
278
|
].filter((note): note is string => typeof note === "string");
|
|
276
279
|
return notes.join(" ");
|
|
277
280
|
}
|
|
@@ -36,7 +36,7 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques
|
|
|
36
36
|
method: request.method,
|
|
37
37
|
headers: request.headers,
|
|
38
38
|
body: request.body,
|
|
39
|
-
}, timeoutMs, ctx.abortSignal);
|
|
39
|
+
}, timeoutMs, ctx.abortSignal, ctx.stream);
|
|
40
40
|
if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) {
|
|
41
41
|
return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal);
|
|
42
42
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OcxContentPart } from "../types";
|
|
2
|
+
import { normalizeImageTargets, type NormalizeOptions, type NormalizeTarget } from "./anthropic-image-normalize";
|
|
2
3
|
|
|
3
4
|
// CodeWhisperer native image part (matches Kiro IDE wire format): the base64 bytes live directly in
|
|
4
5
|
// userInputMessage.images, NOT in userInputMessageContext. Verified against kiro-gateway.
|
|
@@ -33,3 +34,96 @@ export function extractKiroImages(content: string | OcxContentPart[]): KiroImage
|
|
|
33
34
|
}
|
|
34
35
|
return out;
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Conservative POLICY caps for the CodeWhisperer GenerateAssistantResponse payload,
|
|
40
|
+
* whose limits are undocumented. Derived from adjacent AWS surfaces
|
|
41
|
+
* (devlog/260714_image_normalization_pipeline/050): Bedrock `Message` allows 20 images
|
|
42
|
+
* per message (Converse), and `InvokeModel` caps requests at 25,000,000 bytes — 18MiB
|
|
43
|
+
* bounds the IMAGE share of the body with headroom for text/tools.
|
|
44
|
+
*/
|
|
45
|
+
export const KIRO_IMAGE_BASE64_BUDGET = 18 * 1024 * 1024;
|
|
46
|
+
export const KIRO_MAX_IMAGES_PER_MESSAGE = 20;
|
|
47
|
+
|
|
48
|
+
const COUNT_CAP_NOTE = "[image omitted: exceeded the 20-image per-message cap; oldest images in this message were dropped]";
|
|
49
|
+
|
|
50
|
+
/** A kiro wire message that can carry images (history userInputMessage or currentMessage). */
|
|
51
|
+
interface KiroImageCarrier {
|
|
52
|
+
content?: string;
|
|
53
|
+
images?: KiroImage[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isCarrier(v: unknown): v is KiroImageCarrier {
|
|
57
|
+
return typeof v === "object" && v !== null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Collect image-bearing userInputMessages in wire order (history oldest-first, then current). */
|
|
61
|
+
function collectKiroImageCarriers(payload: unknown): KiroImageCarrier[] {
|
|
62
|
+
const state = (payload as { conversationState?: { history?: unknown[]; currentMessage?: { userInputMessage?: unknown } } })?.conversationState;
|
|
63
|
+
if (!state) return [];
|
|
64
|
+
const carriers: KiroImageCarrier[] = [];
|
|
65
|
+
for (const entry of state.history ?? []) {
|
|
66
|
+
const uim = (entry as { userInputMessage?: unknown })?.userInputMessage;
|
|
67
|
+
if (isCarrier(uim)) carriers.push(uim);
|
|
68
|
+
}
|
|
69
|
+
const current = state.currentMessage?.userInputMessage;
|
|
70
|
+
if (isCarrier(current)) carriers.push(current);
|
|
71
|
+
return carriers;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function appendNote(carrier: KiroImageCarrier, note: string): void {
|
|
75
|
+
carrier.content = carrier.content ? `${carrier.content}\n${note}` : note;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Apply the generous image pipeline to a built CodeWhisperer payload (mutates in
|
|
80
|
+
* place): per-message 20-image cap first (oldest dropped), then the shared tier
|
|
81
|
+
* machinery with the kiro budget and terminal-overflow DROP (kiro has no downstream
|
|
82
|
+
* guard). Test seams (encode/validate) forward into the core.
|
|
83
|
+
*/
|
|
84
|
+
export async function normalizeKiroImages(
|
|
85
|
+
payload: unknown,
|
|
86
|
+
opts?: Pick<NormalizeOptions, "encode" | "validate">,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const carriers = collectKiroImageCarriers(payload);
|
|
89
|
+
if (carriers.length === 0) return;
|
|
90
|
+
|
|
91
|
+
// Pre-pass: per-message count cap (drop oldest within the message).
|
|
92
|
+
for (const carrier of carriers) {
|
|
93
|
+
const images = carrier.images;
|
|
94
|
+
if (!images || images.length <= KIRO_MAX_IMAGES_PER_MESSAGE) continue;
|
|
95
|
+
images.splice(0, images.length - KIRO_MAX_IMAGES_PER_MESSAGE);
|
|
96
|
+
appendNote(carrier, COUNT_CAP_NOTE);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Targets over the survivors, oldest→newest across carriers. Drops resolve the image
|
|
100
|
+
// by OBJECT IDENTITY at execution time (indices go stale after earlier splices) and
|
|
101
|
+
// delete an emptied images field per the builder's omission contract.
|
|
102
|
+
const targets: NormalizeTarget[] = [];
|
|
103
|
+
for (const carrier of carriers) {
|
|
104
|
+
for (const img of carrier.images ?? []) {
|
|
105
|
+
targets.push({
|
|
106
|
+
base64: typeof img.source?.bytes === "string" && img.source.bytes.length > 0 ? img.source.bytes : null,
|
|
107
|
+
mediaType: `image/${(img.format || "jpeg").toLowerCase()}`,
|
|
108
|
+
replace: (data: string, mediaType: string) => {
|
|
109
|
+
img.source.bytes = data;
|
|
110
|
+
img.format = (mediaType.split("/")[1] ?? "jpeg").toLowerCase();
|
|
111
|
+
},
|
|
112
|
+
drop: (note: string) => {
|
|
113
|
+
const arr = carrier.images;
|
|
114
|
+
if (arr) {
|
|
115
|
+
const idx = arr.indexOf(img);
|
|
116
|
+
if (idx !== -1) arr.splice(idx, 1);
|
|
117
|
+
if (arr.length === 0) delete carrier.images;
|
|
118
|
+
}
|
|
119
|
+
appendNote(carrier, note);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await normalizeImageTargets(targets, {
|
|
125
|
+
budget: KIRO_IMAGE_BASE64_BUDGET,
|
|
126
|
+
overflowAction: "drop",
|
|
127
|
+
...(opts ?? {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -39,7 +39,7 @@ export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFe
|
|
|
39
39
|
method: request.method,
|
|
40
40
|
headers: request.headers,
|
|
41
41
|
body: request.body,
|
|
42
|
-
}, timeoutMs, ctx.abortSignal);
|
|
42
|
+
}, timeoutMs, ctx.abortSignal, ctx.stream);
|
|
43
43
|
if (!retryableKiroStatus(res.status) || attempt === KIRO_RETRY_ATTEMPTS - 1) {
|
|
44
44
|
return ctx.returnRawErrors ? res : normalizeFinalKiroHttpError(res, ctx.abortSignal);
|
|
45
45
|
}
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from "../types";
|
|
26
26
|
import type { ProviderAdapter } from "./base";
|
|
27
27
|
import type { AdapterFetchContext, AdapterRequest } from "./base";
|
|
28
|
-
import { extractKiroImages, type KiroImage } from "./kiro-images";
|
|
28
|
+
import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images";
|
|
29
29
|
import { fetchKiroWithRetry } from "./kiro-retry";
|
|
30
30
|
import { convertKiroToolContext } from "./kiro-tools";
|
|
31
31
|
import { neutralizeIdentity } from "./identity";
|
|
@@ -497,7 +497,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
|
|
|
497
497
|
let toolNameMap: Map<string, string> | undefined;
|
|
498
498
|
return {
|
|
499
499
|
name: "kiro",
|
|
500
|
-
buildRequest(parsed: OcxParsedRequest) {
|
|
500
|
+
async buildRequest(parsed: OcxParsedRequest) {
|
|
501
501
|
if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
|
|
502
502
|
throw new Error("kiro token missing — run ocx login kiro");
|
|
503
503
|
}
|
|
@@ -520,6 +520,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
|
|
|
520
520
|
// fake-reasoning contract by injecting effort-derived thinking tags into only the current user turn.
|
|
521
521
|
const built = buildKiroPayload(parsed, profileArn);
|
|
522
522
|
toolNameMap = built.nameMap;
|
|
523
|
+
// Generous image pipeline (devlog/260714_image_normalization_pipeline/050):
|
|
524
|
+
// tier-normalize + cap images before serialization so bodyBytes below reflects
|
|
525
|
+
// the normalized size.
|
|
526
|
+
await normalizeKiroImages(built.payload);
|
|
523
527
|
const body = JSON.stringify(built.payload);
|
|
524
528
|
debugProviderDiagnostic("kiro", "request", {
|
|
525
529
|
region,
|
|
@@ -2,6 +2,7 @@ import type { ProviderAdapter } from "./base";
|
|
|
2
2
|
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
|
|
3
3
|
import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
|
|
4
4
|
import { mapReasoningEffort } from "../reasoning-effort";
|
|
5
|
+
import { redactSecretString } from "../lib/redact";
|
|
5
6
|
import { contentPartsToText } from "./image";
|
|
6
7
|
import { neutralizeIdentity } from "./identity";
|
|
7
8
|
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
|
|
@@ -13,6 +14,52 @@ export function stripBracketedModelSuffix(modelId: string): string {
|
|
|
13
14
|
return modelId.replace(/\[[^\]]*\]\s*$/, "");
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
// 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
|
|
18
|
+
// loop.ts only appends a suffix to "Provider error N" when the adapter exposes
|
|
19
|
+
// formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic
|
|
20
|
+
// validation, "This model only supports single tool-calls at once!", etc.) were reduced
|
|
21
|
+
// to a bare status code. JSON-only extraction: recognized string fields are returned,
|
|
22
|
+
// HTML/non-JSON bodies yield "" so raw markup is never echoed to the client.
|
|
23
|
+
export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string {
|
|
24
|
+
let parsed: unknown;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(payloadText);
|
|
27
|
+
} catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
const detail = extractErrorDetail(parsed);
|
|
31
|
+
if (!detail) return "";
|
|
32
|
+
return redactSecretString(detail).slice(0, 400);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractErrorDetail(parsed: unknown): string | undefined {
|
|
36
|
+
if (typeof parsed === "string") return parsed.trim() || undefined;
|
|
37
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
38
|
+
const obj = parsed as Record<string, unknown>;
|
|
39
|
+
// OpenAI shape: { error: { message } } or { error: "..." }
|
|
40
|
+
const err = obj.error;
|
|
41
|
+
if (typeof err === "string" && err.trim()) return err.trim();
|
|
42
|
+
if (err !== null && typeof err === "object" && !Array.isArray(err)) {
|
|
43
|
+
const msg = (err as Record<string, unknown>).message;
|
|
44
|
+
if (typeof msg === "string" && msg.trim()) return msg.trim();
|
|
45
|
+
}
|
|
46
|
+
// FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
|
|
47
|
+
const det = obj.detail;
|
|
48
|
+
if (typeof det === "string" && det.trim()) return det.trim();
|
|
49
|
+
if (Array.isArray(det)) {
|
|
50
|
+
const msgs = det
|
|
51
|
+
.map(item => (item !== null && typeof item === "object" && typeof (item as Record<string, unknown>).msg === "string"
|
|
52
|
+
? ((item as Record<string, unknown>).msg as string).trim()
|
|
53
|
+
: ""))
|
|
54
|
+
.filter(m => m.length > 0);
|
|
55
|
+
if (msgs.length > 0) return msgs.join("; ");
|
|
56
|
+
}
|
|
57
|
+
// Generic fallbacks: { message } / RFC7807 { title }
|
|
58
|
+
if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
|
|
59
|
+
if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
16
63
|
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
|
|
17
64
|
const out: unknown[] = [];
|
|
18
65
|
const { context, options } = parsed;
|
|
@@ -125,7 +172,49 @@ function safeToolName(name: string | undefined): string {
|
|
|
125
172
|
return sanitized;
|
|
126
173
|
}
|
|
127
174
|
|
|
128
|
-
|
|
175
|
+
const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
|
|
176
|
+
|
|
177
|
+
function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
|
|
178
|
+
try {
|
|
179
|
+
return XAI_SCHEMA_BASE_URLS.has(new URL(provider.baseUrl).hostname);
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function expandXaiRootObjectSchemas(schema: unknown): Record<string, unknown>[] | undefined {
|
|
186
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
|
187
|
+
const obj = schema as Record<string, unknown>;
|
|
188
|
+
const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(obj[key]));
|
|
189
|
+
if (!compositionKey) {
|
|
190
|
+
if (obj.type !== undefined && obj.type !== "object") return undefined;
|
|
191
|
+
return [{ ...obj, type: "object" }];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const siblings = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== compositionKey));
|
|
195
|
+
const branches = obj[compositionKey];
|
|
196
|
+
if (!Array.isArray(branches)) return undefined;
|
|
197
|
+
const expanded: Record<string, unknown>[] = [];
|
|
198
|
+
for (const branch of branches) {
|
|
199
|
+
const variants = expandXaiRootObjectSchemas(branch);
|
|
200
|
+
if (!variants) return undefined;
|
|
201
|
+
for (const variant of variants) expanded.push({ ...siblings, ...variant });
|
|
202
|
+
}
|
|
203
|
+
return expanded.length > 0 ? expanded : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown> | undefined {
|
|
207
|
+
const variants = expandXaiRootObjectSchemas(parameters);
|
|
208
|
+
if (!variants) return undefined;
|
|
209
|
+
if (variants.length === 1) return variants[0];
|
|
210
|
+
const root = parameters && typeof parameters === "object" && !Array.isArray(parameters)
|
|
211
|
+
? parameters as Record<string, unknown>
|
|
212
|
+
: {};
|
|
213
|
+
const metadata = Object.fromEntries(Object.entries(root).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
|
|
214
|
+
return { ...metadata, oneOf: variants };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
129
218
|
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
|
|
130
219
|
const allowed = isAllowedToolChoice(parsed.options.toolChoice)
|
|
131
220
|
? new Set(parsed.options.toolChoice.allowedTools)
|
|
@@ -134,15 +223,21 @@ function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
|
|
|
134
223
|
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
|
|
135
224
|
: parsed.context.tools;
|
|
136
225
|
if (tools.length === 0) return undefined;
|
|
137
|
-
|
|
226
|
+
const xaiTarget = isXaiSchemaTarget(provider);
|
|
227
|
+
const formatted = tools.flatMap(t => {
|
|
228
|
+
const parameters = xaiTarget ? normalizeXaiToolParameters(t.parameters) : t.parameters;
|
|
229
|
+
if (parameters === undefined) return [];
|
|
230
|
+
return [{
|
|
138
231
|
type: "function",
|
|
139
232
|
function: {
|
|
140
233
|
name: namespacedToolName(t.namespace, t.name),
|
|
141
234
|
description: t.description,
|
|
142
|
-
parameters
|
|
235
|
+
parameters,
|
|
143
236
|
...(t.strict !== undefined ? { strict: t.strict } : {}),
|
|
144
237
|
},
|
|
145
|
-
|
|
238
|
+
}];
|
|
239
|
+
});
|
|
240
|
+
return formatted.length > 0 ? formatted : undefined;
|
|
146
241
|
}
|
|
147
242
|
|
|
148
243
|
function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
|
|
@@ -183,6 +278,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
183
278
|
return {
|
|
184
279
|
name: "openai-chat",
|
|
185
280
|
|
|
281
|
+
formatErrorBody: formatOpenAIChatErrorBody,
|
|
282
|
+
|
|
186
283
|
buildRequest(parsed: OcxParsedRequest) {
|
|
187
284
|
const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
|
|
188
285
|
if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
|
|
@@ -190,7 +287,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
190
287
|
}
|
|
191
288
|
|
|
192
289
|
const messages = messagesToChatFormat(parsed, provider);
|
|
193
|
-
const tools = toolsToChatFormat(parsed);
|
|
290
|
+
const tools = toolsToChatFormat(parsed, provider);
|
|
194
291
|
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
|
|
195
292
|
|
|
196
293
|
const body: Record<string, unknown> = {
|
|
@@ -52,6 +52,55 @@ function sanitizeReasoningInputContent(body: unknown): unknown {
|
|
|
52
52
|
return changed ? { ...raw, input } : body;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function stripInvalidItemIds(body: unknown): unknown {
|
|
56
|
+
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
57
|
+
|
|
58
|
+
const validPrefixes: Record<string, string> = {
|
|
59
|
+
message: "msg_",
|
|
60
|
+
reasoning: "rs_",
|
|
61
|
+
function_call: "fc_",
|
|
62
|
+
custom_tool_call: "ctc_",
|
|
63
|
+
tool_search_call: "tsc_",
|
|
64
|
+
web_search_call: "ws_",
|
|
65
|
+
};
|
|
66
|
+
let changed = false;
|
|
67
|
+
const input = body.input.map(item => {
|
|
68
|
+
if (!isPlainObject(item) || typeof item.type !== "string") return item;
|
|
69
|
+
const validPrefix = validPrefixes[item.type];
|
|
70
|
+
if (!validPrefix) return item;
|
|
71
|
+
if (typeof item.id === "string" && item.id.startsWith(validPrefix)) return item;
|
|
72
|
+
if (!("id" in item)) return item;
|
|
73
|
+
changed = true;
|
|
74
|
+
const next = { ...item };
|
|
75
|
+
delete next.id;
|
|
76
|
+
return next;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return changed ? { ...body, input } : body;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* When `store` is false, the upstream API does not persist response items. Any item ID
|
|
84
|
+
* forwarded in `input` is then interpreted as a reference to a stored item that does not
|
|
85
|
+
* exist, producing a 404. Strip all item IDs in this case — `call_id` pairing is unaffected.
|
|
86
|
+
* Matches codex-rs behavior (core/src/client.rs:918-925).
|
|
87
|
+
*/
|
|
88
|
+
function stripItemIdsWhenUnstored(body: unknown): unknown {
|
|
89
|
+
if (!isPlainObject(body) || body.store !== false) return body;
|
|
90
|
+
if (!Array.isArray(body.input)) return body;
|
|
91
|
+
|
|
92
|
+
let changed = false;
|
|
93
|
+
const input = body.input.map(item => {
|
|
94
|
+
if (!isPlainObject(item) || !("id" in item)) return item;
|
|
95
|
+
changed = true;
|
|
96
|
+
const next = { ...item };
|
|
97
|
+
delete next.id;
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return changed ? { ...body, input } : body;
|
|
102
|
+
}
|
|
103
|
+
|
|
55
104
|
/**
|
|
56
105
|
* Replace proxy-minted compaction items (`encrypted_content` starting with `ocx1:`) with plain
|
|
57
106
|
* user messages before forwarding to the ChatGPT backend. Our envelope is transparent base64, not
|
|
@@ -87,9 +136,135 @@ function scrubOcxCompactionItems(body: unknown): unknown {
|
|
|
87
136
|
* Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`).
|
|
88
137
|
*/
|
|
89
138
|
const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
|
|
90
|
-
{ match: model => model.includes("codex-spark"), tools: new Set(["image_generation"]) },
|
|
139
|
+
{ match: model => model.includes("codex-spark"), tools: new Set(["image_generation", "tool_search"]) },
|
|
91
140
|
];
|
|
92
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Strip unsupported `reasoning` sub-parameters for native slugs that reject them (e.g. Spark).
|
|
144
|
+
* codex-rs injects `reasoning.context` and `reasoning.summary` based on catalog flags; Spark's
|
|
145
|
+
* backend rejects both. The catalog fix prevents `use_responses_lite` from being set, but this
|
|
146
|
+
* is a defense-in-depth guard so stale on-disk catalogs don't break until the user runs `ocx sync`.
|
|
147
|
+
*/
|
|
148
|
+
function stripUnsupportedReasoningParams(body: unknown): unknown {
|
|
149
|
+
if (!isPlainObject(body)) return body;
|
|
150
|
+
const model = typeof body.model === "string" ? body.model : "";
|
|
151
|
+
if (!model.includes("codex-spark")) return body;
|
|
152
|
+
if (!isPlainObject(body.reasoning)) return body;
|
|
153
|
+
const reasoning = body.reasoning as Record<string, unknown>;
|
|
154
|
+
// Spark supports reasoning.effort but rejects context, summary, and generate_summary.
|
|
155
|
+
const { context: _ctx, summary: _sum, generate_summary: _gs, ...rest } = reasoning;
|
|
156
|
+
if (_ctx === undefined && _sum === undefined && _gs === undefined) return body;
|
|
157
|
+
return { ...body, reasoning: Object.keys(rest).length > 0 ? rest : undefined };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Comprehensive Spark compatibility layer. codex-rs emits five tool types (function,
|
|
162
|
+
* namespace, tool_search, web_search, custom) plus extensions (defer_loading,
|
|
163
|
+
* parallel_tool_calls, tool_search_call/output items). Spark's serving path only
|
|
164
|
+
* supports flat function tools and hosted web_search. This function:
|
|
165
|
+
* - Flattens namespace tools → promotes inner functions to top level
|
|
166
|
+
* - Drops unsupported tool types (tool_search, custom)
|
|
167
|
+
* - Strips defer_loading from function tools
|
|
168
|
+
* - Strips namespace from input items
|
|
169
|
+
* - Drops tool_search_call/tool_search_output input items
|
|
170
|
+
* - Sets parallel_tool_calls to false
|
|
171
|
+
*/
|
|
172
|
+
function stripSparkCompatibility(body: unknown): unknown {
|
|
173
|
+
if (!isPlainObject(body)) return body;
|
|
174
|
+
const model = typeof body.model === "string" ? body.model : "";
|
|
175
|
+
if (!model.includes("codex-spark")) return body;
|
|
176
|
+
|
|
177
|
+
let changed = false;
|
|
178
|
+
|
|
179
|
+
const SPARK_SAFE_TOOL_TYPES = new Set(["function", "web_search", "web_search_preview"]);
|
|
180
|
+
|
|
181
|
+
let tools = body.tools;
|
|
182
|
+
if (Array.isArray(tools)) {
|
|
183
|
+
const flattened: unknown[] = [];
|
|
184
|
+
for (const t of tools) {
|
|
185
|
+
if (isPlainObject(t) && t.type === "namespace") {
|
|
186
|
+
changed = true;
|
|
187
|
+
if (Array.isArray(t.tools)) {
|
|
188
|
+
for (const inner of t.tools) flattened.push(inner);
|
|
189
|
+
}
|
|
190
|
+
} else if (isPlainObject(t) && typeof t.type === "string" && !SPARK_SAFE_TOOL_TYPES.has(t.type)) {
|
|
191
|
+
changed = true;
|
|
192
|
+
} else {
|
|
193
|
+
flattened.push(t);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Strip defer_loading from promoted/remaining function tools.
|
|
197
|
+
tools = flattened.map(t => {
|
|
198
|
+
if (isPlainObject(t) && t.type === "function" && "defer_loading" in t) {
|
|
199
|
+
const { defer_loading: _, ...rest } = t;
|
|
200
|
+
changed = true;
|
|
201
|
+
return rest;
|
|
202
|
+
}
|
|
203
|
+
return t;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Clean input items: strip namespace, drop tool_search_call/tool_search_output.
|
|
208
|
+
const SPARK_UNSUPPORTED_INPUT_TYPES = new Set([
|
|
209
|
+
"tool_search_call", "tool_search_output",
|
|
210
|
+
"custom_tool_call", "custom_tool_call_output",
|
|
211
|
+
]);
|
|
212
|
+
let input = body.input;
|
|
213
|
+
if (Array.isArray(input)) {
|
|
214
|
+
const cleaned: unknown[] = [];
|
|
215
|
+
for (const item of input) {
|
|
216
|
+
if (isPlainObject(item) && typeof item.type === "string" && SPARK_UNSUPPORTED_INPUT_TYPES.has(item.type)) {
|
|
217
|
+
changed = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Process additional_tools items: filter their inner tools array the same way.
|
|
221
|
+
if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
|
|
222
|
+
const innerTools = item.tools as unknown[];
|
|
223
|
+
const filteredInner: unknown[] = [];
|
|
224
|
+
for (const t of innerTools) {
|
|
225
|
+
if (isPlainObject(t) && t.type === "namespace") {
|
|
226
|
+
changed = true;
|
|
227
|
+
if (Array.isArray(t.tools)) {
|
|
228
|
+
for (const fn of t.tools) filteredInner.push(fn);
|
|
229
|
+
}
|
|
230
|
+
} else if (isPlainObject(t) && typeof t.type === "string" && !SPARK_SAFE_TOOL_TYPES.has(t.type)) {
|
|
231
|
+
changed = true; // drop custom, tool_search, etc.
|
|
232
|
+
} else {
|
|
233
|
+
filteredInner.push(t);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Strip defer_loading from remaining function tools.
|
|
237
|
+
const cleanedInner = filteredInner.map(t => {
|
|
238
|
+
if (isPlainObject(t) && t.type === "function" && "defer_loading" in t) {
|
|
239
|
+
const { defer_loading: _, ...rest } = t;
|
|
240
|
+
changed = true;
|
|
241
|
+
return rest;
|
|
242
|
+
}
|
|
243
|
+
return t;
|
|
244
|
+
});
|
|
245
|
+
cleaned.push({ ...item, tools: cleanedInner });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (isPlainObject(item) && "namespace" in item) {
|
|
249
|
+
const { namespace: _, ...rest } = item;
|
|
250
|
+
changed = true;
|
|
251
|
+
cleaned.push(rest);
|
|
252
|
+
} else {
|
|
253
|
+
cleaned.push(item);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (changed) input = cleaned;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Force parallel_tool_calls off for Spark.
|
|
260
|
+
const extraOverrides: Record<string, unknown> = {};
|
|
261
|
+
if (body.parallel_tool_calls === true) { extraOverrides.parallel_tool_calls = false; changed = true; }
|
|
262
|
+
|
|
263
|
+
return changed
|
|
264
|
+
? { ...body, ...(tools !== body.tools ? { tools } : {}), ...(input !== body.input ? { input } : {}), ...extraOverrides }
|
|
265
|
+
: body;
|
|
266
|
+
}
|
|
267
|
+
|
|
93
268
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
94
269
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
95
270
|
}
|
|
@@ -282,7 +457,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
282
457
|
url,
|
|
283
458
|
method: "POST",
|
|
284
459
|
headers,
|
|
285
|
-
body: JSON.stringify(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))),
|
|
460
|
+
body: JSON.stringify(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))))),
|
|
286
461
|
};
|
|
287
462
|
},
|
|
288
463
|
|