diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index ab533d2110..73b670b044 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -23,8 +23,10 @@ When Codex requests hosted `web_search` for a non-passthrough routed model, open to the routed model instead. The original hosted-tool options are retained for the sidecar call. 2. Runs the routed model in a small **agentic loop**. When it calls `web_search`, opencodex uses the selected sidecar backend: OpenAI runs hosted `web_search` with `gpt-5.6-luna` by default; - Anthropic runs `web_search_20250305` with `claude-sonnet-5` by default. The streamed answer and - citations become a tool result. + Anthropic runs `web_search_20250305` with `claude-sonnet-5` by default; a `keyed` backend POSTs + hosted `web_search` to a configured key-auth `openai-responses` provider that natively serves the + tool (for example OpenCode Zen with `deepseek-v4-flash`). The streamed answer and citations + become a tool result. 3. **Loops** until the model answers or the total real-query budget reaches `maxSearchesPerTurn` (default 3), then removes the search tool and forces a final answer. Real client tools such as `apply_patch` or shell finalize the turn so those calls reach Codex. @@ -38,7 +40,7 @@ Opt-in `webSearchSidecar.streamRoutedModelOutput` (default `false`) streams each leading text/thinking deltas live instead — the client sees output as soon as the model produces it, exactly like the sidecar-less path. The live window closes permanently at the first tool-call boundary, so the decision to intercept `web_search` stays atomic and nothing is ever delivered -twice (the terminal replay skips what already streamed). Tradeoff: text the model emits *before* +twice (the terminal replay skips what already streamed). Tradeoff: text the model emits _before_ deciding to search — which buffered mode silently drops — becomes visible and may partially repeat in the post-search answer. The Dashboard overview page exposes this as the **Stream answers live** toggle on the web-search sidecar card (`PUT /api/sidecar-settings` with @@ -73,6 +75,25 @@ relevant images in words and include their source URLs. search is returned to the routed model as a bounded error result, allowing it to answer from the context it already has. +For the `keyed` backend, set `webSearchSidecar.provider` to an enabled key-auth provider whose model +supports native hosted web_search over the Responses wire, and `webSearchSidecar.model` to that model: + +```json +{ + "webSearchSidecar": { + "enabled": true, + "backend": "keyed", + "provider": "opencode-go", + "model": "deepseek-v4-flash" + } +} +``` + +Selection fails closed: if the provider is disabled, has no resolvable API key, does not use the +Responses wire, or does not declare hosted web_search support for the configured model, the sidecar +is not selected and the routed model takes the normal non-search path rather than borrowing +ChatGPT/Anthropic quota. + Four separate clocks apply. `stallTimeoutSec` is the base bridge event-stall budget. `connectTimeoutMs` (default `200000`) covers only DNS/TCP/TLS and final response headers. Config-file-only `webSearchSidecar.routedModelStallTimeoutMs` (default `200000`, integer diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index a3f7b69503..39a18909d0 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -18,7 +18,7 @@ const CLAUDE_USAGE = `Usage: [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] - [--blocked-skills ] [--web-model ] [--web-backend ] + [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]`; const GROK_USAGE = `Usage: diff --git a/src/providers/registry.ts b/src/providers/registry.ts index d188185ed3..7358d7de72 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -175,6 +175,8 @@ export interface ProviderRegistryEntry { modelResponsesUpstreamStreaming?: Record; /** Registry-only repair for a model whose native Responses stream may omit its terminal. */ modelResponsesTerminalRepair?: Record; + /** Registry-only capability: model ids that natively serve hosted web_search over the Responses wire. */ + hostedWebSearchResponsesModels?: Record; /** * Registry-only client-facing item-id repair policy (#938), filled onto the * runtime provider only when the user has no explicit policy (derive.ts); @@ -1242,6 +1244,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.7-code", jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…", + // #1616: OpenCode Zen natively serves hosted web_search over /zen/go/v1/responses for + // deepseek-v4-flash (verified 2026-08-13). Per-model and fail-closed: other models must be + // probed before enabling the keyed web-search sidecar on them. + hostedWebSearchResponsesModels: { "deepseek-v4-flash": true }, /* [Decision Log] - 목적과 의도: Route GPT 5.6 Luna to the Responses endpoint that OpenCode Go documents for that exact model. - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative. @@ -1250,7 +1256,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ - 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence. - 장점, 단점 및 영향: Luna reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. */ - modelWireDefaults: { "gpt-5.6-luna": "openai-responses" }, + modelWireDefaults: { + "gpt-5.6-luna": "openai-responses", + // #1616: deepseek-v4-flash rides the Responses wire (hosted web_search needs it). + "deepseek-v4-flash": "openai-responses", + }, modelContextWindows: { "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW }, modelInputModalities: { "kimi-k3": ["text", "image"] }, modelReasoningEfforts: { @@ -2645,6 +2655,21 @@ export function providerModelResponsesTerminalRepair( return { graceMs }; } +/** + * True when a registry entry declares the given model natively serves hosted web_search over the + * Responses wire. The keyed web-search sidecar uses this as its capability gate: without it, a + * misconfigured provider would send its API key to an endpoint that cannot answer. + */ +export function providerHostsHostedWebSearchResponses( + id: string, + provider: Pick & Partial>, + modelId: string, +): boolean { + const entry = getProviderRegistryEntry(id); + if (!entry?.hostedWebSearchResponsesModels || !providerMatchesRegistryTransport(id, provider)) return false; + return entry.hostedWebSearchResponsesModels[modelId.trim().toLowerCase()] === true; +} + /** * Effective Codex account mode for a provider. For canonical `openai`, a valid persisted * `codexAccountMode` on the provider config wins and a missing/invalid value defaults to diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 8fc82f961f..96ec36a432 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1018,7 +1018,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise blockedSkills: config.claudeCode?.blockedSkills ?? null, injectAgents: config.claudeCode?.injectAgents !== false, ...(webSearchOverride && Object.keys(webSearchOverride).length > 0 - ? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model } } + ? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model, provider: webSearchOverride.provider } } : {}), ...(visionOverride && Object.keys(visionOverride).length > 0 ? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model } } @@ -1050,10 +1050,29 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise for (const field of ["webSearchSidecar", "visionSidecar"] as const) { const section = body[field]; if (section === undefined || section === null) continue; - if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400); - if (section.backend !== undefined && section.backend !== null - && section.backend !== "openai" && section.backend !== "anthropic") { - return jsonResponse({ error: `${field}.backend must be openai, anthropic, or null` }, 400); + if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400); + if (section.backend !== undefined && section.backend !== null) { + // keyed is web-search-only. Vision still only supports openai/anthropic. + const allowed = + section.backend === "openai" + || section.backend === "anthropic" + || (field === "webSearchSidecar" && section.backend === "keyed"); + if (!allowed) { + return jsonResponse({ + error: field === "webSearchSidecar" + ? "webSearchSidecar.backend must be openai, anthropic, keyed, or null" + : "visionSidecar.backend must be openai, anthropic, or null", + }, 400); + } + } + if (section.provider !== undefined && section.provider !== null) { + // provider is a keyed-web-search field only; vision has no provider override. + if (field !== "webSearchSidecar") { + return jsonResponse({ error: "visionSidecar.provider is not supported" }, 400); + } + if (typeof section.provider !== "string" || section.provider.trim() === "") { + return jsonResponse({ error: "webSearchSidecar.provider must be a nonblank provider name" }, 400); + } } if (section.model !== undefined && typeof section.model !== "string") { return jsonResponse({ error: `${field}.model must be a string` }, 400); @@ -1080,10 +1099,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise delete next[field]; continue; } - const requested = section as { backend?: "openai" | "anthropic" | null; model?: string }; - const override: NonNullable = { ...next[field] }; + const requested = section as { + backend?: "openai" | "anthropic" | "keyed" | null; + model?: string; + provider?: string | null; + }; + const override: Record = { ...next[field] }; if (requested.backend === null) delete override.backend; else if (requested.backend !== undefined) override.backend = requested.backend; + // provider only exists on webSearchSidecar (keyed backend). Never persist it for vision. + if (field === "webSearchSidecar") { + if (requested.provider === null || requested.provider === "") delete override.provider; + else if (requested.provider !== undefined) override.provider = requested.provider; + } if (requested.model === "") delete override.model; else if (requested.model !== undefined) override.model = requested.model; if (Object.keys(override).length > 0) next[field] = override; diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 82ab4c96cb..19ed3729c1 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -438,6 +438,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise` — it must NEVER touch the ChatGPT forward headers + * or the Anthropic stored-OAuth credential, so neither ChatGPT nor Anthropic quota is consumed. + * Reuses the hosted web_search tool verbatim and the shared SSE parser. Never throws. + */ +export async function runKeyedWebSearch( + query: string, + hostedTool: Record, + sidecar: KeyedWebSearchRequest, + settings: SidecarSettings, + abortSignal?: AbortSignal, +): Promise { + // Headers() normalizes casing so provider headers cannot create a second + // Authorization/Content-Type entry that would race with the required ones. + const headers = new Headers(sidecar.provider.headers); + headers.set("Content-Type", "application/json"); + headers.set("Authorization", `Bearer ${sidecar.apiKey}`); + const body = { + model: settings.model, + instructions: settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: query }] }], + tools: [hostedTool], + tool_choice: "auto", + reasoning: { effort: settings.reasoning }, + store: false, + stream: true, + }; + const baseUrl = sidecar.provider.baseUrl.replace(/\/+$/, ""); + const responsesPath = sidecar.provider.responsesPath ?? "/responses"; + const url = `${baseUrl}${responsesPath}`; + const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); + const sidecarExit = sidecarEnter("web-search"); + const t0 = Date.now(); + try { + const res = await fetchWithResetRetry( + () => fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: linkedSignal.signal, + // Credential-bearing: never follow a 3xx that could leak the API key to the redirect target. + redirect: "manual", + }), + { abortSignal: linkedSignal.signal, label: "web-search-sidecar-keyed" }, + ); + const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); + if (!res.ok) { + const t = await res.text().catch(() => ""); + detachBodyGuard(); + console.warn(`[web-search] keyed sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); + return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; + } + try { + return await parseSidecarSSE(res); + } finally { + detachBodyGuard(); + } + } catch (e) { + const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + console.warn(`[web-search] keyed sidecar ${kind} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); + return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) }; + } finally { + sidecarExit(); + linkedSignal.cleanup(); + } +} diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 6278b8ca05..febe7a1ff7 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -4,12 +4,16 @@ import { isModelTextOnly } from "../vision"; import type { SidecarSettings } from "./executor"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import { getAccountSet } from "../oauth/store"; +import { resolveEnvValue } from "../config"; +import { getProviderRegistryEntry, providerHostsHostedWebSearchResponses, providerModelWireDefault } from "../providers/registry"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED } from "../types"; import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout"; import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; export { runWithWebSearch } from "./loop"; export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME }; export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor"; +export { runKeyedWebSearch } from "./executor"; const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna"; // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset). @@ -102,14 +106,64 @@ export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSideca * to the Anthropic API. */ export function resolveSidecarBackend( - explicit: "openai" | "anthropic" | undefined, -): "openai" | "anthropic" { + explicit: "openai" | "anthropic" | "keyed" | undefined, +): WebSearchSidecarBackend { + if (explicit === "keyed") return "keyed"; return explicit === "anthropic" ? "anthropic" : "openai"; } +export type WebSearchSidecarBackend = "openai" | "anthropic" | "keyed"; + +/** + * A resolved key-auth web-search sidecar. Fail-closed eligibility: the provider must be enabled, + * key-fed with a resolvable key, use an openai-responses wire for the chosen model, and declare + * hosted web_search support for that model in the registry. Absent any check, the sidecar would + * send the provider API key to an endpoint that cannot answer. + */ +export interface KeyedWebSearchSidecar { + providerName: string; + provider: OcxProviderConfig; + apiKey: string; + model: string; +} + +const KEYED_MODEL_ADAPTER = "openai-responses" as const; + +/** + * Resolve the keyed web-search sidecar from config, or undefined when any requirement fails. + * Selection must fail closed: an ineligible provider (disabled, missing capability flag, unresolvable + * key, or a model that does not ride the Responses wire for it) yields NO plan rather than borrowing + * ChatGPT/Anthropic credentials. + */ +export function resolveKeyedWebSearchSidecar(config: OcxConfig): KeyedWebSearchSidecar | undefined { + const cfg = config.webSearchSidecar ?? {}; + const providerName = typeof cfg.provider === "string" && cfg.provider.trim() !== "" ? cfg.provider.trim() : ""; + if (!providerName) return undefined; + const provider = config.providers[providerName]; + if (!provider || provider.disabled === true) return undefined; + const entry = getProviderRegistryEntry(providerName); + const model = typeof cfg.model === "string" && cfg.model.trim() !== "" ? cfg.model.trim() : (entry?.defaultModel ?? ""); + if (model === "") return undefined; + if (!providerHostsHostedWebSearchResponses(providerName, provider, model)) return undefined; + const wire = providerModelWireDefault(providerName, provider, model, MODEL_ADAPTER_OVERRIDE_ALLOWED, "responses"); + if (wire !== KEYED_MODEL_ADAPTER && provider.adapter !== KEYED_MODEL_ADAPTER) return undefined; + const apiKey = resolveEnvValue(provider.apiKey)?.trim(); + if (!apiKey) return undefined; + // Fail closed: never attach a Bearer key to a non-HTTPS base URL. + try { + const base = new URL(provider.baseUrl); + if (base.protocol !== "https:") return undefined; + } catch { + return undefined; + } + return { providerName, provider, apiKey, model }; +} + export interface SidecarPlan { /** Which executor runs the search. Anthropic does not require a forward provider. */ - backend: "openai" | "anthropic"; + backend: WebSearchSidecarBackend; + /** Present for the keyed backend (key-auth openai-responses provider); undefined otherwise. */ + keyedSidecar?: KeyedWebSearchSidecar; /** Present for the openai backend (ChatGPT forward path); undefined for anthropic. */ forwardSidecar?: ResolvedOpenAiForwardSidecar; /** Present for the anthropic backend (stored-OAuth /v1/messages path); undefined for openai. */ @@ -158,7 +212,8 @@ export function planWebSearch( // Same `?? 200_000` default the server applies when threading connectTimeoutMs into the loop. const connectTimeoutMs = config.connectTimeoutMs ?? 200_000; const anthropicSidecar = findAnthropicSidecarProvider(config); - const backend = resolveSidecarBackend(cfg.backend); + const keyedSidecar = resolveKeyedWebSearchSidecar(config); + const backend = cfg.backend === "keyed" ? ("keyed" as const) : resolveSidecarBackend(cfg.backend); const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES; const stallTimeoutSec = webSearchStallTimeoutSec( config.stallTimeoutSec, @@ -190,6 +245,24 @@ export function planWebSearch( } // OpenAI backend: needs a ChatGPT login (main) and a forward provider to reach server-side web_search. + + // Keyed backend: authenticates with the provider's own API key over the Responses wire — no ChatGPT + // forward headers and no Anthropic stored-OAuth credential. Fails closed (no plan) when the provider is + // not eligible, so a misconfiguration never borrows ChatGPT/Anthropic quota. + if (backend === "keyed") { + if (!keyedSidecar) return undefined; + return { + backend: "keyed", + keyedSidecar, + hostedTool: parsed._webSearch, + settings: { model: keyedSidecar.model, reasoning, timeoutMs, describeImages }, + maxSearches, + routedModelStallTimeoutMs, + stallTimeoutSec, + streamRoutedModelOutput, + }; + } + if (!openAiSidecar) return undefined; return { backend: "openai", diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index e6129bfc2c..995aad0356 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -5,6 +5,7 @@ import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaqu import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; +import { runKeyedWebSearch, type KeyedWebSearchRequest } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; import { clearableDeadline } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; @@ -251,11 +252,13 @@ export interface WebSearchLoopDeps { adapter: ProviderAdapter; incomingMeta: IncomingMeta; /** Which executor runs searches. Defaults to "openai" so existing callers keep the ChatGPT path (audit F4). */ - backend?: "openai" | "anthropic"; + backend?: "openai" | "anthropic" | "keyed"; /** Required for the openai backend; unused (and typically undefined) for the anthropic backend. */ forwardProvider?: OcxProviderConfig; /** Required for the anthropic backend: the stored-OAuth provider that runs web_search_20250305. */ anthropicSidecar?: { providerName: string; provider: OcxProviderConfig }; + /** Required for the keyed backend: the resolved key-fed openai-responses sidecar. */ + keyedSidecar?: KeyedWebSearchRequest; hostedTool: Record; selectedForwardHeaders: Headers; settings: SidecarSettings; @@ -305,8 +308,9 @@ export interface WebSearchLoopDeps { export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { const translatorBudget = deps.incomingMeta.translatorBudget; const { parsed, selectedForwardHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal, recordSidecarOutcome } = deps; - const backend = deps.backend ?? "openai"; - const anthropicSidecar = deps.anthropicSidecar; + const backend = deps.backend ?? "openai"; + const anthropicSidecar = deps.anthropicSidecar; + const keyedSidecar = deps.keyedSidecar; // Mutable: 429 key-failover (deps.on429) can swap in a rebuilt adapter mid-loop. let adapter = deps.adapter; @@ -655,11 +659,13 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { + const server = startServer(0); + const put = (body: unknown) => fetch(new URL("/api/claude-code", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + try { + const response = await put({ + webSearchSidecar: { + backend: "keyed", + provider: "opencode-go", + model: "deepseek-v4-flash", + }, + }); + expect(response.status).toBe(200); + expect(loadConfig().claudeCode?.webSearchSidecar).toEqual({ + backend: "keyed", + provider: "opencode-go", + model: "deepseek-v4-flash", + }); + + const get = await fetch(new URL("/api/claude-code", server.url)).then(r => r.json()) as Record; + expect(get.webSearchSidecar).toEqual({ + backend: "keyed", + provider: "opencode-go", + model: "deepseek-v4-flash", + }); + } finally { + await server.stop(true); + } +}); + +test("Claude visionSidecar rejects keyed backend and provider", async () => { + const server = startServer(0); + const put = (body: unknown) => fetch(new URL("/api/claude-code", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + try { + // Seed a valid vision override so a rejected PUT leaves it untouched. + const seed = await put({ visionSidecar: { backend: "openai", model: "gpt-vision" } }); + expect(seed.status).toBe(200); + const before = loadConfig().claudeCode; + + for (const body of [ + { visionSidecar: { backend: "keyed" } }, + { visionSidecar: { provider: "opencode-go" } }, + ]) { + const response = await put(body); + expect(response.status).toBe(400); + expect(loadConfig().claudeCode).toEqual(before); + } + } finally { + await server.stop(true); + } +}); + test("PUT immediately restores generated agents after re-enable and roster changes", async () => { const server = startServer(0); const agentsDir = join(process.env.CLAUDE_CONFIG_DIR!, "agents"); diff --git a/tests/sidecar-settings-web-search-stream.test.ts b/tests/sidecar-settings-web-search-stream.test.ts index 19847d848a..d0c40acce7 100644 --- a/tests/sidecar-settings-web-search-stream.test.ts +++ b/tests/sidecar-settings-web-search-stream.test.ts @@ -104,4 +104,33 @@ describe("sidecar-settings webSearch.streamRoutedModelOutput", () => { expect(response.status).toBe(200); expect(config.webSearchSidecar?.streamRoutedModelOutput).toBe(true); }); + + test("PUT accepts keyed backend and provider and GET round-trips them", async () => { + const config = emptyConfig(); + const put = await putSidecarSettings(config, { + backend: "keyed", + provider: "opencode-go", + model: "deepseek-v4-flash", + }); + expect(put.status).toBe(200); + expect(config.webSearchSidecar).toMatchObject({ + backend: "keyed", + provider: "opencode-go", + model: "deepseek-v4-flash", + }); + expect(loadConfig().webSearchSidecar).toMatchObject({ + backend: "keyed", + provider: "opencode-go", + model: "deepseek-v4-flash", + }); + + const get = await getSidecarSettings(config); + expect(get.status).toBe(200); + const body = (await get.json()) as { + webSearch: { backend?: string; provider?: string; model?: string }; + }; + expect(body.webSearch.backend).toBe("keyed"); + expect(body.webSearch.provider).toBe("opencode-go"); + expect(body.webSearch.model).toBe("deepseek-v4-flash"); + }); }); diff --git a/tests/web-search-keyed.test.ts b/tests/web-search-keyed.test.ts new file mode 100644 index 0000000000..0fb426502c --- /dev/null +++ b/tests/web-search-keyed.test.ts @@ -0,0 +1,397 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { parseRequest } from "../src/responses/parser"; +import { + planWebSearch, + resolveKeyedWebSearchSidecar, + resolveSidecarBackend, +} from "../src/web-search"; +import { runKeyedWebSearch } from "../src/web-search/executor"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const routed: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://routed.test/v1", + apiKey: "routed-key", +}; + +/** Use the registry golden row (key-auth Zen Go gateway). */ +function zenProvider( + overrides: Partial = {}, +): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + apiKey: "zen-key", + ...overrides, + }; +} + +function configFor( + webSearchSidecar: Record, + provider = zenProvider(), +): OcxConfig { + return { + port: 10100, + defaultProvider: "opencode-go", + providers: { "opencode-go": provider }, + webSearchSidecar, + } as OcxConfig; +} + +function parsedWithWebSearch() { + return parseRequest({ + model: "opencode-go/deepseek-v4-flash", + input: "Search current docs", + stream: true, + tools: [{ type: "web_search" }], + }); +} + +describe("keyed web-search sidecar eligibility", () => { + test("resolveSidecarBackend widens to keyed", () => { + expect(resolveSidecarBackend("keyed")).toBe("keyed"); + expect(resolveSidecarBackend("openai")).toBe("openai"); + expect(resolveSidecarBackend("anthropic")).toBe("anthropic"); + expect(resolveSidecarBackend(undefined)).toBe("openai"); + }); + + test("deepseek-v4-flash on opencode-go resolves to a keyed sidecar", () => { + const cfg = configFor({ + backend: "keyed", + provider: "opencode-go", + model: "deepseek-v4-flash", + }); + const sidecar = resolveKeyedWebSearchSidecar(cfg); + expect(sidecar?.providerName).toBe("opencode-go"); + expect(sidecar?.model).toBe("deepseek-v4-flash"); + expect(sidecar?.apiKey).toBe("zen-key"); + }); + + test("planWebSearch returns a keyed plan that never selects ChatGPT/Anthropic credentials", () => { + const cfg = configFor({ + backend: "keyed", + provider: "opencode-go", + model: "deepseek-v4-flash", + }); + const plan = planWebSearch( + cfg, + parsedWithWebSearch(), + false, + routed, + "model", + ); + expect(plan?.backend).toBe("keyed"); + expect(plan?.keyedSidecar?.apiKey).toBe("zen-key"); + expect(plan?.forwardSidecar).toBeUndefined(); + expect(plan?.anthropicSidecar).toBeUndefined(); + }); + + test("fails closed when the provider is disabled", () => { + expect( + resolveKeyedWebSearchSidecar( + configFor( + { backend: "keyed", provider: "opencode-go" }, + zenProvider({ disabled: true }), + ), + ), + ).toBeUndefined(); + }); + + test("fails closed when the API key is missing", () => { + expect( + resolveKeyedWebSearchSidecar( + configFor( + { backend: "keyed", provider: "opencode-go" }, + zenProvider({ apiKey: "" }), + ), + ), + ).toBeUndefined(); + }); + + test("fails closed when the model is not declared to host hosted web_search", () => { + expect( + resolveKeyedWebSearchSidecar( + configFor({ + backend: "keyed", + provider: "opencode-go", + model: "kimi-k3", + }), + ), + ).toBeUndefined(); + }); + + test("fails closed when the provider is unknown", () => { + expect( + resolveKeyedWebSearchSidecar( + configFor({ + backend: "keyed", + provider: "nope", + model: "deepseek-v4-flash", + }), + ), + ).toBeUndefined(); + }); + + test("fails closed for an explicit keyed backend with no provider name", () => { + expect( + planWebSearch( + configFor({ backend: "keyed" }), + parsedWithWebSearch(), + false, + routed, + "model", + ), + ).toBeUndefined(); + }); + + test("fails closed when the provider baseUrl is not HTTPS", () => { + expect( + resolveKeyedWebSearchSidecar( + configFor( + { backend: "keyed", provider: "opencode-go", model: "deepseek-v4-flash" }, + zenProvider({ baseUrl: "http://opencode.ai/zen/go/v1" }), + ), + ), + ).toBeUndefined(); + }); + + test("fails closed when the provider baseUrl is not a valid URL", () => { + expect( + resolveKeyedWebSearchSidecar( + configFor( + { backend: "keyed", provider: "opencode-go", model: "deepseek-v4-flash" }, + zenProvider({ baseUrl: "not-a-url" }), + ), + ), + ).toBeUndefined(); + }); +}); + +describe("runKeyedWebSearch request shape", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("POSTs baseUrl/responses with Bearer api key, hosted tool, and manual redirect", async () => { + let captured: { + url: string; + headers: Record; + body: Record; + redirect?: string; + } | null = null; + globalThis.fetch = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + const headers: Record = {}; + new Headers(init?.headers).forEach((v, k) => { + headers[k] = v; + }); + captured = { + url: String(url), + headers, + body: JSON.parse(String(init?.body)), + redirect: (init as { redirect?: string })?.redirect, + }; + const body = [ + "event: response.output_text.done\ndata: " + + JSON.stringify({ + type: "response.output_text.done", + text: "zen answer", + }) + + "\n\n", + "event: response.completed\ndata: " + + JSON.stringify({ + type: "response.completed", + response: { + output: [ + { + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: "zen answer", + annotations: [ + { + type: "url_citation", + url: "https://example.com", + title: "Ex", + }, + ], + }, + ], + }, + ], + }, + }) + + "\n\n", + ].join(""); + return new Response( + new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode(body)); + c.close(); + }, + }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + const out = await runKeyedWebSearch( + "latest bun release", + { type: "web_search", max_results: 5 } as unknown as Record< + string, + unknown + >, + { + providerName: "opencode-go", + provider: zenProvider(), + apiKey: "zen-key", + }, + { + model: "deepseek-v4-flash", + reasoning: "low", + timeoutMs: 5000, + describeImages: false, + }, + ); + expect(out.error).toBeUndefined(); + expect(out.text).toBe("zen answer"); + expect(out.sources).toEqual([{ url: "https://example.com", title: "Ex" }]); + + const c = captured!; + expect(c.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(c.headers["authorization"]).toBe("Bearer zen-key"); + expect(c.headers["content-type"]).toBe("application/json"); + expect(c.redirect).toBe("manual"); + expect(c.body.model).toBe("deepseek-v4-flash"); + expect(c.body.tools).toEqual([{ type: "web_search", max_results: 5 }]); + }); + + test("honors provider.responsesPath when configured", async () => { + let capturedUrl: string | null = null; + globalThis.fetch = (async (url: string | URL | Request) => { + capturedUrl = String(url); + const body = [ + "event: response.output_text.done\ndata: " + + JSON.stringify({ type: "response.output_text.done", text: "ok" }) + + "\n\n", + "event: response.completed\ndata: " + + JSON.stringify({ + type: "response.completed", + response: { + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "ok", annotations: [] }], + }, + ], + }, + }) + + "\n\n", + ].join(""); + return new Response( + new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode(body)); + c.close(); + }, + }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + const out = await runKeyedWebSearch( + "latest bun release", + { type: "web_search" } as unknown as Record, + { + providerName: "custom", + provider: zenProvider({ + baseUrl: "https://search.example/v1", + responsesPath: "/custom-responses", + }), + apiKey: "custom-key", + }, + { + model: "deepseek-v4-flash", + reasoning: "low", + timeoutMs: 5000, + describeImages: false, + }, + ); + expect(out.error).toBeUndefined(); + expect(capturedUrl).toBe("https://search.example/v1/custom-responses"); + }); + + test("normalizes case-variant content-type and keeps a single Authorization", async () => { + let captured: { headers: Record } | null = null; + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + const headers: Record = {}; + new Headers(init?.headers).forEach((v, k) => { + headers[k] = v; + }); + captured = { headers }; + const body = [ + "event: response.output_text.done\ndata: " + + JSON.stringify({ type: "response.output_text.done", text: "ok" }) + + "\n\n", + "event: response.completed\ndata: " + + JSON.stringify({ + type: "response.completed", + response: { + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "ok", annotations: [] }], + }, + ], + }, + }) + + "\n\n", + ].join(""); + return new Response( + new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode(body)); + c.close(); + }, + }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + const out = await runKeyedWebSearch( + "latest bun release", + { type: "web_search" } as unknown as Record, + { + providerName: "opencode-go", + provider: zenProvider({ + headers: { + "content-type": "text/plain", + Authorization: "Bearer stale-key", + }, + }), + apiKey: "zen-key", + }, + { + model: "deepseek-v4-flash", + reasoning: "low", + timeoutMs: 5000, + describeImages: false, + }, + ); + expect(out.error).toBeUndefined(); + const c = captured!; + // Headers() + set() wins over the provider's case-variant entries. + expect(c.headers["content-type"]).toBe("application/json"); + expect(c.headers["authorization"]).toBe("Bearer zen-key"); + // No second authorization key left behind by Object.assign-style merges. + expect(Object.keys(c.headers).filter((k) => k.toLowerCase() === "authorization")).toEqual([ + "authorization", + ]); + }); +});