@bitkyc08/opencodex 2.7.17 → 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 +6 -0
- package/src/adapters/cursor/exec-policy.ts +9 -1
- package/src/adapters/cursor/live-transport.ts +19 -11
- package/src/adapters/cursor/protobuf-request.ts +7 -6
- package/src/adapters/kiro-images.ts +94 -0
- package/src/adapters/kiro.ts +6 -2
- package/src/adapters/openai-chat.ts +49 -0
- package/src/adapters/openai-responses.ts +128 -2
- package/src/cli/claude.ts +3 -0
- package/src/codex/catalog.ts +11 -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 +74 -22
- package/src/server/system-env.ts +7 -3
- package/src/types.ts +22 -4
- package/src/web-search/index.ts +8 -5
- package/gui/dist/assets/index-Cq8maiJf.css +0 -1
- package/gui/dist/assets/index-m4o3xsSn.js +0 -40
|
@@ -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)}`;
|
|
@@ -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
|
+
}
|
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;
|
|
@@ -231,6 +278,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
231
278
|
return {
|
|
232
279
|
name: "openai-chat",
|
|
233
280
|
|
|
281
|
+
formatErrorBody: formatOpenAIChatErrorBody,
|
|
282
|
+
|
|
234
283
|
buildRequest(parsed: OcxParsedRequest) {
|
|
235
284
|
const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
|
|
236
285
|
if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
|
|
@@ -136,9 +136,135 @@ function scrubOcxCompactionItems(body: unknown): unknown {
|
|
|
136
136
|
* Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`).
|
|
137
137
|
*/
|
|
138
138
|
const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
|
|
139
|
-
{ 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"]) },
|
|
140
140
|
];
|
|
141
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
|
+
|
|
142
268
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
143
269
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
144
270
|
}
|
|
@@ -331,7 +457,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
331
457
|
url,
|
|
332
458
|
method: "POST",
|
|
333
459
|
headers,
|
|
334
|
-
body: JSON.stringify(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))),
|
|
460
|
+
body: JSON.stringify(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))))),
|
|
335
461
|
};
|
|
336
462
|
},
|
|
337
463
|
|
package/src/cli/claude.ts
CHANGED
|
@@ -54,6 +54,9 @@ export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaun
|
|
|
54
54
|
if ((config.apiKeys?.length ?? 0) > 0) {
|
|
55
55
|
setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
|
|
56
56
|
}
|
|
57
|
+
if (!env.ANTHROPIC_AUTH_TOKEN && config.claudeCode?.authMode === "proxy") {
|
|
58
|
+
env.ANTHROPIC_AUTH_TOKEN = "opencodex-proxy";
|
|
59
|
+
}
|
|
57
60
|
// NOTE: do NOT set _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL here. While it enables
|
|
58
61
|
// Design/Remote Control, it DISABLES gateway model discovery (Claude Code's eligibility
|
|
59
62
|
// check returns false when isFirstPartyBaseUrl() is true). Model routing through the
|
package/src/codex/catalog.ts
CHANGED
|
@@ -851,6 +851,17 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
|
|
|
851
851
|
applyNativeOpenAiContextOverride(e);
|
|
852
852
|
if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
|
|
853
853
|
else ensureUltraReasoningLevel(e);
|
|
854
|
+
// Non-5.6 natives (5.5, 5.4, 5.4-mini, spark) do not support responses-lite;
|
|
855
|
+
// the template may carry the flag from a 5.6 entry — strip it so codex-rs does
|
|
856
|
+
// not inject reasoning.context: "all_turns" for models that reject it.
|
|
857
|
+
if (!isGpt56NativeSlug(slug)) {
|
|
858
|
+
// Spark NEEDS use_responses_lite: true — it controls the tool delivery format
|
|
859
|
+
// (AdditionalTools in input vs top-level tools). The reasoning params that
|
|
860
|
+
// use_responses_lite triggers (context: "all_turns", summary) are stripped
|
|
861
|
+
// separately in the passthrough adapter (stripUnsupportedReasoningParams).
|
|
862
|
+
if (!slug.includes("codex-spark")) delete e.use_responses_lite;
|
|
863
|
+
delete e.supports_websockets;
|
|
864
|
+
}
|
|
854
865
|
}
|
|
855
866
|
return ensureStrictCatalogFields(normalizeServiceTiers(e));
|
|
856
867
|
}
|
|
@@ -153,6 +153,17 @@ const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-hig
|
|
|
153
153
|
const KIMI_API_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
|
|
154
154
|
KIMI_API_MODELS.map(id => [id, 262_144]),
|
|
155
155
|
);
|
|
156
|
+
|
|
157
|
+
// 260715 NVIDIA NIM kimi family (issue #126): documented served ids on integrate
|
|
158
|
+
// chat/completions per docs.api.nvidia.com/nim/reference/llm-apis; live /v1/models
|
|
159
|
+
// currently lists only kimi-k2.6 but the list is dynamic, so carry the documented family.
|
|
160
|
+
const NVIDIA_NIM_KIMI_THINKING_MODELS = [
|
|
161
|
+
"moonshotai/kimi-k2.6", "moonshotai/kimi-k2.5", "moonshotai/kimi-k2-thinking",
|
|
162
|
+
];
|
|
163
|
+
const NVIDIA_NIM_KIMI_MODELS = [
|
|
164
|
+
...NVIDIA_NIM_KIMI_THINKING_MODELS,
|
|
165
|
+
"moonshotai/kimi-k2-instruct", "moonshotai/kimi-k2-instruct-0905",
|
|
166
|
+
];
|
|
156
167
|
const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
|
|
157
168
|
KIMI_CODING_MODELS.map(id => [id, 262_144]),
|
|
158
169
|
);
|
|
@@ -202,7 +213,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
202
213
|
authKind: "oauth",
|
|
203
214
|
featured: false,
|
|
204
215
|
dashboardPreset: true,
|
|
205
|
-
note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution defaults to codex-sandbox mode (auto-enabled when the request declares Codex danger-full-access sandbox); override with \"nativeLocalExec\": \"on\" (always) or \"codex-sandbox\" (only for requests declaring the Codex danger-full-access sandbox; the declaration is caller-controlled prose the proxy cannot verify, and the auth-free loopback bind admits any process on this host, including other local users — enable only where every data-plane client is trusted) — legacy \"unsafeAllowNativeLocalExec\": true still means \"on\" — on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) for a trusted local experiment.",
|
|
216
|
+
note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution defaults to codex-sandbox mode (auto-enabled when the request declares Codex danger-full-access sandbox); override with \"nativeLocalExec\": \"on\" (always), \"off\" (never), or \"codex-sandbox\" (only for requests declaring the Codex danger-full-access sandbox; the declaration is caller-controlled prose the proxy cannot verify, and the auth-free loopback bind admits any process on this host, including other local users — enable only where every data-plane client is trusted) — legacy \"unsafeAllowNativeLocalExec\": true still means \"on\" — on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) for a trusted local experiment.",
|
|
206
217
|
models: cursorModelIds(CURSOR_STATIC_MODELS),
|
|
207
218
|
liveModels: true,
|
|
208
219
|
defaultModel: "auto",
|
|
@@ -497,7 +508,21 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
497
508
|
preserveReasoningContentModels: KIMI_API_MODELS,
|
|
498
509
|
},
|
|
499
510
|
{ id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" },
|
|
500
|
-
|
|
511
|
+
// 260715 NIM hardening (issue #126, devlog/_plan/260715_issue126_nim_kimi):
|
|
512
|
+
// - NIM kimi rejects `parallel_tool_calls: true` with 400 "This model only supports single
|
|
513
|
+
// tool-calls at once!" (openclaw#37048). NVIDIA's own function-calling docs default the
|
|
514
|
+
// Boolean to false, so provider-wide `false` is the documented-safe wire value.
|
|
515
|
+
// - `reasoning_effort` is not portable on NIM (models use chat_template_kwargs); the kimi
|
|
516
|
+
// family is live-discovered with no capability metadata, so Codex would otherwise send
|
|
517
|
+
// reasoning_effort=medium. Exact-id lists per modelInList semantics; gpt-oss on NIM keeps
|
|
518
|
+
// its working reasoning_effort. Future kimi ids must be appended individually.
|
|
519
|
+
{
|
|
520
|
+
id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com",
|
|
521
|
+
parallelToolCalls: false,
|
|
522
|
+
noReasoningModels: NVIDIA_NIM_KIMI_MODELS,
|
|
523
|
+
modelReasoningEfforts: Object.fromEntries(NVIDIA_NIM_KIMI_MODELS.map(id => [id, []])),
|
|
524
|
+
preserveReasoningContentModels: NVIDIA_NIM_KIMI_THINKING_MODELS,
|
|
525
|
+
},
|
|
501
526
|
{ id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" },
|
|
502
527
|
// 260710 GLM-5.2 context and path-specific ids: Tier-2 evidence in
|
|
503
528
|
// devlog/_plan/260710_provider_hardening/002_research_cn.md.
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
|
|
8
8
|
*/
|
|
9
9
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
10
|
+
import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard";
|
|
11
|
+
import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
|
|
10
12
|
import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
|
|
11
13
|
import { stripOneMillionMarker } from "../claude/context-windows";
|
|
12
14
|
import { captureClaudeInbound } from "../claude/inbound-debug";
|
|
@@ -187,6 +189,15 @@ async function anthropicNativePassthrough(
|
|
|
187
189
|
|
|
188
190
|
const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, "");
|
|
189
191
|
const search = new URL(req.url).search;
|
|
192
|
+
// Native passthrough bypasses the anthropic adapter, so the generous image pipeline
|
|
193
|
+
// (devlog/260714_image_normalization_pipeline/040) must run here: tier-normalize then
|
|
194
|
+
// guard the already-Anthropic-wire messages before serialization. Applies to
|
|
195
|
+
// count_tokens too — counts must match what the real send will contain, and the 32MB
|
|
196
|
+
// body cap applies to it equally. Non-message bodies pass through untouched.
|
|
197
|
+
if (Array.isArray(body.messages)) {
|
|
198
|
+
await normalizeAnthropicImages(body.messages);
|
|
199
|
+
enforceAnthropicImageLimits(body.messages);
|
|
200
|
+
}
|
|
190
201
|
const headers = new Headers();
|
|
191
202
|
req.headers.forEach((value, name) => {
|
|
192
203
|
if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream-413 tightened-retry gate (devlog/260714_image_normalization_pipeline/030).
|
|
3
|
+
*
|
|
4
|
+
* When Anthropic still rejects a normalized request with 413 request_too_large (budget
|
|
5
|
+
* estimate missed: giant text share, tool schemas, ...), the proxy rebuilds the SAME
|
|
6
|
+
* request with `imageTierBias: 1` — every image one ladder position lower — and retries
|
|
7
|
+
* exactly once. The decision logic lives here so it is unit-testable; the fetch loop in
|
|
8
|
+
* responses.ts consumes it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { OcxParsedRequest } from "../types";
|
|
12
|
+
|
|
13
|
+
/** True when the parsed request carries at least one inline (data-URL) image. */
|
|
14
|
+
export function parsedHasInlineImage(parsed: OcxParsedRequest): boolean {
|
|
15
|
+
const messages = (parsed as { context?: { messages?: unknown[] } }).context?.messages ?? [];
|
|
16
|
+
for (const message of messages) {
|
|
17
|
+
const content = (message as { content?: unknown }).content;
|
|
18
|
+
if (!Array.isArray(content)) continue;
|
|
19
|
+
for (const part of content) {
|
|
20
|
+
const imageUrl = (part as { imageUrl?: unknown })?.imageUrl;
|
|
21
|
+
if (typeof imageUrl === "string" && imageUrl.startsWith("data:")) return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One tier-biased rebuild per request (spiral guard), only for the anthropic adapter
|
|
29
|
+
* (others ignore imageTierBias — an identical retry would just duplicate cost), and only
|
|
30
|
+
* when the request actually carries inline images the bias can shrink.
|
|
31
|
+
*/
|
|
32
|
+
export function shouldAttemptImageTierRetry(args: {
|
|
33
|
+
status: number;
|
|
34
|
+
adapterName: string;
|
|
35
|
+
parsed: OcxParsedRequest;
|
|
36
|
+
alreadyAttempted: boolean;
|
|
37
|
+
}): boolean {
|
|
38
|
+
return args.status === 413
|
|
39
|
+
&& !args.alreadyAttempted
|
|
40
|
+
&& args.adapterName === "anthropic"
|
|
41
|
+
&& parsedHasInlineImage(args.parsed);
|
|
42
|
+
}
|
|
@@ -273,6 +273,33 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
273
273
|
});
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
|
|
277
|
+
const sci = config.shadowCallIntercept ?? {};
|
|
278
|
+
return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
|
|
282
|
+
let raw: unknown;
|
|
283
|
+
try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
284
|
+
if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
|
|
285
|
+
const body = raw as { enabled?: unknown; model?: unknown };
|
|
286
|
+
if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
|
|
287
|
+
return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
288
|
+
}
|
|
289
|
+
if (body.model !== undefined && typeof body.model !== "string") {
|
|
290
|
+
return jsonResponse({ error: "model must be a string" }, 400);
|
|
291
|
+
}
|
|
292
|
+
config.shadowCallIntercept = { ...config.shadowCallIntercept };
|
|
293
|
+
if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled;
|
|
294
|
+
if (typeof body.model === "string") {
|
|
295
|
+
if (body.model === "") delete config.shadowCallIntercept.model;
|
|
296
|
+
else config.shadowCallIntercept.model = body.model;
|
|
297
|
+
}
|
|
298
|
+
saveConfig(config);
|
|
299
|
+
const sci = config.shadowCallIntercept;
|
|
300
|
+
return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
|
|
301
|
+
}
|
|
302
|
+
|
|
276
303
|
if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
277
304
|
return jsonResponse(filterRequestLogs(getRequestLogEntries(), url.searchParams));
|
|
278
305
|
}
|