@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.
Files changed (39) hide show
  1. package/README.md +3 -1
  2. package/gui/dist/assets/index-BAAFKwsh.js +40 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +2 -2
  5. package/src/adapters/cursor/transport-retry.ts +5 -3
  6. package/src/adapters/google-errors.ts +9 -19
  7. package/src/adapters/google-http.ts +29 -66
  8. package/src/adapters/kiro-errors.ts +10 -23
  9. package/src/adapters/kiro-retry.ts +26 -58
  10. package/src/adapters/upstream-http-error.ts +48 -0
  11. package/src/bridge.ts +6 -2
  12. package/src/claude/gateway-cache.ts +3 -3
  13. package/src/claude/outbound.ts +117 -40
  14. package/src/cli/claude.ts +36 -4
  15. package/src/config.ts +54 -3
  16. package/src/lib/destination-policy.ts +167 -0
  17. package/src/lib/injection-debug-log.ts +34 -0
  18. package/src/lib/upstream-retry.ts +53 -3
  19. package/src/lib/windows-secret-acl.ts +173 -0
  20. package/src/oauth/index.ts +9 -7
  21. package/src/oauth/store.ts +1 -0
  22. package/src/providers/registry.ts +10 -3
  23. package/src/providers/xai-transport.ts +89 -0
  24. package/src/router.ts +6 -1
  25. package/src/server/auth-cors.ts +4 -0
  26. package/src/server/claude-messages.ts +32 -2
  27. package/src/server/management-api.ts +159 -33
  28. package/src/server/request-decompress.ts +45 -12
  29. package/src/server/responses.ts +21 -12
  30. package/src/server/system-env.ts +110 -68
  31. package/src/service.ts +4 -0
  32. package/src/types.ts +25 -5
  33. package/src/vision/anthropic-describe.ts +185 -0
  34. package/src/vision/index.ts +219 -10
  35. package/src/web-search/anthropic-executor.ts +187 -0
  36. package/src/web-search/executor.ts +4 -2
  37. package/src/web-search/index.ts +80 -18
  38. package/src/web-search/loop.ts +14 -2
  39. package/gui/dist/assets/index-Csp2AZYr.js +0 -40
@@ -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="http://127.0.0.1:${port}"`,
22
- `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,
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="${config.apiKeys[0].key.replace(/"/g, '\\"')}"`);
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}="${value.replace(/"/g, '\\"')}"`;
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="${modelEnv.ANTHROPIC_MODEL.replace(/"/g, '\\"')}"`);
37
+ lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`);
34
38
  } else if (config.claudeCode?.model) {
35
- lines.push(`export ANTHROPIC_MODEL="${config.claudeCode.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
- setLaunchctlEnv("ANTHROPIC_BASE_URL", ownedBaseUrl(port));
196
- setLaunchctlEnv("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
197
- const injectedKeys = [
198
- "ANTHROPIC_BASE_URL",
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
- const { refreshGatewayModelCacheFromProxy } = await import("../claude/gateway-cache");
245
- await refreshGatewayModelCacheFromProxy(port);
246
- } catch { /* best-effort */ }
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
- // Roster agent definitions (devlog 070): same launch-time sync for plain `claude`.
249
- // Reuses the window map computed above (audit 071 #5 — no second acquisition).
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
- mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
256
- writeFileSync(getSystemEnvTrackingPath(), JSON.stringify({
257
- pid: process.pid,
258
- port,
259
- injectedAt: new Date().toISOString(),
260
- injectedKeys,
261
- }), { encoding: "utf8", mode: 0o600 });
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
- if (launchctlGetenv("ANTHROPIC_BASE_URL") !== ownedBaseUrl(tracking.port)) {
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
- * 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: true when `enabled` is not false. macOS only.
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 a forward (ChatGPT) provider exists and the caller is logged in. */
492
+ /** Master switch. Default: enabled when the selected backend has a usable credential. */
489
493
  enabled?: boolean;
490
- /** Vision model that describes images (must be a native ChatGPT model with image input). */
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
+ }