Skip to content

Commit bd79ab5

Browse files
authored
feat(web-search): backend registry and executor-runnable candidate set (#2188 L3) (#2206)
* feat(web-search): backend registry and candidate intersection (#2188) WEB_SEARCH_BACKENDS registers only executor-backed, auth-probed backends (openai forward, anthropic stored-OAuth); future backends stay out until a live probe and executor land. webSearchSidecarCandidates = (picker ∪ auth slots) ∩ active backend family. planWebSearch consumes the shared auth state for presence; resolveSidecarBackend's explicit-or-openai preference is unchanged, and the config comment that claimed the opposite now tells the truth. * fix(web-search): restrict the openai backend to executor-runnable ids Account-bound selector/slug rows and custom openai-keyed rows reach the candidate set as provider openai, but the forward executor POSTs settings.model verbatim with no routeModel prefix-stripping, so those ids can never run. eligibleModel now requires a bare native slug or the auth slot. docs-site stops claiming anthropic-preferred for web-search (that default belongs to vision only). * docs(vision): describe the vision backend default on its own terms
1 parent a141859 commit bd79ab5

7 files changed

Lines changed: 215 additions & 11 deletions

File tree

‎docs-site/src/content/docs/guides/claude-code.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,9 @@ Both sidecars can use either backend:
332332
| `openai` | A small GPT model through the ChatGPT `forward` provider | A ChatGPT login and an enabled `authMode: "forward"` provider |
333333
| `anthropic` | Claude through stored Anthropic OAuth; web search uses `web_search_20250305` and vision sends the image to Claude for description | An enabled `adapter: "anthropic"`, `authMode: "oauth"` provider whose active stored account is not marked `needsReauth` |
334334

335-
An explicit `backend` always wins. When it is omitted, opencodex selects `anthropic` if a usable
336-
stored Anthropic OAuth account exists; otherwise it selects `openai`. Explicitly selecting
335+
An explicit `backend` always wins. When it is omitted, the **web-search** sidecar always selects
336+
`openai` (`anthropic` runs only when explicitly configured), while the **vision** sidecar selects
337+
`anthropic` if a usable stored Anthropic OAuth account exists, otherwise `openai`. Explicitly selecting
337338
`anthropic` without a usable credential **fails closed**: opencodex does not silently borrow
338339
ChatGPT credentials or switch backends. The OpenAI backend likewise stays off without both login
339340
auth and a forward provider.

‎docs-site/src/content/docs/guides/sidecars.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ stored Anthropic OAuth provider. Sidecar errors become bounded tool results or i
99
of failing the whole turn.
1010

1111
:::note[Automatic backend selection]
12-
Explicit `backend` config wins. When unset, opencodex uses `anthropic` if an enabled Anthropic OAuth
13-
provider has an active account not marked `needsReauth`; otherwise it uses `openai`. Explicit
14-
`anthropic` without that credential fails closed. `openai` requires both ChatGPT login auth and an
15-
enabled `forward` provider.
12+
Explicit `backend` config wins. The two sidecars default differently when `backend` is unset:
13+
**web search** always defaults to `openai` — `anthropic` runs only when explicitly configured.
14+
**Vision** defaults to `anthropic` if an enabled Anthropic OAuth provider has an active account not
15+
marked `needsReauth`, otherwise `openai`. Explicit `anthropic` without that credential fails
16+
closed. `openai` requires both ChatGPT login auth and an enabled `forward` provider.
1617
:::
1718

1819
## Web-search sidecar

‎docs-site/src/content/docs/reference/configuration/server.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ Images API paths and response shape expected by Codex.
205205
| Field | Type | Default | Meaning |
206206
| --- | --- | --- | --- |
207207
| `enabled?` | `boolean` | on when usable | Master switch. |
208-
| `backend?` | `"openai" \| "anthropic"` | auto | Explicit wins; otherwise usable stored Anthropic OAuth selects `anthropic`, then `openai`. |
208+
| `backend?` | `"openai" \| "anthropic"` | `openai` | Explicit wins; unset always resolves to `openai`. `anthropic` runs only when explicitly configured. |
209209
| `model?` | `string` | backend-dependent | `gpt-5.6-luna` for OpenAI or `claude-sonnet-5` for Anthropic. Legacy explicit `gpt-5.4-mini` migrates on start. |
210210
| `reasoning?` | `string` | `low` | Sidecar effort. `minimal` is rejected with web search. |
211211
| `maxSearchesPerTurn?` | `number` | `3` | Real searches allowed per main-model turn. |
@@ -227,7 +227,7 @@ an inactivity guard, not a total generation deadline.
227227
| Field | Type | Default | Meaning |
228228
| --- | --- | --- | --- |
229229
| `enabled?` | `boolean` | on when usable | Master image-description switch. |
230-
| `backend?` | `"openai" \| "anthropic"` | auto | Same explicit-first, Anthropic-credential-aware selection as web search. |
230+
| `backend?` | `"openai" \| "anthropic"` | auto | Explicit wins; unset prefers a usable stored Anthropic OAuth credential, else `openai`. |
231231
| `model?` | `string` | backend-dependent | `gpt-5.4-mini` for OpenAI or `claude-sonnet-5` for Anthropic. |
232232
| `maxDescriptionsPerTurn?` | `number` | `8` | New description cache misses admitted per main turn. `0` disables calls; invalid values use default. |
233233
| `timeoutMs?` | `number` | `45000` | Sidecar fetch timeout. Integer 1–2147483647. |

‎src/types/config.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,8 +790,9 @@ export interface OcxWebSearchSidecarConfig {
790790
/**
791791
* Which backend actually runs the server-side search. "openai" replays the hosted web_search via
792792
* the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude
793-
* model authenticated by the STORED anthropic OAuth credential. Unset resolves to "anthropic" when a
794-
* usable anthropic OAuth credential exists, else "openai".
793+
* model authenticated by the STORED anthropic OAuth credential. Unset resolves to "openai";
794+
* "anthropic" runs only when explicitly configured (auto-selecting it from credential availability
795+
* once sent incompatible models to the Anthropic API — see resolveSidecarBackend).
795796
*/
796797
backend?: "openai" | "anthropic";
797798
/** Sidecar model that runs the real server-side web_search (must be a native ChatGPT model). */

‎src/web-search/backends.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Which backends may RUN a hosted web_search, and which candidate rows each
3+
* can run (#2188 rule 2 for web-search: ∩ probed backend with an executor).
4+
*
5+
* A backend registers here only when BOTH hold:
6+
* - an executor exists in this repository (src/web-search/executor.ts or
7+
* anthropic-executor.ts today), and
8+
* - its liveness probe passes. For the two shipped backends the probe IS
9+
* auth presence — the ChatGPT forward path and the stored Anthropic OAuth
10+
* path fail closed without a credential, so a live credential is the
11+
* strongest pre-flight signal short of spending a search.
12+
*
13+
* Future backends (Gemini google_search, Grok web_search, Zen hosted search,
14+
* Exa-class vendors) stay OUT of this table until a live probe and an
15+
* executor land — the research and probe contracts are recorded in
16+
* devlog/_plan/260820_sidecar_selection_unification/002 and 031. Documenting
17+
* a tool is not the same as being able to run it.
18+
*/
19+
import type { OcxConfig } from "../types";
20+
import { AUTH_SLOT_MODELS, type SidecarAuthState } from "../sidecar/auth";
21+
import type { SidecarCandidate } from "../sidecar/candidates";
22+
23+
export interface WebSearchBackendDescriptor {
24+
backend: "openai" | "anthropic";
25+
/** Liveness signal for this backend (auth presence for the shipped two). */
26+
isActive(auth: SidecarAuthState): boolean;
27+
/** Which candidate rows this backend's executor can actually run. */
28+
eligibleModel(candidate: SidecarCandidate, auth: SidecarAuthState): boolean;
29+
}
30+
31+
export const WEB_SEARCH_BACKENDS: readonly WebSearchBackendDescriptor[] = [
32+
{
33+
backend: "openai",
34+
isActive: auth => auth.isCodexAuth,
35+
// The ChatGPT forward executor runs BARE native slugs and the Codex auth
36+
// slot — settings.model is POSTed verbatim to the forward /responses, so
37+
// an account-bound "selector/slug" row (model-rows emits those as
38+
// provider "openai", native true) or a custom openai-keyed row would
39+
// persist an id the executor cannot run (review F1). The sidecar never
40+
// calls routeModel; there is no prefix-stripping on this path.
41+
eligibleModel: candidate => candidate.provider === "openai"
42+
&& (candidate.native === true || candidate.authSlot === true)
43+
&& !candidate.id.includes("/"),
44+
},
45+
{
46+
backend: "anthropic",
47+
isActive: auth => auth.isAnthropicAuth,
48+
// The stored-OAuth Messages executor dispatches through exactly ONE
49+
// provider — the one the shared auth module resolved. Same-adapter keyed
50+
// rows are unreachable, mirroring visionBackendForCandidate's stance.
51+
eligibleModel: (candidate, auth) => candidate.provider === auth.anthropicProviderName,
52+
},
53+
];
54+
55+
/**
56+
* (picker-visible ∪ auth slots) ∩ (active backend able to run the row).
57+
* The auth slots always survive their own side's activation: a logged-in
58+
* side keeps Luna/Haiku even when the picker hides them.
59+
*/
60+
export function webSearchSidecarCandidates(
61+
_config: OcxConfig,
62+
auth: SidecarAuthState,
63+
all: readonly SidecarCandidate[],
64+
): SidecarCandidate[] {
65+
const active = WEB_SEARCH_BACKENDS.filter(descriptor => descriptor.isActive(auth));
66+
return all.filter(candidate => active.some(descriptor => descriptor.eligibleModel(candidate, auth)));
67+
}
68+
69+
/** True when the id is one of the fixed auth-slot models (#2188 write-gate exception). */
70+
export function isWebSearchAuthSlotModel(id: string): boolean {
71+
return id === AUTH_SLOT_MODELS.codex || id === AUTH_SLOT_MODELS.anthropic;
72+
}

‎src/web-search/index.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,12 @@ export function planWebSearch(
154154
const routedModelStallTimeoutMs = resolveRoutedModelStallTimeoutMs(cfg.routedModelStallTimeoutMs);
155155
// Same `?? 200_000` default the server applies when threading connectTimeoutMs into the loop.
156156
const connectTimeoutMs = config.connectTimeoutMs ?? 200_000;
157-
const anthropicSidecar = findAnthropicSidecarProvider(config);
157+
// Shared auth state (#2188): presence only — backend PREFERENCE stays with
158+
// resolveSidecarBackend's explicit-or-openai contract.
159+
const auth = resolveSidecarAuth(config);
160+
const anthropicSidecar = auth.isAnthropicAuth && auth.anthropicProviderName && auth.anthropicProvider
161+
? { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }
162+
: undefined;
158163
const backend = resolveSidecarBackend(cfg.backend);
159164
const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES;
160165
const stallTimeoutSec = webSearchStallTimeoutSec(
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { afterEach, describe, expect, mock, test } from "bun:test";
2+
import * as storeModule from "../src/oauth/store";
3+
import * as usabilityModule from "../src/codex/account-usability";
4+
import * as modelRowsModule from "../src/server/management/model-rows";
5+
6+
let accountSets: Record<string, { accounts: Array<{ id: string; needsReauth?: boolean }>; activeAccountId?: string }> = {};
7+
let usableCodexAccounts: Set<string> = new Set();
8+
let managementRows: Array<Record<string, unknown>> = [];
9+
10+
mock.module("../src/oauth/store", () => ({
11+
...storeModule,
12+
getAccountSet: (provider: string) => accountSets[provider] ?? null,
13+
}));
14+
mock.module("../src/codex/account-usability", () => ({
15+
...usabilityModule,
16+
isCodexAccountUsable: (_config: unknown, accountId: string) => usableCodexAccounts.has(accountId),
17+
}));
18+
mock.module("../src/server/management/model-rows", () => ({
19+
...modelRowsModule,
20+
listManagementModelRows: async () => managementRows,
21+
}));
22+
23+
import { isWebSearchAuthSlotModel, webSearchSidecarCandidates } from "../src/web-search/backends";
24+
import { pickerVisibleSidecarCandidates } from "../src/sidecar/candidates";
25+
import { resolveSidecarAuth } from "../src/sidecar/auth";
26+
import { resolveSidecarBackend } from "../src/web-search";
27+
import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/account-id";
28+
import type { OcxConfig, OcxProviderConfig } from "../src/types";
29+
30+
const forward: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" };
31+
const anthropicOAuth: OcxProviderConfig = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" };
32+
33+
function config(overrides: Partial<OcxConfig> = {}): OcxConfig {
34+
return { port: 10100, defaultProvider: "openai", providers: { openai: forward, claude: anthropicOAuth }, ...overrides };
35+
}
36+
37+
afterEach(() => {
38+
accountSets = {};
39+
usableCodexAccounts = new Set();
40+
managementRows = [];
41+
});
42+
43+
async function candidatesFor(cfg: OcxConfig) {
44+
const auth = resolveSidecarAuth(cfg);
45+
const all = await pickerVisibleSidecarCandidates(cfg, auth);
46+
return webSearchSidecarCandidates(cfg, auth, all);
47+
}
48+
49+
describe("webSearchSidecarCandidates = (picker ∪ slots) ∩ active backend", () => {
50+
test("no Codex login: openai side inactive — native rows and Luna absent, Haiku present", async () => {
51+
accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } };
52+
managementRows = [
53+
{ provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true },
54+
{ provider: "claude", id: "claude-sonnet-5", disabled: false },
55+
];
56+
const ids = (await candidatesFor(config())).map(c => c.id).sort();
57+
expect(ids).toEqual(["claude-haiku-4-5", "claude-sonnet-5"]);
58+
});
59+
60+
test("hidden Luna + Codex login: present via the slot", async () => {
61+
usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID);
62+
managementRows = [{ provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }];
63+
const cfg = config({ disabledModels: ["gpt-5.6-luna"] });
64+
const ids = (await candidatesFor(cfg)).map(c => c.id).sort();
65+
expect(ids).toEqual(["gpt-5.6-luna", "gpt-5.6-terra"]);
66+
});
67+
68+
test("rows outside both backend families are excluded even when picker-visible", async () => {
69+
usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID);
70+
accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } };
71+
managementRows = [
72+
{ provider: "routed", id: "grok-4.6", disabled: false },
73+
{ provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true },
74+
];
75+
const ids = (await candidatesFor(config())).map(c => c.id).sort();
76+
expect(ids).toEqual(["claude-haiku-4-5", "gpt-5.6-luna", "gpt-5.6-terra"]);
77+
});
78+
79+
test("keyed same-adapter anthropic provider rows stay unreachable", async () => {
80+
accountSets = { claude: { accounts: [{ id: "a1" }], activeAccountId: "a1" } };
81+
const keyedClaude: OcxProviderConfig = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key", apiKey: "k" };
82+
managementRows = [{ provider: "keyed-claude", id: "claude-sonnet-5", disabled: false }];
83+
const cfg = config({ providers: { openai: forward, claude: anthropicOAuth, "keyed-claude": keyedClaude } });
84+
const ids = (await candidatesFor(cfg)).map(c => c.id).sort();
85+
expect(ids).toEqual(["claude-haiku-4-5"]);
86+
});
87+
88+
test("no logins at all -> empty candidate set", async () => {
89+
managementRows = [{ provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true }];
90+
expect(await candidatesFor(config())).toEqual([]);
91+
});
92+
93+
test("account-bound selector/slug native rows are excluded (executor cannot run them)", async () => {
94+
usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID);
95+
managementRows = [
96+
{ provider: "openai", id: "work/gpt-future-unlisted", disabled: false, native: true },
97+
{ provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true },
98+
];
99+
const ids = (await candidatesFor(config())).map(c => c.id).sort();
100+
expect(ids).toEqual(["gpt-5.6-luna", "gpt-5.6-terra"]);
101+
});
102+
103+
test("custom openai-keyed rows without the native flag are excluded", async () => {
104+
usableCodexAccounts.add(MAIN_CODEX_ACCOUNT_ID);
105+
managementRows = [
106+
{ provider: "openai", id: "my-custom-gpt", disabled: false },
107+
{ provider: "openai", id: "gpt-5.6-terra", disabled: false, native: true },
108+
];
109+
const ids = (await candidatesFor(config())).map(c => c.id).sort();
110+
expect(ids).toEqual(["gpt-5.6-luna", "gpt-5.6-terra"]);
111+
});
112+
});
113+
114+
describe("existing contracts stay pinned", () => {
115+
test("resolveSidecarBackend(undefined) is still openai (B4: no default change)", () => {
116+
expect(resolveSidecarBackend(undefined)).toBe("openai");
117+
});
118+
119+
test("auth-slot membership helper knows exactly the two slot models", () => {
120+
expect(isWebSearchAuthSlotModel("gpt-5.6-luna")).toBe(true);
121+
expect(isWebSearchAuthSlotModel("claude-haiku-4-5")).toBe(true);
122+
expect(isWebSearchAuthSlotModel("gpt-5.6-terra")).toBe(false);
123+
});
124+
});

0 commit comments

Comments
 (0)