Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions docs-site/src/content/docs/guides/sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/cli/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const CLAUDE_USAGE = `Usage:
[--system-env <on|off>] [--fast-mode <on|off>] [--auto-context <on|off>]
[--compact-window <tokens|default>] [--inject-agents <on|off>]
[--small-fast-model <id|->] [--model-map <from=to,from=to|->]
[--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|->]
[--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|keyed|->]
[--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]`;

const GROK_USAGE = `Usage:
Expand Down
27 changes: 26 additions & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export interface ProviderRegistryEntry {
modelResponsesUpstreamStreaming?: Record<string, boolean>;
/** Registry-only repair for a model whose native Responses stream may omit its terminal. */
modelResponsesTerminalRepair?: Record<string, ResponsesTerminalRepairPolicy>;
/** Registry-only capability: model ids that natively serve hosted web_search over the Responses wire. */
hostedWebSearchResponsesModels?: Record<string, boolean>;
/**
* Registry-only client-facing item-id repair policy (#938), filled onto the
* runtime provider only when the user has no explicit policy (derive.ts);
Expand Down Expand Up @@ -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.
Expand All @@ -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: {
Expand Down Expand Up @@ -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<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
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
Expand Down
42 changes: 35 additions & 7 deletions src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down Expand Up @@ -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);
Expand All @@ -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<OcxClaudeCodeConfig[typeof field]> = { ...next[field] };
const requested = section as {
backend?: "openai" | "anthropic" | "keyed" | null;
model?: string;
provider?: string | null;
};
const override: Record<string, unknown> = { ...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;
Expand Down
24 changes: 20 additions & 4 deletions src/server/management/config-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
webSearch: {
model: ws.model ?? "gpt-5.6-luna",
backend: ws.backend,
provider: ws.provider,
streamRoutedModelOutput: ws.streamRoutedModelOutput === true,
},
vision: publicVisionSidecarSettings(config, vision),
Expand All @@ -454,7 +455,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
if (raw.webSearch !== undefined && !isPlainRecord(raw.webSearch)) return jsonResponse({ error: "webSearch must be an object" }, 400);
if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400);
const body = raw as {
webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown; streamRoutedModelOutput?: unknown };
webSearch?: { model?: unknown; backend?: unknown; provider?: unknown; reasoning?: unknown; streamRoutedModelOutput?: unknown };
vision?: {
model?: unknown;
backend?: unknown;
Expand All @@ -465,8 +466,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
};
};
if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null
&& body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") {
return jsonResponse({ error: "webSearch.backend must be openai, anthropic, or null" }, 400);
&& body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic"
&& body.webSearch.backend !== "keyed") {
return jsonResponse({ error: "webSearch.backend must be openai, anthropic, keyed, or null" }, 400);
}
if (body.webSearch && body.webSearch.provider !== undefined && body.webSearch.provider !== null
&& (typeof body.webSearch.provider !== "string" || body.webSearch.provider.trim() === "")) {
return jsonResponse({ error: "webSearch.provider must be a nonblank provider name" }, 400);
}
if (body.webSearch && body.webSearch.streamRoutedModelOutput !== undefined
&& typeof body.webSearch.streamRoutedModelOutput !== "boolean") {
Expand Down Expand Up @@ -529,9 +535,18 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
else config.webSearchSidecar.model = body.webSearch.model;
}
if (body.webSearch.backend === null) delete config.webSearchSidecar.backend;
else if (body.webSearch.backend === "openai" || body.webSearch.backend === "anthropic") {
else if (
body.webSearch.backend === "openai"
|| body.webSearch.backend === "anthropic"
|| body.webSearch.backend === "keyed"
) {
config.webSearchSidecar.backend = body.webSearch.backend;
}
if (body.webSearch.provider === null || body.webSearch.provider === "") {
delete config.webSearchSidecar.provider;
} else if (typeof body.webSearch.provider === "string") {
config.webSearchSidecar.provider = body.webSearch.provider;
}
if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
if (typeof body.webSearch.streamRoutedModelOutput === "boolean") {
// `false` is the default — drop the key so config files stay minimal.
Expand Down Expand Up @@ -573,6 +588,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
webSearch: {
model: ws.model ?? "gpt-5.6-luna",
backend: ws.backend,
provider: ws.provider,
streamRoutedModelOutput: ws.streamRoutedModelOutput === true,
},
vision: publicVisionSidecarSettings(config, vision),
Expand Down
7 changes: 4 additions & 3 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3261,9 +3261,10 @@ async function handleResponsesInner(
const wsResponse = await runWithWebSearch({
parsed, adapter,
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
backend: wsPlan.backend,
forwardProvider: wsPlan.forwardSidecar?.provider,
anthropicSidecar: wsPlan.anthropicSidecar,
backend: wsPlan.backend,
forwardProvider: wsPlan.forwardSidecar?.provider,
anthropicSidecar: wsPlan.anthropicSidecar,
keyedSidecar: wsPlan.keyedSidecar,
hostedTool: wsPlan.hostedTool,
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
settings: wsPlan.settings,
Expand Down
21 changes: 13 additions & 8 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,8 @@ export interface OcxClaudeCodeConfig {
* definition. Unset inherits the parent session effort.
*/
subagentEffort?: "low" | "medium" | "high" | "xhigh" | "max";
/** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */
webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string };
/** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */
webSearchSidecar?: { backend?: "openai" | "anthropic" | "keyed"; model?: string; provider?: string };
/** Claude-originated vision override. Unset fields inherit the global sidecar settings. */
visionSidecar?: { backend?: "openai" | "anthropic"; model?: string };
/** Persisted Claude Desktop four-family routing profile. */
Expand Down Expand Up @@ -1211,14 +1211,19 @@ export interface OcxVisionSidecarConfig {
export interface OcxWebSearchSidecarConfig {
/** Master switch. Default: enabled when a forward (ChatGPT) provider exists and the caller is logged in. */
enabled?: boolean;
/**
* Which backend actually runs the server-side search. "openai" replays the hosted web_search via
* the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude
* model authenticated by the STORED anthropic OAuth credential. Unset resolves to "anthropic" when a
* usable anthropic OAuth credential exists, else "openai".
*/
backend?: "openai" | "anthropic" | "keyed";
/**
* Which backend actually runs the server-side search. "openai" replays the hosted web_search via
* the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude
* model authenticated by the STORED anthropic OAuth credential. Unset resolves to "anthropic" when a
* usable anthropic OAuth credential exists, else "openai".
* Provider name backing the `keyed` backend. Must name an enabled key-auth provider that uses an
* openai-responses wire and declares hosted web_search support via its registry capability flag.
*/
backend?: "openai" | "anthropic";
/** Sidecar model that runs the real server-side web_search (must be a native ChatGPT model). */
provider?: string;
/** Sidecar model that runs the real server-side web_search (must be a native ChatGPT model). */
model?: string;
/** Reasoning effort for the sidecar — "minimal" (non-thinking) keeps it fast/cheap. */
reasoning?: string;
Expand Down
Loading
Loading