@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/gui/dist/assets/index-BAAFKwsh.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/registry.ts +5 -0
- package/src/providers/xai-transport.ts +89 -0
- package/src/server/claude-messages.ts +25 -1
- package/src/server/management-api.ts +99 -10
- package/src/server/responses.ts +16 -8
- 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/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;
|