@bitkyc08/opencodex 2.7.9-preview.20260712.1 → 2.7.9
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/README.md +3 -1
- package/gui/dist/assets/index-BAAFKwsh.js +40 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/cursor/transport-retry.ts +5 -3
- package/src/adapters/google-errors.ts +9 -19
- package/src/adapters/google-http.ts +29 -66
- package/src/adapters/kiro-errors.ts +10 -23
- package/src/adapters/kiro-retry.ts +26 -58
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/bridge.ts +6 -2
- package/src/claude/gateway-cache.ts +3 -3
- package/src/claude/outbound.ts +117 -40
- package/src/cli/claude.ts +36 -4
- package/src/config.ts +54 -3
- package/src/lib/destination-policy.ts +167 -0
- package/src/lib/injection-debug-log.ts +34 -0
- package/src/lib/upstream-retry.ts +53 -3
- package/src/lib/windows-secret-acl.ts +173 -0
- package/src/oauth/index.ts +9 -7
- package/src/oauth/store.ts +1 -0
- package/src/providers/registry.ts +10 -3
- package/src/providers/xai-transport.ts +89 -0
- package/src/router.ts +6 -1
- package/src/server/auth-cors.ts +4 -0
- package/src/server/claude-messages.ts +32 -2
- package/src/server/management-api.ts +159 -33
- package/src/server/request-decompress.ts +45 -12
- package/src/server/responses.ts +21 -12
- package/src/server/system-env.ts +110 -68
- package/src/service.ts +4 -0
- package/src/types.ts +25 -5
- package/src/vision/anthropic-describe.ts +185 -0
- package/src/vision/index.ts +219 -10
- package/src/web-search/anthropic-executor.ts +187 -0
- package/src/web-search/executor.ts +4 -2
- package/src/web-search/index.ts +80 -18
- package/src/web-search/loop.ts +14 -2
- package/gui/dist/assets/index-Csp2AZYr.js +0 -40
|
@@ -14,12 +14,19 @@
|
|
|
14
14
|
* MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports
|
|
15
15
|
* the shared abort helpers from here).
|
|
16
16
|
*/
|
|
17
|
+
import { clearableDeadline } from "./abort";
|
|
17
18
|
|
|
18
19
|
// 1 initial + 2 retries: the pool may hold more than one stale socket.
|
|
19
20
|
const RESET_RETRY_MAX_ATTEMPTS = 3;
|
|
20
21
|
const RESET_RETRY_BASE_DELAY_MS = 150;
|
|
21
22
|
const RESET_RETRY_MAX_DELAY_MS = 1_000;
|
|
22
23
|
|
|
24
|
+
export interface RetryBackoffOptions {
|
|
25
|
+
baseDelayMs: number;
|
|
26
|
+
maxDelayMs: number;
|
|
27
|
+
headers?: Headers;
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
export function abortError(signal?: AbortSignal): unknown {
|
|
24
31
|
return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
25
32
|
}
|
|
@@ -56,11 +63,51 @@ export function isConnectionResetError(err: unknown): boolean {
|
|
|
56
63
|
|| msg.includes("connection reset by peer");
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
function
|
|
60
|
-
const
|
|
66
|
+
function retryAfterDelayMs(headers: Headers): number | undefined {
|
|
67
|
+
const raw = headers.get("retry-after")?.trim();
|
|
68
|
+
if (!raw) return undefined;
|
|
69
|
+
const seconds = Number(raw);
|
|
70
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
71
|
+
const dateMs = Date.parse(raw);
|
|
72
|
+
if (!Number.isFinite(dateMs)) return undefined;
|
|
73
|
+
return Math.max(0, dateMs - Date.now());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function retryBackoffDelayMs(attempt: number, opts: RetryBackoffOptions): number {
|
|
77
|
+
const retryAfter = opts.headers ? retryAfterDelayMs(opts.headers) : undefined;
|
|
78
|
+
if (retryAfter !== undefined) return Math.min(retryAfter, opts.maxDelayMs);
|
|
79
|
+
const exp = Math.min(opts.baseDelayMs * (2 ** attempt), opts.maxDelayMs);
|
|
61
80
|
return Math.floor(exp * (0.8 + Math.random() * 0.4));
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
export function cancelResponseBodyBestEffort(res: Response): void {
|
|
84
|
+
try {
|
|
85
|
+
const cancellation = res.body?.cancel();
|
|
86
|
+
if (cancellation) void cancellation.catch(() => {});
|
|
87
|
+
} catch {
|
|
88
|
+
// Cancellation is cleanup only; retries must not wait for or fail because of it.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function fetchWithAttemptDeadline(
|
|
93
|
+
url: string,
|
|
94
|
+
init: RequestInit,
|
|
95
|
+
timeoutMs: number,
|
|
96
|
+
abortSignal?: AbortSignal,
|
|
97
|
+
): Promise<Response> {
|
|
98
|
+
const attemptTimeout = clearableDeadline(timeoutMs, abortSignal);
|
|
99
|
+
try {
|
|
100
|
+
return await fetch(url, {
|
|
101
|
+
...init,
|
|
102
|
+
signal: attemptTimeout.signal,
|
|
103
|
+
});
|
|
104
|
+
} finally {
|
|
105
|
+
// Only the header timer is cleared. The composed signal still contains the parent, so a
|
|
106
|
+
// caller abort after headers continue to cancel consumption of the returned response body.
|
|
107
|
+
attemptTimeout.clear();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
64
111
|
export interface ResetRetryOptions {
|
|
65
112
|
abortSignal?: AbortSignal;
|
|
66
113
|
/** Short host/path label for the retry warn log (no secrets/query strings). */
|
|
@@ -89,7 +136,10 @@ export async function fetchWithResetRetry(
|
|
|
89
136
|
console.warn(
|
|
90
137
|
`[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
|
|
91
138
|
);
|
|
92
|
-
await sleepWithAbort(
|
|
139
|
+
await sleepWithAbort(retryBackoffDelayMs(attempt, {
|
|
140
|
+
baseDelayMs: RESET_RETRY_BASE_DELAY_MS,
|
|
141
|
+
maxDelayMs: RESET_RETRY_MAX_DELAY_MS,
|
|
142
|
+
}), opts.abortSignal);
|
|
93
143
|
}
|
|
94
144
|
}
|
|
95
145
|
throw lastError ?? new Error("upstream fetch failed");
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows per-user NTFS ACL hardening for secret files and directories.
|
|
3
|
+
*
|
|
4
|
+
* On Windows, `chmod` only controls POSIX-style bits in the ACE list and does NOT remove
|
|
5
|
+
* inherited permissions from other users. Real per-user isolation requires icacls to:
|
|
6
|
+
* 1. Disable inheritance (icacls path /inheritance:r)
|
|
7
|
+
* 2. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users)
|
|
8
|
+
* 3. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)")
|
|
9
|
+
*
|
|
10
|
+
* On non-Windows platforms the helpers fall through to the caller's existing chmod-based
|
|
11
|
+
* behaviour: they return ok:true without invoking any external process.
|
|
12
|
+
*
|
|
13
|
+
* Design:
|
|
14
|
+
* hardenSecretPath(path, { required: false }) — non-fatal read-path mode.
|
|
15
|
+
* Never throws. Returns { ok, diagnostics? }.
|
|
16
|
+
* hardenSecretPath(path, { required: true }) — write-path mode.
|
|
17
|
+
* Throws a sanitized error (no raw path) on Windows ACL failure.
|
|
18
|
+
* hardenSecretDir — same contract for directories.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { env, platform } from "node:process";
|
|
24
|
+
|
|
25
|
+
const hardenedDirectories = new Set<string>();
|
|
26
|
+
const hardenedPaths = new Set<string>();
|
|
27
|
+
|
|
28
|
+
export interface HardenResult {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
diagnostics?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HardenOptions {
|
|
34
|
+
required: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return the current Windows username from the environment.
|
|
39
|
+
* Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous.
|
|
40
|
+
* The value is used directly in icacls arguments, so it must be present.
|
|
41
|
+
*/
|
|
42
|
+
function currentWindowsUser(): string | undefined {
|
|
43
|
+
const username = env["USERNAME"];
|
|
44
|
+
const domain = env["USERDOMAIN"];
|
|
45
|
+
if (!username) return undefined;
|
|
46
|
+
// USERDOMAIN is the machine/domain name; USERNAME is the account name.
|
|
47
|
+
// icacls accepts "DOMAIN\User" or just "User" for local accounts.
|
|
48
|
+
return domain ? `${domain}\\${username}` : username;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run icacls to harden a single file system entry.
|
|
53
|
+
* - Disables inheritance (keeps nothing: /inheritance:r)
|
|
54
|
+
* - Grants the current user Full Control
|
|
55
|
+
*
|
|
56
|
+
* We do NOT use a shell string; all arguments are passed as an array so no
|
|
57
|
+
* shell injection is possible even for paths with unusual characters.
|
|
58
|
+
*
|
|
59
|
+
* Throws the raw child_process error on failure (caller sanitizes).
|
|
60
|
+
*/
|
|
61
|
+
function runIcacls(targetPath: string, directory: boolean): void {
|
|
62
|
+
const user = currentWindowsUser();
|
|
63
|
+
if (!user) {
|
|
64
|
+
throw new Error("Cannot determine current Windows user for ACL hardening");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Step 1: disable inheritance and remove inherited ACEs
|
|
68
|
+
execFileSync("icacls.exe", [targetPath, "/inheritance:r"], {
|
|
69
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
shell: false,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Step 2: remove broad explicit grants using stable SIDs (not localized names).
|
|
75
|
+
execFileSync("icacls.exe", [
|
|
76
|
+
targetPath,
|
|
77
|
+
"/remove:g",
|
|
78
|
+
"*S-1-1-0",
|
|
79
|
+
"*S-1-5-11",
|
|
80
|
+
"*S-1-5-32-545",
|
|
81
|
+
], {
|
|
82
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
83
|
+
timeout: 5000,
|
|
84
|
+
shell: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Step 3: grant current user full control.
|
|
88
|
+
const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
|
|
89
|
+
execFileSync("icacls.exe", [targetPath, "/grant:r", grant], {
|
|
90
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
shell: false,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sanitize an error from a failed ACL operation into a safe diagnostic string.
|
|
98
|
+
* The raw path must not appear in the returned string (it may contain
|
|
99
|
+
* sensitive username components or PII from the home directory path).
|
|
100
|
+
*/
|
|
101
|
+
function sanitizeDiagnostics(error: unknown): string {
|
|
102
|
+
// We do not expose the raw error message or any path-like fragments.
|
|
103
|
+
// Just describe what failed generically.
|
|
104
|
+
const code = error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code) : "";
|
|
105
|
+
const codePart = code ? ` (${code})` : "";
|
|
106
|
+
return `ACL hardening failed${codePart} — filesystem may not support per-user NTFS ACLs`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Harden a single file path with per-user NTFS ACLs on Windows.
|
|
111
|
+
* On non-Windows platforms, returns ok:true immediately (caller owns chmod).
|
|
112
|
+
*
|
|
113
|
+
* @param targetPath Absolute path to the file to harden.
|
|
114
|
+
* @param opts { required: boolean } — required:true throws on failure.
|
|
115
|
+
*/
|
|
116
|
+
export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
|
|
117
|
+
// Skip for missing files — we cannot harden what does not exist yet.
|
|
118
|
+
if (!existsSync(targetPath)) {
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Non-Windows: no NTFS ACLs; caller handles chmod.
|
|
123
|
+
if (platform !== "win32") {
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (hardenedPaths.has(targetPath)) return { ok: true };
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
runIcacls(targetPath, false);
|
|
131
|
+
hardenedPaths.add(targetPath);
|
|
132
|
+
return { ok: true };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const diagnostics = sanitizeDiagnostics(err);
|
|
135
|
+
if (opts.required) {
|
|
136
|
+
throw new Error(diagnostics);
|
|
137
|
+
}
|
|
138
|
+
return { ok: false, diagnostics };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Harden a directory path with per-user NTFS ACLs on Windows.
|
|
144
|
+
* On non-Windows platforms, returns ok:true immediately (caller owns chmod).
|
|
145
|
+
*
|
|
146
|
+
* @param targetPath Absolute path to the directory to harden.
|
|
147
|
+
* @param opts { required: boolean } — required:true throws on failure.
|
|
148
|
+
*/
|
|
149
|
+
export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
|
|
150
|
+
// Skip for missing directories — we cannot harden what does not exist yet.
|
|
151
|
+
if (!existsSync(targetPath)) {
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Non-Windows: no NTFS ACLs; caller handles chmod.
|
|
156
|
+
if (platform !== "win32") {
|
|
157
|
+
return { ok: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (hardenedDirectories.has(targetPath)) return { ok: true };
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
runIcacls(targetPath, true);
|
|
164
|
+
hardenedDirectories.add(targetPath);
|
|
165
|
+
return { ok: true };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const diagnostics = sanitizeDiagnostics(err);
|
|
168
|
+
if (opts.required) {
|
|
169
|
+
throw new Error(diagnostics);
|
|
170
|
+
}
|
|
171
|
+
return { ok: false, diagnostics };
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
|
|
|
12
12
|
import { loginCursor, refreshCursorToken } from "./cursor";
|
|
13
13
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
14
14
|
import { effectiveGoogleMode } from "../providers/registry";
|
|
15
|
+
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
15
16
|
|
|
16
17
|
const REFRESH_SKEW_MS = 60_000;
|
|
17
18
|
const tokenRefreshes = new Map<string, Promise<string>>();
|
|
@@ -254,27 +255,28 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
|
|
|
254
255
|
* response.
|
|
255
256
|
*/
|
|
256
257
|
export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
|
|
257
|
-
const
|
|
258
|
-
|
|
258
|
+
const effectiveProvider = resolveProviderTransport(providerName, prov);
|
|
259
|
+
const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
|
|
260
|
+
if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
|
|
259
261
|
// Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
|
|
260
262
|
// models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
|
|
261
263
|
// enough to list everything without a pageToken loop. Vertex/antigravity keep the
|
|
262
264
|
// generic branch (they fall back to their static model lists).
|
|
263
265
|
if (apiKey) headers["x-goog-api-key"] = apiKey;
|
|
264
|
-
return { url: `${
|
|
266
|
+
return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers };
|
|
265
267
|
}
|
|
266
|
-
if (
|
|
268
|
+
if (effectiveProvider.adapter === "anthropic") {
|
|
267
269
|
headers["anthropic-version"] = "2023-06-01";
|
|
268
|
-
if (
|
|
270
|
+
if (effectiveProvider.authMode === "oauth") {
|
|
269
271
|
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
|
|
270
272
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
271
273
|
} else if (apiKey) {
|
|
272
274
|
headers["x-api-key"] = apiKey;
|
|
273
275
|
}
|
|
274
|
-
return { url: `${
|
|
276
|
+
return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
|
|
275
277
|
}
|
|
276
278
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
277
|
-
return { url: `${
|
|
279
|
+
return { url: `${effectiveProvider.baseUrl}/models`, headers };
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
/**
|
package/src/oauth/store.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface ProviderRegistryEntry {
|
|
|
18
18
|
adapter: string;
|
|
19
19
|
baseUrl: string;
|
|
20
20
|
authKind: ProviderAuthKind;
|
|
21
|
+
allowPrivateNetworkByDefault?: boolean;
|
|
21
22
|
keyOptional?: boolean;
|
|
22
23
|
allowBaseUrlOverride?: boolean;
|
|
23
24
|
modelSuffixBracketStrip?: boolean;
|
|
@@ -235,6 +236,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
235
236
|
models: ["grok-4.5", "grok-4.3", "grok-4.20-multi-agent-0309", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
|
|
236
237
|
defaultModel: "grok-4.5",
|
|
237
238
|
noReasoningModels: ["grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
|
|
239
|
+
// Replay assistant reasoning_content for grok reasoning models: xAI documents dropped
|
|
240
|
+
// reasoning_content as the top cause of prompt-cache misses on multi-turn conversations
|
|
241
|
+
// (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching).
|
|
242
|
+
// Models that never emit reasoning simply have no thinking parts to replay (no-op).
|
|
243
|
+
preserveReasoningContentModels: ["grok-4.5", "grok-4.3", "grok-4.20-multi-agent-0309", "grok-4.20-0309-reasoning"],
|
|
238
244
|
// grok-4.5 reasoning is always-on with low/medium/high control (no off tier upstream).
|
|
239
245
|
modelReasoningEfforts: { "grok-4.5": ["low", "medium", "high"] },
|
|
240
246
|
modelContextWindows: {
|
|
@@ -436,9 +442,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
436
442
|
{ id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
|
|
437
443
|
{ id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.5-flash-low", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
|
|
438
444
|
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
|
|
439
|
-
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
440
|
-
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
441
|
-
{ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
|
|
445
|
+
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
446
|
+
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
447
|
+
{ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
|
|
442
448
|
{
|
|
443
449
|
id: "deepseek",
|
|
444
450
|
label: "DeepSeek",
|
|
@@ -521,6 +527,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
521
527
|
{
|
|
522
528
|
id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key",
|
|
523
529
|
dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start",
|
|
530
|
+
allowPrivateNetworkByDefault: true,
|
|
524
531
|
allowBaseUrlOverride: true,
|
|
525
532
|
// A self-hosted proxy may legitimately run without a master key.
|
|
526
533
|
keyOptional: true,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { OcxProviderConfig } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* xAI account OAuth and xAI API keys share a bearer shape but not a billing
|
|
6
|
+
* transport. OAuth represents the Grok CLI subscription entitlement, while a
|
|
7
|
+
* key represents the API team. Keep the saved provider preset compatible with
|
|
8
|
+
* the dashboard's "Use an API key instead" switch and resolve the transport at
|
|
9
|
+
* request time.
|
|
10
|
+
*/
|
|
11
|
+
export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
12
|
+
|
|
13
|
+
/** Minimum-compatible official Grok CLI wire version verified with the proxy. */
|
|
14
|
+
export const XAI_GROK_CLIENT_VERSION = "0.2.93";
|
|
15
|
+
|
|
16
|
+
const XAI_GROK_CLI_HEADERS: Readonly<Record<string, string>> = {
|
|
17
|
+
"x-grok-client-identifier": "opencodex",
|
|
18
|
+
"x-grok-client-version": XAI_GROK_CLIENT_VERSION,
|
|
19
|
+
"x-xai-token-auth": "xai-grok-cli",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sticky-routing hint for xAI's automatic prefix cache. xAI routes requests
|
|
24
|
+
* carrying the same `x-grok-conv-id` to the same server, which is where the
|
|
25
|
+
* prompt cache lives (docs.x.ai prompt-caching best-practices; verified
|
|
26
|
+
* 2026-07-13, devlog/_plan/260713_grok_caching). Codex clients send a stable
|
|
27
|
+
* per-conversation `prompt_cache_key`; hash it so the raw session id never
|
|
28
|
+
* leaves the proxy.
|
|
29
|
+
*/
|
|
30
|
+
export const XAI_CONV_ID_HEADER = "x-grok-conv-id";
|
|
31
|
+
|
|
32
|
+
function hasHeaderCaseInsensitive(headers: Record<string, string> | undefined, name: string): boolean {
|
|
33
|
+
if (!headers) return false;
|
|
34
|
+
const target = name.toLowerCase();
|
|
35
|
+
return Object.keys(headers).some(key => key.toLowerCase() === target);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Drop default entries the user already overrides under any header-name casing. */
|
|
39
|
+
function withoutUserOverridden(defaults: Readonly<Record<string, string>>, userHeaders: Record<string, string> | undefined): Record<string, string> {
|
|
40
|
+
if (!userHeaders) return { ...defaults };
|
|
41
|
+
const out: Record<string, string> = {};
|
|
42
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
43
|
+
if (!hasHeaderCaseInsensitive(userHeaders, key)) out[key] = value;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function deriveXaiConvId(promptCacheKey: string): string {
|
|
49
|
+
return createHash("sha256").update(promptCacheKey).digest("hex").slice(0, 32);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the effective xAI transport without mutating persisted config.
|
|
54
|
+
* User-provided headers are preserved and may advance the compatibility
|
|
55
|
+
* version without waiting for an opencodex release.
|
|
56
|
+
*
|
|
57
|
+
* `promptCacheKey` (the client's stable conversation key) additionally pins
|
|
58
|
+
* cache-affinity routing via `x-grok-conv-id` in BOTH auth modes. Blank or
|
|
59
|
+
* whitespace-only keys are ignored so unrelated requests can never collapse
|
|
60
|
+
* onto one shared conv id, and any user-configured header (any case) wins.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveProviderTransport(
|
|
63
|
+
providerName: string,
|
|
64
|
+
provider: OcxProviderConfig,
|
|
65
|
+
promptCacheKey?: string,
|
|
66
|
+
): OcxProviderConfig {
|
|
67
|
+
if (providerName !== "xai") return provider;
|
|
68
|
+
const cacheKey = promptCacheKey?.trim();
|
|
69
|
+
const convIdHeaders: Record<string, string> =
|
|
70
|
+
cacheKey && !hasHeaderCaseInsensitive(provider.headers, XAI_CONV_ID_HEADER)
|
|
71
|
+
? { [XAI_CONV_ID_HEADER]: deriveXaiConvId(cacheKey) }
|
|
72
|
+
: {};
|
|
73
|
+
if (provider.authMode !== "oauth") {
|
|
74
|
+
if (Object.keys(convIdHeaders).length === 0) return provider;
|
|
75
|
+
return {
|
|
76
|
+
...provider,
|
|
77
|
+
headers: { ...convIdHeaders, ...(provider.headers ?? {}) },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...provider,
|
|
82
|
+
baseUrl: XAI_GROK_CLI_BASE_URL,
|
|
83
|
+
headers: {
|
|
84
|
+
...withoutUserOverridden(XAI_GROK_CLI_HEADERS, provider.headers),
|
|
85
|
+
...convIdHeaders,
|
|
86
|
+
...(provider.headers ?? {}),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
2
2
|
import { hasOwnProvider, resolveEnvValue } from "./config";
|
|
3
|
+
import { assertProviderDestinationAllowed } from "./lib/destination-policy";
|
|
3
4
|
import { PROVIDER_REGISTRY } from "./providers/registry";
|
|
4
5
|
|
|
5
6
|
interface RouteResult {
|
|
@@ -79,7 +80,10 @@ function mergeStringArrayRecord(
|
|
|
79
80
|
|
|
80
81
|
function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
81
82
|
const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
|
|
82
|
-
if (!registryEntry)
|
|
83
|
+
if (!registryEntry) {
|
|
84
|
+
assertProviderDestinationAllowed(providerName, provider);
|
|
85
|
+
return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
|
|
86
|
+
}
|
|
83
87
|
const canonicalAuthMode = registryEntry.authKind === "forward" || registryEntry.authKind === "oauth"
|
|
84
88
|
? registryEntry.authKind
|
|
85
89
|
: provider.authMode === "forward" ? undefined : provider.authMode;
|
|
@@ -107,6 +111,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
107
111
|
const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved
|
|
108
112
|
? userBaseUrl
|
|
109
113
|
: registryEntry.baseUrl;
|
|
114
|
+
assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork });
|
|
110
115
|
|
|
111
116
|
return {
|
|
112
117
|
...provider,
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
providerBaseUrlConfigError,
|
|
6
6
|
providerHeadersConfigError,
|
|
7
7
|
} from "../config";
|
|
8
|
+
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
8
9
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
9
10
|
|
|
10
11
|
let _corsOrigin = "http://localhost:10100";
|
|
@@ -156,6 +157,8 @@ export function requireApiAuth(req: Request, config: OcxConfig, kind: "managemen
|
|
|
156
157
|
export function providerManagementConfigError(name: string, provider: OcxProviderConfig): string | null {
|
|
157
158
|
const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
|
|
158
159
|
if (baseUrlError) return `provider ${name} ${baseUrlError}`;
|
|
160
|
+
const destinationError = providerDestinationConfigError(name, provider);
|
|
161
|
+
if (destinationError) return `provider ${name} ${destinationError}`;
|
|
159
162
|
const headersError = providerHeadersConfigError(provider.headers);
|
|
160
163
|
if (headersError) return `provider ${name} ${headersError}`;
|
|
161
164
|
if (provider.authMode === "forward") {
|
|
@@ -205,6 +208,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
205
208
|
for (const key of [
|
|
206
209
|
"defaultModel",
|
|
207
210
|
"disabled",
|
|
211
|
+
"allowPrivateNetwork",
|
|
208
212
|
"authMode",
|
|
209
213
|
"liveModels",
|
|
210
214
|
"models",
|
|
@@ -31,6 +31,21 @@ function isRec(v: unknown): v is Rec {
|
|
|
31
31
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Resolve Claude-only sidecar overrides without mutating the shared server config. */
|
|
35
|
+
export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig {
|
|
36
|
+
return {
|
|
37
|
+
...config,
|
|
38
|
+
webSearchSidecar: {
|
|
39
|
+
...config.webSearchSidecar,
|
|
40
|
+
...config.claudeCode?.webSearchSidecar,
|
|
41
|
+
},
|
|
42
|
+
visionSidecar: {
|
|
43
|
+
...config.visionSidecar,
|
|
44
|
+
...config.claudeCode?.visionSidecar,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
function claudeInboundDisabled(config: OcxConfig): Response | null {
|
|
35
50
|
if (config.claudeCode?.enabled === false) {
|
|
36
51
|
return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error");
|
|
@@ -178,15 +193,21 @@ async function anthropicNativePassthrough(
|
|
|
178
193
|
});
|
|
179
194
|
headers.set("content-type", "application/json");
|
|
180
195
|
|
|
196
|
+
const timeoutSignal = AbortSignal.timeout(config.connectTimeoutMs ?? 120_000);
|
|
197
|
+
const upstreamSignal = AbortSignal.any([req.signal, timeoutSignal]);
|
|
181
198
|
let upstream: Response;
|
|
182
199
|
try {
|
|
183
200
|
upstream = await fetch(`${base}${pathname}${search}`, {
|
|
184
201
|
method: "POST",
|
|
185
202
|
headers,
|
|
186
203
|
body: JSON.stringify(body),
|
|
187
|
-
signal:
|
|
204
|
+
signal: upstreamSignal,
|
|
188
205
|
});
|
|
189
206
|
} catch (err) {
|
|
207
|
+
if (timeoutSignal.aborted && upstreamSignal.reason === timeoutSignal.reason) {
|
|
208
|
+
finalize(504, { closeReason: "non_stream" });
|
|
209
|
+
return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
|
|
210
|
+
}
|
|
190
211
|
finalize(502, { closeReason: "non_stream" });
|
|
191
212
|
return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
|
|
192
213
|
}
|
|
@@ -326,6 +347,15 @@ export async function handleClaudeMessages(
|
|
|
326
347
|
const value = req.headers.get(name);
|
|
327
348
|
if (value) headers.set(name, value);
|
|
328
349
|
}
|
|
350
|
+
if (!nativeRoute) {
|
|
351
|
+
// Routed replays need main ChatGPT auth so OpenAI-backed sidecars remain reachable.
|
|
352
|
+
const { getMainAccountToken } = await import("../codex/main-account");
|
|
353
|
+
const token = getMainAccountToken();
|
|
354
|
+
if (token) {
|
|
355
|
+
headers.set("authorization", `Bearer ${token.accessToken}`);
|
|
356
|
+
headers.set("chatgpt-account-id", token.chatgptAccountId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
329
359
|
if (nativeRoute) {
|
|
330
360
|
// No forwarded ChatGPT auth exists on this surface. Attach the main codex login
|
|
331
361
|
// (read-only auth.json token); account-pool rotation still overrides downstream.
|
|
@@ -362,7 +392,7 @@ export async function handleClaudeMessages(
|
|
|
362
392
|
nativeLogged = true;
|
|
363
393
|
addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
|
|
364
394
|
};
|
|
365
|
-
const upstream = await handleResponses(internalReq, config, logCtx, {
|
|
395
|
+
const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, {
|
|
366
396
|
abortSignal: req.signal,
|
|
367
397
|
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
|
|
368
398
|
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
|