@bitkyc08/opencodex 2.7.9-preview.20260712.1 → 2.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -1
  2. package/gui/dist/assets/index-BAAFKwsh.js +40 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +2 -2
  5. package/src/adapters/cursor/transport-retry.ts +5 -3
  6. package/src/adapters/google-errors.ts +9 -19
  7. package/src/adapters/google-http.ts +29 -66
  8. package/src/adapters/kiro-errors.ts +10 -23
  9. package/src/adapters/kiro-retry.ts +26 -58
  10. package/src/adapters/upstream-http-error.ts +48 -0
  11. package/src/bridge.ts +6 -2
  12. package/src/claude/gateway-cache.ts +3 -3
  13. package/src/claude/outbound.ts +117 -40
  14. package/src/cli/claude.ts +36 -4
  15. package/src/config.ts +54 -3
  16. package/src/lib/destination-policy.ts +167 -0
  17. package/src/lib/injection-debug-log.ts +34 -0
  18. package/src/lib/upstream-retry.ts +53 -3
  19. package/src/lib/windows-secret-acl.ts +173 -0
  20. package/src/oauth/index.ts +9 -7
  21. package/src/oauth/store.ts +1 -0
  22. package/src/providers/registry.ts +10 -3
  23. package/src/providers/xai-transport.ts +89 -0
  24. package/src/router.ts +6 -1
  25. package/src/server/auth-cors.ts +4 -0
  26. package/src/server/claude-messages.ts +32 -2
  27. package/src/server/management-api.ts +159 -33
  28. package/src/server/request-decompress.ts +45 -12
  29. package/src/server/responses.ts +21 -12
  30. package/src/server/system-env.ts +110 -68
  31. package/src/service.ts +4 -0
  32. package/src/types.ts +25 -5
  33. package/src/vision/anthropic-describe.ts +185 -0
  34. package/src/vision/index.ts +219 -10
  35. package/src/web-search/anthropic-executor.ts +187 -0
  36. package/src/web-search/executor.ts +4 -2
  37. package/src/web-search/index.ts +80 -18
  38. package/src/web-search/loop.ts +14 -2
  39. package/gui/dist/assets/index-Csp2AZYr.js +0 -40
@@ -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;
@@ -0,0 +1,187 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import { getValidAccessToken } from "../oauth";
3
+ import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../oauth/anthropic";
4
+ import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fingerprint";
5
+ import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
6
+ import { sidecarEnter } from "../lib/sidecar-tracker";
7
+ import { fetchWithResetRetry } from "../lib/upstream-retry";
8
+ import type { WebSearchSource } from "./parse";
9
+ import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor";
10
+
11
+ /** Hardcoded per-turn search bound handed to the server tool (mirrors the loop's maxSearches intent). */
12
+ const ANTHROPIC_MAX_USES = 3;
13
+ /** Answer budget; the injected tool_result is clamped downstream, so this only bounds the sidecar turn. */
14
+ const ANTHROPIC_MAX_TOKENS = 8192;
15
+
16
+ function isRec(v: unknown): v is Record<string, unknown> {
17
+ return !!v && typeof v === "object" && !Array.isArray(v);
18
+ }
19
+
20
+ /**
21
+ * Fold an Anthropic Messages SSE stream (a web_search_20250305 turn) into a WebSearchResult.
22
+ *
23
+ * Anthropic streams the FULL `web_search_tool_result.content` array on `content_block_start` (not via
24
+ * deltas), so sources are collected there; the answer text arrives as `text_delta` events, and
25
+ * `citations_delta` (web_search_result_location) contributes any additional cited URLs. A
26
+ * `web_search_tool_result_error` content object yields no sources. Never throws.
27
+ */
28
+ export async function parseAnthropicSidecarSSE(res: Response): Promise<SidecarOutcome> {
29
+ const sources: WebSearchSource[] = [];
30
+ const seen = new Set<string>();
31
+ const pushSource = (url: unknown, title: unknown): void => {
32
+ if (typeof url !== "string" || url.length === 0 || seen.has(url)) return;
33
+ seen.add(url);
34
+ sources.push(typeof title === "string" && title.length > 0 ? { url, title } : { url });
35
+ };
36
+
37
+ let text = "";
38
+ let sawToolResultError = false;
39
+ if (!res.body) return { text: "", sources, error: "anthropic sidecar returned no response body" };
40
+
41
+ const decoder = new TextDecoder();
42
+ const reader = res.body.getReader();
43
+ let buffer = "";
44
+
45
+ const handleFrame = (data: Record<string, unknown>): void => {
46
+ const type = typeof data.type === "string" ? data.type : "";
47
+ if (type === "content_block_start") {
48
+ const block = isRec(data.content_block) ? data.content_block : {};
49
+ if (block.type === "web_search_tool_result") {
50
+ if (Array.isArray(block.content)) {
51
+ for (const hit of block.content) {
52
+ if (isRec(hit) && hit.type === "web_search_result") pushSource(hit.url, hit.title);
53
+ }
54
+ } else if (isRec(block.content) && block.content.type === "web_search_tool_result_error") {
55
+ sawToolResultError = true;
56
+ }
57
+ }
58
+ } else if (type === "content_block_delta") {
59
+ const delta = isRec(data.delta) ? data.delta : {};
60
+ if (delta.type === "text_delta" && typeof delta.text === "string") {
61
+ text += delta.text;
62
+ } else if (delta.type === "citations_delta") {
63
+ const citation = isRec(delta.citation) ? delta.citation : {};
64
+ if (citation.type === "web_search_result_location") pushSource(citation.url, citation.title);
65
+ }
66
+ }
67
+ };
68
+
69
+ // Parse one SSE frame's `data:` payload and fold it. Shared by the streaming loop and the EOF flush.
70
+ const processFrame = (rawFrame: string): void => {
71
+ let dataLine = "";
72
+ for (const line of rawFrame.split("\n")) {
73
+ if (line.startsWith("data:")) dataLine += line.slice(line.startsWith("data: ") ? 6 : 5);
74
+ }
75
+ if (!dataLine || dataLine === "[DONE]") return;
76
+ let data: unknown;
77
+ try { data = JSON.parse(dataLine); } catch { return; }
78
+ if (isRec(data)) handleFrame(data);
79
+ };
80
+
81
+ try {
82
+ for (;;) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ // Normalize CRLF on the ACCUMULATED buffer so a `\r\n` pair split across two network chunks
86
+ // (chunk ends in `\r`, next starts with `\n`) still collapses to `\n` (audit round-2 F2).
87
+ buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
88
+ let sep: number;
89
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
90
+ const rawFrame = buffer.slice(0, sep);
91
+ buffer = buffer.slice(sep + 2);
92
+ processFrame(rawFrame);
93
+ }
94
+ }
95
+ // Flush the decoder and process any final unterminated frame (a stream that ends without \n\n).
96
+ buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n");
97
+ if (buffer.trim().length > 0) processFrame(buffer);
98
+ } catch {
99
+ /* mid-stream abort/decode failure: fall through with whatever text/sources were gathered */
100
+ }
101
+
102
+ const trimmed = text.trim();
103
+ if (trimmed.length === 0) {
104
+ return { text: "", sources, error: sawToolResultError ? "anthropic web search returned an error result" : "anthropic sidecar produced no answer" };
105
+ }
106
+ return { text: trimmed, sources };
107
+ }
108
+
109
+ /**
110
+ * Execute ONE web search via a Claude sidecar through the STORED anthropic OAuth credential — the
111
+ * Anthropic-backed analog of runWebSearch. Authenticates with getValidAccessToken (refresh handled)
112
+ * and reproduces the Claude Code OAuth fingerprint (identity system block first, oauth beta, client
113
+ * headers, stable session id) so the request is first-party-shaped. Never throws — returns `{error}`
114
+ * so the caller injects a graceful tool result.
115
+ */
116
+ export async function runAnthropicWebSearch(
117
+ query: string,
118
+ providerName: string,
119
+ provider: OcxProviderConfig,
120
+ settings: SidecarSettings,
121
+ abortSignal?: AbortSignal,
122
+ ): Promise<SidecarOutcome> {
123
+ const base = provider.baseUrl.replace(/\/v1\/?$/, "");
124
+ const url = `${base}/v1/messages`;
125
+ let token: string;
126
+ try {
127
+ token = await getValidAccessToken(providerName);
128
+ } catch (e) {
129
+ return { text: "", sources: [], error: `anthropic sidecar auth failed: ${e instanceof Error ? e.message : String(e)}` };
130
+ }
131
+ const headers: Record<string, string> = {
132
+ "Content-Type": "application/json",
133
+ "anthropic-version": "2023-06-01",
134
+ "Accept": "text/event-stream",
135
+ "User-Agent": "@anthropic-ai/sdk/0.74.0",
136
+ "Authorization": `Bearer ${token}`,
137
+ "anthropic-beta": ANTHROPIC_OAUTH_BETA,
138
+ ...CLAUDE_CODE_HEADERS,
139
+ "X-Claude-Code-Session-Id": claudeCodeSessionId(token),
140
+ "x-client-request-id": crypto.randomUUID(),
141
+ };
142
+ if (provider.headers) Object.assign(headers, provider.headers);
143
+
144
+ const instruction = settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION;
145
+ const body = {
146
+ model: settings.model,
147
+ max_tokens: ANTHROPIC_MAX_TOKENS,
148
+ // sonnet-5 defaults to adaptive thinking when omitted; keep the sidecar fast/cheap (audit F2).
149
+ thinking: { type: "disabled" },
150
+ // OAuth fingerprint requires the Claude Code identity as the FIRST system block (audit F6/anthropic.ts).
151
+ system: [
152
+ { type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION },
153
+ { type: "text", text: instruction },
154
+ ],
155
+ messages: [{ role: "user", content: [{ type: "text", text: query }] }],
156
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: ANTHROPIC_MAX_USES }],
157
+ stream: true,
158
+ };
159
+
160
+ const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
161
+ const sidecarExit = sidecarEnter("web-search");
162
+ const t0 = Date.now();
163
+ try {
164
+ const res = await fetchWithResetRetry(
165
+ () => fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: linkedSignal.signal }),
166
+ { abortSignal: linkedSignal.signal, label: "web-search-sidecar-anthropic" },
167
+ );
168
+ if (!res.ok) {
169
+ const t = await res.text().catch(() => "");
170
+ console.warn(`[web-search] anthropic sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
171
+ return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${t.slice(0, 200)}` };
172
+ }
173
+ const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
174
+ try {
175
+ return await parseAnthropicSidecarSSE(res);
176
+ } finally {
177
+ detachBodyGuard();
178
+ }
179
+ } catch (e) {
180
+ const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
181
+ console.warn(`[web-search] anthropic sidecar ${kind} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
182
+ return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) };
183
+ } finally {
184
+ sidecarExit();
185
+ linkedSignal.cleanup();
186
+ }
187
+ }
@@ -18,11 +18,13 @@ export interface SidecarSettings {
18
18
  describeImages?: boolean;
19
19
  }
20
20
 
21
- const BASE_INSTRUCTION =
21
+ // Shared with the anthropic-backed executor (single source; audit F3). The instruction is
22
+ // backend-agnostic — both the gpt-mini sidecar and a Claude sidecar answer the same way.
23
+ export const BASE_INSTRUCTION =
22
24
  "You are a web-search assistant. Use the web_search tool to find current information for the " +
23
25
  "user's query, then reply with a concise, factual answer. End your reply with a `Sources:` " +
24
26
  "section listing each source you used on its own line as `- Title: URL` (one per line).";
25
- const IMAGE_INSTRUCTION =
27
+ export const IMAGE_INSTRUCTION =
26
28
  " The model that will read your answer is TEXT-ONLY and cannot see images: if the results include " +
27
29
  "relevant images, describe what they show in words and include their source URLs in your answer.";
28
30
 
@@ -2,11 +2,15 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
2
2
  import { modelInList } from "../types";
3
3
  import type { SidecarSettings } from "./executor";
4
4
  import type { CodexAuthContext } from "../codex/auth-context";
5
+ import { getAccountSet } from "../oauth/store";
5
6
 
6
7
  export { runWithWebSearch } from "./loop";
7
8
  export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
9
+ export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor";
8
10
 
9
11
  const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
12
+ // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset).
13
+ const DEFAULT_ANTHROPIC_SIDECAR_MODEL = "claude-sonnet-5";
10
14
  // "low" is the lightest effort the ChatGPT backend allows with web_search ("minimal" is rejected:
11
15
  // "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap.
12
16
  const DEFAULT_SIDECAR_REASONING = "low";
@@ -70,8 +74,45 @@ export function findForwardProvider(config: OcxConfig): OcxProviderConfig | unde
70
74
  return undefined;
71
75
  }
72
76
 
77
+ /** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */
78
+ export interface AnthropicSidecarProvider {
79
+ providerName: string;
80
+ provider: OcxProviderConfig;
81
+ }
82
+
83
+ /**
84
+ * First enabled anthropic-adapter OAuth provider whose ACTIVE account holds a usable credential — the
85
+ * only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by
86
+ * getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a
87
+ * terminally-invalid account); token refresh happens later at executor time.
88
+ */
89
+ export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined {
90
+ for (const [name, prov] of Object.entries(config.providers)) {
91
+ if (prov.disabled === true) continue;
92
+ if (prov.adapter !== "anthropic" || prov.authMode !== "oauth") continue;
93
+ const set = getAccountSet(name);
94
+ const active = set?.accounts.find(a => a.id === set.activeAccountId);
95
+ if (active && active.needsReauth !== true) return { providerName: name, provider: prov };
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /** Precedence (audit F4/F7): explicit config wins; unset resolves to anthropic when a usable credential exists, else openai. */
101
+ export function resolveSidecarBackend(
102
+ explicit: "openai" | "anthropic" | undefined,
103
+ anthropicSidecar: AnthropicSidecarProvider | undefined,
104
+ ): "openai" | "anthropic" {
105
+ if (explicit === "anthropic" || explicit === "openai") return explicit;
106
+ return anthropicSidecar ? "anthropic" : "openai";
107
+ }
108
+
73
109
  export interface SidecarPlan {
74
- forwardProvider: OcxProviderConfig;
110
+ /** Which executor runs the search. Anthropic does not require a forward provider. */
111
+ backend: "openai" | "anthropic";
112
+ /** Present for the openai backend (ChatGPT forward path); undefined for anthropic. */
113
+ forwardProvider?: OcxProviderConfig;
114
+ /** Present for the anthropic backend (stored-OAuth /v1/messages path); undefined for openai. */
115
+ anthropicSidecar?: AnthropicSidecarProvider;
75
116
  hostedTool: Record<string, unknown>;
76
117
  settings: SidecarSettings;
77
118
  maxSearches: number;
@@ -99,30 +140,51 @@ export function planWebSearch(
99
140
  if (!parsed._webSearch || isPassthrough) return undefined;
100
141
  const cfg = config.webSearchSidecar ?? {};
101
142
  if (cfg.enabled === false) return undefined;
102
- if (authContext.kind === "main" && !incomingHeaders.get("authorization")) return undefined; // not logged into ChatGPT → sidecar can't run
103
- const forwardProvider = findForwardProvider(config);
104
- if (!forwardProvider) return undefined;
105
143
  const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
106
144
  const routedModelStallTimeoutMs = resolveRoutedModelStallTimeoutMs(cfg.routedModelStallTimeoutMs);
107
145
  // Same `?? 200_000` default the server applies when threading connectTimeoutMs into the loop.
108
146
  const connectTimeoutMs = config.connectTimeoutMs ?? 200_000;
147
+ const anthropicSidecar = findAnthropicSidecarProvider(config);
148
+ const backend = resolveSidecarBackend(cfg.backend, anthropicSidecar);
149
+ const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES;
150
+ const stallTimeoutSec = webSearchStallTimeoutSec(
151
+ config.stallTimeoutSec,
152
+ connectTimeoutMs,
153
+ routedModelStallTimeoutMs,
154
+ timeoutMs,
155
+ );
156
+ // The routed model being text-only means the search model must verbalize image results (either backend).
157
+ const describeImages = modelInList(provider.noVisionModels, modelId);
158
+ const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING;
159
+
160
+ // Anthropic backend authenticates with the STORED credential — no forward provider or ChatGPT login gate.
161
+ // resolveSidecarBackend only returns "anthropic" when it was explicitly configured OR a usable credential
162
+ // exists; an EXPLICIT anthropic choice with no usable credential FAILS CLOSED (no plan) rather than
163
+ // silently borrowing ChatGPT credentials (audit round-2 F1).
164
+ if (backend === "anthropic") {
165
+ if (!anthropicSidecar) return undefined;
166
+ return {
167
+ backend: "anthropic",
168
+ anthropicSidecar,
169
+ hostedTool: parsed._webSearch,
170
+ settings: { model: cfg.model ?? DEFAULT_ANTHROPIC_SIDECAR_MODEL, reasoning, timeoutMs, describeImages },
171
+ maxSearches,
172
+ routedModelStallTimeoutMs,
173
+ stallTimeoutSec,
174
+ };
175
+ }
176
+
177
+ // OpenAI backend: needs a ChatGPT login (main) and a forward provider to reach server-side web_search.
178
+ if (authContext.kind === "main" && !incomingHeaders.get("authorization")) return undefined;
179
+ const forwardProvider = findForwardProvider(config);
180
+ if (!forwardProvider) return undefined;
109
181
  return {
182
+ backend: "openai",
110
183
  forwardProvider,
111
184
  hostedTool: parsed._webSearch,
112
- settings: {
113
- model: cfg.model ?? DEFAULT_SIDECAR_MODEL,
114
- reasoning: cfg.reasoning ?? DEFAULT_SIDECAR_REASONING,
115
- timeoutMs,
116
- // The routed model is text-only → have the search model verbalize image results.
117
- describeImages: modelInList(provider.noVisionModels, modelId),
118
- },
119
- maxSearches: cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES,
185
+ settings: { model: cfg.model ?? DEFAULT_SIDECAR_MODEL, reasoning, timeoutMs, describeImages },
186
+ maxSearches,
120
187
  routedModelStallTimeoutMs,
121
- stallTimeoutSec: webSearchStallTimeoutSec(
122
- config.stallTimeoutSec,
123
- connectTimeoutMs,
124
- routedModelStallTimeoutMs,
125
- timeoutMs,
126
- ),
188
+ stallTimeoutSec,
127
189
  };
128
190
  }