@bitkyc08/opencodex 2.7.9-preview.20260712.2 → 2.7.10-preview.20260713
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/gui/dist/assets/index-HRom0Cw2.js +40 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/bridge.ts +6 -2
- package/src/claude/outbound.ts +77 -5
- package/src/oauth/index.ts +9 -7
- package/src/providers/key-failover.ts +26 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/xai-transport.ts +89 -0
- package/src/router.ts +7 -2
- package/src/server/claude-messages.ts +25 -1
- package/src/server/management-api.ts +99 -10
- package/src/server/responses.ts +23 -9
- package/src/types.ts +17 -2
- 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-SnN_1Qr9.js +0 -40
package/src/server/responses.ts
CHANGED
|
@@ -40,7 +40,8 @@ import { fetchWithResetRetry } from "../lib/upstream-retry";
|
|
|
40
40
|
import { isUsageDebugEnabled } from "../usage/debug";
|
|
41
41
|
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
|
|
42
42
|
import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
|
|
43
|
-
import { hasKeyPoolFailover,
|
|
43
|
+
import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../providers/key-failover";
|
|
44
|
+
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
44
45
|
import type { WsData } from "./ws-bridge";
|
|
45
46
|
import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
|
|
46
47
|
import { redactSecretString } from "../lib/redact";
|
|
@@ -612,14 +613,15 @@ export async function handleResponses(
|
|
|
612
613
|
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
613
614
|
}
|
|
614
615
|
}
|
|
616
|
+
route.provider = resolveProviderTransport(route.providerName, route.provider, parsed.options.promptCacheKey);
|
|
615
617
|
|
|
616
|
-
// Vision sidecar: the routed model can't see images (provider.noVisionModels).
|
|
617
|
-
//
|
|
618
|
-
//
|
|
618
|
+
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
|
|
619
|
+
// attached image through the selected sidecar backend and replace it with text BEFORE the main
|
|
620
|
+
// call, so the text-only model can reason about it.
|
|
619
621
|
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, selectedForwardHeaders, authCtx);
|
|
620
622
|
const recordSidecarOutcome = sidecarOutcomeRecorder(config, authCtx);
|
|
621
623
|
if (visionPlan) {
|
|
622
|
-
await describeImagesInPlace(parsed, visionPlan
|
|
624
|
+
await describeImagesInPlace(parsed, visionPlan, selectedForwardHeaders, options.abortSignal, recordSidecarOutcome);
|
|
623
625
|
} else if (modelInList(route.provider.noVisionModels, route.modelId)) {
|
|
624
626
|
// Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
|
|
625
627
|
// disabled): fail closed — never forward raw images to a text-only upstream.
|
|
@@ -870,7 +872,9 @@ export async function handleResponses(
|
|
|
870
872
|
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
871
873
|
const wsResponse = await runWithWebSearch({
|
|
872
874
|
parsed, adapter,
|
|
875
|
+
backend: wsPlan.backend,
|
|
873
876
|
forwardProvider: wsPlan.forwardProvider,
|
|
877
|
+
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
874
878
|
hostedTool: wsPlan.hostedTool,
|
|
875
879
|
selectedForwardHeaders,
|
|
876
880
|
settings: wsPlan.settings,
|
|
@@ -882,11 +886,16 @@ export async function handleResponses(
|
|
|
882
886
|
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
883
887
|
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
884
888
|
on429: retryAfter => {
|
|
885
|
-
const rotated =
|
|
889
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
890
|
+
retryAfter,
|
|
891
|
+
now: Date.now(),
|
|
892
|
+
attemptedKey: route.provider.apiKey,
|
|
893
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
894
|
+
});
|
|
886
895
|
if (!rotated) return null;
|
|
887
896
|
route.provider = rotated;
|
|
888
897
|
return resolveAdapter(
|
|
889
|
-
resolveWireProtocolOverride(route.providerName, route.modelId,
|
|
898
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
890
899
|
config.cacheRetention,
|
|
891
900
|
);
|
|
892
901
|
},
|
|
@@ -935,14 +944,19 @@ export async function handleResponses(
|
|
|
935
944
|
// request once per remaining key. OAuth/forward providers and single-key pools return null
|
|
936
945
|
// immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
937
946
|
while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
938
|
-
const rotated =
|
|
947
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
948
|
+
retryAfter: upstreamResponse.headers.get("retry-after"),
|
|
949
|
+
now: Date.now(),
|
|
950
|
+
attemptedKey: route.provider.apiKey,
|
|
951
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
952
|
+
});
|
|
939
953
|
if (!rotated) break;
|
|
940
954
|
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
941
955
|
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
942
956
|
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
943
957
|
route.provider = rotated;
|
|
944
958
|
const retryAdapter = resolveAdapter(
|
|
945
|
-
resolveWireProtocolOverride(route.providerName, route.modelId,
|
|
959
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
946
960
|
config.cacheRetention,
|
|
947
961
|
);
|
|
948
962
|
const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
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
|
|
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. */
|
|
@@ -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
|
+
}
|
package/src/vision/index.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
64
|
-
*
|
|
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
|
-
|
|
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.
|
|
137
|
-
const
|
|
138
|
-
|
|
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;
|