@bitkyc08/opencodex 2.7.17 → 2.7.19
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-D8ODGlXj.js +40 -0
- package/gui/dist/assets/index-DbIT5GLo.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- 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/mimo-free.ts +228 -0
- package/src/adapters/openai-chat.ts +153 -1
- package/src/adapters/openai-responses.ts +128 -2
- package/src/cli/claude.ts +3 -0
- package/src/codex/catalog.ts +25 -1
- package/src/oauth/callback-server.ts +17 -7
- package/src/oauth/index.ts +93 -0
- package/src/oauth/types.ts +1 -1
- package/src/providers/derive.ts +4 -0
- package/src/providers/registry.ts +64 -3
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/claude-messages.ts +11 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/management-api.ts +57 -1
- package/src/server/relay.ts +6 -2
- package/src/server/request-log.ts +4 -1
- 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
|
@@ -23,6 +23,8 @@ export interface ProviderRegistryEntry {
|
|
|
23
23
|
allowPrivateNetworkByDefault?: boolean;
|
|
24
24
|
keyOptional?: boolean;
|
|
25
25
|
allowBaseUrlOverride?: boolean;
|
|
26
|
+
/** Static headers merged into every upstream request for this provider. */
|
|
27
|
+
staticHeaders?: Record<string, string>;
|
|
26
28
|
modelSuffixBracketStrip?: boolean;
|
|
27
29
|
featured?: boolean;
|
|
28
30
|
dashboardPreset?: boolean;
|
|
@@ -66,7 +68,7 @@ export type ProviderConfigSeed = Pick<
|
|
|
66
68
|
| "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
|
|
67
69
|
| "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
|
|
68
70
|
| "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
|
|
69
|
-
| "googleMode" | "project" | "location"
|
|
71
|
+
| "googleMode" | "project" | "location" | "headers"
|
|
70
72
|
>;
|
|
71
73
|
|
|
72
74
|
// Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the
|
|
@@ -133,6 +135,7 @@ const THINKING_BUDGET_MODELS = [
|
|
|
133
135
|
];
|
|
134
136
|
const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"];
|
|
135
137
|
const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
|
|
138
|
+
const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
|
|
136
139
|
// "max" is advertised too: the wire map routes xhigh->max and max->max, so the picker
|
|
137
140
|
// should surface the max tier instead of hiding it behind xhigh.
|
|
138
141
|
const DEEPSEEK_THINKING_EFFORTS = ["high", "xhigh", "max"];
|
|
@@ -153,6 +156,17 @@ const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-hig
|
|
|
153
156
|
const KIMI_API_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
|
|
154
157
|
KIMI_API_MODELS.map(id => [id, 262_144]),
|
|
155
158
|
);
|
|
159
|
+
|
|
160
|
+
// 260715 NVIDIA NIM kimi family (issue #126): documented served ids on integrate
|
|
161
|
+
// chat/completions per docs.api.nvidia.com/nim/reference/llm-apis; live /v1/models
|
|
162
|
+
// currently lists only kimi-k2.6 but the list is dynamic, so carry the documented family.
|
|
163
|
+
const NVIDIA_NIM_KIMI_THINKING_MODELS = [
|
|
164
|
+
"moonshotai/kimi-k2.6", "moonshotai/kimi-k2.5", "moonshotai/kimi-k2-thinking",
|
|
165
|
+
];
|
|
166
|
+
const NVIDIA_NIM_KIMI_MODELS = [
|
|
167
|
+
...NVIDIA_NIM_KIMI_THINKING_MODELS,
|
|
168
|
+
"moonshotai/kimi-k2-instruct", "moonshotai/kimi-k2-instruct-0905",
|
|
169
|
+
];
|
|
156
170
|
const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
|
|
157
171
|
KIMI_CODING_MODELS.map(id => [id, 262_144]),
|
|
158
172
|
);
|
|
@@ -202,7 +216,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
202
216
|
authKind: "oauth",
|
|
203
217
|
featured: false,
|
|
204
218
|
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.",
|
|
219
|
+
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
220
|
models: cursorModelIds(CURSOR_STATIC_MODELS),
|
|
207
221
|
liveModels: true,
|
|
208
222
|
defaultModel: "auto",
|
|
@@ -497,7 +511,21 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
497
511
|
preserveReasoningContentModels: KIMI_API_MODELS,
|
|
498
512
|
},
|
|
499
513
|
{ id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" },
|
|
500
|
-
|
|
514
|
+
// 260715 NIM hardening (issue #126, devlog/_plan/260715_issue126_nim_kimi):
|
|
515
|
+
// - NIM kimi rejects `parallel_tool_calls: true` with 400 "This model only supports single
|
|
516
|
+
// tool-calls at once!" (openclaw#37048). NVIDIA's own function-calling docs default the
|
|
517
|
+
// Boolean to false, so provider-wide `false` is the documented-safe wire value.
|
|
518
|
+
// - `reasoning_effort` is not portable on NIM (models use chat_template_kwargs); the kimi
|
|
519
|
+
// family is live-discovered with no capability metadata, so Codex would otherwise send
|
|
520
|
+
// reasoning_effort=medium. Exact-id lists per modelInList semantics; gpt-oss on NIM keeps
|
|
521
|
+
// its working reasoning_effort. Future kimi ids must be appended individually.
|
|
522
|
+
{
|
|
523
|
+
id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com",
|
|
524
|
+
parallelToolCalls: false,
|
|
525
|
+
noReasoningModels: NVIDIA_NIM_KIMI_MODELS,
|
|
526
|
+
modelReasoningEfforts: Object.fromEntries(NVIDIA_NIM_KIMI_MODELS.map(id => [id, []])),
|
|
527
|
+
preserveReasoningContentModels: NVIDIA_NIM_KIMI_THINKING_MODELS,
|
|
528
|
+
},
|
|
501
529
|
{ id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" },
|
|
502
530
|
// 260710 GLM-5.2 context and path-specific ids: Tier-2 evidence in
|
|
503
531
|
// devlog/_plan/260710_provider_hardening/002_research_cn.md.
|
|
@@ -583,8 +611,41 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
583
611
|
},
|
|
584
612
|
{ id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth" },
|
|
585
613
|
{ id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
|
|
614
|
+
{
|
|
615
|
+
id: "opencode-free",
|
|
616
|
+
label: "OpenCode Free",
|
|
617
|
+
adapter: "openai-chat",
|
|
618
|
+
baseUrl: "https://opencode.ai/zen/v1",
|
|
619
|
+
authKind: "key",
|
|
620
|
+
keyOptional: true,
|
|
621
|
+
featured: true,
|
|
622
|
+
liveModels: true,
|
|
623
|
+
note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
|
|
624
|
+
dashboardUrl: "https://opencode.ai",
|
|
625
|
+
staticHeaders: {
|
|
626
|
+
"x-opencode-client": "desktop",
|
|
627
|
+
},
|
|
628
|
+
modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
|
|
629
|
+
modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
|
|
630
|
+
preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS,
|
|
631
|
+
noVisionModels: OPENCODE_FREE_DEEPSEEK_MODELS,
|
|
632
|
+
},
|
|
586
633
|
{ id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
|
|
587
634
|
{ id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
|
|
635
|
+
{
|
|
636
|
+
id: "mimo-free",
|
|
637
|
+
label: "MiMo Free",
|
|
638
|
+
adapter: "mimo-free",
|
|
639
|
+
baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat",
|
|
640
|
+
authKind: "key",
|
|
641
|
+
keyOptional: true,
|
|
642
|
+
featured: true,
|
|
643
|
+
liveModels: true,
|
|
644
|
+
dashboardUrl: "https://xiaomimimo.com",
|
|
645
|
+
defaultModel: "mimo-auto",
|
|
646
|
+
models: ["mimo-auto"],
|
|
647
|
+
note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.",
|
|
648
|
+
},
|
|
588
649
|
{ id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
|
|
589
650
|
// FREEZE 2026-07-10: /models is auth-gated, so ids remain unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
|
|
590
651
|
{ id: "github-copilot", label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://github.com/settings/copilot" },
|
|
@@ -3,6 +3,7 @@ import { createAzureAdapter } from "../adapters/azure";
|
|
|
3
3
|
import { createCursorAdapter } from "../adapters/cursor";
|
|
4
4
|
import { createGoogleAdapter } from "../adapters/google";
|
|
5
5
|
import { createKiroAdapter } from "../adapters/kiro";
|
|
6
|
+
import { createMimoFreeAdapter } from "../adapters/mimo-free";
|
|
6
7
|
import { createOpenAIChatAdapter } from "../adapters/openai-chat";
|
|
7
8
|
import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
|
|
8
9
|
import type { OcxProviderConfig } from "../types";
|
|
@@ -40,6 +41,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention
|
|
|
40
41
|
return createAzureAdapter(providerConfig);
|
|
41
42
|
case "cursor":
|
|
42
43
|
return createCursorAdapter(providerConfig);
|
|
44
|
+
case "mimo-free":
|
|
45
|
+
return createMimoFreeAdapter(providerConfig);
|
|
43
46
|
default:
|
|
44
47
|
throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
|
|
45
48
|
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
providerHeadersConfigError,
|
|
7
7
|
} from "../config";
|
|
8
8
|
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
9
|
+
import { getProviderRegistryEntry } from "../providers/registry";
|
|
9
10
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
10
11
|
|
|
11
12
|
let _corsOrigin = "http://localhost:10100";
|
|
@@ -210,6 +211,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
210
211
|
"disabled",
|
|
211
212
|
"allowPrivateNetwork",
|
|
212
213
|
"authMode",
|
|
214
|
+
"keyOptional",
|
|
213
215
|
"liveModels",
|
|
214
216
|
"models",
|
|
215
217
|
"contextWindow",
|
|
@@ -227,6 +229,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
227
229
|
] as const) {
|
|
228
230
|
copyIfDefined(dto, provider, key);
|
|
229
231
|
}
|
|
232
|
+
const registryNote = getProviderRegistryEntry(name)?.note;
|
|
233
|
+
if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote;
|
|
230
234
|
providers[name] = dto;
|
|
231
235
|
}
|
|
232
236
|
return {
|
|
@@ -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
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isOAuthProvider,
|
|
17
17
|
listOAuthProviders,
|
|
18
18
|
startLoginFlow,
|
|
19
|
+
submitManualLoginCode,
|
|
19
20
|
upsertOAuthProvider,
|
|
20
21
|
} from "../oauth";
|
|
21
22
|
import { removeCredential } from "../oauth/store";
|
|
@@ -28,6 +29,7 @@ import { readUsageEntries } from "../usage/log";
|
|
|
28
29
|
import { getUsageDebugLogEntries } from "../usage/debug";
|
|
29
30
|
import { parseRange, summarizeUsage } from "../usage/summary";
|
|
30
31
|
import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
|
|
32
|
+
import { getProviderRegistryEntry } from "../providers/registry";
|
|
31
33
|
import { getDebugLogEntries } from "../lib/debug-log-buffer";
|
|
32
34
|
import { getInjectionDebugLogEntries } from "../lib/injection-debug-log";
|
|
33
35
|
import {
|
|
@@ -273,6 +275,33 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
273
275
|
});
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
|
|
279
|
+
const sci = config.shadowCallIntercept ?? {};
|
|
280
|
+
return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
|
|
284
|
+
let raw: unknown;
|
|
285
|
+
try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
286
|
+
if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
|
|
287
|
+
const body = raw as { enabled?: unknown; model?: unknown };
|
|
288
|
+
if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
|
|
289
|
+
return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
290
|
+
}
|
|
291
|
+
if (body.model !== undefined && typeof body.model !== "string") {
|
|
292
|
+
return jsonResponse({ error: "model must be a string" }, 400);
|
|
293
|
+
}
|
|
294
|
+
config.shadowCallIntercept = { ...config.shadowCallIntercept };
|
|
295
|
+
if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled;
|
|
296
|
+
if (typeof body.model === "string") {
|
|
297
|
+
if (body.model === "") delete config.shadowCallIntercept.model;
|
|
298
|
+
else config.shadowCallIntercept.model = body.model;
|
|
299
|
+
}
|
|
300
|
+
saveConfig(config);
|
|
301
|
+
const sci = config.shadowCallIntercept;
|
|
302
|
+
return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
|
|
303
|
+
}
|
|
304
|
+
|
|
276
305
|
if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
277
306
|
return jsonResponse(filterRequestLogs(getRequestLogEntries(), url.searchParams));
|
|
278
307
|
}
|
|
@@ -403,7 +432,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
403
432
|
// let the (possibly new) apiKey join the pool as the active entry.
|
|
404
433
|
const existingPool = config.providers[name]?.apiKeyPool;
|
|
405
434
|
if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
|
|
406
|
-
config.providers[name] = prov;
|
|
435
|
+
config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
|
|
407
436
|
if (body.setDefault) config.defaultProvider = name;
|
|
408
437
|
save(config);
|
|
409
438
|
if (prov.apiKey && prov.apiKeyPool) {
|
|
@@ -1016,6 +1045,21 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1016
1045
|
}
|
|
1017
1046
|
}
|
|
1018
1047
|
|
|
1048
|
+
// Manual fallback for browser OAuth: paste the final redirect URL (or authorization code)
|
|
1049
|
+
// when the browser cannot reach the loopback callback (remote/SSH/blocked localhost).
|
|
1050
|
+
if (url.pathname === "/api/oauth/login/code" && req.method === "POST") {
|
|
1051
|
+
const body = await req.json().catch(() => ({})) as { provider?: string; input?: string; code?: string };
|
|
1052
|
+
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
1053
|
+
if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1054
|
+
const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : "";
|
|
1055
|
+
// Authorization responses are measured in hundreds of bytes; never accept the
|
|
1056
|
+
// generic management-body allowance here.
|
|
1057
|
+
if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
|
|
1058
|
+
const result = submitManualLoginCode(provider, input);
|
|
1059
|
+
if (!result.ok) return jsonResponse({ error: result.error }, 409);
|
|
1060
|
+
return jsonResponse({ ok: true });
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1019
1063
|
if (url.pathname === "/api/oauth/status" && req.method === "GET") {
|
|
1020
1064
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
1021
1065
|
return jsonResponse(getLoginStatus(provider));
|
|
@@ -1180,3 +1224,15 @@ export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]>
|
|
|
1180
1224
|
const { gatherRoutedModels } = await import("../codex/catalog");
|
|
1181
1225
|
return gatherRoutedModels(config);
|
|
1182
1226
|
}
|
|
1227
|
+
|
|
1228
|
+
function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
1229
|
+
const entry = getProviderRegistryEntry(name);
|
|
1230
|
+
if (!entry?.staticHeaders || !provider.headers) return provider;
|
|
1231
|
+
const headerEntries = Object.entries(provider.headers);
|
|
1232
|
+
const staticEntries = Object.entries(entry.staticHeaders);
|
|
1233
|
+
if (headerEntries.length !== staticEntries.length) return provider;
|
|
1234
|
+
const matchesRegistryStaticHeaders = staticEntries.every(([key, value]) => provider.headers?.[key] === value);
|
|
1235
|
+
if (!matchesRegistryStaticHeaders) return provider;
|
|
1236
|
+
const { headers: _headers, ...rest } = provider;
|
|
1237
|
+
return rest;
|
|
1238
|
+
}
|
package/src/server/relay.ts
CHANGED
|
@@ -212,10 +212,14 @@ export function responseWithDeferredRequestLog(
|
|
|
212
212
|
return response;
|
|
213
213
|
}
|
|
214
214
|
if (!response.body || !contentType.includes("text/event-stream")) {
|
|
215
|
-
if (response.body && contentType.includes("application/json")) {
|
|
215
|
+
if (response.body && (contentType.includes("application/json") || response.status >= 400)) {
|
|
216
216
|
const finalizeJsonLog = async () => {
|
|
217
217
|
const text = await response.text();
|
|
218
|
-
|
|
218
|
+
// Non-JSON error bodies: inspect/log only a bounded prefix (the stored
|
|
219
|
+
// upstreamError is 500 chars anyway); the FULL text is still forwarded to the
|
|
220
|
+
// client below, unchanged. JSON bodies keep full inspection (usage parsing).
|
|
221
|
+
const isJson = contentType.includes("application/json");
|
|
222
|
+
inspectResponseLogJson(logCtx, isJson ? text : text.slice(0, 8192));
|
|
219
223
|
addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
|
|
220
224
|
return text;
|
|
221
225
|
};
|
|
@@ -294,7 +294,10 @@ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): v
|
|
|
294
294
|
logCtx.upstreamError = redactSecretString(incompleteReasonLabel(reason.trim())).slice(0, 500);
|
|
295
295
|
}
|
|
296
296
|
} catch {
|
|
297
|
-
|
|
297
|
+
const trimmed = text.trim();
|
|
298
|
+
if (trimmed) {
|
|
299
|
+
logCtx.upstreamError = redactSecretString(trimmed).slice(0, 500);
|
|
300
|
+
}
|
|
298
301
|
}
|
|
299
302
|
}
|
|
300
303
|
|
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";
|
|
@@ -466,6 +467,22 @@ export async function handleResponses(
|
|
|
466
467
|
logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
|
|
467
468
|
logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
|
|
468
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
|
+
|
|
469
486
|
let route;
|
|
470
487
|
try {
|
|
471
488
|
route = routeModel(config, parsed.modelId);
|
|
@@ -941,29 +958,22 @@ export async function handleResponses(
|
|
|
941
958
|
}
|
|
942
959
|
|
|
943
960
|
if (!upstreamResponse.ok) {
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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 } : {}),
|
|
953
973
|
});
|
|
954
|
-
if (!rotated) break;
|
|
955
|
-
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
956
|
-
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
957
|
-
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
958
|
-
route.provider = rotated;
|
|
959
|
-
const retryAdapter = resolveAdapter(
|
|
960
|
-
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
961
|
-
config.cacheRetention,
|
|
962
|
-
);
|
|
963
|
-
const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
964
974
|
try {
|
|
965
|
-
|
|
966
|
-
? await
|
|
975
|
+
return activeAdapter.fetchResponse
|
|
976
|
+
? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
|
|
967
977
|
: await fetchWithHeaderTimeout(retryRequest.url, {
|
|
968
978
|
method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
|
|
969
979
|
}, upstream.signal, connectMs, parsed.stream);
|
|
@@ -973,8 +983,50 @@ export async function handleResponses(
|
|
|
973
983
|
const msg = err instanceof Error && err.name === "TimeoutError"
|
|
974
984
|
? `Provider connect timeout after ${connectMs}ms`
|
|
975
985
|
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
976
|
-
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;
|
|
977
1028
|
}
|
|
1029
|
+
break;
|
|
978
1030
|
}
|
|
979
1031
|
if (!upstreamResponse.ok) {
|
|
980
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,
|