@bitkyc08/opencodex 2.7.9-preview.20260712.2 → 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/src/types.ts CHANGED
@@ -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. */
@@ -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
+ }
@@ -1,13 +1,20 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
2
3
  import { modelInList } from "../types";
3
- import { describeImage, type VisionSettings } from "./describe";
4
+ import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe";
5
+ import { describeImageAnthropic } from "./anthropic-describe";
4
6
  import type { CodexAuthContext } from "../codex/auth-context";
7
+ import { getAccountSet } from "../oauth/store";
5
8
  import type { SidecarOutcomeRecorder } from "../web-search/executor";
6
9
 
7
10
  export { describeImage } from "./describe";
11
+ export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe";
8
12
 
9
13
  const DEFAULT_VISION_MODEL = "gpt-5.4-mini";
14
+ const DEFAULT_ANTHROPIC_VISION_MODEL = "claude-sonnet-5";
10
15
  const DEFAULT_TIMEOUT_MS = 45_000;
16
+ const DEFAULT_MAX_DESCRIPTIONS_PER_TURN = 8;
17
+ const DESCRIPTION_CACHE_MAX_ENTRIES = 256;
11
18
  /** Max images described in parallel — keeps first-token latency bounded without flooding the backend. */
12
19
  const VISION_CONCURRENCY = 3;
13
20
  /** Per-image description hard cap (chars) so multi-image turns can't blow the main model's context. */
@@ -15,6 +22,59 @@ const DESC_MAX_CHARS = 2000;
15
22
  /** User-text context passed to the describer, capped. */
16
23
  const CONTEXT_MAX_CHARS = 800;
17
24
 
25
+ export interface VisionDescriptionCache {
26
+ get(key: string): string | undefined;
27
+ set(key: string, value: string): void;
28
+ clear(): void;
29
+ }
30
+
31
+ class BoundedLruDescriptionCache implements VisionDescriptionCache {
32
+ private readonly entries = new Map<string, string>();
33
+
34
+ constructor(private readonly maxEntries: number) {}
35
+
36
+ get(key: string): string | undefined {
37
+ const value = this.entries.get(key);
38
+ if (value === undefined) return undefined;
39
+ this.entries.delete(key);
40
+ this.entries.set(key, value);
41
+ return value;
42
+ }
43
+
44
+ set(key: string, value: string): void {
45
+ this.entries.delete(key);
46
+ this.entries.set(key, value);
47
+ while (this.entries.size > this.maxEntries) {
48
+ const oldest = this.entries.keys().next().value;
49
+ if (oldest === undefined) break;
50
+ this.entries.delete(oldest);
51
+ }
52
+ }
53
+
54
+ clear(): void {
55
+ this.entries.clear();
56
+ }
57
+ }
58
+
59
+ let descriptionCache: VisionDescriptionCache = new BoundedLruDescriptionCache(DESCRIPTION_CACHE_MAX_ENTRIES);
60
+
61
+ /** Replace the process cache (primarily for deterministic tests). Passing undefined restores the default LRU. */
62
+ export function setVisionDescriptionCache(cache?: VisionDescriptionCache): void {
63
+ descriptionCache = cache ?? new BoundedLruDescriptionCache(DESCRIPTION_CACHE_MAX_ENTRIES);
64
+ }
65
+
66
+ export function resetVisionDescriptionCache(): void {
67
+ descriptionCache.clear();
68
+ }
69
+
70
+ /** Runtime config is permissive: zero is intentional; malformed values fall back to the bounded default. */
71
+ export function resolveMaxDescriptionsPerTurn(value: unknown): number {
72
+ if (value === 0) return 0;
73
+ return typeof value === "number" && Number.isInteger(value) && value > 0
74
+ ? value
75
+ : DEFAULT_MAX_DESCRIPTIONS_PER_TURN;
76
+ }
77
+
18
78
  /** Run `worker` over `items` with bounded concurrency, preserving input order in the result array. */
19
79
  async function runBounded<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
20
80
  const results = new Array<R>(items.length);
@@ -33,7 +93,7 @@ function clamp(s: string, max: number): string {
33
93
  return s.length <= max ? s : `${s.slice(0, max)}\n…[description truncated]`;
34
94
  }
35
95
 
36
- /** First configured forward (ChatGPT passthrough) provider — the path with native image input. */
96
+ /** First configured forward (ChatGPT passthrough) provider — the OpenAI path with native image input. */
37
97
  function findForwardProvider(config: OcxConfig): OcxProviderConfig | undefined {
38
98
  for (const prov of Object.values(config.providers)) {
39
99
  if (prov.disabled === true) continue;
@@ -42,6 +102,30 @@ function findForwardProvider(config: OcxConfig): OcxProviderConfig | undefined {
42
102
  return undefined;
43
103
  }
44
104
 
105
+ export interface AnthropicVisionProvider {
106
+ providerName: string;
107
+ provider: OcxProviderConfig;
108
+ }
109
+
110
+ /** First enabled Anthropic OAuth provider whose active stored account is not marked for reauth. */
111
+ export function findAnthropicVisionProvider(config: OcxConfig): AnthropicVisionProvider | undefined {
112
+ for (const [providerName, provider] of Object.entries(config.providers)) {
113
+ if (provider.disabled === true || provider.adapter !== "anthropic" || provider.authMode !== "oauth") continue;
114
+ const accountSet = getAccountSet(providerName);
115
+ const active = accountSet?.accounts.find(account => account.id === accountSet.activeAccountId);
116
+ if (active && active.needsReauth !== true) return { providerName, provider };
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ export function resolveVisionBackend(
122
+ explicit: "openai" | "anthropic" | undefined,
123
+ anthropicSidecar: AnthropicVisionProvider | undefined,
124
+ ): "openai" | "anthropic" {
125
+ if (explicit === "openai" || explicit === "anthropic") return explicit;
126
+ return anthropicSidecar ? "anthropic" : "openai";
127
+ }
128
+
45
129
  /** A user/developer/toolResult message can carry images (toolResult: e.g. Codex view_image output). */
46
130
  function carriesImages(role: string): boolean {
47
131
  return role === "user" || role === "developer" || role === "toolResult";
@@ -53,15 +137,18 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean {
53
137
  }
54
138
 
55
139
  export interface VisionPlan {
56
- forwardProvider: OcxProviderConfig;
140
+ backend: "openai" | "anthropic";
141
+ forwardProvider?: OcxProviderConfig;
142
+ anthropicSidecar?: AnthropicVisionProvider;
57
143
  settings: VisionSettings;
144
+ maxDescriptionsPerTurn: number;
58
145
  }
59
146
 
60
147
  /**
61
148
  * Decide whether the vision sidecar should pre-describe images for this request, returning the plan
62
149
  * if so. Active when: the routed model is in `provider.noVisionModels`, the request actually carries
63
- * an image, a forward provider exists, the sidecar isn't disabled, and the caller forwarded ChatGPT
64
- * auth. Returns undefined otherwise (the request takes the normal path images sent natively).
150
+ * an image, the sidecar isn't disabled, and the selected backend has usable auth. Returns undefined
151
+ * otherwise (the caller strips images before sending to a text-only model).
65
152
  */
66
153
  export function planVisionSidecar(
67
154
  config: OcxConfig,
@@ -75,12 +162,28 @@ export function planVisionSidecar(
75
162
  if (!messagesHaveImage(parsed)) return undefined;
76
163
  const cfg = config.visionSidecar ?? {};
77
164
  if (cfg.enabled === false) return undefined;
165
+ const anthropicSidecar = findAnthropicVisionProvider(config);
166
+ const backend = resolveVisionBackend(cfg.backend, anthropicSidecar);
167
+ const maxDescriptionsPerTurn = resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn);
168
+
169
+ if (backend === "anthropic") {
170
+ if (!anthropicSidecar) return undefined;
171
+ return {
172
+ backend,
173
+ anthropicSidecar,
174
+ settings: { model: cfg.model ?? DEFAULT_ANTHROPIC_VISION_MODEL, timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS },
175
+ maxDescriptionsPerTurn,
176
+ };
177
+ }
178
+
78
179
  if (authContext.kind === "main" && !incomingHeaders.get("authorization")) return undefined;
79
180
  const forwardProvider = findForwardProvider(config);
80
181
  if (!forwardProvider) return undefined;
81
182
  return {
183
+ backend,
82
184
  forwardProvider,
83
185
  settings: { model: cfg.model ?? DEFAULT_VISION_MODEL, timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS },
186
+ maxDescriptionsPerTurn,
84
187
  };
85
188
  }
86
189
 
@@ -100,6 +203,69 @@ function renderDescription(out: { text: string; error?: string }): OcxTextConten
100
203
  };
101
204
  }
102
205
 
206
+ function sha256(value: string | Uint8Array): string {
207
+ return createHash("sha256").update(value).digest("hex");
208
+ }
209
+
210
+ function normalizedContext(contextText: string): string {
211
+ return contextText.trim().replace(/\s+/g, " ");
212
+ }
213
+
214
+ function descriptionIdentity(job: ImageJob, plan: VisionPlan): { key: string; persistent: boolean } {
215
+ let imageHash: string;
216
+ let persistent = false;
217
+ const data = /^data:[^;,]+;base64,(.*)$/s.exec(job.imageUrl);
218
+ if (data) {
219
+ imageHash = sha256(Buffer.from(data[1], "base64"));
220
+ persistent = true;
221
+ } else {
222
+ imageHash = sha256(job.imageUrl);
223
+ }
224
+ return {
225
+ key: JSON.stringify([
226
+ plan.backend,
227
+ plan.settings.model,
228
+ job.detail ?? "high",
229
+ imageHash,
230
+ sha256(normalizedContext(job.contextText)),
231
+ ]),
232
+ persistent,
233
+ };
234
+ }
235
+
236
+ async function executeDescription(
237
+ job: ImageJob,
238
+ plan: VisionPlan,
239
+ selectedForwardHeaders: Headers,
240
+ abortSignal?: AbortSignal,
241
+ recordSidecarOutcome?: SidecarOutcomeRecorder,
242
+ ): Promise<DescribeOutcome> {
243
+ if (plan.backend === "anthropic") {
244
+ const sidecar = plan.anthropicSidecar;
245
+ if (!sidecar) return { text: "", error: "anthropic vision sidecar is unavailable" };
246
+ return describeImageAnthropic(
247
+ job.imageUrl,
248
+ job.detail,
249
+ job.contextText,
250
+ sidecar.providerName,
251
+ sidecar.provider,
252
+ plan.settings,
253
+ abortSignal,
254
+ );
255
+ }
256
+ if (!plan.forwardProvider) return { text: "", error: "OpenAI vision sidecar is unavailable" };
257
+ return describeImage(
258
+ job.imageUrl,
259
+ job.detail,
260
+ job.contextText,
261
+ plan.forwardProvider,
262
+ selectedForwardHeaders,
263
+ plan.settings,
264
+ abortSignal,
265
+ recordSidecarOutcome,
266
+ );
267
+ }
268
+
103
269
  /**
104
270
  * Replace every image part in the request with a gpt-described text part, so a text-only model can
105
271
  * reason about it. Mutates `parsed.context.messages` in place; uses the message's own text as the
@@ -108,9 +274,8 @@ function renderDescription(out: { text: string; error?: string }): OcxTextConten
108
274
  */
109
275
  export async function describeImagesInPlace(
110
276
  parsed: OcxParsedRequest,
111
- forwardProvider: OcxProviderConfig,
277
+ plan: VisionPlan,
112
278
  selectedForwardHeaders: Headers,
113
- settings: VisionSettings,
114
279
  abortSignal?: AbortSignal,
115
280
  recordSidecarOutcome?: SidecarOutcomeRecorder,
116
281
  ): Promise<void> {
@@ -133,9 +298,53 @@ export async function describeImagesInPlace(
133
298
  }
134
299
  if (jobs.length === 0) return;
135
300
 
136
- // 2. Describe all images with bounded concurrency (order preserved).
137
- const outcomes = await runBounded(jobs, VISION_CONCURRENCY, j =>
138
- describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, selectedForwardHeaders, settings, abortSignal, recordSidecarOutcome));
301
+ // 2. Admit misses in source order. Cache hits and same-turn waiters do not consume the cap.
302
+ const inFlight = new Map<string, Promise<DescribeOutcome>>();
303
+ const executions: Array<() => Promise<void>> = [];
304
+ const outcomePromises: Array<Promise<DescribeOutcome>> = [];
305
+ let misses = 0;
306
+
307
+ for (const job of jobs) {
308
+ const identity = descriptionIdentity(job, plan);
309
+ const cached = identity.persistent ? descriptionCache.get(identity.key) : undefined;
310
+ if (cached !== undefined) {
311
+ outcomePromises.push(Promise.resolve({ text: cached }));
312
+ continue;
313
+ }
314
+
315
+ const existing = inFlight.get(identity.key);
316
+ if (existing) {
317
+ outcomePromises.push(existing);
318
+ continue;
319
+ }
320
+
321
+ if (misses >= plan.maxDescriptionsPerTurn) {
322
+ const capped = Promise.resolve<DescribeOutcome>({ text: "", error: "description cap reached for this turn" });
323
+ inFlight.set(identity.key, capped);
324
+ outcomePromises.push(capped);
325
+ continue;
326
+ }
327
+
328
+ misses += 1;
329
+ let resolveOutcome!: (outcome: DescribeOutcome) => void;
330
+ const pending = new Promise<DescribeOutcome>(resolve => { resolveOutcome = resolve; });
331
+ inFlight.set(identity.key, pending);
332
+ outcomePromises.push(pending);
333
+ executions.push(async () => {
334
+ let outcome: DescribeOutcome;
335
+ try {
336
+ outcome = await executeDescription(job, plan, selectedForwardHeaders, abortSignal, recordSidecarOutcome);
337
+ } catch (error) {
338
+ outcome = { text: "", error: error instanceof Error ? error.message : String(error) };
339
+ }
340
+ const successfulText = outcome.error ? "" : outcome.text.trim();
341
+ if (identity.persistent && successfulText) descriptionCache.set(identity.key, successfulText);
342
+ resolveOutcome(outcome);
343
+ });
344
+ }
345
+
346
+ await runBounded(executions, VISION_CONCURRENCY, execute => execute());
347
+ const outcomes = await Promise.all(outcomePromises);
139
348
 
140
349
  // 3. Rebuild each message, replacing image parts with their descriptions in order.
141
350
  let oi = 0;