@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
package/src/server/system-env.ts
CHANGED
|
@@ -15,24 +15,28 @@ export function getShellEnvFilePath(): string {
|
|
|
15
15
|
return join(getConfigDir(), "claude-env.sh");
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function shellValue(value: string): string {
|
|
19
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
20
|
+
}
|
|
21
|
+
|
|
18
22
|
function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<string, string> = {}, auto?: AutoContextMode): void {
|
|
19
23
|
const lines = [
|
|
20
24
|
`# Generated by opencodex — do not edit manually`,
|
|
21
|
-
`export ANTHROPIC_BASE_URL
|
|
22
|
-
`export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY
|
|
25
|
+
`export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`,
|
|
26
|
+
`export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`,
|
|
23
27
|
];
|
|
24
28
|
if (config.apiKeys?.length) {
|
|
25
|
-
lines.push(`export ANTHROPIC_AUTH_TOKEN
|
|
29
|
+
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
|
|
26
30
|
}
|
|
27
31
|
// New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already
|
|
28
32
|
// exported in their shell wins even though launchctl knows nothing about it.
|
|
29
33
|
const conditional = (name: string, value: string) =>
|
|
30
|
-
`[ -z "\${${name}+x}" ] && export ${name}
|
|
34
|
+
`[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
|
|
31
35
|
// Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
|
|
32
36
|
if (modelEnv.ANTHROPIC_MODEL) {
|
|
33
|
-
lines.push(`export ANTHROPIC_MODEL
|
|
37
|
+
lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`);
|
|
34
38
|
} else if (config.claudeCode?.model) {
|
|
35
|
-
lines.push(`export ANTHROPIC_MODEL
|
|
39
|
+
lines.push(`export ANTHROPIC_MODEL=${shellValue(config.claudeCode.model)}`);
|
|
36
40
|
}
|
|
37
41
|
for (const [name, value] of Object.entries(modelEnv)) {
|
|
38
42
|
if (name === "ANTHROPIC_MODEL") continue;
|
|
@@ -159,6 +163,34 @@ function ownedBaseUrl(port: number): string {
|
|
|
159
163
|
return `http://127.0.0.1:${port}`;
|
|
160
164
|
}
|
|
161
165
|
|
|
166
|
+
function writeTracking(port: number, injectedKeys: string[]): void {
|
|
167
|
+
mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
|
|
168
|
+
writeFileSync(getSystemEnvTrackingPath(), JSON.stringify({
|
|
169
|
+
pid: process.pid,
|
|
170
|
+
port,
|
|
171
|
+
injectedAt: new Date().toISOString(),
|
|
172
|
+
injectedKeys,
|
|
173
|
+
}), { encoding: "utf8", mode: 0o600 });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function rollbackInjectedKeys(port: number, injectedKeys: string[]): void {
|
|
177
|
+
const rollbackFailed: string[] = [];
|
|
178
|
+
for (const name of [...injectedKeys].reverse()) {
|
|
179
|
+
try {
|
|
180
|
+
unsetLaunchctlEnv(name);
|
|
181
|
+
} catch {
|
|
182
|
+
rollbackFailed.unshift(name);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (rollbackFailed.length > 0) {
|
|
187
|
+
writeTracking(port, rollbackFailed);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try { unlinkSync(getSystemEnvTrackingPath()); } catch { /* already gone */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
162
194
|
/**
|
|
163
195
|
* In-process effective model-env (default + tier slots, [1m] applied) under the shared
|
|
164
196
|
* 3s bound (audit R4#3). Returns {} on timeout/failure so injection degrades safely.
|
|
@@ -192,77 +224,86 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
|
|
|
192
224
|
return { injected: false, reason: `another instance owns env (port ${existingTracking.port})` };
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
|
|
200
|
-
];
|
|
201
|
-
if (config.apiKeys?.length) {
|
|
202
|
-
setLaunchctlEnv("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
|
|
203
|
-
injectedKeys.push("ANTHROPIC_AUTH_TOKEN");
|
|
204
|
-
}
|
|
205
|
-
// Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
|
|
206
|
-
// launchd domain, and track ONLY the keys we actually injected so revert cannot
|
|
207
|
-
// delete a pre-existing user value (audit 139 #3).
|
|
208
|
-
const injectLever = (name: string, value: string) => {
|
|
209
|
-
if (launchctlGetenv(name) !== undefined) return;
|
|
227
|
+
const injectedKeys: string[] = existingTracking
|
|
228
|
+
? [...(existingTracking.injectedKeys ?? SYSTEM_ENV_NAMES)]
|
|
229
|
+
: [];
|
|
230
|
+
const inject = (name: string, value: string) => {
|
|
210
231
|
setLaunchctlEnv(name, value);
|
|
211
|
-
injectedKeys.push(name);
|
|
232
|
+
if (!injectedKeys.includes(name)) injectedKeys.push(name);
|
|
233
|
+
writeTracking(port, injectedKeys);
|
|
212
234
|
};
|
|
213
|
-
// Model slots (default + tier defaults + legacy small-fast) with [1m] auto-marking
|
|
214
|
-
// (devlog 260712 B2, audit R2#3/R4#3): in-process context-window computation under
|
|
215
|
-
// the same 3s bound; on timeout the tier keys are simply not injected this run.
|
|
216
|
-
// Auto-context: a user-owned launchd value drives the marking predicate so the
|
|
217
|
-
// marker and threshold never separate (audit 021 #2); injectLever's user-wins
|
|
218
|
-
// check below keeps that value untouched.
|
|
219
|
-
const userAutoCompact = launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
|
|
220
|
-
const auto = resolveAutoContext(config.claudeCode, userAutoCompact);
|
|
221
|
-
const { modelEnv, windows } = await computeEffectiveModelEnv(config, auto);
|
|
222
|
-
for (const [name, value] of Object.entries(modelEnv)) {
|
|
223
|
-
if (name === "ANTHROPIC_MODEL") continue; // legacy slot handled by shell file only (back-compat)
|
|
224
|
-
injectLever(name, value);
|
|
225
|
-
}
|
|
226
|
-
const maxCtx = config.claudeCode?.maxContextTokens;
|
|
227
|
-
if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
|
|
228
|
-
injectLever("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)));
|
|
229
|
-
injectLever("DISABLE_COMPACT", "1");
|
|
230
|
-
}
|
|
231
|
-
// Auto-context (devlog 260712 020): user-wins lever, inert when maxContextTokens set.
|
|
232
|
-
if (auto.enabled) injectLever("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(auto.compactWindow));
|
|
233
|
-
if (config.claudeCode?.alwaysEnableEffort === true) {
|
|
234
|
-
injectLever("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
|
|
235
|
-
}
|
|
236
235
|
|
|
237
|
-
// Shell-hook env file: works for new shells in already-running Terminal.app.
|
|
238
|
-
writeShellEnvFile(port, config, modelEnv, auto);
|
|
239
|
-
|
|
240
|
-
// Gateway-model cache pre-write (devlog 030): plain `claude` sessions read the
|
|
241
|
-
// picker list from ~/.claude/cache/gateway-models.json and cannot refresh it
|
|
242
|
-
// without a token — keep it in sync with this proxy's /v1/models. Best-effort.
|
|
243
236
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
237
|
+
inject("ANTHROPIC_BASE_URL", ownedBaseUrl(port));
|
|
238
|
+
inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
|
|
239
|
+
if (config.apiKeys?.length) {
|
|
240
|
+
inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
|
|
241
|
+
}
|
|
242
|
+
// Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
|
|
243
|
+
// launchd domain, and track ONLY the keys we actually injected so revert cannot
|
|
244
|
+
// delete a pre-existing user value (audit 139 #3).
|
|
245
|
+
const injectLever = (name: string, value: string) => {
|
|
246
|
+
if (launchctlGetenv(name) !== undefined) return;
|
|
247
|
+
inject(name, value);
|
|
248
|
+
};
|
|
249
|
+
// Model slots (default + tier defaults + legacy small-fast) with [1m] auto-marking
|
|
250
|
+
// (devlog 260712 B2, audit R2#3/R4#3): in-process context-window computation under
|
|
251
|
+
// the same 3s bound; on timeout the tier keys are simply not injected this run.
|
|
252
|
+
// Auto-context: a user-owned launchd value drives the marking predicate so the
|
|
253
|
+
// marker and threshold never separate (audit 021 #2); injectLever's user-wins
|
|
254
|
+
// check below keeps that value untouched.
|
|
255
|
+
const userAutoCompact = launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
|
|
256
|
+
const auto = resolveAutoContext(config.claudeCode, userAutoCompact);
|
|
257
|
+
const { modelEnv, windows } = await computeEffectiveModelEnv(config, auto);
|
|
258
|
+
for (const [name, value] of Object.entries(modelEnv)) {
|
|
259
|
+
if (name === "ANTHROPIC_MODEL") continue; // legacy slot handled by shell file only (back-compat)
|
|
260
|
+
injectLever(name, value);
|
|
261
|
+
}
|
|
262
|
+
const maxCtx = config.claudeCode?.maxContextTokens;
|
|
263
|
+
if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
|
|
264
|
+
injectLever("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)));
|
|
265
|
+
injectLever("DISABLE_COMPACT", "1");
|
|
266
|
+
}
|
|
267
|
+
// Auto-context (devlog 260712 020): user-wins lever, inert when maxContextTokens set.
|
|
268
|
+
if (auto.enabled) injectLever("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(auto.compactWindow));
|
|
269
|
+
if (config.claudeCode?.alwaysEnableEffort === true) {
|
|
270
|
+
injectLever("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
|
|
271
|
+
}
|
|
247
272
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
|
|
252
|
-
injectClaudeAgentDefs(config, windows);
|
|
253
|
-
} catch { /* best-effort */ }
|
|
273
|
+
// Shell-hook env file: works for new shells in already-running Terminal.app.
|
|
274
|
+
writeShellEnvFile(port, config, modelEnv, auto);
|
|
254
275
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
276
|
+
// Gateway-model cache pre-write (devlog 030): plain `claude` sessions read the
|
|
277
|
+
// picker list from ~/.claude/cache/gateway-models.json and cannot refresh it
|
|
278
|
+
// without a token — keep it in sync with this proxy's /v1/models. Best-effort.
|
|
279
|
+
try {
|
|
280
|
+
const { refreshGatewayModelCacheFromProxy } = await import("../claude/gateway-cache");
|
|
281
|
+
await refreshGatewayModelCacheFromProxy(port);
|
|
282
|
+
} catch { /* best-effort */ }
|
|
283
|
+
|
|
284
|
+
// Roster agent definitions (devlog 070): same launch-time sync for plain `claude`.
|
|
285
|
+
// Reuses the window map computed above (audit 071 #5 — no second acquisition).
|
|
286
|
+
try {
|
|
287
|
+
const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
|
|
288
|
+
injectClaudeAgentDefs(config, windows);
|
|
289
|
+
} catch { /* best-effort */ }
|
|
290
|
+
|
|
291
|
+
writeTracking(port, injectedKeys);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
rollbackInjectedKeys(port, injectedKeys);
|
|
294
|
+
removeShellEnvFile();
|
|
295
|
+
console.error("Failed to inject system environment; rolled back launchctl changes:", error);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
262
298
|
|
|
263
299
|
return { injected: true };
|
|
264
300
|
}
|
|
265
301
|
|
|
302
|
+
export async function applySystemEnvToggle(config: OcxConfig, port: number): Promise<SystemEnvResult | RevertResult> {
|
|
303
|
+
if (config.claudeCode?.systemEnv === true) return injectSystemEnv(port, config);
|
|
304
|
+
return revertSystemEnv();
|
|
305
|
+
}
|
|
306
|
+
|
|
266
307
|
export function revertSystemEnv(): RevertResult {
|
|
267
308
|
if (process.platform !== "darwin") return { reverted: false, reason: "not macOS" };
|
|
268
309
|
|
|
@@ -270,7 +311,8 @@ export function revertSystemEnv(): RevertResult {
|
|
|
270
311
|
if (!tracking) return { reverted: false, reason: "no tracking file" };
|
|
271
312
|
|
|
272
313
|
try {
|
|
273
|
-
|
|
314
|
+
const tracksBaseUrl = tracking.injectedKeys?.includes("ANTHROPIC_BASE_URL") ?? true;
|
|
315
|
+
if (tracksBaseUrl && launchctlGetenv("ANTHROPIC_BASE_URL") !== ownedBaseUrl(tracking.port)) {
|
|
274
316
|
return { reverted: false, reason: "ownership mismatch" };
|
|
275
317
|
}
|
|
276
318
|
|
package/src/service.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { isWslRuntime } from "./codex/home";
|
|
|
16
16
|
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
|
|
17
17
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
18
18
|
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
19
|
+
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
19
20
|
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
|
|
20
21
|
|
|
21
22
|
const LABEL = "com.opencodex.proxy";
|
|
@@ -103,6 +104,7 @@ function writeServiceInstallState(): void {
|
|
|
103
104
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
104
105
|
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
105
106
|
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
107
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: true });
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
|
|
@@ -169,8 +171,10 @@ function writeServiceApiTokenFile(): string | null {
|
|
|
169
171
|
const path = serviceApiTokenFilePath();
|
|
170
172
|
const dir = getConfigDir();
|
|
171
173
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
174
|
+
if (process.platform === "win32") hardenSecretDir(dir, { required: true });
|
|
172
175
|
writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
|
|
173
176
|
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
177
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: true });
|
|
174
178
|
return path;
|
|
175
179
|
}
|
|
176
180
|
|
package/src/types.ts
CHANGED
|
@@ -265,9 +265,9 @@ export interface OcxClaudeCodeConfig {
|
|
|
265
265
|
/** Inbound model id remaps: exact id first, then date-stripped (`-\d{8}$`). */
|
|
266
266
|
modelMap?: Record<string, string>;
|
|
267
267
|
/**
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
* on stop/shutdown. Default:
|
|
268
|
+
* Inject ANTHROPIC_BASE_URL etc. into the macOS user domain via `launchctl setenv`
|
|
269
|
+
* so plain `claude` commands route through the proxy without `ocx claude`. Reverted
|
|
270
|
+
* on stop/shutdown. Default: false (opt-in). macOS only.
|
|
271
271
|
*/
|
|
272
272
|
systemEnv?: boolean;
|
|
273
273
|
/**
|
|
@@ -320,6 +320,10 @@ export interface OcxClaudeCodeConfig {
|
|
|
320
320
|
* free. Only ocx-*.md files are owned/pruned. Default: enabled.
|
|
321
321
|
*/
|
|
322
322
|
injectAgents?: boolean;
|
|
323
|
+
/** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */
|
|
324
|
+
webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string };
|
|
325
|
+
/** Claude-originated vision override. Unset fields inherit the global sidecar settings. */
|
|
326
|
+
visionSidecar?: { backend?: "openai" | "anthropic"; model?: string };
|
|
323
327
|
}
|
|
324
328
|
|
|
325
329
|
export interface OcxConfig {
|
|
@@ -485,10 +489,14 @@ export interface OcxSearchConfig {
|
|
|
485
489
|
}
|
|
486
490
|
|
|
487
491
|
export interface OcxVisionSidecarConfig {
|
|
488
|
-
/** Master switch. Default: enabled when
|
|
492
|
+
/** Master switch. Default: enabled when the selected backend has a usable credential. */
|
|
489
493
|
enabled?: boolean;
|
|
490
|
-
/**
|
|
494
|
+
/** Description backend. Unset prefers a usable stored Anthropic OAuth credential, else OpenAI. */
|
|
495
|
+
backend?: "openai" | "anthropic";
|
|
496
|
+
/** Vision model that describes images. */
|
|
491
497
|
model?: string;
|
|
498
|
+
/** Max description cache misses admitted in one main-model turn. Zero disables description calls. */
|
|
499
|
+
maxDescriptionsPerTurn?: number;
|
|
492
500
|
/** Sidecar fetch timeout (ms). */
|
|
493
501
|
timeoutMs?: number;
|
|
494
502
|
}
|
|
@@ -496,6 +504,13 @@ export interface OcxVisionSidecarConfig {
|
|
|
496
504
|
export interface OcxWebSearchSidecarConfig {
|
|
497
505
|
/** Master switch. Default: enabled when a forward (ChatGPT) provider exists and the caller is logged in. */
|
|
498
506
|
enabled?: boolean;
|
|
507
|
+
/**
|
|
508
|
+
* Which backend actually runs the server-side search. "openai" replays the hosted web_search via
|
|
509
|
+
* the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude
|
|
510
|
+
* model authenticated by the STORED anthropic OAuth credential. Unset resolves to "anthropic" when a
|
|
511
|
+
* usable anthropic OAuth credential exists, else "openai".
|
|
512
|
+
*/
|
|
513
|
+
backend?: "openai" | "anthropic";
|
|
499
514
|
/** Sidecar model that runs the real server-side web_search (must be a native ChatGPT model). */
|
|
500
515
|
model?: string;
|
|
501
516
|
/** Reasoning effort for the sidecar — "minimal" (non-thinking) keeps it fast/cheap. */
|
|
@@ -514,6 +529,11 @@ export interface OcxWebSearchSidecarConfig {
|
|
|
514
529
|
export interface OcxProviderConfig {
|
|
515
530
|
adapter: string;
|
|
516
531
|
baseUrl: string;
|
|
532
|
+
/**
|
|
533
|
+
* Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918,
|
|
534
|
+
* link-local, or unique-local upstreams. Metadata endpoints remain blocked.
|
|
535
|
+
*/
|
|
536
|
+
allowPrivateNetwork?: boolean;
|
|
517
537
|
/** Keep provider settings on disk but exclude it from routing and model/catalog listings. */
|
|
518
538
|
disabled?: boolean;
|
|
519
539
|
apiKey?: string;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fingerprint";
|
|
3
|
+
import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
|
|
4
|
+
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
5
|
+
import { fetchWithResetRetry } from "../lib/upstream-retry";
|
|
6
|
+
import { getValidAccessToken } from "../oauth";
|
|
7
|
+
import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../oauth/anthropic";
|
|
8
|
+
import type { DescribeOutcome, VisionSettings } from "./describe";
|
|
9
|
+
|
|
10
|
+
const ANTHROPIC_VISION_MAX_TOKENS = 1024;
|
|
11
|
+
const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]);
|
|
12
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
13
|
+
const DESCRIBE_INSTRUCTION =
|
|
14
|
+
"You are a vision describer for a text-only model that cannot see the image. Describe the image " +
|
|
15
|
+
"thoroughly and factually so that model can fully reason about it: transcribe any visible text " +
|
|
16
|
+
"verbatim, and note UI/layout, colors, branding/logos, charts, and notable details. Focus on " +
|
|
17
|
+
"what's relevant to the user's request. Output only the description.";
|
|
18
|
+
|
|
19
|
+
type AnthropicImageBlock =
|
|
20
|
+
| { type: "image"; source: { type: "base64"; media_type: string; data: string } }
|
|
21
|
+
| { type: "image"; source: { type: "url"; url: string } };
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildImageBlock(imageUrl: string): { block?: AnthropicImageBlock; error?: string } {
|
|
28
|
+
if (imageUrl.startsWith("data:")) {
|
|
29
|
+
// Anthropic's base64 image source requires actual base64 bytes, so a non-base64 data URL
|
|
30
|
+
// (e.g. `data:image/png,raw`) is rejected here. This is intentionally stricter than the OpenAI
|
|
31
|
+
// vision executor, which forwards the raw data URL to `image_url` (review F3, documented delta).
|
|
32
|
+
const match = /^data:([^;,]+?)(;base64)?,(.*)$/s.exec(imageUrl);
|
|
33
|
+
if (!match || !match[2]) return { error: "malformed data URL" };
|
|
34
|
+
const mime = match[1].toLowerCase();
|
|
35
|
+
if (!ALLOWED_IMAGE_MIME.has(mime)) return { error: `unsupported image type "${mime}"` };
|
|
36
|
+
const bytes = Math.floor((match[3].length * 3) / 4);
|
|
37
|
+
if (bytes > MAX_IMAGE_BYTES) return { error: `image too large (~${Math.round(bytes / 1024 / 1024)}MB)` };
|
|
38
|
+
return { block: { type: "image", source: { type: "base64", media_type: mime, data: match[3] } } };
|
|
39
|
+
}
|
|
40
|
+
if (imageUrl.startsWith("https://")) {
|
|
41
|
+
return { block: { type: "image", source: { type: "url", url: imageUrl } } };
|
|
42
|
+
}
|
|
43
|
+
return { error: "unsupported image URL scheme (expected data: or https:)" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fold Anthropic Messages text deltas into one description. Malformed frames are ignored. */
|
|
47
|
+
export async function parseAnthropicVisionSSE(res: Response): Promise<DescribeOutcome> {
|
|
48
|
+
if (!res.body) return { text: "", error: "anthropic vision sidecar returned no response body" };
|
|
49
|
+
|
|
50
|
+
let text = "";
|
|
51
|
+
let terminalError = "";
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
const reader = res.body.getReader();
|
|
54
|
+
let buffer = "";
|
|
55
|
+
|
|
56
|
+
const processFrame = (rawFrame: string): void => {
|
|
57
|
+
let dataLine = "";
|
|
58
|
+
for (const line of rawFrame.split("\n")) {
|
|
59
|
+
if (line.startsWith("data:")) dataLine += line.slice(line.startsWith("data: ") ? 6 : 5);
|
|
60
|
+
}
|
|
61
|
+
if (!dataLine || dataLine === "[DONE]") return;
|
|
62
|
+
let data: unknown;
|
|
63
|
+
try { data = JSON.parse(dataLine); } catch { return; }
|
|
64
|
+
if (!isRecord(data)) return;
|
|
65
|
+
|
|
66
|
+
if (data.type === "content_block_delta") {
|
|
67
|
+
const delta = isRecord(data.delta) ? data.delta : {};
|
|
68
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") text += delta.text;
|
|
69
|
+
} else if (data.type === "error") {
|
|
70
|
+
const error = isRecord(data.error) ? data.error : {};
|
|
71
|
+
terminalError = typeof error.message === "string" ? error.message : "anthropic vision sidecar stream error";
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
for (;;) {
|
|
77
|
+
const { done, value } = await reader.read();
|
|
78
|
+
if (done) break;
|
|
79
|
+
buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
|
|
80
|
+
let separator: number;
|
|
81
|
+
while ((separator = buffer.indexOf("\n\n")) !== -1) {
|
|
82
|
+
processFrame(buffer.slice(0, separator));
|
|
83
|
+
buffer = buffer.slice(separator + 2);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n");
|
|
87
|
+
if (buffer.trim()) processFrame(buffer);
|
|
88
|
+
} catch {
|
|
89
|
+
// A mid-stream read/decode failure after partial text is NOT a usable description. Mark it
|
|
90
|
+
// terminal so the caller returns an error and never caches an incomplete result (review F1).
|
|
91
|
+
if (!terminalError) terminalError = "anthropic vision sidecar stream ended abnormally";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const trimmed = text.trim();
|
|
95
|
+
// A terminal error (an in-stream `error` frame OR an abnormal body failure) invalidates any partial
|
|
96
|
+
// text: return an error outcome so vision/index.ts never caches an incomplete description (review F1).
|
|
97
|
+
if (terminalError) return { text: "", error: terminalError };
|
|
98
|
+
if (!trimmed) return { text: "", error: "anthropic vision sidecar produced no description" };
|
|
99
|
+
return { text: trimmed };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Describe one image through a stored Anthropic OAuth credential. Never throws. */
|
|
103
|
+
export async function describeImageAnthropic(
|
|
104
|
+
imageUrl: string,
|
|
105
|
+
detail: string | undefined,
|
|
106
|
+
contextText: string,
|
|
107
|
+
providerName: string,
|
|
108
|
+
provider: OcxProviderConfig,
|
|
109
|
+
settings: VisionSettings,
|
|
110
|
+
abortSignal?: AbortSignal,
|
|
111
|
+
): Promise<DescribeOutcome> {
|
|
112
|
+
const image = buildImageBlock(imageUrl);
|
|
113
|
+
if (!image.block) return { text: "", error: image.error ?? "invalid image" };
|
|
114
|
+
|
|
115
|
+
let token: string;
|
|
116
|
+
try {
|
|
117
|
+
token = await getValidAccessToken(providerName);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { text: "", error: `anthropic vision sidecar auth failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const headers: Record<string, string> = {
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
"anthropic-version": "2023-06-01",
|
|
125
|
+
"Accept": "text/event-stream",
|
|
126
|
+
"User-Agent": "@anthropic-ai/sdk/0.74.0",
|
|
127
|
+
"Authorization": `Bearer ${token}`,
|
|
128
|
+
"anthropic-beta": ANTHROPIC_OAUTH_BETA,
|
|
129
|
+
...CLAUDE_CODE_HEADERS,
|
|
130
|
+
"X-Claude-Code-Session-Id": claudeCodeSessionId(token),
|
|
131
|
+
"x-client-request-id": crypto.randomUUID(),
|
|
132
|
+
};
|
|
133
|
+
if (provider.headers) Object.assign(headers, provider.headers);
|
|
134
|
+
|
|
135
|
+
const content: unknown[] = [];
|
|
136
|
+
if (contextText) content.push({ type: "text", text: `The user's request about this image: ${contextText}` });
|
|
137
|
+
content.push(image.block);
|
|
138
|
+
const body = {
|
|
139
|
+
model: settings.model,
|
|
140
|
+
max_tokens: ANTHROPIC_VISION_MAX_TOKENS,
|
|
141
|
+
thinking: { type: "disabled" },
|
|
142
|
+
system: [
|
|
143
|
+
{ type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION },
|
|
144
|
+
{ type: "text", text: DESCRIBE_INSTRUCTION },
|
|
145
|
+
],
|
|
146
|
+
messages: [{ role: "user", content }],
|
|
147
|
+
stream: true,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Anthropic image blocks have no detail field, but detail remains part of the cache identity.
|
|
151
|
+
void detail;
|
|
152
|
+
const base = provider.baseUrl.replace(/\/v1\/?$/, "");
|
|
153
|
+
const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
|
|
154
|
+
const sidecarExit = sidecarEnter("vision");
|
|
155
|
+
const startedAt = Date.now();
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetchWithResetRetry(
|
|
158
|
+
() => fetch(`${base}/v1/messages`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers,
|
|
161
|
+
body: JSON.stringify(body),
|
|
162
|
+
signal: linkedSignal.signal,
|
|
163
|
+
}),
|
|
164
|
+
{ abortSignal: linkedSignal.signal, label: "vision-sidecar-anthropic" },
|
|
165
|
+
);
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
const responseText = await res.text().catch(() => "");
|
|
168
|
+
console.warn(`[vision] anthropic sidecar HTTP ${res.status} (${Date.now() - startedAt}ms)`);
|
|
169
|
+
return { text: "", error: `anthropic vision sidecar HTTP ${res.status}: ${responseText.slice(0, 200)}` };
|
|
170
|
+
}
|
|
171
|
+
const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
|
|
172
|
+
try {
|
|
173
|
+
return await parseAnthropicVisionSSE(res);
|
|
174
|
+
} finally {
|
|
175
|
+
detachBodyGuard();
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
const kind = error instanceof Error && error.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
179
|
+
console.warn(`[vision] anthropic sidecar ${kind} (${Date.now() - startedAt}ms)`);
|
|
180
|
+
return { text: "", error: error instanceof Error ? error.message : String(error) };
|
|
181
|
+
} finally {
|
|
182
|
+
sidecarExit();
|
|
183
|
+
linkedSignal.cleanup();
|
|
184
|
+
}
|
|
185
|
+
}
|