@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.
@@ -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
- for (const part of message.content) {
112
- if (typeof part === "string" || part.type !== "toolCall") continue;
113
- const toolName = namespacedToolName(part.namespace, part.name);
114
- const callText = `[Tool Call]\ncall_id: ${part.id}\nname: ${toolName}\narguments:\n${JSON.stringify(part.arguments ?? {})}`;
115
- entries.push(storeCursorBlob(jsonBlob({ role: "assistant", content: [{ type: "text", text: callText }] })));
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
+ }
@@ -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
@@ -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
- { id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com" },
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
  }