@bitkyc08/opencodex 2.29.0 → 2.31.0-preview.20260822

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 (68) hide show
  1. package/README.md +5 -5
  2. package/gui/dist/assets/index-DyWYnr-t.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +3 -3
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +29 -2
  7. package/src/adapters/cursor/effort-map.ts +6 -0
  8. package/src/adapters/cursor/h2-pool.ts +123 -0
  9. package/src/adapters/cursor/images.ts +704 -0
  10. package/src/adapters/cursor/live-models.ts +21 -26
  11. package/src/adapters/cursor/live-transport.ts +239 -8
  12. package/src/adapters/cursor/native-exec-common.ts +17 -0
  13. package/src/adapters/cursor/native-exec.ts +9 -4
  14. package/src/adapters/cursor/protobuf-events.ts +5 -1
  15. package/src/adapters/cursor/protobuf-request.ts +46 -9
  16. package/src/adapters/cursor/request-builder.ts +29 -14
  17. package/src/adapters/cursor/tool-definitions.ts +20 -0
  18. package/src/adapters/cursor/transport.ts +10 -0
  19. package/src/adapters/cursor/types.ts +8 -1
  20. package/src/adapters/cursor.ts +23 -5
  21. package/src/adapters/google.ts +16 -3
  22. package/src/adapters/openai-responses.ts +66 -20
  23. package/src/adapters/xai-web-search.ts +185 -0
  24. package/src/cli/agent.ts +2 -1
  25. package/src/cli/dispatch.ts +2 -2
  26. package/src/cli/doctor.ts +89 -0
  27. package/src/cli/help.ts +2 -0
  28. package/src/cli/registry.ts +7 -2
  29. package/src/codex/auth-context.ts +41 -2
  30. package/src/codex/catalog/effort.ts +1 -1
  31. package/src/codex/catalog/parsing.ts +2 -0
  32. package/src/codex/catalog/provider-fetch.ts +20 -5
  33. package/src/codex/coordinator-doctor.ts +332 -0
  34. package/src/codex/features.ts +58 -0
  35. package/src/codex/inject-coordination.ts +39 -6
  36. package/src/codex/transition-state.ts +12 -12
  37. package/src/generated/compatibility-version.json +86 -58
  38. package/src/lib/bun-stream-caps.ts +7 -4
  39. package/src/lib/errors.ts +8 -2
  40. package/src/oauth/cursor.ts +21 -0
  41. package/src/providers/command-code-efforts.ts +7 -0
  42. package/src/providers/cursor-pool.ts +72 -0
  43. package/src/providers/derive.ts +3 -0
  44. package/src/providers/fastwire.ts +12 -1
  45. package/src/providers/openai-sidecar.ts +1 -0
  46. package/src/providers/quota.ts +98 -25
  47. package/src/providers/registry.ts +115 -10
  48. package/src/providers/service-tier.ts +22 -7
  49. package/src/responses/custom-tool-compat.ts +24 -8
  50. package/src/responses/namespace-tool-compat.ts +2 -3
  51. package/src/router.ts +3 -0
  52. package/src/server/chat-completions.ts +4 -0
  53. package/src/server/chat-native.ts +20 -0
  54. package/src/server/management/agent-settings-routes.ts +16 -5
  55. package/src/server/management/config-routes.ts +25 -5
  56. package/src/server/management/vision-sidecar-options.ts +54 -19
  57. package/src/server/responses/compact.ts +1 -2
  58. package/src/server/responses/core.ts +54 -13
  59. package/src/service.ts +122 -14
  60. package/src/types/config.ts +9 -3
  61. package/src/types/provider.ts +6 -0
  62. package/src/usage/cost.ts +52 -38
  63. package/src/usage/expected-prices.ts +79 -9
  64. package/src/vision/backends.ts +97 -0
  65. package/src/vision/eligibility.ts +43 -22
  66. package/src/vision/index.ts +73 -5
  67. package/src/vision/routed-describe.ts +175 -0
  68. package/gui/dist/assets/index-BNESwCzn.js +0 -102
@@ -26,15 +26,27 @@ import { nativeInputModalities } from "../codex/catalog/metadata";
26
26
  import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
27
27
  import { enrichProviderFromRegistry } from "../providers/derive";
28
28
 
29
- /** The two wire protocols `planVisionSidecar` can actually dispatch to. */
30
- export type VisionSidecarBackend = "openai" | "anthropic";
29
+ /**
30
+ * The wire protocols `planVisionSidecar` can dispatch to (#2188 roadmap 170
31
+ * REVISED). "routed" describes through the proxy's OWN router via loopback —
32
+ * one executor for every non-forward, non-OAuth-Anthropic provider row.
33
+ */
34
+ export type VisionSidecarBackend = "openai" | "anthropic" | "routed";
35
+
36
+ /** The two sides every deployment has; also the empty-auth fallback set. */
37
+ export type UniversalVisionBackend = "openai" | "anthropic";
31
38
 
32
39
  /**
33
40
  * Default entry per backend: cheap, image-capable, and present in every deployment. Offered
34
41
  * whenever its side is enabled, and withheld only when that provider explicitly lists it as a
35
42
  * model the sidecar describes FOR — never merely because a metadata table stayed silent.
43
+ *
44
+ * Keyed by the UNIVERSAL subset on purpose (roadmap 170, audit blocker A):
45
+ * xai/gemini are auth-gated sides whose catalogs are present whenever the side
46
+ * is, so they carry no baseline, and a narrow-key total record documents that
47
+ * without sprinkling non-null assertions at the consumers.
36
48
  */
37
- export const BASELINE_VISION_MODELS: Record<VisionSidecarBackend, string> = {
49
+ export const BASELINE_VISION_MODELS: Record<UniversalVisionBackend, string> = {
38
50
  openai: "gpt-5.6-luna",
39
51
  anthropic: "claude-haiku-4-5",
40
52
  };
@@ -146,7 +158,7 @@ function isVisionEligibleModelWithCache(
146
158
  return modelAcceptsImageInputWithCache(config, candidate, cache) !== false;
147
159
  }
148
160
 
149
- /** Which executor can describe through this row, or undefined when neither can. */
161
+ /** Which executor can describe through this row. */
150
162
  export function visionBackendForCandidate(
151
163
  config: Pick<OcxConfig, "providers">,
152
164
  candidate: VisionCandidateModel,
@@ -158,17 +170,17 @@ export function visionBackendForCandidate(
158
170
  // Messages wire is not enough: a key-auth row of the same adapter is unreachable, and an
159
171
  // option that cannot be dispatched is worse than a missing one, because selecting it fails
160
172
  // at describe time rather than at pick time.
161
- //
162
- // So the executor's name is REQUIRED for an Anthropic suggestion. When the caller has no
163
- // executor to name, no catalog row qualifies; the side's baseline is added separately and
164
- // keeps the picker populated. Narrowing here never widens the write gate, which is a
165
- // different predicate (`modelAcceptsImageInput`) and still treats unknown as allowed.
166
- if (anthropicProviderName === undefined) return undefined;
167
- return candidate.provider === anthropicProviderName ? "anthropic" : undefined;
173
+ if (anthropicProviderName !== undefined && candidate.provider === anthropicProviderName) {
174
+ return "anthropic";
175
+ }
176
+ // EVERY other provider row describes through the proxy's own router
177
+ // (roadmap 170 revised): the loopback executor covers all provider wires,
178
+ // so no row is left without an executor.
179
+ return "routed";
168
180
  }
169
181
 
170
182
  function baselineCandidate(
171
- backend: VisionSidecarBackend,
183
+ backend: UniversalVisionBackend,
172
184
  anthropicProviderName: string | undefined,
173
185
  ): VisionCandidateModel {
174
186
  return {
@@ -181,12 +193,13 @@ function baselineCandidate(
181
193
  }
182
194
 
183
195
  /**
184
- * The picker's option list: every eligible row reachable by one of the two
185
- * executors, plus each enabled side's baseline unless that baseline is explicitly
186
- * excluded, de-duplicated and stably ordered (openai side first, baselines first
187
- * within a side). Anthropic rows must belong to the OAuth provider that would
188
- * actually execute them, so `anthropicProviderName` is what makes that side's
189
- * catalog rows eligible at all.
196
+ * The picker's option list: every eligible row reachable by an enabled
197
+ * executor, plus each enabled universal side's baseline unless that baseline
198
+ * is explicitly excluded, de-duplicated and stably ordered (side rank order,
199
+ * baselines first within a side). Anthropic rows must belong to the OAuth
200
+ * provider that would actually execute them, so `anthropicProviderName` is
201
+ * what makes that side's catalog rows eligible at all; xai/gemini rows map by
202
+ * provider identity and appear only when the caller enabled those backends.
190
203
  *
191
204
  * This is the SUGGESTION list (narrow): it emits only rows an executor can reach
192
205
  * and some source has heard of. It is deliberately NOT the same set as the write
@@ -208,7 +221,7 @@ export function visionEligibleModelOptions(
208
221
  const byValue = new Map<string, VisionModelOption>();
209
222
  const enrichedProviders: EnrichedProviderCache = new Map();
210
223
 
211
- for (const backend of ["openai", "anthropic"] as const) {
224
+ for (const backend of Object.keys(BASELINE_VISION_MODELS) as UniversalVisionBackend[]) {
212
225
  if (!enabled.has(backend)) continue;
213
226
  const candidate = baselineCandidate(backend, anthropicProviderName);
214
227
  if (!isVisionEligibleModelWithCache(config, candidate, enrichedProviders)) continue;
@@ -219,11 +232,19 @@ export function visionEligibleModelOptions(
219
232
  const backend = visionBackendForCandidate(config, candidate, anthropicProviderName);
220
233
  if (!backend || !enabled.has(backend)) continue;
221
234
  if (!isVisionEligibleModelWithCache(config, candidate, enrichedProviders)) continue;
222
- if (byValue.has(candidate.id)) continue;
223
- byValue.set(candidate.id, { value: candidate.id, label: candidate.id, backend });
235
+ // Routed rows carry NAMESPACED values ("provider/model") so the loopback
236
+ // dispatch is unambiguous under routeModel; the legacy sides keep bare ids
237
+ // (GUI current-value compatibility, and the forward/OAuth executors POST
238
+ // the string verbatim). De-dup stays keyed by the emitted value.
239
+ const value = backend === "routed" ? `${candidate.provider}/${candidate.id}` : candidate.id;
240
+ if (byValue.has(value)) continue;
241
+ byValue.set(value, { value, label: value, backend });
224
242
  }
225
243
 
244
+ // Two slots per side (baseline first), ranked openai < anthropic < routed
245
+ // so widening the union appends rather than interleaves (roadmap 170).
246
+ const sideRank: Record<VisionSidecarBackend, number> = { openai: 0, anthropic: 2, routed: 4 };
226
247
  const order = (option: VisionModelOption) =>
227
- (option.backend === "openai" ? 0 : 2) + (option.baseline ? 0 : 1);
248
+ sideRank[option.backend] + (option.baseline ? 0 : 1);
228
249
  return [...byValue.values()].sort((a, b) => order(a) - order(b) || a.value.localeCompare(b.value));
229
250
  }
@@ -5,6 +5,8 @@ import { modelRecordValue } from "../reasoning-effort";
5
5
  import type { VisionReasoningEffort } from "../reasoning-effort";
6
6
  import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe";
7
7
  import { describeImageAnthropic } from "./anthropic-describe";
8
+ import { describeImageRouted } from "./routed-describe";
9
+ import { modelAcceptsImageInput } from "./eligibility";
8
10
  import { normalizeVisionReasoningForModel } from "./reasoning";
9
11
  import type { CodexAuthContext } from "../codex/auth-context";
10
12
  import { resolveSidecarAuth } from "../sidecar/auth";
@@ -226,16 +228,23 @@ export function findAnthropicVisionProvider(config: OcxConfig): AnthropicVisionP
226
228
  }
227
229
 
228
230
  export function resolveVisionBackend(
229
- explicit: "openai" | "anthropic" | undefined,
231
+ explicit: "openai" | "anthropic" | "routed" | undefined,
230
232
  anthropicSidecar: AnthropicVisionProvider | undefined,
231
233
  ): "openai" | "anthropic" {
232
234
  if (explicit === "openai" || explicit === "anthropic") return explicit;
235
+ // "routed" collapses to the legacy default order until its describe executor
236
+ // lands (roadmap 170 → 180 revised): a persisted routed backend without a
237
+ // dispatchable arm degrades exactly like unset rather than crashing. wp3
238
+ // replaces this collapse with the real routed arm in planVisionSidecar.
233
239
  return anthropicSidecar ? "anthropic" : "openai";
234
240
  }
235
241
 
236
242
  /** Native model used by the OpenAI vision helper, including its bounded default. */
237
243
  export function resolveOpenAiVisionModel(config: Pick<OcxConfig, "visionSidecar">): string {
238
- return config.visionSidecar?.model || DEFAULT_VISION_MODEL;
244
+ const configured = config.visionSidecar?.model;
245
+ // Namespaced routed ids never reach the forward executor (see
246
+ // resolveEffectiveVisionModel).
247
+ return configured && !configured.includes("/") ? configured : DEFAULT_VISION_MODEL;
239
248
  }
240
249
 
241
250
  /** Effective describer model for the backend `planVisionSidecar` selected. */
@@ -243,9 +252,15 @@ export function resolveEffectiveVisionModel(
243
252
  config: Pick<OcxConfig, "visionSidecar">,
244
253
  backend: "openai" | "anthropic",
245
254
  ): string {
255
+ const configured = config.visionSidecar?.model;
256
+ // A namespaced "provider/model" id belongs to the routed backend only; the
257
+ // forward/OAuth executors POST the model string verbatim, so it falls back
258
+ // to the side's default here (PUT coherence rejects new writes of this
259
+ // shape, but a legacy or hand-edited config must not break the executor).
260
+ const usable = configured && !configured.includes("/") ? configured : undefined;
246
261
  return backend === "anthropic"
247
- ? config.visionSidecar?.model || DEFAULT_ANTHROPIC_VISION_MODEL
248
- : resolveOpenAiVisionModel(config);
262
+ ? usable || DEFAULT_ANTHROPIC_VISION_MODEL
263
+ : usable || DEFAULT_VISION_MODEL;
249
264
  }
250
265
 
251
266
  /** A user/developer/toolResult message can carry images (toolResult: e.g. Codex view_image output). */
@@ -271,9 +286,13 @@ export function shouldResolveOpenAiVisionSidecar(
271
286
  }
272
287
 
273
288
  export interface VisionPlan {
274
- backend: "openai" | "anthropic";
289
+ backend: "openai" | "anthropic" | "routed";
275
290
  forwardSidecar?: ResolvedOpenAiForwardSidecar;
276
291
  anthropicSidecar?: AnthropicVisionProvider;
292
+ /** Namespaced "provider/model" describer for the routed backend (roadmap 180). */
293
+ routedModel?: string;
294
+ /** Loopback dispatch inputs for the routed backend. */
295
+ routedConfig?: Pick<OcxConfig, "port" | "apiKeys">;
277
296
  settings: VisionSettings;
278
297
  maxDescriptionsPerTurn: number;
279
298
  }
@@ -295,8 +314,45 @@ export function planVisionSidecar(
295
314
  if (!messagesHaveImage(parsed)) return undefined;
296
315
  const cfg = config.visionSidecar ?? {};
297
316
  if (cfg.enabled === false) return undefined;
317
+
318
+ // Routed arm (roadmap 180 revised): explicit backend + NAMESPACED explicit
319
+ // model only — never inferred from credential availability. Plan-time
320
+ // fence: the target must not be provably blind, and must not itself be a
321
+ // model this planner would re-enter for (belt; the terminal marker on the
322
+ // loopback request is the braces).
323
+ if (cfg.backend === "routed") {
324
+ const routedModel = cfg.model;
325
+ const sep = routedModel ? routedModel.indexOf("/") : -1;
326
+ if (routedModel && sep > 0) {
327
+ const targetProvider = routedModel.slice(0, sep);
328
+ const targetId = routedModel.slice(sep + 1);
329
+ const targetProviderConfig = config.providers?.[targetProvider];
330
+ const targetVisible = modelAcceptsImageInput(config, { provider: targetProvider, id: targetId }) !== false
331
+ && !(targetProviderConfig && isModelTextOnly(targetProviderConfig, targetId));
332
+ if (targetVisible) {
333
+ return {
334
+ backend: "routed",
335
+ routedModel,
336
+ routedConfig: { port: config.port, ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}) },
337
+ settings: {
338
+ model: routedModel,
339
+ reasoning: DEFAULT_REASONING,
340
+ timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs),
341
+ },
342
+ maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn),
343
+ };
344
+ }
345
+ }
346
+ // Misconfigured routed backend (bare id, unknown provider, or provably
347
+ // blind target): fall through to the legacy default order below rather
348
+ // than dispatching a describe that cannot work.
349
+ }
350
+
298
351
  const anthropicSidecar = findAnthropicVisionProvider(config);
299
352
  const backend = resolveVisionBackend(cfg.backend, anthropicSidecar);
353
+ // A namespaced routed model must never reach the forward/OAuth executors
354
+ // (they POST the string verbatim); the effective-model resolver falls back
355
+ // to each side's default in that case.
300
356
  const model = resolveEffectiveVisionModel(config, backend);
301
357
  const maxDescriptionsPerTurn = resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn);
302
358
 
@@ -447,6 +503,18 @@ async function executeDescription(
447
503
  abortSignal?: AbortSignal,
448
504
  recordSidecarOutcome?: SidecarOutcomeRecorder,
449
505
  ): Promise<DescribeOutcome> {
506
+ if (plan.backend === "routed") {
507
+ if (!plan.routedModel || !plan.routedConfig) return { text: "", error: "routed vision sidecar is unavailable" };
508
+ return describeImageRouted(
509
+ job.imageUrl,
510
+ job.detail,
511
+ job.contextText,
512
+ plan.routedModel,
513
+ plan.routedConfig,
514
+ plan.settings,
515
+ abortSignal,
516
+ );
517
+ }
450
518
  if (plan.backend === "anthropic") {
451
519
  const sidecar = plan.anthropicSidecar;
452
520
  if (!sidecar) return { text: "", error: "anthropic vision sidecar is unavailable" };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Describe ONE image via a ROUTED model through the proxy's own
3
+ * /v1/chat/completions on loopback (#2188 roadmap 180 revised).
4
+ *
5
+ * One executor for every provider the router can reach: the chat inbound
6
+ * translates image_url parts and each adapter compiles its own wire
7
+ * (Anthropic blocks, Antigravity inlineData, xai Responses input_image, plain
8
+ * openai-chat), so provider coverage is the router's job, not this file's.
9
+ *
10
+ * Recursion fence: the request carries `x-opencodex-vision-describe: 1`.
11
+ * The Chat surface detects the raw header before its bridge rebuilds headers
12
+ * and carries it into handleResponses as `visionDescribeTerminal`; a marked
13
+ * request STRIPS images instead of planning another describe (depth cap 1,
14
+ * holds under predicate drift and combo re-resolution — audit rounds 2-4).
15
+ *
16
+ * Admission ladder (audit round 3): configuredApiAuthToken() (env token) ||
17
+ * service token file || first config.apiKeys entry, sent as
18
+ * `x-opencodex-api-key` — never Authorization (gateway-cache.ts rule: an
19
+ * admission secret in a forwardable header is a forwarding hazard). Loopback
20
+ * binds require no token at all (resolveApiAuth admits loopback).
21
+ *
22
+ * Known limitation (recorded in roadmap 170): a bindHost where 127.0.0.1
23
+ * does not answer cannot reach its own loopback — same latent limitation
24
+ * gateway-cache has.
25
+ */
26
+ import type { OcxConfig } from "../types";
27
+ import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
28
+ import { redactSecretString } from "../lib/redact";
29
+ import { sidecarEnter } from "../lib/sidecar-tracker";
30
+ import { configuredApiAuthToken, configuredPort } from "../server/auth-cors";
31
+ import { loadServiceTokenFromFile } from "../lib/service-secrets";
32
+ import type { DescribeOutcome, VisionSettings } from "./describe";
33
+
34
+ export const VISION_DESCRIBE_TERMINAL_HEADER = "x-opencodex-vision-describe";
35
+
36
+ const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]);
37
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
38
+ /** Bound the loopback JSON response; descriptions are clamped to ~2k chars by the caller anyway. */
39
+ const MAX_ROUTED_RESPONSE_BYTES = 4 * 1024 * 1024;
40
+
41
+ const DESCRIBE_INSTRUCTION =
42
+ "You are a vision describer for a text-only model that cannot see the image. Describe the image "
43
+ + "thoroughly and factually so that model can fully reason about it: transcribe any visible text "
44
+ + "verbatim, and note UI/layout, colors, branding/logos, charts, and notable details. Focus on "
45
+ + "what's relevant to the user's request. Output only the description.";
46
+
47
+ function validateImageUrl(url: string): string | null {
48
+ if (url.startsWith("data:")) {
49
+ const match = /^data:([^;,]+?)(;base64)?,(.*)$/s.exec(url);
50
+ if (!match) return "malformed data URL";
51
+ const mime = match[1].toLowerCase();
52
+ if (!ALLOWED_IMAGE_MIME.has(mime)) return `unsupported image type "${mime}"`;
53
+ if (match[2]) {
54
+ const bytes = Math.floor((match[3].length * 3) / 4);
55
+ if (bytes > MAX_IMAGE_BYTES) return `image too large (~${Math.round(bytes / 1024 / 1024)}MB)`;
56
+ }
57
+ return null;
58
+ }
59
+ if (url.startsWith("https://")) return null;
60
+ return "unsupported image URL scheme (expected data: or https:)";
61
+ }
62
+
63
+ /** The admission ladder: env token, service token file, first configured API key. */
64
+ export function routedDescribeAdmissionToken(config: Pick<OcxConfig, "apiKeys">): string | undefined {
65
+ const envToken = configuredApiAuthToken();
66
+ if (envToken) return envToken;
67
+ const fileToken = loadServiceTokenFromFile(process.env);
68
+ if (fileToken) return fileToken;
69
+ const first = config.apiKeys?.[0]?.key?.trim();
70
+ return first || undefined;
71
+ }
72
+
73
+ /** Base URL seam for tests; production always self-fetches loopback. */
74
+ export function routedDescribeBaseUrl(config: Pick<OcxConfig, "port">): string {
75
+ // config.port can be 0 (ephemeral bind, tests) or stale after a live port
76
+ // override; the server records its ACTUAL bound port via setCorsOrigin at
77
+ // startup, so prefer that when config carries no positive port.
78
+ const port = config.port && config.port > 0 ? String(config.port) : configuredPort();
79
+ return `http://127.0.0.1:${port}`;
80
+ }
81
+
82
+ export async function describeImageRouted(
83
+ imageUrl: string,
84
+ _detail: string | undefined,
85
+ contextText: string,
86
+ routedModel: string,
87
+ config: Pick<OcxConfig, "port" | "apiKeys">,
88
+ settings: VisionSettings,
89
+ abortSignal?: AbortSignal,
90
+ baseUrlOverride?: string,
91
+ ): Promise<DescribeOutcome> {
92
+ const invalid = validateImageUrl(imageUrl);
93
+ if (invalid) return { text: "", error: invalid };
94
+
95
+ const headers: Record<string, string> = {
96
+ "Content-Type": "application/json",
97
+ [VISION_DESCRIBE_TERMINAL_HEADER]: "1",
98
+ };
99
+ const admission = routedDescribeAdmissionToken(config);
100
+ if (admission) headers["x-opencodex-api-key"] = admission;
101
+
102
+ const requestBody = {
103
+ model: routedModel,
104
+ stream: false,
105
+ messages: [
106
+ { role: "system", content: DESCRIBE_INSTRUCTION },
107
+ {
108
+ role: "user",
109
+ content: [
110
+ ...(contextText ? [{ type: "text", text: `User's request context: ${contextText}` }] : []),
111
+ { type: "image_url", image_url: { url: imageUrl } },
112
+ ],
113
+ },
114
+ ],
115
+ };
116
+
117
+ const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
118
+ const sidecarExit = sidecarEnter("vision");
119
+ const t0 = Date.now();
120
+ try {
121
+ const res = await fetch(`${baseUrlOverride ?? routedDescribeBaseUrl(config)}/v1/chat/completions`, {
122
+ method: "POST",
123
+ headers,
124
+ body: JSON.stringify(requestBody),
125
+ signal: linkedSignal.signal,
126
+ redirect: "manual",
127
+ });
128
+ const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
129
+ try {
130
+ const raw = await res.text();
131
+ if (raw.length > MAX_ROUTED_RESPONSE_BYTES) {
132
+ return { text: "", error: "routed describe response exceeded byte bound" };
133
+ }
134
+ if (!res.ok) {
135
+ return { text: "", error: `routed describe HTTP ${res.status}: ${redactSecretString(raw.slice(0, 200))}` };
136
+ }
137
+ let payload: unknown;
138
+ try { payload = JSON.parse(raw); } catch {
139
+ return { text: "", error: "routed describe returned non-JSON" };
140
+ }
141
+ const content = extractChatContent(payload);
142
+ if (!content) return { text: "", error: "routed describe returned no text" };
143
+ return { text: content };
144
+ } finally {
145
+ detachBodyGuard();
146
+ }
147
+ } catch (e) {
148
+ const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
149
+ console.warn(`[vision] routed describe ${kind} (${Date.now() - t0}ms)`);
150
+ return { text: "", error: redactSecretString(e instanceof Error ? e.message : String(e)) };
151
+ } finally {
152
+ sidecarExit();
153
+ linkedSignal.cleanup();
154
+ }
155
+ }
156
+
157
+ function extractChatContent(payload: unknown): string | undefined {
158
+ if (!payload || typeof payload !== "object") return undefined;
159
+ const choices = (payload as { choices?: unknown }).choices;
160
+ if (!Array.isArray(choices) || choices.length === 0) return undefined;
161
+ const message = (choices[0] as { message?: unknown })?.message;
162
+ if (!message || typeof message !== "object") return undefined;
163
+ const content = (message as { content?: unknown }).content;
164
+ if (typeof content === "string" && content.trim().length > 0) return content;
165
+ // Some adapters emit content parts; join text parts.
166
+ if (Array.isArray(content)) {
167
+ const joined = content
168
+ .map(part => (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
169
+ ? (part as { text: string }).text
170
+ : ""))
171
+ .join("");
172
+ if (joined.trim().length > 0) return joined;
173
+ }
174
+ return undefined;
175
+ }