@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
package/src/bridge.ts
CHANGED
|
@@ -146,11 +146,13 @@ export function bridgeToResponsesSSE(
|
|
|
146
146
|
|
|
147
147
|
return new ReadableStream<Uint8Array>({
|
|
148
148
|
async start(controller) {
|
|
149
|
+
let emittedSinceYield = false;
|
|
149
150
|
const emit = (name: string, data: Record<string, unknown>) => {
|
|
150
151
|
if (closed) return;
|
|
151
152
|
activity = true;
|
|
152
153
|
try {
|
|
153
154
|
controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
|
|
155
|
+
emittedSinceYield = true;
|
|
154
156
|
} catch {
|
|
155
157
|
closed = true;
|
|
156
158
|
}
|
|
@@ -264,7 +266,7 @@ export function bridgeToResponsesSSE(
|
|
|
264
266
|
let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
|
|
265
267
|
// Open native web-search cell (between begin and end). Holds the output index allocated on
|
|
266
268
|
// begin so the matching done reuses it; closed as `failed` if the stream terminates early.
|
|
267
|
-
let currentWebSearch: { itemId: string; outputIndex: number } | null = null;
|
|
269
|
+
let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null;
|
|
268
270
|
// Sources from completed web searches, awaiting the next assistant message. Attached as
|
|
269
271
|
// url_citation annotations on that message (the desktop app's Sources chip), then cleared so
|
|
270
272
|
// they bind to exactly one message. Deduped by URL across multiple searches in the turn.
|
|
@@ -399,9 +401,19 @@ export function bridgeToResponsesSSE(
|
|
|
399
401
|
// we synthesize response.completed below, so Codex never hits the parser's
|
|
400
402
|
// "stream closed before response.completed" (responses.rs) -> ApiError::Stream.
|
|
401
403
|
let terminated = false;
|
|
404
|
+
let macrotaskFired = true;
|
|
405
|
+
let macrotaskTimer: ReturnType<typeof setTimeout> | undefined;
|
|
402
406
|
|
|
403
407
|
try {
|
|
404
408
|
for await (const event of events) {
|
|
409
|
+
if (!macrotaskFired && emittedSinceYield) {
|
|
410
|
+
await new Promise<void>(r => setTimeout(r, 0));
|
|
411
|
+
macrotaskFired = true;
|
|
412
|
+
}
|
|
413
|
+
emittedSinceYield = false;
|
|
414
|
+
macrotaskFired = false;
|
|
415
|
+
if (macrotaskTimer !== undefined) clearTimeout(macrotaskTimer);
|
|
416
|
+
macrotaskTimer = setTimeout(() => { macrotaskFired = true; macrotaskTimer = undefined; }, 0);
|
|
405
417
|
activity = true;
|
|
406
418
|
stallTicks = 0;
|
|
407
419
|
// Compaction turns emit ONLY the synthetic compaction item + response.completed. The
|
|
@@ -498,12 +510,12 @@ export function bridgeToResponsesSSE(
|
|
|
498
510
|
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
499
511
|
flushHiddenRawReasoning();
|
|
500
512
|
if (currentToolCall) closeCurrentToolCall();
|
|
501
|
-
const itemId = `fc_${uuid()}`;
|
|
502
513
|
const mapped = toolNsMap?.get(event.name);
|
|
503
514
|
const realName = mapped?.name ?? event.name;
|
|
504
515
|
const ns = mapped?.namespace;
|
|
505
516
|
const toolSearch = toolSearchToolNames?.has(realName) ?? false;
|
|
506
517
|
const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
|
|
518
|
+
const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`;
|
|
507
519
|
const item = toolSearch
|
|
508
520
|
? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
|
|
509
521
|
: freeform
|
|
@@ -554,23 +566,25 @@ export function bridgeToResponsesSSE(
|
|
|
554
566
|
flushHiddenRawReasoning();
|
|
555
567
|
if (currentToolCall) closeCurrentToolCall();
|
|
556
568
|
if (currentWebSearch) closeCurrentWebSearch("completed", []);
|
|
569
|
+
const wsItemId = `ws_${uuid()}`;
|
|
557
570
|
emit("response.output_item.added", {
|
|
558
571
|
output_index: outputIndex,
|
|
559
|
-
item: { type: "web_search_call", id:
|
|
572
|
+
item: { type: "web_search_call", id: wsItemId, status: "in_progress" },
|
|
560
573
|
});
|
|
561
|
-
currentWebSearch = { itemId: event.id, outputIndex };
|
|
574
|
+
currentWebSearch = { itemId: wsItemId, eventId: event.id, outputIndex };
|
|
562
575
|
break;
|
|
563
576
|
}
|
|
564
577
|
case "web_search_call_end": {
|
|
565
578
|
// The sidecar resolved — finalize the cell as "Searched <query>". If no begin opened
|
|
566
579
|
// (defensive), synthesize the added frame first so the done has a matching item.
|
|
567
|
-
if (!currentWebSearch || currentWebSearch.
|
|
580
|
+
if (!currentWebSearch || currentWebSearch.eventId !== event.id) {
|
|
568
581
|
if (currentWebSearch) closeCurrentWebSearch("completed", []);
|
|
582
|
+
const wsItemId2 = `ws_${uuid()}`;
|
|
569
583
|
emit("response.output_item.added", {
|
|
570
584
|
output_index: outputIndex,
|
|
571
|
-
item: { type: "web_search_call", id:
|
|
585
|
+
item: { type: "web_search_call", id: wsItemId2, status: "in_progress" },
|
|
572
586
|
});
|
|
573
|
-
currentWebSearch = { itemId: event.id, outputIndex };
|
|
587
|
+
currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex };
|
|
574
588
|
}
|
|
575
589
|
closeCurrentWebSearch(event.status ?? "completed", event.queries, event.sources);
|
|
576
590
|
// Queue this search's sources for the next assistant message (dedup by URL).
|
|
@@ -650,6 +664,7 @@ export function bridgeToResponsesSSE(
|
|
|
650
664
|
}
|
|
651
665
|
|
|
652
666
|
if (beat) clearInterval(beat);
|
|
667
|
+
if (macrotaskTimer !== undefined) clearTimeout(macrotaskTimer);
|
|
653
668
|
|
|
654
669
|
if (!terminated) {
|
|
655
670
|
// The adapter generator ended without an explicit done/error event. Mark as incomplete
|
|
@@ -782,13 +797,13 @@ export function buildResponseJSON(
|
|
|
782
797
|
const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
|
|
783
798
|
if (toolSearch) {
|
|
784
799
|
output.push({
|
|
785
|
-
type: "tool_search_call", id: `
|
|
800
|
+
type: "tool_search_call", id: `tsc_${uuid()}`,
|
|
786
801
|
call_id: currentToolCallId, execution: "client",
|
|
787
802
|
arguments: parseArgsObj(currentToolCallArgs), status: "completed",
|
|
788
803
|
});
|
|
789
804
|
} else if (freeform) {
|
|
790
805
|
output.push({
|
|
791
|
-
type: "custom_tool_call", id: `
|
|
806
|
+
type: "custom_tool_call", id: `ctc_${uuid()}`,
|
|
792
807
|
call_id: currentToolCallId, name: realName,
|
|
793
808
|
input: freeformInput(currentToolCallArgs), status: "completed",
|
|
794
809
|
});
|
|
@@ -862,7 +877,7 @@ export function buildResponseJSON(
|
|
|
862
877
|
if (currentRawReasoning) flushRawReasoning();
|
|
863
878
|
flushToolCall();
|
|
864
879
|
output.push({
|
|
865
|
-
type: "web_search_call", id:
|
|
880
|
+
type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed",
|
|
866
881
|
action: webSearchAction(e.queries),
|
|
867
882
|
...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}),
|
|
868
883
|
});
|
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
|
}
|
|
@@ -94,11 +94,17 @@ export async function fetchWithAttemptDeadline(
|
|
|
94
94
|
init: RequestInit,
|
|
95
95
|
timeoutMs: number,
|
|
96
96
|
abortSignal?: AbortSignal,
|
|
97
|
+
preferIdentityEncoding = false,
|
|
97
98
|
): Promise<Response> {
|
|
98
99
|
const attemptTimeout = clearableDeadline(timeoutMs, abortSignal);
|
|
100
|
+
const headers = new Headers(init.headers);
|
|
101
|
+
if (preferIdentityEncoding && !headers.has("accept-encoding")) {
|
|
102
|
+
headers.set("accept-encoding", "identity");
|
|
103
|
+
}
|
|
99
104
|
try {
|
|
100
105
|
return await fetch(url, {
|
|
101
106
|
...init,
|
|
107
|
+
headers,
|
|
102
108
|
signal: attemptTimeout.signal,
|
|
103
109
|
});
|
|
104
110
|
} finally {
|
|
@@ -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
|
|
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
|
}
|
package/src/server/responses.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { isUsageDebugEnabled } from "../usage/debug";
|
|
|
41
41
|
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
|
|
42
42
|
import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
|
|
43
43
|
import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../providers/key-failover";
|
|
44
|
+
import { shouldAttemptImageTierRetry } from "./image-retry";
|
|
44
45
|
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
45
46
|
import type { WsData } from "./ws-bridge";
|
|
46
47
|
import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
|
|
@@ -346,9 +347,10 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
|
|
|
346
347
|
&& (child as { type?: unknown }).type === "agent_message"
|
|
347
348
|
&& !hasEncryptedContentPart((child as { content?: unknown }).content)
|
|
348
349
|
) {
|
|
349
|
-
const message = child as { type: string; role?: string; author?: unknown; recipient?: unknown };
|
|
350
|
+
const message = child as { type: string; role?: string; id?: unknown; author?: unknown; recipient?: unknown };
|
|
350
351
|
message.type = "message";
|
|
351
352
|
message.role = "user";
|
|
353
|
+
delete message.id;
|
|
352
354
|
delete message.author;
|
|
353
355
|
delete message.recipient;
|
|
354
356
|
}
|
|
@@ -465,6 +467,22 @@ export async function handleResponses(
|
|
|
465
467
|
logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
|
|
466
468
|
logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
|
|
467
469
|
|
|
470
|
+
// Shadow call intercept: rewrite Codex Desktop's hard-coded gpt-5.4-mini helper calls
|
|
471
|
+
const _sci = config.shadowCallIntercept;
|
|
472
|
+
if (_sci?.enabled && _sci.model && parsed.modelId.startsWith("gpt-5.4-mini")) {
|
|
473
|
+
const _sciOriginal = parsed.modelId;
|
|
474
|
+
parsed.modelId = _sci.model;
|
|
475
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
476
|
+
(parsed._rawBody as { model?: string }).model = _sci.model;
|
|
477
|
+
}
|
|
478
|
+
// Force effort to low for shadow/helper calls (matching upstream behavior)
|
|
479
|
+
parsed.options.reasoning = "low";
|
|
480
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
481
|
+
(parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
|
|
482
|
+
}
|
|
483
|
+
(logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
|
|
484
|
+
}
|
|
485
|
+
|
|
468
486
|
let route;
|
|
469
487
|
try {
|
|
470
488
|
route = routeModel(config, parsed.modelId);
|
|
@@ -923,7 +941,7 @@ export async function handleResponses(
|
|
|
923
941
|
let upstreamResponse: Response;
|
|
924
942
|
try {
|
|
925
943
|
upstreamResponse = adapter.fetchResponse
|
|
926
|
-
? await adapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs })
|
|
944
|
+
? await adapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
|
|
927
945
|
: await fetchWithResetRetry(
|
|
928
946
|
() => fetchWithHeaderTimeout(request.url, {
|
|
929
947
|
method: request.method, headers: request.headers, body: request.body,
|
|
@@ -940,29 +958,22 @@ export async function handleResponses(
|
|
|
940
958
|
}
|
|
941
959
|
|
|
942
960
|
if (!upstreamResponse.ok) {
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
961
|
+
// Recovery loop: multi-key 429 failover + at most ONE anthropic 413 tightened retry
|
|
962
|
+
// (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves
|
|
963
|
+
// both paths so a 429→413 sequence never rebuilds against a stale pre-rotation
|
|
964
|
+
// adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a
|
|
965
|
+
// 413→429 rotation cannot silently undo the tightening.
|
|
966
|
+
let activeAdapter = adapter;
|
|
967
|
+
let imageTierBias = 0;
|
|
968
|
+
let imageRetryAttempted = false;
|
|
969
|
+
const rebuildAndRefetch = async (): Promise<Response | { failed: Response }> => {
|
|
970
|
+
const retryRequest = await activeAdapter.buildRequest(parsed, {
|
|
971
|
+
headers: selectedForwardHeaders,
|
|
972
|
+
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
952
973
|
});
|
|
953
|
-
if (!rotated) break;
|
|
954
|
-
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
955
|
-
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
956
|
-
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
957
|
-
route.provider = rotated;
|
|
958
|
-
const retryAdapter = resolveAdapter(
|
|
959
|
-
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
960
|
-
config.cacheRetention,
|
|
961
|
-
);
|
|
962
|
-
const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
963
974
|
try {
|
|
964
|
-
|
|
965
|
-
? await
|
|
975
|
+
return activeAdapter.fetchResponse
|
|
976
|
+
? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
|
|
966
977
|
: await fetchWithHeaderTimeout(retryRequest.url, {
|
|
967
978
|
method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
|
|
968
979
|
}, upstream.signal, connectMs, parsed.stream);
|
|
@@ -972,8 +983,50 @@ export async function handleResponses(
|
|
|
972
983
|
const msg = err instanceof Error && err.name === "TimeoutError"
|
|
973
984
|
? `Provider connect timeout after ${connectMs}ms`
|
|
974
985
|
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
975
|
-
return formatErrorResponse(502, "upstream_error", msg);
|
|
986
|
+
return { failed: formatErrorResponse(502, "upstream_error", msg) };
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
recovery: for (;;) {
|
|
990
|
+
// Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
|
|
991
|
+
// SAME request once per remaining key. OAuth/forward providers and single-key pools
|
|
992
|
+
// return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
993
|
+
while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
994
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
995
|
+
retryAfter: upstreamResponse.headers.get("retry-after"),
|
|
996
|
+
now: Date.now(),
|
|
997
|
+
attemptedKey: route.provider.apiKey,
|
|
998
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
999
|
+
});
|
|
1000
|
+
if (!rotated) break;
|
|
1001
|
+
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
1002
|
+
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
1003
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
1004
|
+
route.provider = rotated;
|
|
1005
|
+
activeAdapter = resolveAdapter(
|
|
1006
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
1007
|
+
config.cacheRetention,
|
|
1008
|
+
);
|
|
1009
|
+
const result = await rebuildAndRefetch();
|
|
1010
|
+
if ("failed" in result) return result.failed;
|
|
1011
|
+
upstreamResponse = result;
|
|
1012
|
+
}
|
|
1013
|
+
// Anthropic 413 request_too_large: rebuild once with every image one tier lower
|
|
1014
|
+
// (spiral guard: single attempt). The biased response re-enters the 429 check above.
|
|
1015
|
+
if (shouldAttemptImageTierRetry({
|
|
1016
|
+
status: upstreamResponse.status,
|
|
1017
|
+
adapterName: activeAdapter.name,
|
|
1018
|
+
parsed,
|
|
1019
|
+
alreadyAttempted: imageRetryAttempted,
|
|
1020
|
+
})) {
|
|
1021
|
+
imageRetryAttempted = true;
|
|
1022
|
+
imageTierBias = 1;
|
|
1023
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
1024
|
+
const result = await rebuildAndRefetch();
|
|
1025
|
+
if ("failed" in result) return result.failed;
|
|
1026
|
+
upstreamResponse = result;
|
|
1027
|
+
continue recovery;
|
|
976
1028
|
}
|
|
1029
|
+
break;
|
|
977
1030
|
}
|
|
978
1031
|
if (!upstreamResponse.ok) {
|
|
979
1032
|
const errorText = await upstreamResponse.text().catch(() => "unknown error");
|
package/src/server/system-env.ts
CHANGED
|
@@ -25,13 +25,15 @@ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<str
|
|
|
25
25
|
`export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`,
|
|
26
26
|
`export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`,
|
|
27
27
|
];
|
|
28
|
-
if (config.apiKeys?.length) {
|
|
29
|
-
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
|
|
30
|
-
}
|
|
31
28
|
// New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already
|
|
32
29
|
// exported in their shell wins even though launchctl knows nothing about it.
|
|
33
30
|
const conditional = (name: string, value: string) =>
|
|
34
31
|
`[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
|
|
32
|
+
if (config.apiKeys?.length) {
|
|
33
|
+
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
|
|
34
|
+
} else if (config.claudeCode?.authMode === "proxy") {
|
|
35
|
+
lines.push(conditional("ANTHROPIC_AUTH_TOKEN", "opencodex-proxy"));
|
|
36
|
+
}
|
|
35
37
|
// Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
|
|
36
38
|
if (modelEnv.ANTHROPIC_MODEL) {
|
|
37
39
|
lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`);
|
|
@@ -238,6 +240,8 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
|
|
|
238
240
|
inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
|
|
239
241
|
if (config.apiKeys?.length) {
|
|
240
242
|
inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
|
|
243
|
+
} else if (config.claudeCode?.authMode === "proxy" && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
|
|
244
|
+
inject("ANTHROPIC_AUTH_TOKEN", "opencodex-proxy");
|
|
241
245
|
}
|
|
242
246
|
// Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
|
|
243
247
|
// launchd domain, and track ONLY the keys we actually injected so revert cannot
|
package/src/types.ts
CHANGED
|
@@ -270,6 +270,12 @@ export interface OcxClaudeCodeConfig {
|
|
|
270
270
|
* on stop/shutdown. Default: false (opt-in). macOS only.
|
|
271
271
|
*/
|
|
272
272
|
systemEnv?: boolean;
|
|
273
|
+
/**
|
|
274
|
+
* Auth mode for Claude Code inbound requests. "proxy" injects a dummy
|
|
275
|
+
* ANTHROPIC_AUTH_TOKEN so Claude Code routes through the proxy without a
|
|
276
|
+
* real Anthropic key. Default: undefined (no token injection).
|
|
277
|
+
*/
|
|
278
|
+
authMode?: "proxy";
|
|
273
279
|
/**
|
|
274
280
|
* Context-window override for Claude Code/Desktop clients (devlog 136 B6):
|
|
275
281
|
* injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official
|
|
@@ -379,6 +385,17 @@ export interface OcxConfig {
|
|
|
379
385
|
* are omitted from the bare /v1/models list.
|
|
380
386
|
*/
|
|
381
387
|
disabledModels?: string[];
|
|
388
|
+
/**
|
|
389
|
+
* Shadow call intercept: redirect Codex Desktop's hard-coded gpt-5.4-mini helper calls
|
|
390
|
+
* (title generation, commit messages, skill orchestration) to a user-chosen model.
|
|
391
|
+
* Opt-in; disabled by default. When enabled, effort is forced to low.
|
|
392
|
+
*/
|
|
393
|
+
shadowCallIntercept?: {
|
|
394
|
+
/** When true, all gpt-5.4-mini* requests are rewritten to the configured model. */
|
|
395
|
+
enabled?: boolean;
|
|
396
|
+
/** Replacement model id (e.g. "gpt-5.5"). */
|
|
397
|
+
model?: string;
|
|
398
|
+
};
|
|
382
399
|
/**
|
|
383
400
|
* 3-state multi-agent surface override:
|
|
384
401
|
* - "v1": force ALL models to v1 surface (override upstream pins)
|
|
@@ -668,10 +685,11 @@ export interface OcxProviderConfig {
|
|
|
668
685
|
unsafeAllowNativeLocalExec?: boolean;
|
|
669
686
|
/**
|
|
670
687
|
* Cursor adapter only: native local exec policy mode (exec-policy.ts).
|
|
671
|
-
* "
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
688
|
+
* "codex-sandbox" (default) allows server-driven local exec only when the
|
|
689
|
+
* request's instructions/developer text declares the Codex danger-full-access
|
|
690
|
+
* sandbox (approves the normal full-access flow, denies undeclared requests);
|
|
691
|
+
* "off" rejects all server-driven local exec; "on" always allows (same as legacy
|
|
692
|
+
* unsafeAllowNativeLocalExec:true). NOTE: the declaration is CALLER-CONTROLLED prose —
|
|
675
693
|
* the proxy cannot verify it. Enable "codex-sandbox" only where every client
|
|
676
694
|
* that can reach the data plane is trusted: the default loopback bind admits
|
|
677
695
|
* ANY process on this host without auth (including other local users on
|
package/src/web-search/index.ts
CHANGED
|
@@ -97,13 +97,16 @@ export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSideca
|
|
|
97
97
|
return undefined;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The
|
|
102
|
+
* anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting
|
|
103
|
+
* it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna)
|
|
104
|
+
* to the Anthropic API.
|
|
105
|
+
*/
|
|
101
106
|
export function resolveSidecarBackend(
|
|
102
107
|
explicit: "openai" | "anthropic" | undefined,
|
|
103
|
-
anthropicSidecar: AnthropicSidecarProvider | undefined,
|
|
104
108
|
): "openai" | "anthropic" {
|
|
105
|
-
|
|
106
|
-
return anthropicSidecar ? "anthropic" : "openai";
|
|
109
|
+
return explicit === "anthropic" ? "anthropic" : "openai";
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
export interface SidecarPlan {
|
|
@@ -145,7 +148,7 @@ export function planWebSearch(
|
|
|
145
148
|
// Same `?? 200_000` default the server applies when threading connectTimeoutMs into the loop.
|
|
146
149
|
const connectTimeoutMs = config.connectTimeoutMs ?? 200_000;
|
|
147
150
|
const anthropicSidecar = findAnthropicSidecarProvider(config);
|
|
148
|
-
const backend = resolveSidecarBackend(cfg.backend
|
|
151
|
+
const backend = resolveSidecarBackend(cfg.backend);
|
|
149
152
|
const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES;
|
|
150
153
|
const stallTimeoutSec = webSearchStallTimeoutSec(
|
|
151
154
|
config.stallTimeoutSec,
|
package/src/web-search/loop.ts
CHANGED
|
@@ -271,14 +271,19 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
271
271
|
abortSignal: headerDeadline.signal,
|
|
272
272
|
timeoutMs: connectTimeoutMs,
|
|
273
273
|
returnRawErrors: true,
|
|
274
|
+
stream: true,
|
|
274
275
|
})
|
|
275
276
|
: await fetchWithResetRetry(
|
|
276
|
-
() =>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
277
|
+
() => {
|
|
278
|
+
const h = new Headers(request.headers);
|
|
279
|
+
if (!h.has("accept-encoding")) h.set("accept-encoding", "identity");
|
|
280
|
+
return fetch(request.url, {
|
|
281
|
+
method: request.method,
|
|
282
|
+
headers: h,
|
|
283
|
+
body: request.body,
|
|
284
|
+
signal: headerDeadline.signal,
|
|
285
|
+
});
|
|
286
|
+
},
|
|
282
287
|
{ abortSignal: headerDeadline.signal, label: "web-search-loop" },
|
|
283
288
|
);
|
|
284
289
|
return { response, responseAdapter: requestAdapter };
|