From 2b1430dbd805e1b4427227fd8412bb0d9818fd2d Mon Sep 17 00:00:00 2001 From: Maciej Makowski <377311+maciej-makowski@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:28:03 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20multi-gateway=20providers=20=E2=80=94?= =?UTF-8?q?=20named,=20typed=20OpenAI-compatible=20gateways?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the single hardcoded AI Gateway integration into an ordered list of named, typed OpenAI-compatible gateways. Each gateway has {id, name, url, type, enabled}; two types are supported: - aigw — Bedrock-routed Claude ids (singleton, name pinned to "aigw") - openai-compatible — raw OpenAI-style endpoints for local models (llama.cpp/llama-swap, LM Studio, Ollama, vLLM, etc.) Gateways persist under the new `modelGateways` preference. A boot-time, idempotent migration upgrades the legacy single `aigw.url` (+exclusive) pref into the list. Type-driven models.json writers and model discovery dispatch on gateway type; exclusivity is derived (any enabled aigw-type gateway implies exclusive mode). Includes the REST surface, a settings list editor, and a display-only 'hide unconfigured / Has key' model-picker filter, plus design docs, reference docs, and unit + API + browser E2E coverage. Co-authored-by: bobbit-ai Co-Authored-By: Bobbit (Claude Opus 4 8) --- docs/debugging.md | 13 +- docs/design/multi-gateway-providers.md | 871 ++++++++++++++++++ docs/internals.md | 30 +- docs/multi-gateway-providers.md | 329 +++++++ docs/rest-api.md | 32 +- docs/thinking-levels.md | 18 + src/app/settings-page.ts | 471 ++++++---- src/server/agent/aigw-manager.ts | 543 ++++++++--- src/server/agent/model-registry.ts | 90 +- src/server/agent/session-manager.ts | 106 ++- src/server/agent/title-generator.ts | 25 +- src/server/server.ts | 255 ++++- src/ui/dialogs/AigwModelsDialog.ts | 30 +- src/ui/dialogs/ModelSelector.ts | 53 ++ tests/aigw-headers.test.ts | 171 ---- tests/aigw-pricing.test.ts | 17 +- tests/e2e/aigw-configure.spec.ts | 10 +- tests/e2e/models-api.spec.ts | 39 +- tests/e2e/multi-gateway-api.spec.ts | 358 +++++++ tests/e2e/ui/model-picker-hide-filter.spec.ts | 166 ++++ tests/e2e/ui/multi-gateway-settings.spec.ts | 205 +++++ tests/multi-gateway-exclusivity.test.ts | 47 + tests/multi-gateway-migration.test.ts | 108 +++ tests/multi-gateway-validation.test.ts | 100 ++ tests/multi-gateway-writer.test.ts | 319 +++++++ 25 files changed, 3754 insertions(+), 652 deletions(-) create mode 100644 docs/design/multi-gateway-providers.md create mode 100644 docs/multi-gateway-providers.md delete mode 100644 tests/aigw-headers.test.ts create mode 100644 tests/e2e/multi-gateway-api.spec.ts create mode 100644 tests/e2e/ui/model-picker-hide-filter.spec.ts create mode 100644 tests/e2e/ui/multi-gateway-settings.spec.ts create mode 100644 tests/multi-gateway-exclusivity.test.ts create mode 100644 tests/multi-gateway-migration.test.ts create mode 100644 tests/multi-gateway-validation.test.ts create mode 100644 tests/multi-gateway-writer.test.ts diff --git a/docs/debugging.md b/docs/debugging.md index 2827223d0..a3f2ace88 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -912,13 +912,15 @@ git ls-remote origin | grep -oE 'refs/heads/(session|goal|staff)[^[:space:]]*' | Full design + bug archaeology in [docs/design/orphan-remote-branch-cleanup.md](design/orphan-remote-branch-cleanup.md). Architecture summary: [docs/internals.md — Remote branch cleanup](internals.md#remote-branch-cleanup). -## `models.json` stale / missing AI Gateway headers after gateway upgrade +## `models.json` stale / missing gateway models or AI Gateway headers after gateway upgrade -Symptom: a new aigw-side model isn't selectable, gateway operators don't see `User-Agent: Bobbit/`, or per-session header partitioning isn't happening for users whose `~/.bobbit/agent/models.json` predates the generated header block. +Symptom: a new gateway-side model isn't selectable, gateway operators don't see `User-Agent: Bobbit/`, per-session header partitioning isn't happening, or a disabled/removed gateway's models still appear for users whose `~/.bobbit/agent/models.json` predates the change. -Resolution: restart the gateway. `startupAigwCheck` in `src/server/agent/aigw-manager.ts` now re-discovers models and rewrites `~/.bobbit/agent/models.json` on every startup when aigw is configured, preserving non-aigw providers and user `modelOverrides` while refreshing `providers.aigw.headers`. Look for `[aigw] re-discovered models on startup, refreshed models.json` in the gateway log to confirm. If you instead see `[aigw] gateway unreachable on startup (), keeping existing models.json`, the gateway HTTP probe failed and the file was deliberately left as-is — fix gateway connectivity and restart again. +Bobbit talks to a list of named, typed gateways (`modelGateways` pref), each surfaced under a provider key equal to its `name`; the `aigw`-type gateway's header/Bedrock block lives under `providers.aigw`. See [docs/multi-gateway-providers.md](multi-gateway-providers.md). -`BOBBIT_SKIP_AIGW_DISCOVERY=1` semantics shifted with this change: it now skips only the network call. When aigw is already configured, Bedrock env vars are still applied and the existing `models.json` is kept untouched. Previously this flag short-circuited everything pre-config; the post-config refresh path is the new behaviour. +Resolution: restart the gateway. `startupAigwCheck` in `src/server/agent/aigw-manager.ts` runs `syncGatewaysModelsJson` on every startup, which re-discovers **all enabled** gateways, (re)writes each `providers.` block, and **prunes** the blocks of disabled/removed/renamed gateways (tracked via `_managedGatewayProviders` ∪ the legacy `"aigw"` key) — while preserving non-gateway providers (`anthropic`, `amazon-bedrock`, custom) and user `modelOverrides`. Look for `[aigw] re-discovered gateway models on startup, refreshed models.json` in the gateway log to confirm. If you instead see `[aigw] gateway unreachable on startup (), keeping existing models.json for ""`, that gateway's HTTP probe failed and its last-good block was deliberately kept — fix connectivity and restart again. + +`BOBBIT_SKIP_AIGW_DISCOVERY=1` still skips only the network call (logged as `[aigw] gateways configured, skipping startup re-discovery`). Bedrock env vars are still applied (from the enabled `aigw`-type gateway, if any) and the existing `models.json` is kept untouched. See [docs/internals.md — Startup refresh behavior](internals.md#startup-refresh-behavior). @@ -932,7 +934,8 @@ Troubleshooting checklist: 2. Does the pref resolve? Open Settings → Models; if the row shows a red "Unavailable" badge, the stored pref does not match any current `/api/models` entry. Click Clear and re-pick. 3. Does the Test button succeed for that row? Failure reveals whether the gateway rejects the model id (drift / wrong provider prefix). 4. If Test passes but reviewers still abort: check the goal dashboard gate verification output — `applyReviewModelOverrides` (`src/server/agent/review-model-override.ts`) logs at `console.error` with the pref, normalized id, and the mismatched model id the agent actually reports. -5. For naming-model issues under an AI Gateway: confirm the gateway exposes at least one Claude model (any tier); otherwise title generation falls back to direct `api.anthropic.com` (see `pickFallbackAigwNamingModel` in `title-generator.ts`). +5. Confirm the stored pref's **provider matches a gateway `name`**. The provider key in `default.sessionModel` / `default.reviewModel` (`/`) must equal an enabled gateway's `name` (for the enterprise gateway that is the pinned `"aigw"`); a renamed or disabled gateway makes the pref unresolvable. See [docs/multi-gateway-providers.md](multi-gateway-providers.md). +6. For naming-model issues: the implicit "auto-pick cheapest Claude" fallback (`pickFallbackAigwNamingModel` in `title-generator.ts`) is now gated on the **first enabled `aigw`-type gateway**. A merged/local-only setup (no `aigw`-type gateway) has no implicit Claude naming fallback — title generation falls through to `default.sessionModel` / direct `api.anthropic.com`. When an `aigw` gateway is enabled, confirm it exposes at least one Claude model (any tier). ## Role model override not applied diff --git a/docs/design/multi-gateway-providers.md b/docs/design/multi-gateway-providers.md new file mode 100644 index 000000000..d1c0fab62 --- /dev/null +++ b/docs/design/multi-gateway-providers.md @@ -0,0 +1,871 @@ +# Multi-gateway providers + +Status: in-progress · Tracked in goal `goal/multi-gateway-8e973d9d`. + +## Problem + +Bobbit's AI-Gateway integration is hardcoded to a **single** gateway stored +under the literal provider key `"aigw"`: + +- `preferences-store.ts` holds one URL (`aigw.url`) and one exclusivity toggle + (`aigw.exclusive`). +- `aigw-manager.ts::writeAigwModelsJson()` writes exactly one + `providers.aigw` block into `~/.bobbit/agent/models.json`, and bakes in a + **global heuristic**: "model id contains `claude` ⇒ route through Bedrock + Converse + attach `x-opencode-session`/User-Agent headers + set Bedrock env". +- `model-registry.ts` pushes gateway models with `provider: "aigw"` and gates + built-ins on the `aigw.exclusive` pref. +- `session-manager.ts`, `title-generator.ts`, and `server.ts` all branch on the + string `"aigw"`. + +This blocks adding local OpenAI-compatible gateways (ollama, llama-swap / +llama-server) **alongside** — or, for an enterprise `aigw`, **exclusively +instead of** — the built-in cloud providers. It also carries a latent bug: the +Claude→Bedrock heuristic is global, so a *local* gateway exposing a model +literally named `claude-*` would be wrongly Bedrock-routed. + +ollama and llama-swap are already OpenAI-compatible gateways. The existing +`aigw` consumer path (discovery → `models.json` → bindable `set_model`) already +does everything we need — it is just single-instance and `claude`-heuristic +bound. **This design generalizes that one proven path into an ordered list of +named, typed gateways.** No new daemon. No repair of the custom-provider path +(`model-registry.ts::discoverCustomProviderModels` / `mapManualModels`). Image +generation is explicitly out of scope. + +> **Prerequisite (separate housekeeping task, not a slice here):** PR #6 +> (squash `bc7b83dd`) is reverted on the goal branch before implementation +> starts. That restores the custom-provider path to its pre-#6 state — fine, +> because this goal does not use it. + +## Glossary + +| Term | Meaning | +|---|---| +| **Gateway** | One OpenAI-compatible endpoint Bobbit talks to, identified by a user-chosen `name`. | +| **Provider key** | The string that appears as `provider` in the picker, as the `models.json` block key, and in `set_model(provider, id)`. For a gateway it equals `gateway.name`. | +| **`aigw` type** | Enterprise AI-Gateway: Bedrock-routes Claude ids, sends special headers, is **exclusive**. | +| **`openai-compatible` type** | Plain OpenAI gateway (ollama/llama-swap/vLLM/…): no Bedrock, no special headers, never exclusive. | +| **Exclusive mode** | Built-in cloud providers (and `openai-compatible` gateways) are suppressed; only `aigw`-type gateways contribute models. | +| **Merged mode** | Built-ins + all enabled `openai-compatible` gateways all contribute. | + +--- + +## 1. The `ModelGateway` contract + +Declared and **exported from `src/server/agent/aigw-manager.ts`** (it is the +foundation module every other slice imports): + +```ts +export type GatewayType = "aigw" | "openai-compatible"; + +export interface ModelGateway { + /** Stable identity (crypto.randomUUID()); never shown, used only as a UI row key. */ + id: string; + /** Provider key used EVERYWHERE: picker `provider`, models.json block key, set_model(name, id). */ + name: string; + /** Base URL as the user entered it (may or may not end with /v1). */ + url: string; + type: GatewayType; + enabled: boolean; +} +``` + +Persisted under the **new pref key `modelGateways`** (a JSON array) in +`preferences-store.ts`. Conceptual shorthand the user described: `:`. + +`name` constraints (validated server-side on write, §6): + +- non-empty after trim, `^[a-zA-Z0-9._-]+$` (so it is a safe `models.json` key + and `provider/modelId` token), +- unique within the list (case-sensitive), +- must not collide with a pi-ai built-in provider id (`anthropic`, `openai`, + `google`, `xai`, `amazon-bedrock`, `groq`, `mistral`, …) — see + `model-registry.ts::ENV_MAP` for the canonical list; reject with 400. + +### `aigw`-type naming constraint (singleton enterprise gateway) + +An **`aigw`-type** gateway MUST be named exactly **`"aigw"`**, and at most one +`aigw`-type gateway may exist. `saveGateways` rejects (400) any `aigw`-type row +whose `name !== "aigw"`, and any list containing more than one `aigw`-type row. +`openai-compatible` gateways may use any valid `name` (per the rules above). + +Rationale: exclusivity already makes the enterprise gateway a singleton (one +enabled `aigw` shadows all built-ins **and** every `openai-compatible` +gateway, §4), and **three behavior-bearing guards key on the literal provider +string `"aigw"`** and cannot be generalized cheaply: + +- `src/server/agent/pi-ai-bedrock-headers-patch.ts` (`model?.provider !== "aigw"`) + — Bedrock SDK middleware that injects the `x-opencode-session` / User-Agent + request headers at the AWS client layer. +- `src/server/agent/model-completion.ts::resolveProviderHeaders` + (`provider !== "aigw"`) — resolves the provider-level `headers` block from + `models.json` for completion / title-gen requests. +- `src/shared/thinking-levels.ts::providerMatches` (`provider === "aigw"`) — + **client-side** guard (provider **string only**, no prefs access) that lets + an `aigw`-routed `claude-opus`/`claude-*` light up the `xhigh` thinking + capability. + +By pinning the `aigw` name to `"aigw"`, these three guards stay correct +**unchanged**: the migrated default (which already keeps `name:"aigw"`, §2) and +any user-created `aigw` gateway both retain header injection, Bedrock routing, +and `xhigh`. **This is why no slice owns `pi-ai-bedrock-headers-patch.ts`, +`model-completion.ts`, or `shared/thinking-levels.ts` — they require no change.** +Generalizing them to "any enabled `aigw`-type gateway name" was considered and +rejected: `shared/thinking-levels.ts` runs client-side with only a provider +string and cannot look up gateway types, so it would need a separate +canonical-family-hint mechanism — disproportionate for a singleton enterprise +gateway. (If a future requirement truly needs multiple differently-named +enterprise gateways, revisit this by emitting a canonical-family hint on the +`ApiModel` record and switching all three guards to consume it.) + +### Accessor helpers (Slice A, exported from `aigw-manager.ts`) + +```ts +export function listGateways(prefs: PreferencesStore): ModelGateway[]; // [] when unset +export function getEnabledGateways(prefs: PreferencesStore): ModelGateway[]; +export function getGatewayByName(prefs: PreferencesStore, name: string): ModelGateway | undefined; +export function saveGateways(prefs: PreferencesStore, gateways: ModelGateway[]): void; // validates + persists +export function isExclusiveMode(gateways: ModelGateway[]): boolean; // §4 +``` + +`listGateways` is defensive: it parses `prefs.get("modelGateways")`, drops +malformed rows, and returns `[]` for any non-array value. + +--- + +## 2. Pref migration (`aigw.url` + `aigw.exclusive` → `modelGateways`) + +A fresh, idempotent, pure-ish function in `aigw-manager.ts`, called **once at +server boot** from `server.ts::start()` immediately **before** +`startupAigwCheck(...)` (so the rest of boot sees only the new schema): + +```ts +export function migrateGatewayPrefs(prefs: PreferencesStore): { + migrated: boolean; + gateways: ModelGateway[]; +}; +``` + +Rules: + +1. If `modelGateways` is already present (even `[]`) → **no-op**; defensively + strip any leftover `aigw.url` / `aigw.exclusive`; return `{ migrated:false }`. +2. Else if `aigw.url` is a non-empty string → create + `[{ id: randomUUID(), name: "aigw", url: , type: "aigw", + enabled: true }]`, persist as `modelGateways`, **remove** `aigw.url` and + `aigw.exclusive`, return `{ migrated:true, gateways }`. + - `aigw.exclusive` is intentionally dropped: exclusivity is now derived + (§4). The migrated single `aigw`-type gateway is exclusive by derivation, + which matches the **default** (`aigw.exclusive` defaulted to `true`). The + rare user who had set `aigw.exclusive=false` loses merged mode for an + `aigw`-type gateway — documented behavior change, surfaced by the UI + warning banner (§7). +3. Else (no `aigw.url`, no `modelGateways`) → leave prefs untouched (readers + treat absent as `[]`). Return `{ migrated:false, gateways:[] }`. + +Because the migrated gateway keeps `name:"aigw"`, the **provider key stays +`"aigw"`**, so existing `default.sessionModel = "aigw/"` (and +`default.namingModel`, role/review prefs) continue to resolve unchanged. + +### Unit-test assertions (Slice A, `tests/multi-gateway-migration.test.ts`) + +Use a `PreferencesStore` rooted in a `mkdtempSync` dir. + +- **Migrates a configured single URL:** seed `{ "aigw.url":"http://gw/v1", + "aigw.exclusive":false, "default.sessionModel":"aigw/claude-sonnet-4-6" }`. + After `migrateGatewayPrefs`: + - `listGateways(prefs)` deep-equals `[{ id:, name:"aigw", + url:"http://gw/v1", type:"aigw", enabled:true }]` (assert `id` is a + non-empty string separately). + - `prefs.get("aigw.url") === undefined` and `prefs.get("aigw.exclusive") === undefined`. + - `prefs.get("default.sessionModel") === "aigw/claude-sonnet-4-6"` (untouched). +- **Idempotent:** running `migrateGatewayPrefs` a second time returns + `migrated:false` and leaves `modelGateways` byte-identical. +- **No-op when nothing to migrate:** empty prefs → `modelGateways` stays + `undefined`, `listGateways` returns `[]`. +- **Defensive cleanup:** seed both `modelGateways:[...]` and a stale + `aigw.url` → after migrate, `aigw.url` removed, `modelGateways` unchanged. + +--- + +## 3. Type-driven `models.json` writers + +Replace the single `writeAigwModelsJson` with a **dispatch table** plus a +sync orchestrator. All in `aigw-manager.ts` (Slice A). + +```ts +type ProviderBlock = Record; +type GatewayWriter = (gateway: ModelGateway, models: AigwModel[]) => ProviderBlock; + +const PROVIDER_WRITERS: Record = { + "aigw": buildAigwProviderBlock, + "openai-compatible": buildOpenAiCompatibleProviderBlock, +}; + +const DISCOVERY: Record Promise> = { + "aigw": discoverAigwModels, + "openai-compatible": discoverAigwModels, // same GET /v1/models for now (see §5) +}; +``` + +### 3a. `aigw` writer — `buildAigwProviderBlock(gateway, models)` + +**Byte-for-byte the behavior of today's `writeAigwModelsJson`**, just keyed by +`gateway.name` instead of the literal `aigw` and reading the URL from +`gateway.url`: + +- `baseUrl` = `gateway.url` with trailing slashes stripped. +- `apiKey: "none"`, `api: "openai-completions"`. +- Provider-level `headers`: `{ "User-Agent": BOBBIT_AIGW_USER_AGENT, + "x-opencode-session": "!node -e \"process.stdout.write(process.env.BOBBIT_SESSION_ID || '')\"" }`. +- Claude ids (`id.toLowerCase().includes("claude")`) → strip provider prefix, + `api: "bedrock-converse-stream"`, per-model `baseUrl = /aws`. +- Non-Claude ids → `openai-completions` with the conservative `openaiCompat` + flags merged with any `m.compat`. +- **No per-model `headers`** (pinned by `tests/aigw-headers.test.ts`). + +Example (`name:"aigw"`, `url:"http://gw/v1"`): + +```json +"aigw": { + "baseUrl": "http://gw/v1", + "apiKey": "none", + "api": "openai-completions", + "headers": { + "User-Agent": "Bobbit/1.2.3", + "x-opencode-session": "!node -e \"process.stdout.write(process.env.BOBBIT_SESSION_ID || '')\"" + }, + "models": [ + { "id": "us.anthropic.claude-sonnet-4-6", "name": "Claude Sonnet 4.6 (aws)", + "api": "bedrock-converse-stream", "baseUrl": "http://gw/aws", + "contextWindow": 1000000, "maxTokens": 16384, "reasoning": true, + "input": ["text","image"], "cost": { "input":0,"output":0,"cacheRead":0,"cacheWrite":0 } }, + { "id": "openai/gpt-5.2", "name": "Gpt 5.2 (openai)", + "contextWindow": 400000, "maxTokens": 128000, "reasoning": true, + "input": ["text","image"], "cost": {…}, + "compat": { "supportsDeveloperRole": false, "supportsStore": false, + "supportsUsageInStreaming": false, "supportsReasoningEffort": false, + "supportsStrictMode": false, "maxTokensField": "max_tokens" } } + ] +} +``` + +Bedrock env: only an `aigw`-type gateway calls `setBedrockEnvVars(gateway.url)` +— driven from the sync orchestrator (§3c), **not** from the writer, so merged +mode never hijacks a real `amazon-bedrock` provider. + +### 3b. `openai-compatible` writer — `buildOpenAiCompatibleProviderBlock(gateway, models)` + +Plain OpenAI for **every** model — including a model literally named +`claude-*`. This is the key fix to the latent multi-gateway bug. + +- `baseUrl` = `normalizeOpenAiBaseUrl(gateway.url)` — strip trailing slashes, + append `/v1` if not already ending in `/v1`. +- `apiKey: "none"`, `api: "openai-completions"`. +- **No `headers` block at all** (no `x-opencode-session`, no User-Agent + override). The browser proxy still adds a User-Agent at the HTTP layer (§6); + the agent subprocess config carries none. +- Every model: `api: "openai-completions"`, id **unchanged** (no prefix strip), + `compat` = conservative `GATEWAY_COMPAT` flags merged with any `m.compat`. +- **Never** `bedrock-converse-stream`, **never** a per-model `/aws` baseUrl, + **never** Bedrock env. + +Example (`name:"llama-swap"`, `url:"http://host:9292"`): + +```json +"llama-swap": { + "baseUrl": "http://host:9292/v1", + "apiKey": "none", + "api": "openai-completions", + "models": [ + { "id": "qwen-coder-medium", "name": "Qwen Coder Medium", + "contextWindow": 1000000, "maxTokens": 32768, "reasoning": false, + "input": ["text"], "cost": {…}, "compat": { …conservative flags… } }, + { "id": "claude-local", "name": "Claude Local", + "contextWindow": 200000, "maxTokens": 16384, "reasoning": false, + "input": ["text"], "cost": {…}, "compat": { …conservative flags… } } + ] +} +``` + +Note `claude-local`: **`openai-completions`, no `baseUrl` override, no Bedrock**. + +### 3c. Sync orchestrator — `syncGatewaysModelsJson(prefs)` + +```ts +export async function syncGatewaysModelsJson( + prefs: PreferencesStore, +): Promise>; // discovered models keyed by gateway name +``` + +Semantics (writes the enabled gateways, prunes everything it previously managed, +never clobbers unrelated providers like `anthropic`/`amazon-bedrock`/custom): + +1. `gateways = listGateways(prefs)`; `enabled = gateways.filter(g => g.enabled)`; + `enabledNames = new Set(enabled.map(g => g.name))`. +2. `data = readModelsJson()`; snapshot `existingBlocks = { ...data.providers }`. +3. **Prune:** the set of keys we own = `prefs.get("_managedGatewayProviders")` + (`string[]`, written in step 6) **∪ `{"aigw"}`** (legacy single-URL block). + For each owned key not in `enabledNames`, `delete data.providers[key]`. + This handles disabled, removed, and renamed gateways without needing the + previous full list. +4. **Discover + write** each enabled gateway: + - `models = await DISCOVERY[g.type](g.url)` (per-gateway try/catch). + - On success: `data.providers[g.name] = PROVIDER_WRITERS[g.type](g, models)`; + record `discovered[g.name] = models`. + - On failure: if `existingBlocks[g.name]` exists, **keep it** (preserves the + current "gateway unreachable on startup ⇒ keep existing models.json" + behavior); else skip. `discovered[g.name] = []`. +5. **Bedrock env:** `applyAigwBedrockEnv(enabled.find(g => g.type === "aigw"))` + — sets the four `AWS_*` vars from that gateway's URL when present, **clears + them when absent** (so disabling the `aigw` gateway restores a real Bedrock + provider in the same process). +6. `writeModelsJson(data)`; `prefs.set("_managedGatewayProviders", [...enabledNames])`. + +`removeAigwModelsJson()` and the single-URL `writeAigwModelsJson()` are removed; +their callers move to `syncGatewaysModelsJson`. (Legacy REST shims, §6, still +expose configure/DELETE semantics by mutating the list then syncing — so +`tests/aigw-headers.test.ts` and `tests/e2e/aigw-session-header.spec.ts` stay +green.) + +`_managedGatewayProviders` is an internal bookkeeping pref (leading underscore), +never surfaced in the UI or `/api/preferences` editing. + +### 3d. Writer unit tests (Slice A, `tests/multi-gateway-writer.test.ts`) + +Use `BOBBIT_AGENT_DIR=` like `tests/aigw-headers.test.ts`. + +- **`aigw` type, Claude id ⇒ Bedrock block + headers + env:** + `buildAigwProviderBlock`/sync for a gateway `{name:"aigw",type:"aigw"}` with a + `claude` model → block has provider-level `headers["x-opencode-session"]` + literal + `User-Agent`; the Claude model entry has + `api:"bedrock-converse-stream"` and `baseUrl` ending `/aws`; after sync, + `process.env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME` is set. +- **`openai-compatible`, claude-named id stays OpenAI:** gateway + `{name:"llama-swap",type:"openai-compatible"}` exposing `claude-local` → + block has **no `headers`**, the `claude-local` entry has + `api:"openai-completions"`, **no** `baseUrl` override, and `AWS_ENDPOINT_URL_BEDROCK_RUNTIME` + is **not** set by this gateway. +- **Provider key = gateway name:** block lives under `providers["llama-swap"]`, + not `providers["aigw"]`. +- **Multiple gateways ⇒ multiple blocks:** two enabled gateways → both + `providers.aigw` and `providers["llama-swap"]` present after one sync. +- **Pruning:** sync with `[aigw(enabled), llama-swap(enabled)]`, then sync with + `llama-swap` disabled → `providers["llama-swap"]` gone, `providers.aigw` + intact. Removing all gateways → both gone; a pre-seeded `anthropic` block + survives untouched. +- **baseUrl normalization (openai-compatible):** `url:"http://host:9292"` → + block `baseUrl:"http://host:9292/v1"`; `url:"http://host:9292/v1/"` → + `"http://host:9292/v1"` (no double `/v1`). +- **Bedrock env cleared:** sync with one enabled `aigw` (env set), then sync + with it disabled → the four `AWS_*` vars deleted. + +--- + +## 4. Exclusivity (derived, not a manual toggle) + +```ts +export function isExclusiveMode(gateways: ModelGateway[]): boolean { + return gateways.some(g => g.enabled && g.type === "aigw"); +} +``` + +Consumed by `model-registry.ts::assembleModels` (Slice B). Replace the old +`aigw.url` + `aigw.exclusive` logic with: + +```ts +const gateways = listGateways(prefs); +const enabled = getEnabledGateways(prefs); +const exclusive = isExclusiveMode(gateways); + +// 1. Built-in providers — ONLY in merged mode. +if (!exclusive) { /* existing pi-ai getProviders()/getModels() loop, unchanged */ } + +// 2. Gateways. +for (const g of enabled) { + if (exclusive && g.type !== "aigw") continue; // suppress openai-compatible in exclusive mode + try { + const discovered = await discoverGatewayModels(g); // see §5 + for (const m of discovered) { + const bedrock = bedrockRoutesForType(g.type) && isClaudeId(m.id); + const id = bedrock ? stripProviderPrefix(m.id) : m.id; + const meta = inferMeta(id); + results.push({ + id, + name: m.name, + provider: g.name, // ← de-hardcoded + api: bedrock ? "bedrock-converse-stream" : (m.api || "openai-completions"), + baseUrl: g.url, + contextWindow: Math.max(meta.contextWindow, m.contextWindow || 0), + maxTokens: Math.max(meta.maxTokens, m.maxTokens || 0), + reasoning: meta.reasoning || m.reasoning || false, + input: meta.input || ["text"], + cost: m.cost ?? { input:0, output:0, cacheRead:0, cacheWrite:0 }, + authenticated: true, // gateways are always authenticated + }); + } + } catch (err) { /* log, continue with next gateway */ } +} + +// 3. Custom local providers — unchanged (only when !exclusive, to mirror today; +// keep current behavior: custom providers were always shown. Keep them shown +// in BOTH modes as today, since they are not gateways. Verify with a test.) +``` + +Truth table (the exclusivity unit test must cover every row): + +| Gateways enabled | Mode | Contributes | +|---|---|---| +| one `aigw` | exclusive | that `aigw` only (built-ins + openai-compatible suppressed) | +| `aigw` + `openai-compatible` | exclusive | `aigw` only (openai-compatible suppressed) | +| only `openai-compatible`(s) | merged | built-ins + all enabled openai-compatible | +| `aigw` **disabled** + `openai-compatible` enabled | merged | built-ins + openai-compatible (disabled aigw ⇒ NOT exclusive) | +| none enabled | merged | built-ins only | + +Small predicates A exports for B (avoid a `model-registry`↔`aigw-manager` +type cycle on `ApiModel`): + +```ts +export function isClaudeId(id: string): boolean; // id.toLowerCase().includes("claude") +export function stripProviderPrefix(id: string): string; // "aws/x" → "x" +export function bedrockRoutesForType(t: GatewayType): boolean; // t === "aigw" +export function discoverGatewayModels(g: ModelGateway): Promise; // DISCOVERY[g.type](g.url) +``` + +### `getPrefsVersion` (Slice B, `model-registry.ts`) + +Replace the `aigw.url` / `aigw.exclusive` hash inputs with `modelGateways`: + +```ts +const str = JSON.stringify([ + all["modelGateways"], + all["customProviders"], + ...Object.keys(all).filter(k => k.startsWith("providerKey.")).sort(), +]); +``` + +### Exclusivity unit test (Slice A or B — assigned to A's helper, exercised in B's `model-registry` test) + +`isExclusiveMode` is pure → unit-test it directly in +`tests/multi-gateway-exclusivity.test.ts` (Slice A) for all five rows above. +The end-to-end "built-ins suppressed" assertion lives in the API E2E (§10, +Slice C) against a stub gateway, since `assembleModels` calls live discovery. + +--- + +## 5. De-hardcoding `provider:"aigw"` in consumers (Slice B) + +### `session-manager.ts` auto-select (~line 4371) + +Replace the single-URL fallback with a gateway-aware one: + +- Drop `_aigwModelCache` keyed on a single URL; key it on gateway `name` + (`Map`) or just discover per enabled gateway. +- Iterate `getEnabledGateways(prefs)` (respecting exclusive mode the same way as + the registry: in exclusive mode only `aigw`-type), pick the best-ranked model + across the contributing gateways via `modelRecencyRank`, then: + ```ts + await session.rpcClient.setModel(gateway.name, model.id); // ← was setModel("aigw", id) + store.update(session.id, { modelProvider: gateway.name, modelId: model.id }); + broadcast(..., { model: { provider: gateway.name, id: model.id, ... } }); + ``` +- This is what directly fixes the #13/#14 root cause: the agent binds against + the `providers[gateway.name]` block that `syncGatewaysModelsJson` wrote, so + `set_model(name, id)` succeeds instead of silently falling back to Claude. + +### `title-generator.ts` + +- Extend `TitleGenOptions`: + ```ts + /** Enabled gateways, for resolving a naming-model's provider → URL. */ + gateways?: ModelGateway[]; + /** Implicit-fallback gateway URL (first enabled aigw-type), preserves the + * "auto-pick cheapest Claude" behavior. Replaces the old single aigwUrl. */ + aigwUrl?: string; + ``` +- In `generateSessionTitle` / `generateGoalSummaryTitle`, after + `findConfiguredModel(pref)` resolves `{provider, modelId}`: + - If `provider` matches an enabled gateway name (`options.gateways?.find(g => + g.name === provider)`), route via `generateViaGateway(gateway.url, modelId, + …)` — works for **both** types because title-gen always uses the gateway's + `/v1/chat/completions` (OpenAI path); the Bedrock distinction is irrelevant + here. (Remove the `configured.provider === "aigw"` literal check.) + - Else existing direct/anthropic paths. +- The implicit fallback (`pickFallbackAigwNamingModel`) stays but is gated on + `options.aigwUrl`, which `getTitleGenOptions` now sets to the **first enabled + `aigw`-type gateway's URL** (or `undefined`). For a merged/local-only setup + there is no Claude fallback — title-gen then falls through to + `default.sessionModel` / legacy Anthropic, exactly as it does today when no + gateway is configured. + +### `session-manager.ts::getTitleGenOptions` (~line 5088) + +```ts +const gateways = getEnabledGateways(this.preferencesStore); +const aigwGateway = gateways.find(g => g.type === "aigw"); +return { + namingModel: namingModel || undefined, + fallbackModel: sessionModel || undefined, + gateways, + aigwUrl: aigwGateway?.url, // first enabled aigw-type, else undefined + thinkingLevel: "off", + preferencesStore: this.preferencesStore, +}; +``` + +--- + +## 6. REST contract (Slice C, `server.ts`) + +**Canonical list-management surface** (the new UI uses these): + +| Method · Path | Body | Returns | Notes | +|---|---|---|---| +| `GET /api/aigw/gateways` | — | `{ gateways: ModelGateway[] }` | full list incl. disabled | +| `PUT /api/aigw/gateways` | `{ gateways: ModelGateway[] }` | `{ gateways, modelsByGateway: Record }` | **replace whole list**; validates (§1); fills missing `id` with `randomUUID()`; `saveGateways`; `await syncGatewaysModelsJson`; `invalidateModelCache`; `broadcastPreferencesChanged` | +| `POST /api/aigw/test` | `{ url: string; type?: GatewayType }` | `{ ok:true, models }` or 502 | discover without saving (unchanged shape; `type` reserved for future per-type discovery) | +| `POST /api/aigw/gateways/:name/refresh` | — | `{ models }` or 404/502 | re-discover one gateway, re-run sync | +| `GET /api/aigw/gateways/:name/status` | — | `{ configured, name, url, type, enabled, models }` | per-gateway "view models" affordance | + +**Proxy by name:** `/api/aigw/:name/v1/*` → `/v1/*` for the named +enabled gateway (404 when no such enabled gateway). Implementation: parse +`:name`, `getGatewayByName`, build `targetUrl = gateway.url + "/v1/" + rest + +search`, call `proxyRequest`. Keep the **legacy** `/api/aigw/v1/*` mapping to +the gateway named `aigw` (or, if none, the first enabled gateway) for +back-compat. + +**`/api/models/test`** (~line 5990): replace `if (provider !== "aigw")` with a +gateway-name check: + +```ts +const gw = getGatewayByName(preferencesStore, provider); +if (!gw) { /* existing non-gateway path: testModelPreference(...) */ } +else { /* gateway path: POST /v1/chat/completions with the (prefix- + resolved for aigw) model id, exactly as the current aigw branch but + using gw.url instead of getAigwUrl(...) */ } +``` + +**Backward-compatible shims** (keep so existing E2E + any old clients survive): + +- `GET /api/aigw/status` → returns `{configured,url,models}` for the gateway + named `aigw` (or first `aigw`-type), else `{configured:false}`. +- `POST /api/aigw/configure {url}` → upsert a gateway `{name:"aigw", + type:"aigw", enabled:true, url}` into the list, `saveGateways`, sync, return + `{ok:true, models}`. +- `DELETE /api/aigw/configure` → remove the gateway named `aigw`, sync, return + `{ok:true}`. +- `POST /api/aigw/refresh` → `:name="aigw"` refresh. + +These shims keep `tests/e2e/aigw-api.spec.ts`, `aigw-configure.spec.ts`, and +`aigw-session-header.spec.ts` passing untouched. + +**`/api/health` + `/api/status`** `aigw:` flag (lines 2435 / ~2435 region) → +`aigw: getEnabledGateways(preferencesStore).length > 0` (any enabled gateway +means the gateway tier handles LLM egress, so the browser OAuth prompt is +skipped — see `src/app/session-manager.ts::672`). + +**Boot wiring** (`server.ts::start`, ~1604): call `migrateGatewayPrefs(prefs)` +**before** `startupAigwCheck(prefs)`. `startupAigwCheck` keeps its +offline-probe + localhost auto-discovery role but now (a) reads the list, (b) +on "already configured" re-runs `syncGatewaysModelsJson(prefs)` instead of the +single-URL refresh, and (c) auto-discovery creates a `{type:"aigw"}` gateway +(unchanged offline behavior). Slice A owns the `startupAigwCheck` rewrite (it +lives in `aigw-manager.ts`); Slice C only changes the boot call order + +endpoints. + +--- + +## 7. Settings UI — gateway list editor (Slice D, `settings-page.ts`) + +Replace the single-URL "AI Gateway" block (state vars `aigwUrl`, +`aigwConfigured`, `aigwExclusive`, fns `testAigwConnection`/`saveAigwConfig`/ +`setAigwExclusive`/`refreshAigwModels`/`removeAigwConfig`) with a list editor. + +State: + +```ts +let gateways: ModelGateway[] = []; // loaded from GET /api/aigw/gateways +let gatewayRowStatus: Record = {}; // keyed by row id +let gatewayRowError: Record = {}; +let gatewayModelsByName: Record = {}; // for "view models" +``` + +Each row renders: **enable checkbox**, **name** text input, **url** text input, +**type** `` and to `bedrockRoutesForType` if relevant. + +No consumer (registry/session-manager/title-gen/server) needs changing for a new +type beyond the union — they all route through `name`, `type`, +`discoverGatewayModels`, and the dispatch tables. Only `aigw` + +`openai-compatible` are implemented now. + +--- + +## 11. Test plan (NO NETWORK) + +All tests use an **in-process stub gateway** (tiny `http.Server` serving +`GET /v1/models` → `{data:[…]}` and `POST /v1/chat/completions` → a canned +OpenAI completion), as in `tests/e2e/aigw-session-header.spec.ts`. Never the +LAN host `maciekm-z13.local`, never `tools/dummy-aigw` (it needs a real +Anthropic key). Stub-server snippet (reused by Slices C/D): + +```ts +const srv = http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + if (req.url?.endsWith("/v1/models")) { + res.end(JSON.stringify({ data: [ + { id: "qwen-coder-medium" }, { id: "claude-local" }, + { id: "aws/us.anthropic.claude-sonnet-4-6" }, + ]})); + } else { res.end(JSON.stringify({ choices:[{ message:{ content:"OK" }}] })); } +}); +await new Promise(r => srv.listen(0, "127.0.0.1", r)); +const port = (srv.address() as any).port; +``` + +### Unit (node, Slice A) — `tests/multi-gateway-*.test.ts` + +- `multi-gateway-migration.test.ts` — §2 assertions. +- `multi-gateway-writer.test.ts` — §3d assertions (incl. the + claude-named-id-on-openai-compatible-stays-openai case, multiple blocks, + pruning, baseUrl normalization, Bedrock env set/clear). Plus: existing + `tests/aigw-headers.test.ts` must stay green via the legacy shim path. +- `multi-gateway-exclusivity.test.ts` — `isExclusiveMode` truth table (§4). +- `multi-gateway-validation.test.ts` — `saveGateways` **accepts** an `aigw`-type + gateway named exactly `"aigw"`; **rejects** (throws / 400) an `aigw`-type + gateway named anything else and a list containing two `aigw`-type rows; + accepts arbitrarily-named `openai-compatible` gateways; rejects names that + collide with a built-in provider id or violate `^[a-zA-Z0-9._-]+$`. This pins + the §1 naming constraint that keeps `pi-ai-bedrock-headers-patch.ts`, + `model-completion.ts`, and `shared/thinking-levels.ts` correct **unchanged** + (an `aigw` gateway therefore always retains header injection + Bedrock routing + + `xhigh`, regardless of how it was created). + +### API E2E (in-process gateway, Slice C) — `tests/e2e/multi-gateway-api.spec.ts` + +Spin up **two** stub servers. Then: + +- `PUT /api/aigw/gateways` with `[{name:"llama-swap",type:"openai-compatible", + url:,enabled:true}, {name:"aigw",type:"aigw",url:,enabled:false}]` + → `GET /api/models`: provider `"llama-swap"` present (models with that + provider), built-ins present (merged mode), no provider `"aigw"`. On disk, + `~/.bobbit/agent/models.json` has `providers["llama-swap"]` (openai-completions, + no headers) and **no** `providers.aigw`. +- Enable the `aigw` gateway (`PUT` again with `aigw.enabled:true`) → + `GET /api/models`: built-ins **suppressed**, `llama-swap` **suppressed**, + only provider `"aigw"` present; on disk both blocks exist (the agent could + bind either, but the registry surfaces only aigw — exclusive). Assert the + `aigw` block carries the `x-opencode-session` header literal and the + `claude-*` model routes to `bedrock-converse-stream`, while the `llama-swap` + block's `claude-local` stays `openai-completions`. +- Disable everything (`PUT []`) → on disk both gateway blocks pruned; `anthropic` + block (if any) untouched; `GET /api/models` shows built-ins only. +- `POST /api/aigw/test {url:}` → `{ok:true, models}`; unreachable URL → + 502. Legacy `POST/DELETE /api/aigw/configure` still works (smoke). +- "Binding resolves" is asserted at the `models.json` layer (provider block + + correct `api` per type), since `set_model` is agent-subprocess-side and not + reachable from the in-process REST harness — same de-scope rationale as + `tests/e2e/aigw-session-header.spec.ts`. + +### Browser E2E (spawned gateway, Slice D) — `tests/e2e/ui/multi-gateway-settings.spec.ts` + +Pattern: `tests/e2e/ui/*.spec.ts` + a self-hosted stub gateway (like +`aigw-session-header.spec.ts` starts its own `http.Server`). Cover: + +- Add an `openai-compatible` row (name/url/type) and an `aigw` row; Save. +- Toggle the `aigw` row's **enable** checkbox → the + `gateway-exclusivity-warning` banner appears; uncheck → it disappears. +- Open the model picker → models show with the correct `provider` (gateway + name) badge; in exclusive mode built-ins are absent. +- **Persistence:** reload the page → both rows + enabled state survive + (from `GET /api/aigw/gateways`). +- **Removal cleanup:** remove a row + Save → its provider disappears from the + picker and its `providers.` block is pruned from `models.json`. + +### Browser E2E (spawned gateway, Slice E) — `tests/e2e/ui/model-picker-hide-filter.spec.ts` + +- With at least one built-in provider lacking a key (`authenticated:false` + naturally in the test env) and one authenticated provider/gateway present: +- Open picker, toggle **Has key** ON → `authenticated:false` built-ins are + absent; authenticated built-ins and all gateway models remain; the currently + selected model (even if unauthenticated) remains. +- Toggle OFF → the hidden built-ins reappear. +- **Persistence:** reload → toggle state restored from + `localStorage["bobbit.modelPicker.hideUnauthed"]`. +- **No server impact:** capture `/api/models` responses (network) and assert the + payload is identical with the toggle ON vs OFF (display-only). + +--- + +## Implementation partition + +Five non-overlapping file-ownership slices. **Slice A is the foundation; B/C/D +build against its locked exports; D builds against C's REST contract; E is +nearly independent (only reads existing `/api/models` `authenticated`).** No two +slices edit the same file. + +### Slice A — Foundation (`aigw-manager.ts`) + +- **Owns:** `src/server/agent/aigw-manager.ts`; new tests + `tests/multi-gateway-migration.test.ts`, + `tests/multi-gateway-writer.test.ts`, + `tests/multi-gateway-exclusivity.test.ts`. +- **Public contract it must honor (imported by B/C/D):** + `GatewayType`, `ModelGateway`, `listGateways`, `getEnabledGateways`, + `getGatewayByName`, `saveGateways` (validates §1, **incl. the `aigw`-name=`"aigw"` + singleton constraint**), `isExclusiveMode`, + `migrateGatewayPrefs`, `discoverGatewayModels`, `syncGatewaysModelsJson`, + `isClaudeId`, `stripProviderPrefix`, `bedrockRoutesForType`, and the rewritten + `startupAigwCheck` (now list-aware). Keeps exporting `AigwModel`, `inferMeta`, + `deriveName`, `discoverAigwModels`, `proxyRequest`, `writeContextWindowOverrides`, + `applyPiOfflineEnv`. **Removes** `writeAigwModelsJson`/`removeAigwModelsJson`/ + `configureAigw`/`removeAigw`/`getAigwUrl` (callers move to the new API; the + legacy REST shims in Slice C re-implement configure/remove via the list). +- **`aigw`-name constraint (§1):** `saveGateways` enforces the singleton + `aigw`-name=`"aigw"` rule. Consequently **no file outside this slice changes** + for the three literal `"aigw"` guards — `pi-ai-bedrock-headers-patch.ts`, + `model-completion.ts`, and `shared/thinking-levels.ts` are intentionally left + untouched and owned by no slice. +- **Depends on:** nothing (foundation). Must land/lock signatures first so + B/C/D can compile against stubs. + +### Slice B — Consumers (`model-registry.ts`, `session-manager.ts`, `title-generator.ts`) + +- **Owns:** `src/server/agent/model-registry.ts`, + `src/server/agent/session-manager.ts`, + `src/server/agent/title-generator.ts`. +- **Contract:** registry surfaces `provider: gateway.name`, applies + `isExclusiveMode`, loops `getEnabledGateways` (§4); `getPrefsVersion` hashes + `modelGateways`; session auto-select calls `setModel(gateway.name, id)` and + persists `modelProvider: gateway.name`; title-gen resolves a naming model's + provider → gateway URL via `options.gateways`, sets implicit `aigwUrl` to the + first enabled `aigw`-type gateway. No literal `"aigw"` left in these files. +- **Depends on:** Slice A exports (`listGateways`, `getEnabledGateways`, + `isExclusiveMode`, `discoverGatewayModels`, `isClaudeId`, `stripProviderPrefix`, + `bedrockRoutesForType`, `ModelGateway`). + +### Slice C — Server (`server.ts` aigw section) + +- **Owns:** the AI-Gateway region of `src/server/server.ts` (endpoints ~5885–6065, + the boot call ~1604, the `aigw:` flag ~2435); new API E2E + `tests/e2e/multi-gateway-api.spec.ts`. +- **Contract:** the §6 REST surface (`GET/PUT /api/aigw/gateways`, per-gateway + `refresh`/`status`, `POST /api/aigw/test`, `/api/aigw/:name/v1/*` proxy, + generalized `/api/models/test`), the backward-compat shims + (`/api/aigw/status|configure|refresh`, legacy `/api/aigw/v1/*`), boot order + (`migrateGatewayPrefs` before `startupAigwCheck`), and the + `aigw:` health flag. Keeps `tests/e2e/aigw-*.spec.ts` green. +- **Depends on:** Slice A (all list helpers + `syncGatewaysModelsJson` + + `migrateGatewayPrefs`); Slice B's `invalidateModelCache` (already exported). + +### Slice D — Settings UI (`settings-page.ts`, `AigwModelsDialog.ts`) + +- **Owns:** `src/app/settings-page.ts` (Models tab AI-Gateway block + state/fns), + `src/ui/dialogs/AigwModelsDialog.ts`; new browser E2E + `tests/e2e/ui/multi-gateway-settings.spec.ts`. +- **Contract:** the §7 list editor (rows, add/remove, per-row Test, Save, + exclusivity warning), the listed `data-testid`s, persistence via `GET/PUT + /api/aigw/gateways`, AigwModelsDialog as per-gateway view-models. +- **Depends on:** Slice C's REST contract (consumed over HTTP — no shared + source file). May develop against the documented JSON shapes before C lands. + +### Slice E — Picker hide-filter (`ModelSelector.ts`) + +- **Owns:** `src/ui/dialogs/ModelSelector.ts`; new browser E2E + `tests/e2e/ui/model-picker-hide-filter.spec.ts`. +- **Contract:** the §9 default-OFF "Has key" filter, localStorage key + `bobbit.modelPicker.hideUnauthed`, predicate (keep authenticated OR current), + display-only (no `/api/models` change), persistence across reload. +- **Depends on:** nothing new — relies only on the existing + `model.authenticated` field already served by `/api/models`. Fully parallel + with A–D. + + diff --git a/docs/internals.md b/docs/internals.md index 392471285..f41ea5c99 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -1361,7 +1361,7 @@ When a session starts, the model and thinking level are resolved in this order ( 1. **Explicit per-session override** - the user picking a model in the composer mid-run, or callers passing `skipAutoModel: true` after pre-binding (e.g. delegate sessions with an explicit model arg). 2. **Role override** - `role.model` / `role.thinkingLevel` from the resolved cascade. -3. **Global defaults** - `default.sessionModel` / `default.sessionThinkingLevel` (or the AI-Gateway best-ranked fallback when no pref is set). +3. **Global defaults** - `default.sessionModel` / `default.sessionThinkingLevel` (or, when no pref is set, the best-ranked model across the enabled gateways, each surfaced under its own provider key = gateway name; see [docs/multi-gateway-providers.md](multi-gateway-providers.md)). Layers 2 and 3 live in `tryAutoSelectModel` and `tryApplyDefaultThinkingLevel` in `session-manager.ts`. The role layer was added as a new step 0 inside both functions and binds via the `applyModelString` helper exported from `review-model-override.ts` - the same retry-and-verify path `applyReviewModelOverrides` uses, but reading a literal `/` string instead of a prefs key. @@ -1373,7 +1373,7 @@ The verification harness spawns reviewer, QA, and sub-session agents for gate st This is what makes "my `code-reviewer` role always runs on opus" work without changing `default.reviewModel` and without leaking that choice to every other reviewer step. -**Naming model is explicitly unaffected** - `default.namingModel` and `pickFallbackAigwNamingModel` still drive title generation regardless of role. +**Naming model is explicitly unaffected** - `default.namingModel` and `pickFallbackAigwNamingModel` (now gated on the first enabled `aigw`-type gateway's URL) still drive title generation regardless of role. A merged/local-only setup with no `aigw`-type gateway has no implicit Claude naming fallback and falls through to `default.sessionModel` / legacy Anthropic. ### UI @@ -1402,7 +1402,7 @@ Agent processes are now spawned with the desired model and reasoning level passe 1. Role override (`role.model` / `role.thinkingLevel` from the resolved cascade). 2. `default.sessionModel` / `default.sessionThinkingLevel` preference (or `default.reviewModel` for verification sub-sessions). -3. `undefined` - the aigw best-ranked fallback runs post-spawn via `tryAutoSelectModel` and emits a second `model_change` only on a cold cache. +3. `undefined` - the best-ranked model across enabled gateways runs post-spawn via `tryAutoSelectModel` and emits a second `model_change` only on a cold cache. `resolveBridgeOptions` in `src/server/agent/session-setup.ts` is the single call site for the normal-create pipeline; `session-manager.ts` re-runs the helpers at the role-respawn and force-abort respawn sites; `verification-harness.ts` does it at all three reviewer/QA sub-session sites; `server.ts` does it at the continue-archived endpoint. The pinned values are stored on `session.spawnPinnedModel` and `session.spawnPinnedThinkingLevel`. @@ -1440,7 +1440,9 @@ For the Pi 0.77 / Opus 4.8 compatibility contract, see [Pi 0.77 / Claude Opus 4. ## AI Gateway request headers (`User-Agent`, `x-opencode-session`) -Bobbit can route model traffic through a configured AI Gateway instead of directly to public providers. Gateway operators need to identify Bobbit-originated traffic for routing, analytics, and support, while Bobbit sessions still need per-session cache partitioning. Two headers cover those concerns: +> **Multi-gateway:** Bobbit now talks to a list of named, typed gateways, not a single `aigw` URL. The headers and Bedrock routing described here apply **only to the `aigw`-type gateway** (whose name is pinned to `"aigw"`); `openai-compatible` gateways (ollama, llama-swap, …) get plain OpenAI requests with **no** special headers and no Bedrock. Each enabled gateway writes one `providers.` block — for the `aigw` type that key is `aigw`. See [docs/multi-gateway-providers.md](multi-gateway-providers.md) for the full model. + +Bobbit can route model traffic through configured AI Gateways instead of directly to public providers. Gateway operators need to identify Bobbit-originated traffic for routing, analytics, and support, while Bobbit sessions still need per-session cache partitioning. Two headers cover those concerns (on the `aigw`-type gateway): - `User-Agent: Bobbit/` identifies the Bobbit build. The `` comes from Bobbit's current `package.json`, not a duplicated literal. - `x-opencode-session: ` partitions agent inference cache/routing per Bobbit session. It is emitted only when an agent subprocess has `BOBBIT_SESSION_ID` set. @@ -1454,14 +1456,14 @@ The Bobbit AI Gateway user agent is sent only on requests whose target is the co | Path | How the header is applied | |---|---| | Model discovery | `discoverAigwModels()` calls the gateway `/v1/models` endpoint through `httpGet()`, which uses `aigwUserAgentHeaders()`. | -| `/api/aigw/status` | If a gateway is configured, the route discovers fresh models, so the discovery request carries the header. | +| `/api/aigw/status` (legacy shim) | Status of the `aigw` gateway; if configured, the route discovers fresh models, so the discovery request carries the header. | | `/api/aigw/test` | Tests the submitted URL by running discovery against that URL with the header. | -| `/api/aigw/configure` | Runs discovery with the header, persists `aigw.url`, and rewrites `models.json`. | -| `/api/aigw/refresh` | Re-runs the configure flow for the stored gateway URL, so discovery and the generated provider config are refreshed together. | -| Startup refresh / auto-detect | `startupAigwCheck()` uses discovery for existing gateway refreshes and local gateway probing; reachable configured gateways are rewritten with the current headers. | -| `/api/aigw/v1/*` proxy | `proxyRequest()` forwards to the configured gateway with `User-Agent: Bobbit/` alongside content headers. | +| `/api/aigw/configure` (legacy shim) | Upserts the `aigw`-type gateway into the list, runs discovery with the header, and re-syncs `models.json`. | +| `/api/aigw/refresh` (legacy shim) | Re-discovers the `aigw` gateway, so discovery and the generated provider config are refreshed together. | +| Startup refresh / auto-detect | `startupAigwCheck()` uses discovery for existing gateway refreshes and local gateway probing; reachable configured gateways are rewritten with the current headers via `syncGatewaysModelsJson()`. | +| `/api/aigw/:name/v1/*` proxy (and legacy `/api/aigw/v1/*`) | `proxyRequest()` forwards to the named enabled gateway with `User-Agent: Bobbit/` alongside content headers. | | Direct title / goal-summary generation | The gateway title paths in `title-generator.ts` use `aigwUserAgentHeaders()` for both `/v1/models` model-id resolution and `/v1/chat/completions` generation calls. | -| Agent inference | `writeAigwModelsJson()` writes provider-level `providers.aigw.headers`, so pi-coding-agent sends the header on inference traffic routed through the generated `aigw` provider. | +| Agent inference | The `aigw`-type writer (`buildAigwProviderBlock`, run by `syncGatewaysModelsJson()`) writes provider-level `providers.aigw.headers`, so pi-coding-agent sends the header on inference traffic routed through that gateway. `openai-compatible` gateways write no header block. | ### AI Gateway model pricing @@ -1485,7 +1487,7 @@ The converted `cost` values flow through two surfaces: ### Generated `providers.aigw.headers` -`writeAigwModelsJson()` writes the AI Gateway provider into `~/.bobbit/agent/models.json` and preserves existing non-aigw providers and user `modelOverrides`. The generated provider-level header block contains both headers: +The `aigw`-type writer (`buildAigwProviderBlock`, run by `syncGatewaysModelsJson()`) writes the AI Gateway provider into `~/.bobbit/agent/models.json` and preserves existing non-gateway providers and user `modelOverrides`. The generated provider-level header block contains both headers (only `aigw`-type gateways emit it): ```json { @@ -1516,7 +1518,7 @@ pi-ai's Bedrock provider does not normally forward provider-level `headers` into ### Startup refresh behavior -On gateway startup, `startupAigwCheck()` checks whether `aigw.url` is already configured. If it is, Bobbit sets the Bedrock environment variables for subprocesses and, unless `BOBBIT_SKIP_AIGW_DISCOVERY=1` is set, re-discovers models from the configured gateway. A successful refresh rewrites `~/.bobbit/agent/models.json` with: +On gateway startup, `startupAigwCheck()` checks whether any gateway is enabled in the `modelGateways` list (after `migrateGatewayPrefs()` has folded any legacy `aigw.url` into it). If so, Bobbit sets/clears the Bedrock environment variables for subprocesses (from the enabled `aigw`-type gateway, if present) and, unless `BOBBIT_SKIP_AIGW_DISCOVERY=1` is set, re-discovers models for every enabled gateway via `syncGatewaysModelsJson()`. A successful refresh rewrites `~/.bobbit/agent/models.json` with: - the current gateway model list, - the current gateway-derived per-model `cost` values when `/v1/models` provides pricing, @@ -1531,8 +1533,8 @@ This means users with older `models.json` files pick up the user-agent header af The Bobbit AI Gateway user agent is not a process-wide default HTTP header. It is attached only by AI Gateway-specific helpers or by the generated `providers.aigw` entry: - `aigwUserAgentHeaders()` is used for AI Gateway discovery, proxying, and gateway title/goal-summary calls. -- `writeAigwModelsJson()` writes headers only under `providers.aigw`; non-aigw providers are preserved as-is. -- `removeAigwModelsJson()` removes the entire `aigw` provider block and leaves no orphan AI Gateway headers on other providers. +- The `aigw`-type writer writes headers only under `providers.aigw`; non-gateway providers and `openai-compatible` gateway blocks carry no AI Gateway headers. +- `syncGatewaysModelsJson()` prunes the blocks of disabled/removed gateways (tracked via the internal `_managedGatewayProviders` pref, plus the legacy `"aigw"` key) and leaves no orphan AI Gateway headers on other providers. - Direct public-provider paths, such as Anthropic title fallback or non-aigw model completion, do not use the Bobbit AI Gateway user-agent helper. - The Bedrock patch exits immediately for any model whose provider is not `aigw`. diff --git a/docs/multi-gateway-providers.md b/docs/multi-gateway-providers.md new file mode 100644 index 000000000..c90ff49d0 --- /dev/null +++ b/docs/multi-gateway-providers.md @@ -0,0 +1,329 @@ +# Multi-gateway providers + +Bobbit can route model traffic through an arbitrary list of **named, typed, +OpenAI-compatible gateways**. This lets you add local providers (ollama, +llama-swap / llama-server) alongside — or, for an enterprise AI Gateway, +exclusively instead of — the built-in cloud providers (Anthropic, OpenAI, +Google, …). + +> This page documents how the feature behaves and how to use it. For the design +> record and rationale (slices, test plan, alternatives considered), see +> [docs/design/multi-gateway-providers.md](design/multi-gateway-providers.md). + +## Big picture — where it fits + +Bobbit talks to LLMs in two ways: + +1. **Built-in cloud providers** discovered from pi-ai using whatever API keys + the host has configured. These appear in the model picker automatically. +2. **Gateways** — OpenAI-compatible HTTP endpoints Bobbit discovers, writes into + the agent's `~/.bobbit/agent/models.json`, and proxies for the browser. The + agent then binds models from those endpoints via `set_model`. + +Historically the gateway tier was a **single** endpoint, hardcoded under the +literal provider key `"aigw"`. That single, proven path already did everything +multiple gateways need — discovery, `models.json` writes, browser proxying, +startup re-discovery, title-gen routing — it was just one instance and carried a +fragile global heuristic ("model id contains `claude` ⇒ route through Bedrock"). + +Multi-gateway generalizes that one path into an ordered list. ollama and +llama-swap are *already* OpenAI-compatible gateways, so no new daemon was built +— Bobbit just treats each configured endpoint as a gateway with a `name`, a +`url`, and a `type`. **Why this shape:** reusing the working aigw consumer path +(rather than the never-bindable custom-provider path) means selecting a local +model actually binds instead of silently falling back to Claude. + +## The core concept: named, typed gateways + +Each gateway is one record: + +```ts +{ id: string; name: string; url: string; type: "aigw" | "openai-compatible"; enabled: boolean } +``` + +The whole list is persisted under the **`modelGateways`** preference (a JSON +array). The mental model the feature was designed around is `:` — a +human-chosen label bound to an endpoint. + +**`name` is the provider key, used everywhere:** + +- the `provider` field of each model in the picker (`/`), +- the `providers.` block key in `~/.bobbit/agent/models.json`, +- `set_model(, )` when the agent binds a model. + +`name` is validated server-side on save (`saveGateways` in +`src/server/agent/aigw-manager.ts`): non-empty, matching `^[a-zA-Z0-9._-]+$` +(so it is a safe `models.json` key and `provider/modelId` token), unique within +the list, and not colliding with a built-in pi-ai provider id (`anthropic`, +`openai`, `google`, `xai`, `amazon-bedrock`, …). + +`id` is an opaque UUID used only as a UI row key; it is never shown and never +appears in a provider string. + +## The two gateway types + +The `type` drives discovery, request shaping, and exclusivity. Only `aigw` and +`openai-compatible` are implemented today. + +| | `aigw` | `openai-compatible` | +|---|---|---| +| **For** | Enterprise AI Gateway | Local / generic OpenAI gateways (ollama, llama-swap, vLLM, …) | +| **Discovery** | `GET /v1/models` → `inferMeta` | `GET /v1/models` → `inferMeta` | +| **Claude ids** | Routed through **Bedrock Converse** (`api: "bedrock-converse-stream"`, per-model `/aws` baseUrl) | Plain OpenAI — **never** Bedrock, even for an id literally named `claude-*` | +| **Request headers** | Sends `User-Agent: Bobbit/` + `x-opencode-session` (provider-level `headers` block) | **No special headers** — no `x-opencode-session`, no User-Agent override | +| **Bedrock env** | Sets `AWS_*` env for subprocesses | Never touches Bedrock env | +| **`models.json` `api`** | `openai-completions` (+ `bedrock-converse-stream` for Claude ids) | `openai-completions` only | +| **baseUrl** | `url` as entered (trailing slashes stripped) | `url` normalized: trailing slashes stripped, `/v1` appended if missing | +| **Name** | **Pinned to `"aigw"`** (singleton) | Any valid name | +| **Exclusive?** | **Yes** (see below) | No (merged) | + +The key correctness fix here is that Claude→Bedrock routing is a property of the +**`aigw` type only** (`bedrockRoutesForType(t) === (t === "aigw")`). An +`openai-compatible` gateway exposing a model literally named `claude-*` is served +as plain OpenAI — removing the latent bug in the old global heuristic where any +gateway's `claude-*` model would be wrongly Bedrock-routed. + +### Why the `aigw` type's name is pinned to `"aigw"` + +An `aigw`-type gateway **must** be named exactly `"aigw"`, and at most one may +exist (`saveGateways` rejects any other name and any second `aigw` row with a +400). This is not arbitrary: three behavior-bearing guards key on the literal +provider string `"aigw"` and cannot be generalized cheaply: + +- `src/server/agent/pi-ai-bedrock-headers-patch.ts` — `model?.provider !== "aigw"` + short-circuits the Bedrock SDK middleware that injects the + `x-opencode-session` / `User-Agent` headers at the AWS client layer. +- `src/server/agent/model-completion.ts::resolveProviderHeaders` — + `provider !== "aigw"` gates whether the provider `headers` block from + `models.json` is attached to completion / title-gen requests. +- `src/shared/thinking-levels.ts::providerMatches` — `provider === "aigw"` is a + **client-side** guard (it sees only the provider string, with no prefs access) + that lets an aigw-routed `claude-opus-*` light up the `xhigh` thinking level. + +Because exclusivity already makes the enterprise gateway a singleton (one +enabled `aigw` shadows everything else), pinning its name to `"aigw"` keeps all +three guards correct **with zero changes** — the migrated default and any +user-created `aigw` gateway both retain header injection, Bedrock routing, and +`xhigh`. Generalizing the guards to "any `aigw`-type gateway name" was rejected +as disproportionate: `shared/thinking-levels.ts` runs client-side and would need +a separate canonical-family-hint mechanism. See +[docs/thinking-levels.md](thinking-levels.md#provider-guard--fail-closed-on-id-collisions) +for the `xhigh` guard detail. + +## Derived exclusivity + +Exclusivity is **derived from type, not a manual toggle** +(`isExclusiveMode(gateways)` = "is any enabled gateway of type `aigw`?"). There +is no `aigw.exclusive` preference anymore. + +- **Exclusive mode** (any enabled `aigw`-type gateway): only `aigw`-type + gateways contribute models. Built-in cloud providers **and** every + `openai-compatible` gateway are suppressed. +- **Merged mode** (otherwise): built-in cloud providers plus all enabled + `openai-compatible` gateways all contribute and merge together. + +**Why derived:** an enabled enterprise `aigw` is intentionally the *only* egress +in that deployment, so showing built-ins or local gateways alongside it would be +misleading. Disabling the `aigw` gateway (without deleting it) flips the whole +setup back to merged mode — which is how you temporarily switch to local +providers. + +| Gateways enabled | Mode | Contributes | +|---|---|---| +| one `aigw` | exclusive | that `aigw` only (built-ins + openai-compatible suppressed) | +| `aigw` + `openai-compatible` | exclusive | `aigw` only (openai-compatible suppressed) | +| only `openai-compatible`(s) | merged | built-ins + all enabled openai-compatible | +| `aigw` **disabled** + `openai-compatible` enabled | merged | built-ins + openai-compatible (disabled aigw ⇒ NOT exclusive) | +| none enabled | merged | built-ins only | + +The Settings editor surfaces this with an **exclusivity warning banner** +whenever an enabled row has `type === "aigw"`: + +> ⚠️ An AI Gateway (`aigw`) provider is enabled. While active, built-in cloud +> providers and other OpenAI-compatible gateways are **ignored** — only `aigw` +> models are available. Disable it to use local/built-in providers. + +## Configuring gateways + +### Settings → Models → AI Gateways + +The **AI Gateway** block on the Models settings tab is a list editor. Each row +has: + +- an **enable** checkbox (flip a gateway on/off without deleting it), +- a **name** text input (the provider key), +- a **url** text input, +- a **type** dropdown (`openai-compatible` | `aigw`), +- a per-row **Test** button (discovers the URL's models without saving), +- a **Remove** button. + +**+ Add gateway** appends a blank `openai-compatible` row; **Save** persists the +whole list. The exclusivity warning banner appears above the rows whenever an +enabled `aigw` row is present. Saving validates server-side — a misnamed `aigw` +row, a duplicate name, or a built-in-provider-id collision is rejected and the +error is shown inline. + +The configuration survives reload (it is reloaded from `GET /api/aigw/gateways`), +and removing a row + Save prunes that gateway's `providers.` block from +`models.json` so its models disappear from the picker. + +### REST endpoints + +The canonical list-management surface lives in `src/server/server.ts`: + +| Method · Path | Purpose | +|---|---| +| `GET /api/aigw/gateways` | Full list including disabled rows. | +| `PUT /api/aigw/gateways` | Replace the whole list — validates, fills missing `id`, persists, re-syncs `models.json`, invalidates the model cache. Returns the list + discovered `modelsByGateway`. | +| `POST /api/aigw/test` | Discover a URL's models without saving (body `{ url, type? }`). | +| `POST /api/aigw/gateways/:name/refresh` | Re-discover one gateway and re-sync. | +| `GET /api/aigw/gateways/:name/status` | Per-gateway `{ configured, name, url, type, enabled, models }`. | +| `/api/aigw/:name/v1/*` | **Proxy** to `/v1/*` for the named enabled gateway (404 if none). The browser may not reach the gateway host directly, so model discovery / completions route through here. | + +**Backward-compatible shims** (single-URL era) remain so older clients keep +working: `GET /api/aigw/status`, `POST`/`DELETE /api/aigw/configure`, +`POST /api/aigw/refresh`, and the legacy `/api/aigw/v1/*` proxy — all operate on +the gateway named `aigw` (or the first enabled gateway). + +`/api/models/test` accepts any configured gateway name as a `provider`, not just +`"aigw"`; `/api/health` and `/api/status` report `aigw: true` whenever **any** +gateway is enabled (the gateway tier handles LLM egress, so the browser OAuth +prompt is skipped). + +## Migration from the single `aigw` gateway + +On the first server boot after upgrading, `migrateGatewayPrefs` runs once +(before the startup gateway check) and converts the legacy single-URL prefs: + +- An existing non-empty `aigw.url` becomes one gateway + `{ name: "aigw", url, type: "aigw", enabled: true }` in `modelGateways`, and + the old `aigw.url` / `aigw.exclusive` keys are removed. +- If `modelGateways` is already present (even `[]`), migration is a no-op (it + just strips any leftover legacy keys). +- If there was no `aigw.url`, prefs are left untouched (readers treat absent as + `[]`). + +**Behavior is identical to before.** A migrated single `aigw` gateway is +exclusive by derivation, matching the old default (`aigw.exclusive` defaulted to +`true`). Because the migrated gateway keeps `name: "aigw"`, the provider key +stays `"aigw"`, so existing `default.sessionModel = "aigw/..."` (and +`default.namingModel`, role / review model prefs) keep resolving unchanged. + +> **One documented behavior change:** the rare user who had explicitly set +> `aigw.exclusive = false` loses merged mode for an `aigw`-type gateway — +> exclusivity is now derived, and the UI warning banner explains it. + +## `models.json` synchronization + +`syncGatewaysModelsJson` is the orchestrator that keeps +`~/.bobbit/agent/models.json` in step with the gateway list. It runs on every +relevant change (save, refresh, startup re-discovery) and: + +1. Discovers each **enabled** gateway and writes its `providers.` block via + the type-specific writer (`buildAigwProviderBlock` / + `buildOpenAiCompatibleProviderBlock`). +2. **Prunes** the blocks it previously managed (tracked in the internal + `_managedGatewayProviders` pref, plus the legacy `"aigw"` key) that are no + longer enabled — so disabled, removed, and renamed gateways are cleaned up. + Unrelated providers (`anthropic`, `amazon-bedrock`, custom) are never touched. +3. Preserves the last-good block for a gateway unreachable on this run (the + "gateway offline on startup ⇒ keep existing `models.json`" behavior). +4. Sets the `AWS_*` Bedrock env from the enabled `aigw`-type gateway, **or clears + it** when none is enabled — so disabling the `aigw` gateway restores a real + `amazon-bedrock` provider in the same process. + +`BOBBIT_SKIP_AIGW_DISCOVERY=1` still skips only the network discovery call at +startup; Bedrock env is still applied and the existing `models.json` is kept +as-is. + +## The "Has key" model-picker filter + +A separate, smaller addition: a default-OFF picker filter that hides built-in +models with no API key, so the list isn't cluttered by providers you never +configured. + +- It is the third filter button in the model picker's filter row (alongside + Vision and Thinking), labelled **Has key**. +- **Display-only.** The toggle lives entirely in the browser, persisted in + `localStorage` under `bobbit.modelPicker.hideUnauthed` (`"1"` / `"0"`). It is + never sent to `/api/models`, never affects server-side model resolution, and + never touches `default.sessionModel` validation. +- **Scoped to built-ins by construction.** Gateway models and custom-provider + rows are always emitted with `authenticated: true`, so only built-in cloud + models can ever be `authenticated: false` and thus hidden. The currently + selected model is never hidden, even if unauthenticated. + +Toggling persists across reload. Why a filter and not a hard removal: discovery +noise is useful when you're starting out and exploring providers, but pure +clutter once your setup has solidified — so it's an opt-in display preference, +not a behavior change. + +## Vision (image input) + +Image input works unchanged. `rpc-bridge.prompt()` dispatches attached images +unconditionally over the standard multimodal `/v1/chat/completions` path, so a +vision-capable model behind any gateway receives images with no extra code. + +**Limitation:** per-model metadata (including vision labeling) is inferred by +`inferMeta` for v1. A bare local VLM id on an `openai-compatible` gateway may not +be *labeled* vision-capable (no `image` in its `input`) in the picker until a +future native `ollama` type lands — even though images still pass through. This +is a labeling gap, not a functional one. + +## Extending: the future-type seam + +Adding a new gateway type later is deliberately localized to two dispatch tables +in `src/server/agent/aigw-manager.ts`: + +```ts +const DISCOVERY: Record Promise>; +const PROVIDER_WRITERS: Record; +``` + +To add a native type (for example `ollama` via `/api/tags` + `/api/show` for +real context window / capabilities / vision, or a native `llama-server` / +`llama-swap` protocol): + +1. Add the literal to the `GatewayType` union. +2. Register a discovery function in `DISCOVERY`. +3. Register a writer in `PROVIDER_WRITERS` (its own `api` / baseUrl / headers + shape). +4. Add the type to the Settings ` updateGatewayRow(g.id, { enabled: (e.target as HTMLInputElement).checked })} + /> + setGatewayField(g.id, "name", (e.target as HTMLInputElement).value)} + /> + setGatewayField(g.id, "url", (e.target as HTMLInputElement).value)} + /> + + + + + ${err ? html`
${err}
` : ""} + ${!err && (notice || models.length > 0) ? html` +
+ ${notice ? html`${notice}` : ""} + ${models.length > 0 ? html` + + ` : ""} +
+ ` : ""} + + `; +} + export function renderModelsTab() { loadModelsState(); - const busy = aigwStatus !== "idle"; - const hasModels = aigwModels.length > 0; + const busy = gatewaysSaving; + const exclusivePending = gatewaysExclusivePending(); + const combined = combinedGatewayModels(); + const hasModels = combined.length > 0; return html`
- +
-

AI Gateway

+

AI Gateways

- Connect to an AI Gateway for on-prem LLM access through a single - OpenAI-compatible endpoint. When configured, only gateway models are shown. + Connect to one or more OpenAI-compatible gateways for LLM access — an + enterprise aigw endpoint, or local providers such as ollama + or llama-swap. Each gateway's name is its provider in the model picker.

- -
- -
- { aigwUrl = (e.target as HTMLInputElement).value; }} - /> - -
-
- - - ${aigwError ? html` -
- ${aigwError} + ${exclusivePending ? html` +
+ ⚠️ An AI Gateway (aigw) provider is enabled. While active, + built-in cloud providers and other OpenAI-compatible gateways are + ignored — only aigw models are available. + Disable it to use local/built-in providers.
` : ""} - - ${aigwConfigured ? html` -
- - Connected to ${aigwConfiguredUrl} +
+ ${gateways.length === 0 ? html` +

+ No gateways configured — built-in cloud providers are used. +

+ ` : ""} + ${gateways.map((g) => renderGatewayRow(g, busy))} +
+ + ${gatewaysError ? html` +
+ ${gatewaysError}
- ` : ""} -
- ${aigwConfigured ? html` - - - ` : ""} + class="px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground hover:bg-secondary transition-colors disabled:opacity-50" + title="Add a gateway" + data-testid="gateways-add-btn" + ?disabled=${busy} + @click=${addGatewayRow} + >+ Add gateway +
-
@@ -2143,7 +2260,7 @@ export function renderModelsTab() { class="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2" data-testid="view-aigw-models-btn" @click=${openAigwModelsDialog} - >View available models… (${aigwModels.length}) + >View available models… (${combined.length})
` : ""}
diff --git a/src/server/agent/aigw-manager.ts b/src/server/agent/aigw-manager.ts index 5ac83cb90..a96efa0bf 100644 --- a/src/server/agent/aigw-manager.ts +++ b/src/server/agent/aigw-manager.ts @@ -1,21 +1,33 @@ /** - * AI Gateway (aigw) manager — handles model discovery, models.json generation, - * and HTTP proxying for browser-side API access. + * Model-gateway manager — handles discovery, models.json generation, and HTTP + * proxying for an ordered list of named, typed OpenAI-compatible gateways. * - * When the user configures an aigw URL in preferences: - * 1. Server fetches available models from the gateway's /v1/models endpoint - * 2. Server writes/merges an "aigw" provider into ~/.bobbit/agent/models.json - * so agent subprocesses can use `set_model` with provider="aigw" - * 3. Browser discovers models via server proxy (the aigw hostname may not - * resolve from the browser) + * Each gateway is a {@link ModelGateway} identified by a user-chosen `name`, + * which is the provider key used EVERYWHERE: the picker `provider`, the + * `models.json` block key, and `set_model(name, id)`. Two types are supported: * - * When aigw is removed, the "aigw" provider is cleaned from models.json. + * - `aigw` — enterprise AI-Gateway: Bedrock-routes Claude ids, + * sends the `x-opencode-session` / User-Agent headers, + * and is exclusive (shadows all built-ins + other + * gateways). Pinned to the singleton name "aigw" so + * the three literal `"aigw"` guards in + * pi-ai-bedrock-headers-patch.ts / model-completion.ts + * / shared/thinking-levels.ts stay correct unchanged. + * - `openai-compatible` — plain OpenAI gateway (ollama / llama-swap / vLLM …): + * no Bedrock, no special headers, never exclusive. + * + * For each enabled gateway the server discovers models (GET /v1/models) and + * writes one `providers.` block into ~/.bobbit/agent/models.json so agent + * subprocesses can bind it via `set_model`. Removed / disabled gateways are + * pruned. The two dispatch tables ({@link DISCOVERY}, PROVIDER_WRITERS) are the + * documented extension point for future native types (ollama, llama-server …). */ import http from "node:http"; import https from "node:https"; import fs from "node:fs"; import path from "node:path"; +import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { globalAgentDir } from "../bobbit-dir.js"; import { BOBBIT_AIGW_USER_AGENT, aigwUserAgentHeaders } from "./aigw-user-agent.js"; @@ -47,6 +59,25 @@ export interface AigwConfig { models: AigwModel[]; } +/** + * The discovery/request-shaping family a gateway belongs to. Only `aigw` and + * `openai-compatible` are implemented now; new native types (e.g. `ollama`, + * `llama-server`) slot in by extending this union plus the two dispatch tables + * (see §10 of docs/design/multi-gateway-providers.md). + */ +export type GatewayType = "aigw" | "openai-compatible"; + +export interface ModelGateway { + /** Stable identity (crypto.randomUUID()); never shown, used only as a UI row key. */ + id: string; + /** Provider key used EVERYWHERE: picker `provider`, models.json block key, set_model(name, id). */ + name: string; + /** Base URL as the user entered it (may or may not end with /v1). */ + url: string; + type: GatewayType; + enabled: boolean; +} + // ── Well-known model metadata ────────────────────────────────────── interface ModelMeta { @@ -321,13 +352,12 @@ export function writeContextWindowOverrides(): void { } } +// ── Bedrock environment ──────────────────────────────────────────── + /** - * Write aigw models into ~/.bobbit/agent/models.json, merging with existing - * providers (preserving non-aigw entries). - */ -/** - * Set env vars so agent subprocesses route Bedrock calls through the gateway. - * Called both on fresh configuration and on startup when aigw is already configured. + * Set env vars so agent subprocesses route Bedrock calls through an `aigw` + * gateway. Called from {@link syncGatewaysModelsJson} (and the startup check) + * when an enabled `aigw`-type gateway is present. */ function setBedrockEnvVars(aigwUrl: string): void { const bedrockBaseUrl = aigwUrl.replace(/\/+$/, "").replace(/\/v1$/, "") + "/aws"; @@ -340,21 +370,83 @@ function setBedrockEnvVars(aigwUrl: string): void { console.log(`[aigw] Bedrock env configured: endpoint=${bedrockBaseUrl}`); } -export function writeAigwModelsJson(aigwUrl: string, models: AigwModel[]): void { - const data = readModelsJson(); - if (!data.providers) data.providers = {}; +/** + * Clear the four AWS_* vars set by {@link setBedrockEnvVars}. Called when no + * enabled `aigw`-type gateway exists so disabling the gateway restores a real + * `amazon-bedrock` provider in the same process. `AWS_REGION` is intentionally + * left alone (it may be a genuine user setting, and setBedrockEnvVars only sets + * it when previously unset). + */ +function clearBedrockEnvVars(): void { + if (process.env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME !== undefined) { + console.log("[aigw] No enabled aigw-type gateway — clearing Bedrock env"); + } + delete process.env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME; + delete process.env.AWS_BEDROCK_FORCE_HTTP1; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; +} - // AI gateways typically expose both OpenAI-compatible and Bedrock endpoints. - // Route Claude models through the Bedrock Converse API (same path as Claude - // Code) for full feature parity — native tool use, images, streaming. - // Non-Claude models use OpenAI completions with conservative compat. - const normalizedUrl = aigwUrl.replace(/\/+$/, ""); +/** + * Set Bedrock env from the (first) enabled `aigw`-type gateway, or clear it when + * none is present. The single point that owns Bedrock env so merged mode never + * hijacks a real `amazon-bedrock` provider. + */ +function applyAigwBedrockEnv(aigwGateway: ModelGateway | undefined): void { + if (aigwGateway) setBedrockEnvVars(aigwGateway.url); + else clearBedrockEnvVars(); +} + +// ── Type predicates + URL helpers ────────────────────────────────── + +/** Whether a model id should be Bedrock-routed (Claude family). */ +export function isClaudeId(id: string): boolean { + return id.toLowerCase().includes("claude"); +} + +/** Strip a leading `provider/` prefix, e.g. "aws/us.anthropic.x" → "us.anthropic.x". */ +export function stripProviderPrefix(id: string): string { + const i = id.indexOf("/"); + return i >= 0 ? id.slice(i + 1) : id; +} + +/** + * Whether a gateway type Bedrock-routes Claude ids. Only `aigw` does — this is + * the property that makes Claude→Bedrock routing local to the `aigw` type and + * removes the old global heuristic (an `openai-compatible` gateway exposing a + * model literally named `claude-*` must NEVER be Bedrock-routed). + */ +export function bedrockRoutesForType(t: GatewayType): boolean { + return t === "aigw"; +} + +/** Strip trailing slashes; append `/v1` unless already present. */ +function normalizeOpenAiBaseUrl(url: string): string { + const trimmed = url.replace(/\/+$/, ""); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +} + +// ── Type-driven models.json writers ──────────────────────────────── + +type ProviderBlock = Record; +type GatewayWriter = (gateway: ModelGateway, models: AigwModel[]) => ProviderBlock; + +/** + * Build the `aigw`-type provider block. Byte-for-byte the behavior of the old + * single-URL writer, keyed by `gateway.name` and reading `gateway.url`: + * - provider-level `x-opencode-session` / User-Agent headers, + * - Claude ids → prefix stripped + `bedrock-converse-stream` + per-model + * `/aws` baseUrl, + * - non-Claude ids → `openai-completions` with conservative compat flags. + * Does NOT touch Bedrock env — {@link syncGatewaysModelsJson} owns that. + */ +export function buildAigwProviderBlock(gateway: ModelGateway, models: AigwModel[]): ProviderBlock { + const normalizedUrl = gateway.url.replace(/\/+$/, ""); // Bedrock Converse traffic goes to /aws/model//converse-stream; // the provider's normalized baseUrl ends in /v1 for the OpenAI-compatible path // and is wrong for Bedrock. pi-ai uses `model.baseUrl` directly as the // `BedrockRuntimeClient` endpoint, so emit a per-model override on Claude - // entries pointing at the /aws sub-tree. Mirrors the env var written by - // setBedrockEnvVars() but survives across subprocess/env-strip boundaries. + // entries pointing at the /aws sub-tree. const bedrockBaseUrl = normalizedUrl.replace(/\/v1$/, "") + "/aws"; const openaiCompat: Record = { @@ -366,15 +458,7 @@ export function writeAigwModelsJson(aigwUrl: string, models: AigwModel[]): void maxTokensField: "max_tokens", }; - const isClaudeModel = (id: string) => id.toLowerCase().includes("claude"); - - // Strip provider prefix for Bedrock (e.g. "aws/us.anthropic.claude-..." → "us.anthropic.claude-...") - const bedrockModelId = (id: string) => { - const slash = id.indexOf("/"); - return slash >= 0 ? id.slice(slash + 1) : id; - }; - - data.providers.aigw = { + return { baseUrl: normalizedUrl, apiKey: "none", api: "openai-completions", @@ -390,9 +474,9 @@ export function writeAigwModelsJson(aigwUrl: string, models: AigwModel[]): void }, models: models.map(m => { const cost = m.cost ?? zeroAigwCost(); - if (isClaudeModel(m.id)) { + if (isClaudeId(m.id)) { return { - id: bedrockModelId(m.id), + id: stripProviderPrefix(m.id), name: m.name, contextWindow: m.contextWindow, maxTokens: m.maxTokens, @@ -418,21 +502,282 @@ export function writeAigwModelsJson(aigwUrl: string, models: AigwModel[]): void }; }), }; +} - setBedrockEnvVars(aigwUrl); +/** + * Build an `openai-compatible` provider block. Plain OpenAI for EVERY model — + * including a model literally named `claude-*` (the key fix to the latent + * multi-gateway bug). No headers, no Bedrock, baseUrl normalized to end `/v1`. + */ +export function buildOpenAiCompatibleProviderBlock(gateway: ModelGateway, models: AigwModel[]): ProviderBlock { + return { + baseUrl: normalizeOpenAiBaseUrl(gateway.url), + apiKey: "none", + api: "openai-completions", + models: models.map(m => ({ + id: m.id, + name: m.name, + // Plain OpenAI for EVERY model — never bedrock-converse-stream, even for + // a model literally named claude-*. Explicit per-model `api` so the + // no-Bedrock guarantee is visible on each entry, not just inherited. + api: "openai-completions", + contextWindow: m.contextWindow, + maxTokens: m.maxTokens, + reasoning: m.reasoning, + input: m.input, + cost: m.cost ?? zeroAigwCost(), + compat: { ...GATEWAY_COMPAT, ...(m.compat || {}) }, + })), + }; +} - writeModelsJson(data); +/** + * Per-type provider-block writers. Extension point for future native types + * (see §10 of docs/design/multi-gateway-providers.md): register a new writer + * here keyed by the new {@link GatewayType}. + */ +const PROVIDER_WRITERS: Record = { + "aigw": buildAigwProviderBlock, + "openai-compatible": buildOpenAiCompatibleProviderBlock, +}; + +/** + * Per-type discovery. Both implemented types use the same `GET /v1/models` + * shape for now; a future native type registers its own protocol fn here. + */ +const DISCOVERY: Record Promise> = { + "aigw": discoverAigwModels, + "openai-compatible": discoverAigwModels, +}; + +/** Discover a gateway's models via the dispatch table for its type. */ +export function discoverGatewayModels(g: ModelGateway): Promise { + return DISCOVERY[g.type](g.url); } +// ── Gateway list preferences ─────────────────────────────────────── + +const GATEWAYS_PREF_KEY = "modelGateways"; +/** Internal bookkeeping pref — the gateway names we last wrote into models.json. */ +const MANAGED_PROVIDERS_PREF_KEY = "_managedGatewayProviders"; + +const GATEWAY_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; + /** - * Remove the "aigw" provider from models.json. + * pi-ai built-in provider ids a gateway `name` must not collide with. Mirrors + * `model-registry.ts::ENV_MAP` (kept inline to avoid a registry↔manager import + * cycle). Note "aigw" is intentionally NOT here — it is the reserved singleton + * name for the `aigw`-type gateway. */ -export function removeAigwModelsJson(): void { +const BUILTIN_PROVIDER_IDS = new Set([ + "anthropic", + "openai", + "google", + "google-gemini-cli", + "google-vertex", + "xai", + "amazon-bedrock", + "groq", + "mistral", +]); + +/** Parse + sanitise one persisted row into a ModelGateway, or undefined if malformed. */ +function parseGatewayRow(row: unknown): ModelGateway | undefined { + if (!row || typeof row !== "object") return undefined; + const o = row as Record; + if (typeof o.name !== "string" || typeof o.url !== "string") return undefined; + const type = o.type === "aigw" || o.type === "openai-compatible" ? o.type : undefined; + if (!type) return undefined; + return { + id: typeof o.id === "string" && o.id ? o.id : randomUUID(), + name: o.name, + url: o.url, + type, + enabled: typeof o.enabled === "boolean" ? o.enabled : true, + }; +} + +/** Full gateway list (incl. disabled). Defensive: `[]` for any non-array / malformed value. */ +export function listGateways(prefs: PreferencesStore): ModelGateway[] { + const raw = prefs.get(GATEWAYS_PREF_KEY); + if (!Array.isArray(raw)) return []; + const out: ModelGateway[] = []; + for (const row of raw) { + const g = parseGatewayRow(row); + if (g) out.push(g); + } + return out; +} + +/** Only enabled gateways. */ +export function getEnabledGateways(prefs: PreferencesStore): ModelGateway[] { + return listGateways(prefs).filter(g => g.enabled); +} + +/** Look up a gateway by its `name` (provider key), or undefined. */ +export function getGatewayByName(prefs: PreferencesStore, name: string): ModelGateway | undefined { + return listGateways(prefs).find(g => g.name === name); +} + +/** + * Exclusive mode is DERIVED, not a manual toggle: any enabled `aigw`-type + * gateway makes the whole setup exclusive (built-ins + `openai-compatible` + * gateways are suppressed; only `aigw`-type contributes). + */ +export function isExclusiveMode(gateways: ModelGateway[]): boolean { + return gateways.some(g => g.enabled && g.type === "aigw"); +} + +/** + * Validate and persist the gateway list (replaces the whole list). Fills any + * missing `id` with a fresh UUID. Throws on any §1 violation: + * - name empty / not matching `^[a-zA-Z0-9._-]+$`, + * - name colliding with a built-in provider id, + * - duplicate names (case-sensitive), + * - an `aigw`-type gateway named ≠ "aigw", or more than one `aigw`-type row. + */ +export function saveGateways(prefs: PreferencesStore, gateways: ModelGateway[]): void { + const seen = new Set(); + let aigwCount = 0; + const normalized: ModelGateway[] = []; + + for (const g of gateways) { + const name = (g?.name ?? "").trim(); + if (!name) throw new Error("Gateway name must not be empty"); + if (!GATEWAY_NAME_PATTERN.test(name)) { + throw new Error(`Invalid gateway name "${name}": must match ${GATEWAY_NAME_PATTERN.source}`); + } + if (BUILTIN_PROVIDER_IDS.has(name)) { + throw new Error(`Gateway name "${name}" collides with a built-in provider id`); + } + if (seen.has(name)) throw new Error(`Duplicate gateway name "${name}"`); + seen.add(name); + + if (g.type !== "aigw" && g.type !== "openai-compatible") { + throw new Error(`Invalid gateway type "${String(g.type)}" for "${name}"`); + } + if (g.type === "aigw") { + aigwCount++; + if (name !== "aigw") { + throw new Error(`An aigw-type gateway must be named "aigw" (got "${name}")`); + } + if (aigwCount > 1) throw new Error("At most one aigw-type gateway is allowed"); + } + + normalized.push({ + id: typeof g.id === "string" && g.id ? g.id : randomUUID(), + name, + url: (g.url ?? "").trim(), + type: g.type, + enabled: g.enabled !== false, + }); + } + + prefs.set(GATEWAYS_PREF_KEY, normalized); +} + +/** + * Idempotent boot-time migration of the legacy single-URL prefs + * (`aigw.url` [+ `aigw.exclusive`]) into the `modelGateways` list. Called once + * at server boot before {@link startupAigwCheck}. + * + * Rules: + * 1. `modelGateways` already present (even []) → no-op; defensively strip any + * leftover `aigw.url` / `aigw.exclusive`. + * 2. Non-empty `aigw.url` → create one `{name:"aigw", type:"aigw"}` gateway, + * remove `aigw.url` + `aigw.exclusive` (exclusivity is now derived, §4). + * 3. Nothing to migrate → leave prefs untouched (readers treat absent as []). + * + * The migrated gateway keeps `name:"aigw"`, so existing + * `default.sessionModel = "aigw/"` (etc.) continue to resolve unchanged. + */ +export function migrateGatewayPrefs(prefs: PreferencesStore): { migrated: boolean; gateways: ModelGateway[] } { + const existing = prefs.get(GATEWAYS_PREF_KEY); + if (existing !== undefined) { + // Already on the new schema — no-op, but strip any stale legacy keys. + prefs.remove("aigw.url"); + prefs.remove("aigw.exclusive"); + return { migrated: false, gateways: listGateways(prefs) }; + } + + const aigwUrl = prefs.get("aigw.url"); + if (typeof aigwUrl === "string" && aigwUrl.trim()) { + const gateways: ModelGateway[] = [{ + id: randomUUID(), + name: "aigw", + url: aigwUrl.replace(/\/+$/, ""), + type: "aigw", + enabled: true, + }]; + prefs.set(GATEWAYS_PREF_KEY, gateways); + prefs.remove("aigw.url"); + prefs.remove("aigw.exclusive"); + console.log("[aigw] Migrated legacy aigw.url → modelGateways list"); + return { migrated: true, gateways }; + } + + return { migrated: false, gateways: [] }; +} + +// ── models.json sync orchestrator ────────────────────────────────── + +function readManagedProviders(prefs: PreferencesStore): string[] { + const v = prefs.get(MANAGED_PROVIDERS_PREF_KEY); + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +/** + * Discover the enabled gateways and (re)write their `providers.` blocks + * into models.json, pruning everything we previously managed, never clobbering + * unrelated providers (anthropic / amazon-bedrock / custom). Preserves the + * last-good block for a gateway that is unreachable this run. Sets/clears the + * Bedrock env based on the presence of an enabled `aigw`-type gateway. + * + * @returns discovered models keyed by gateway name (empty array on failure). + */ +export async function syncGatewaysModelsJson(prefs: PreferencesStore): Promise> { + const gateways = listGateways(prefs); + const enabled = gateways.filter(g => g.enabled); + const enabledNames = new Set(enabled.map(g => g.name)); + const data = readModelsJson(); - if (data.providers?.aigw) { - delete data.providers.aigw; - writeModelsJson(data); + if (!data.providers) data.providers = {}; + const existingBlocks: Record = { ...data.providers }; + + // Prune: the keys we own = previously-managed names ∪ {"aigw"} (legacy + // single-URL block). Any owned key no longer enabled is removed — this + // handles disabled, removed, and renamed gateways without the previous list. + const owned = new Set([...readManagedProviders(prefs), "aigw"]); + for (const key of owned) { + if (!enabledNames.has(key)) delete data.providers[key]; } + + const discovered: Record = {}; + for (const g of enabled) { + try { + const models = await DISCOVERY[g.type](g.url); + data.providers[g.name] = PROVIDER_WRITERS[g.type](g, models); + discovered[g.name] = models; + } catch (err: any) { + const msg = err?.message || String(err); + if (existingBlocks[g.name] !== undefined) { + // Keep the last-good block (preserves the "gateway unreachable on + // startup ⇒ keep existing models.json" behavior). + data.providers[g.name] = existingBlocks[g.name]; + console.warn(`[aigw] gateway unreachable on startup (${msg}), keeping existing models.json for "${g.name}"`); + } else { + console.warn(`[aigw] gateway "${g.name}" unreachable (${msg}), no existing models to keep`); + } + discovered[g.name] = []; + } + } + + // Bedrock env is owned here so merged mode never hijacks a real bedrock provider. + applyAigwBedrockEnv(enabled.find(g => g.type === "aigw")); + + writeModelsJson(data); + prefs.set(MANAGED_PROVIDERS_PREF_KEY, [...enabledNames]); + return discovered; } // ── Startup internet check ───────────────────────────────────────── @@ -488,22 +833,29 @@ export async function checkInternetAvailable(): Promise { } /** - * Run once at gateway startup: - * - If aigw is already configured, nothing to do. - * - If not configured but internet is unavailable, try to auto-discover - * a gateway at a well-known local URL and configure it. + * Run once at gateway startup (list-aware): + * - Defensively run {@link migrateGatewayPrefs} (idempotent — boot also runs + * it earlier; this keeps standalone callers / tests robust). + * - If any gateway is enabled, set/clear Bedrock env, wire PI_OFFLINE, and + * (unless `BOBBIT_SKIP_AIGW_DISCOVERY`) re-run {@link syncGatewaysModelsJson}. + * - Else, when offline, probe well-known local URLs and, if a gateway is + * found, create a `{type:"aigw"}` gateway and sync. * - * Returns true if aigw is active after this call. + * Returns true if any gateway is active after this call. */ export async function startupAigwCheck(prefs: PreferencesStore): Promise { - // Already configured — ensure env vars are set and models.json is up to date - const existingUrl = getAigwUrl(prefs); - if (existingUrl) { - console.log("[aigw] AI Gateway already configured:", existingUrl); - setBedrockEnvVars(existingUrl); - // Users with a local aigw are typically offline; probe the public - // internet once and wire PI_OFFLINE accordingly. The probe is short - // (≤4s) and runs in parallel with no other startup work below. + migrateGatewayPrefs(prefs); + + const enabled = getEnabledGateways(prefs); + + if (enabled.length > 0) { + // Already configured — set/clear Bedrock env up-front (so it is correct + // even when re-discovery is skipped) and refresh models.json. + applyAigwBedrockEnv(enabled.find(g => g.type === "aigw")); + console.log("[aigw] gateways configured:", enabled.map(g => `${g.name} (${g.type})`).join(", ")); + + // Users with a local gateway are typically offline; probe the public + // internet once and wire PI_OFFLINE accordingly. if (!process.env.BOBBIT_SKIP_AIGW_DISCOVERY) { try { const hasInternet = await checkInternetAvailable(); @@ -512,27 +864,21 @@ export async function startupAigwCheck(prefs: PreferencesStore): Promise 0) { - console.log(`[aigw] Found gateway at ${url} with ${models.length} models — auto-configuring`); - await configureAigw(url, prefs); + const normalizedUrl = url.replace(/\/+$/, ""); + console.log(`[aigw] Found gateway at ${normalizedUrl} with ${models.length} models — auto-configuring`); + saveGateways(prefs, [{ + id: randomUUID(), + name: "aigw", + url: normalizedUrl, + type: "aigw", + enabled: true, + }]); + await syncGatewaysModelsJson(prefs); return true; } } catch { @@ -688,8 +1042,10 @@ export function proxyRequest( // ── Public API ───────────────────────────────────────────────────── /** - * Fetch the model list from an aigw endpoint and return structured model info. + * Fetch the model list from a gateway endpoint and return structured model info. * Hits GET {baseUrl}/v1/models (or {baseUrl}/models if baseUrl already ends with /v1). + * Returns raw ids (no prefix stripping) — the type-driven writers/registry decide + * how to normalise them. */ export async function discoverAigwModels(baseUrl: string): Promise { const url = baseUrl.replace(/\/+$/, ""); @@ -718,46 +1074,3 @@ export async function discoverAigwModels(baseUrl: string): Promise }; }); } - -/** - * Full configure flow: discover models, persist preference, write models.json. - * Returns the discovered models. - */ -export async function configureAigw(baseUrl: string, prefs: PreferencesStore): Promise { - const rawModels = await discoverAigwModels(baseUrl); - const normalizedUrl = baseUrl.replace(/\/+$/, ""); - - // Normalize model IDs: Claude models get the provider prefix stripped - // (e.g. "aws/us.anthropic.claude-..." → "us.anthropic.claude-...") because - // they use the Bedrock API where the ID is just the Bedrock model ARN. - const isClaudeModel = (id: string) => id.toLowerCase().includes("claude"); - const stripPrefix = (id: string) => { const i = id.indexOf("/"); return i >= 0 ? id.slice(i + 1) : id; }; - const models = rawModels.map(m => isClaudeModel(m.id) - ? { ...m, id: stripPrefix(m.id), api: "bedrock-converse-stream" } - : m - ); - - prefs.set("aigw.url", normalizedUrl); - // Note: aigw.models no longer cached in preferences — model-registry discovers fresh each time - - writeAigwModelsJson(normalizedUrl, models); - return models; -} - -/** - * Remove aigw configuration. - */ -export function removeAigw(prefs: PreferencesStore): void { - prefs.remove("aigw.url"); - prefs.remove("aigw.models"); - removeAigwModelsJson(); -} - -/** - * Get the currently configured aigw URL (if any). - */ -export function getAigwUrl(prefs: PreferencesStore): string | undefined { - return prefs.get("aigw.url") as string | undefined; -} - -// getAigwModels() has been removed — model-registry discovers fresh each time diff --git a/src/server/agent/model-registry.ts b/src/server/agent/model-registry.ts index 55e45d09d..16feec68b 100644 --- a/src/server/agent/model-registry.ts +++ b/src/server/agent/model-registry.ts @@ -3,7 +3,7 @@ * * Assembles a merged model list from: * 1. Built-in providers (from pi-ai getProviders()/getModels()) - * 2. AI Gateway models (if configured, live fetch via discoverAigwModels()) + * 2. Gateway models (enabled named/typed gateways, live fetch via discoverGatewayModels()) * 3. Custom local providers (Ollama, LM Studio, vLLM, llama.cpp) * * Served via GET /api/models with a 5-second TTL cache. @@ -15,7 +15,16 @@ import https from "node:https"; import { getProviders, getModels } from "@earendil-works/pi-ai"; import type { PreferencesStore } from "./preferences-store.js"; import { globalAuthPath } from "../bobbit-dir.js"; -import { inferMeta, discoverAigwModels, getAigwUrl } from "./aigw-manager.js"; +import { + inferMeta, + discoverGatewayModels, + listGateways, + getEnabledGateways, + isExclusiveMode, + isClaudeId, + stripProviderPrefix, + bedrockRoutesForType, +} from "./aigw-manager.js"; import { getOpenAIModelAdditions } from "./openai-model-additions.js"; // ── Types ────────────────────────────────────────────────────────── @@ -87,14 +96,13 @@ export async function getAvailableModels(prefs: PreferencesStore): Promise k.startsWith("providerKey.")).sort(), ]); @@ -122,19 +130,21 @@ function builtInNumber(modelId: string, explicitValue: unknown, inferredValue: n async function assembleModels(prefs: PreferencesStore): Promise { const results: ApiModel[] = []; - const aigwUrl = getAigwUrl(prefs); - - // When an AI Gateway is configured, it is treated as the single egress path - // by default — built-in upstream providers (anthropic, openai, bedrock, ...) - // are hidden because in a secure-zone deployment they can't be reached - // directly. Users who need to see built-ins alongside the gateway (e.g. for - // local development against a real API key AND a dev gateway) can opt out - // by setting `aigw.exclusive` to false in preferences. - // Custom local providers (Ollama, LM Studio) are always shown because they - // live on the user's own machine, not behind the gateway. - const aigwExclusive = aigwUrl ? (prefs.get("aigw.exclusive") as boolean | undefined) ?? true : false; - - if (!aigwExclusive) { + const gateways = listGateways(prefs); + const enabled = getEnabledGateways(prefs); + + // Exclusivity is DERIVED from gateway types (see aigw-manager.ts::isExclusiveMode + // and docs/design/multi-gateway-providers.md §4): any enabled `aigw`-type + // gateway makes the setup exclusive. In exclusive mode the enterprise gateway + // is the single egress path — built-in upstream providers (anthropic, openai, + // bedrock, ...) AND `openai-compatible` gateways are suppressed because in a + // secure-zone deployment they can't be reached directly. In merged mode + // built-ins + all enabled `openai-compatible` gateways contribute together. + // Custom local providers (Ollama, LM Studio) are always shown (both modes) + // because they live on the user's own machine, not behind the gateway. + const exclusive = isExclusiveMode(gateways); + + if (!exclusive) { // 1. Built-in providers from pi-ai try { const providers = getProviders(); @@ -171,38 +181,40 @@ async function assembleModels(prefs: PreferencesStore): Promise { } } - // 2. AI Gateway models (if configured) - if (aigwUrl) { + // 2. Gateway models — loop the enabled gateways. In exclusive mode only + // `aigw`-type gateways contribute (matching the built-in suppression above); + // `openai-compatible` gateways are skipped. + for (const g of enabled) { + if (exclusive && g.type !== "aigw") continue; try { - const aigwModels = await discoverAigwModels(aigwUrl); - // IMPORTANT: Claude models get their provider prefix stripped and are - // routed through bedrock-converse-stream by configureAigw() when writing - // models.json. The agent's rpc `set_model` does a strict equality match - // against that file, so the IDs we return here MUST match the stripped - // form — otherwise picking a Claude model from the UI silently fails - // (the agent rejects with "Model not found", the error is swallowed, - // and the next prompt goes to the previously bound model). - const isClaudeModel = (id: string) => id.toLowerCase().includes("claude"); - const stripPrefix = (id: string) => { const i = id.indexOf("/"); return i >= 0 ? id.slice(i + 1) : id; }; - for (const m of aigwModels) { - const normalizedId = isClaudeModel(m.id) ? stripPrefix(m.id) : m.id; - const meta = inferMeta(normalizedId); + const discovered = await discoverGatewayModels(g); + // Claude→Bedrock routing is a property of the `aigw` TYPE only + // (bedrockRoutesForType). An `openai-compatible` gateway exposing a model + // literally named `claude-*` must NEVER be Bedrock-routed. + // For Bedrock-routed ids the provider prefix is stripped to match the + // `providers[name]` block written by syncGatewaysModelsJson — the agent's + // rpc `set_model` does a strict equality match against that file, so a + // mismatch silently falls back to the previously bound model (#13/#14). + for (const m of discovered) { + const bedrock = bedrockRoutesForType(g.type) && isClaudeId(m.id); + const id = bedrock ? stripProviderPrefix(m.id) : m.id; + const meta = inferMeta(id); results.push({ - id: normalizedId, + id, name: m.name, - provider: "aigw", - api: isClaudeModel(m.id) ? "bedrock-converse-stream" : (m.api || "openai-completions"), - baseUrl: aigwUrl, + provider: g.name, + api: bedrock ? "bedrock-converse-stream" : (m.api || "openai-completions"), + baseUrl: g.url, contextWindow: Math.max(meta.contextWindow, m.contextWindow || 0), maxTokens: Math.max(meta.maxTokens, m.maxTokens || 0), reasoning: meta.reasoning || m.reasoning || false, input: meta.input || ["text"], cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - authenticated: true, // aigw is always authenticated (no key needed) + authenticated: true, // gateways are always authenticated (no key needed) }); } } catch (err) { - console.error("[model-registry] Failed to discover AI Gateway models:", err); + console.error(`[model-registry] Failed to discover gateway "${g.name}" models:`, err); } } diff --git a/src/server/agent/session-manager.ts b/src/server/agent/session-manager.ts index 30fc6b998..ca222b4fb 100755 --- a/src/server/agent/session-manager.ts +++ b/src/server/agent/session-manager.ts @@ -57,7 +57,18 @@ import { decideOverflowAction } from "../ws-overflow-guard.js"; import { McpManager } from "../mcp/mcp-manager.js"; import { isTransientReviewError, isProviderBackoffError } from "./verification-logic.js"; import { truncateLargeToolContent, truncateLargeToolContentInMessages } from "./truncate-large-content.js"; -import { getAigwUrl, discoverAigwModels, deriveName, inferMeta } from "./aigw-manager.js"; +import { + discoverGatewayModels, + listGateways, + getEnabledGateways, + isExclusiveMode, + isClaudeId, + stripProviderPrefix, + bedrockRoutesForType, + deriveName, + inferMeta, + type ModelGateway, +} from "./aigw-manager.js"; import { defaultImageModelPref, getAvailableImageModels, parseImageModelPref } from "./image-generation.js"; import { modelRecencyRank } from "./model-registry.js"; import { clampThinkingLevel, isKnownThinkingLevel } from "../../shared/thinking-levels.js"; @@ -703,8 +714,9 @@ export class SessionManager { * See docs/design/unify-session-status.md §3.4. */ private _statusHeartbeatTimer: ReturnType | null = null; private static readonly STATUS_HEARTBEAT_INTERVAL_MS = 15_000; - /** Cached aigw model discovery result (url → { models, timestamp }) */ - private _aigwModelCache: { url: string; models: Awaited>; ts: number } | null = null; + /** Cached gateway model discovery, keyed by gateway name. `url` is tracked so a + * URL change (same name) busts the entry; TTL = AIGW_CACHE_TTL_MS. */ + private _gatewayModelCache = new Map>; ts: number }>(); private static AIGW_CACHE_TTL_MS = 60_000; // 1 minute /** Sessions that restoreSession's mid-turn branch has just re-prompted on @@ -4706,43 +4718,76 @@ export class SessionManager { } } - // Fall back to aigw best-ranked model when gateway is configured - const aigwUrl = getAigwUrl(this.preferencesStore); - if (!aigwUrl) return; - - let aigwModels; - try { - // Use cached model list if fresh (avoids HTTP round-trip per session) - if (this._aigwModelCache && this._aigwModelCache.url === aigwUrl && - Date.now() - this._aigwModelCache.ts < SessionManager.AIGW_CACHE_TTL_MS) { - aigwModels = this._aigwModelCache.models; - } else { - aigwModels = await discoverAigwModels(aigwUrl); - this._aigwModelCache = { url: aigwUrl, models: aigwModels, ts: Date.now() }; + // Fall back to a gateway best-ranked model when any gateway is enabled. + // Iterate the enabled gateways, respecting exclusive mode exactly like the + // registry (in exclusive mode only `aigw`-type gateways contribute), and bind + // the best-ranked model against its OWNING gateway name. This is the direct + // fix for the #13/#14 silent-Claude-fallback bug: set_model(gateway.name, id) + // resolves against the providers[gateway.name] block that + // syncGatewaysModelsJson wrote, instead of hardcoding provider "aigw". + const gateways = listGateways(this.preferencesStore); + const enabledGateways = getEnabledGateways(this.preferencesStore); + if (enabledGateways.length === 0) return; + const exclusive = isExclusiveMode(gateways); + + // Collect candidate models across the contributing gateways. Bedrock-routed + // Claude ids (aigw type only) are prefix-stripped so the bound id matches the + // providers[name] block — binding the raw `aws/...` id would silently fall + // back to the previously bound model. + type GatewayCandidate = { gateway: ModelGateway; bindId: string }; + const candidates: GatewayCandidate[] = []; + for (const g of enabledGateways) { + if (exclusive && g.type !== "aigw") continue; + let models; + try { + models = await this.discoverGatewayModelsCached(g); + } catch (err) { + console.warn(`[session-manager] Failed to discover gateway "${g.name}" models for auto-selection:`, err); + continue; + } + for (const m of models) { + const bedrock = bedrockRoutesForType(g.type) && isClaudeId(m.id); + candidates.push({ gateway: g, bindId: bedrock ? stripProviderPrefix(m.id) : m.id }); } - } catch (err) { - console.warn(`[session-manager] Failed to discover aigw models for auto-selection:`, err); - return; } - if (aigwModels.length === 0) return; + if (candidates.length === 0) return; - try { - const modelToUse = [...aigwModels].sort((a, b) => modelRecencyRank(b.id) - modelRecencyRank(a.id))[0]; + // Pick the best-ranked model across all contributing gateways. + const best = [...candidates].sort((a, b) => modelRecencyRank(b.bindId) - modelRecencyRank(a.bindId))[0]; + const { gateway, bindId } = best; - await session.rpcClient.setModel("aigw", modelToUse.id); - this._writeModelNameFile(session.id, modelToUse.id); - this.resolveStoreForSession(session.id).update(session.id, { modelProvider: "aigw", modelId: modelToUse.id }); - console.log(`[session-manager] Auto-selected aigw model "${modelToUse.id}" for session ${session.id}`); + try { + await session.rpcClient.setModel(gateway.name, bindId); + this._writeModelNameFile(session.id, bindId); + this.resolveStoreForSession(session.id).update(session.id, { modelProvider: gateway.name, modelId: bindId }); + console.log(`[session-manager] Auto-selected gateway model "${gateway.name}/${bindId}" for session ${session.id}`); broadcast(session.clients, { type: "state", - data: { model: { provider: "aigw", id: modelToUse.id, reasoning: inferMeta(modelToUse.id).reasoning } }, + data: { model: { provider: gateway.name, id: bindId, reasoning: inferMeta(bindId).reasoning } }, }); } catch (err) { console.warn(`[session-manager] Failed to auto-select model for ${session.id}:`, err); } } + /** + * Discover a gateway's models with a per-gateway-name cache (avoids an HTTP + * round-trip per session). The cache busts when the gateway's URL changes or + * the entry is older than {@link SessionManager.AIGW_CACHE_TTL_MS}. + */ + private async discoverGatewayModelsCached( + g: ModelGateway, + ): Promise>> { + const cached = this._gatewayModelCache.get(g.name); + if (cached && cached.url === g.url && Date.now() - cached.ts < SessionManager.AIGW_CACHE_TTL_MS) { + return cached.models; + } + const models = await discoverGatewayModels(g); + this._gatewayModelCache.set(g.name, { url: g.url, models, ts: Date.now() }); + return models; + } + /** Apply default thinking level from preferences (per-model). */ private async tryApplyDefaultThinkingLevel(session: SessionInfo): Promise { // 0. Role override (highest non-explicit precedence). Failure is non-fatal @@ -5440,8 +5485,11 @@ export class SessionManager { private getTitleGenOptions(): import("./title-generator.js").TitleGenOptions { const namingModel = this.preferencesStore?.get("default.namingModel") as string | undefined; const sessionModel = this.preferencesStore?.get("default.sessionModel") as string | undefined; - const aigwUrl = this.preferencesStore ? getAigwUrl(this.preferencesStore) : undefined; - return { namingModel: namingModel || undefined, fallbackModel: sessionModel || undefined, aigwUrl, thinkingLevel: "off", preferencesStore: this.preferencesStore }; + const gateways = this.preferencesStore ? getEnabledGateways(this.preferencesStore) : []; + // Implicit-fallback URL = first enabled `aigw`-type gateway (preserves the + // "auto-pick cheapest Claude" title-gen behavior); undefined for local-only setups. + const aigwUrl = gateways.find(g => g.type === "aigw")?.url; + return { namingModel: namingModel || undefined, fallbackModel: sessionModel || undefined, gateways, aigwUrl, thinkingLevel: "off", preferencesStore: this.preferencesStore }; } private async autoGenerateTitleFromText(session: SessionInfo, userText: string): Promise { diff --git a/src/server/agent/title-generator.ts b/src/server/agent/title-generator.ts index a8102398c..ff748d92f 100644 --- a/src/server/agent/title-generator.ts +++ b/src/server/agent/title-generator.ts @@ -2,14 +2,15 @@ * Generates a short session title from conversation messages. * Supports three modes: * 1. Direct Anthropic API (default - uses Claude Haiku via api.anthropic.com) - * 2. AI Gateway proxy (when aigw is configured - routes through the gateway) + * 2. Gateway proxy (routes a naming model whose provider matches a configured + * gateway name through that gateway's OpenAI /v1/chat/completions endpoint) * 3. Custom naming model (user preference - any provider/model via the gateway) */ import { existsSync, readFileSync } from "node:fs"; import { refreshOAuthToken } from "../auth/oauth.js"; import { globalAuthPath } from "../bobbit-dir.js"; -import { discoverAigwModels } from "./aigw-manager.js"; +import { discoverAigwModels, type ModelGateway } from "./aigw-manager.js"; import { aigwUserAgentHeaders } from "./aigw-user-agent.js"; import { completeModelText } from "./model-completion.js"; import { getAvailableModels, modelRecencyRank, type ApiModel } from "./model-registry.js"; @@ -68,7 +69,11 @@ const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"; export interface TitleGenOptions { /** Override model in "provider/modelId" format, e.g. "aigw/claude-haiku-4-5" */ namingModel?: string; - /** AI Gateway URL for proxying requests (used when provider is "aigw") */ + /** Enabled gateways, for resolving a naming model's provider (gateway name) → URL. */ + gateways?: ModelGateway[]; + /** Implicit-fallback gateway URL (first enabled `aigw`-type gateway). Preserves + * the "auto-pick cheapest Claude" behavior when no explicit naming model is set; + * undefined for merged/local-only setups (no Claude fallback). */ aigwUrl?: string; /** Thinking level for title generation: "off"|"minimal"|"low"|"medium"|"high"|"xhigh" */ thinkingLevel?: string; @@ -450,8 +455,13 @@ export async function generateSessionTitle(messages: any[], options?: TitleGenOp if (options?.namingModel) { const configured = await findConfiguredModel(options.namingModel, options); if (configured) { - if (configured.provider === "aigw" && options.aigwUrl) { - return generateViaGateway(options.aigwUrl, configured.modelId, preview, "off"); + // Route via the gateway when the naming model's provider matches an + // enabled gateway name. Title-gen always uses the gateway's OpenAI + // /v1/chat/completions path (the Bedrock distinction is irrelevant here), + // so this works for both `aigw` and `openai-compatible` gateways. + const gateway = options.gateways?.find(g => g.name === configured.provider); + if (gateway) { + return generateViaGateway(gateway.url, configured.modelId, preview, "off"); } if (configured.model) { const userPrompt = `Conversation:\n\n---\n${preview}\n---\n\nReply with ONLY YOUR LABEL:`; @@ -650,8 +660,9 @@ export async function generateGoalSummaryTitle(goalTitle: string, options?: Titl if (options?.namingModel) { const configured = await findConfiguredModel(options.namingModel, options); if (configured) { - if (configured.provider === "aigw" && options.aigwUrl) { - return generateGoalSummaryViaGateway(options.aigwUrl, configured.modelId, goalTitle); + const gateway = options.gateways?.find(g => g.name === configured.provider); + if (gateway) { + return generateGoalSummaryViaGateway(gateway.url, configured.modelId, goalTitle); } if (configured.model) { const userPrompt = `Goal title:\n\n---\n${goalTitle}\n---\n\nReply with ONLY YOUR 3-WORD SUMMARY:`; diff --git a/src/server/server.ts b/src/server/server.ts index ebe457176..d1fd6d6b6 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -245,7 +245,24 @@ import { isSandboxAllowed } from "./auth/sandbox-guard.js"; import * as previewMount from "./preview/mount.js"; import * as previewArtifacts from "./preview/artifacts.js"; import { broadcastPreviewChanged, subscribePreviewChanged } from "./preview/events.js"; -import { configureAigw, removeAigw, getAigwUrl, discoverAigwModels, proxyRequest, startupAigwCheck, writeContextWindowOverrides, inferMeta } from "./agent/aigw-manager.js"; +import { + discoverAigwModels, + discoverGatewayModels, + proxyRequest, + startupAigwCheck, + writeContextWindowOverrides, + inferMeta, + listGateways, + getEnabledGateways, + getGatewayByName, + saveGateways, + migrateGatewayPrefs, + syncGatewaysModelsJson, + isClaudeId, + stripProviderPrefix, + type ModelGateway, + type AigwModel, +} from "./agent/aigw-manager.js"; import { writeOpenAIModelAdditions } from "./agent/openai-model-additions.js"; import { ReviewAnnotationStore, type ReviewAnnotation } from "./review-annotation-store.js"; import { getAvailableModels, discoverModelsForConfig, invalidateModelCache } from "./agent/model-registry.js"; @@ -326,6 +343,20 @@ function clampRoleThinking(value: unknown, modelStr: string | undefined): string return clampThinkingLevel(known, { id: modelId, provider, reasoning: meta.reasoning }); } +/** + * Shape a gateway's freshly-discovered models for an API response (the legacy + * configure/refresh/status shims + the per-gateway refresh/status endpoints). + * Mirrors what the model registry surfaces: an `aigw`-type gateway prefix-strips + * Claude ids and tags them `bedrock-converse-stream`; an `openai-compatible` + * gateway returns raw ids untouched (never Bedrock-routed, even for `claude-*`). + */ +function shapeGatewayModelsForDisplay(gateway: ModelGateway, models: AigwModel[]): AigwModel[] { + if (gateway.type !== "aigw") return models; + return models.map((m) => + isClaudeId(m.id) ? { ...m, id: stripProviderPrefix(m.id), api: "bedrock-converse-stream" } : m, + ); +} + async function deleteRemoteGoalBranches( goal: PersistedGoal, extraBranches: readonly string[], @@ -1781,6 +1812,9 @@ export function createGateway(config: GatewayConfig) { // Check internet and auto-configure AI Gateway if offline // Runs before session restore so models.json is written before // any agent subprocesses start. + // Migrate legacy single-URL aigw prefs → modelGateways list first + // (idempotent; startupAigwCheck also migrates internally). + migrateGatewayPrefs(preferencesStore); await startupAigwCheck(preferencesStore); writeContextWindowOverrides(); writeOpenAIModelAdditions(); @@ -2656,7 +2690,7 @@ async function handleApiRoute( status: "ok", sessions: sessionManager.listSessions().length, localhost: isLocalhost, - aigw: !!getAigwUrl(preferencesStore), + aigw: getEnabledGateways(preferencesStore).length > 0, setupComplete: isSetupComplete(), orphanedTranscripts: sessionManager.orphanedTranscriptsCount, }); @@ -6894,80 +6928,178 @@ async function handleApiRoute( return; } - // ── AI Gateway ── + // ── AI Gateway(s) — multi-gateway provider management ── + // + // Canonical list-management surface (§6 of docs/design/multi-gateway-providers.md): + // GET /api/aigw/gateways → full list (incl. disabled) + // PUT /api/aigw/gateways → replace whole list (validate + sync) + // POST /api/aigw/test → discover a URL without saving + // POST /api/aigw/gateways/:name/refresh → re-discover one gateway + re-sync + // GET /api/aigw/gateways/:name/status → per-gateway status + models + // Plus backward-compat shims (single-URL era) so existing clients/tests survive: + // GET /api/aigw/status, POST/DELETE /api/aigw/configure, POST /api/aigw/refresh. + // Proxy: /api/aigw/:name/v1/* (named) and legacy /api/aigw/v1/* live further below. - // GET /api/aigw/status — check if aigw is configured - if (url.pathname === "/api/aigw/status" && req.method === "GET") { - const aigwUrl = getAigwUrl(preferencesStore); - if (!aigwUrl) { - json({ configured: false }); - } else { - // Discover fresh models instead of reading from preferences cache - try { - const models = await discoverAigwModels(aigwUrl); - json({ configured: true, url: aigwUrl, models }); - } catch { - json({ configured: true, url: aigwUrl, models: [] }); - } + // GET /api/aigw/gateways — full gateway list (including disabled rows). + if (url.pathname === "/api/aigw/gateways" && req.method === "GET") { + json({ gateways: listGateways(preferencesStore) }); + return; + } + + // PUT /api/aigw/gateways — replace the whole gateway list, then re-sync + // models.json. saveGateways validates (§1) and throws on any violation → 400. + if (url.pathname === "/api/aigw/gateways" && req.method === "PUT") { + const body = await readBody(req); + const rows = body?.gateways; + if (!Array.isArray(rows)) { + json({ error: "Missing 'gateways' array" }, 400); + return; + } + try { + saveGateways(preferencesStore, rows as ModelGateway[]); + } catch (err: any) { + json({ error: err?.message || "Invalid gateways" }, 400); + return; + } + try { + const modelsByGateway = await syncGatewaysModelsJson(preferencesStore); + invalidateModelCache(); + broadcastPreferencesChanged(); + json({ gateways: listGateways(preferencesStore), modelsByGateway }); + } catch (err: any) { + jsonError(502, err, { error: `Failed to sync gateways: ${err.message}` }); } return; } - // POST /api/aigw/configure — set aigw URL, discover models, write models.json - if (url.pathname === "/api/aigw/configure" && req.method === "POST") { + // POST /api/aigw/test — discover a URL's models without saving anything. + if (url.pathname === "/api/aigw/test" && req.method === "POST") { const body = await readBody(req); if (!body?.url || typeof body.url !== "string") { json({ error: "Missing 'url' field" }, 400); return; } try { - const models = await configureAigw(body.url, preferencesStore); + const models = await discoverAigwModels(body.url); + json({ ok: true, models }); + } catch (err: any) { + jsonError(502, err); + } + return; + } + + // POST /api/aigw/gateways/:name/refresh — re-discover ONE gateway + re-sync. + const gatewayRefreshMatch = url.pathname.match(/^\/api\/aigw\/gateways\/([^/]+)\/refresh$/); + if (gatewayRefreshMatch && req.method === "POST") { + const name = decodeURIComponent(gatewayRefreshMatch[1]); + const gw = getGatewayByName(preferencesStore, name); + if (!gw) { + json({ error: `Unknown gateway "${name}"` }, 404); + return; + } + try { + const raw = await discoverGatewayModels(gw); + await syncGatewaysModelsJson(preferencesStore); invalidateModelCache(); broadcastPreferencesChanged(); - json({ ok: true, models }); + json({ models: shapeGatewayModelsForDisplay(gw, raw) }); } catch (err: any) { - jsonError(502, err, { error: `Failed to configure AI Gateway: ${err.message}` }); + jsonError(502, err); } return; } - // DELETE /api/aigw/configure — remove aigw config - if (url.pathname === "/api/aigw/configure" && req.method === "DELETE") { - removeAigw(preferencesStore); - invalidateModelCache(); - broadcastPreferencesChanged(); - json({ ok: true }); + // GET /api/aigw/gateways/:name/status — per-gateway "view models" affordance. + const gatewayStatusMatch = url.pathname.match(/^\/api\/aigw\/gateways\/([^/]+)\/status$/); + if (gatewayStatusMatch && req.method === "GET") { + const name = decodeURIComponent(gatewayStatusMatch[1]); + const gw = getGatewayByName(preferencesStore, name); + if (!gw) { + json({ configured: false }); + return; + } + const meta = { configured: true, name: gw.name, url: gw.url, type: gw.type, enabled: gw.enabled }; + try { + const raw = await discoverGatewayModels(gw); + json({ ...meta, models: shapeGatewayModelsForDisplay(gw, raw) }); + } catch { + json({ ...meta, models: [] }); + } return; } - // POST /api/aigw/test — test connection to a URL without saving - if (url.pathname === "/api/aigw/test" && req.method === "POST") { + // ── Backward-compat shims (single-URL era) ── + + // GET /api/aigw/status — legacy: status of the singleton `aigw` gateway (or + // the first aigw-type gateway). { configured:false } when none exists. + if (url.pathname === "/api/aigw/status" && req.method === "GET") { + const gw = getGatewayByName(preferencesStore, "aigw") + ?? listGateways(preferencesStore).find((g) => g.type === "aigw"); + if (!gw) { + json({ configured: false }); + return; + } + try { + const models = await discoverAigwModels(gw.url); + json({ configured: true, url: gw.url, models }); + } catch { + json({ configured: true, url: gw.url, models: [] }); + } + return; + } + + // POST /api/aigw/configure {url} — legacy: upsert the singleton `aigw` gateway, + // discover + sync. Mirrors the old configureAigw return shape (Claude ids + // prefix-stripped + api=bedrock-converse-stream). + if (url.pathname === "/api/aigw/configure" && req.method === "POST") { const body = await readBody(req); if (!body?.url || typeof body.url !== "string") { json({ error: "Missing 'url' field" }, 400); return; } + const normalizedUrl = body.url.replace(/\/+$/, ""); try { - const models = await discoverAigwModels(body.url); - json({ ok: true, models }); + // Discover first so an unreachable gateway yields a 502 (old behavior). + const raw = await discoverAigwModels(normalizedUrl); + const next = listGateways(preferencesStore).filter((g) => g.name !== "aigw"); + next.unshift({ id: randomUUID(), name: "aigw", url: normalizedUrl, type: "aigw", enabled: true }); + saveGateways(preferencesStore, next); + await syncGatewaysModelsJson(preferencesStore); + invalidateModelCache(); + broadcastPreferencesChanged(); + const gw = getGatewayByName(preferencesStore, "aigw")!; + json({ ok: true, models: shapeGatewayModelsForDisplay(gw, raw) }); } catch (err: any) { - jsonError(502, err); + jsonError(502, err, { error: `Failed to configure AI Gateway: ${err.message}` }); } return; } - // POST /api/aigw/refresh — re-discover models from the configured gateway + // DELETE /api/aigw/configure — legacy: remove the `aigw` gateway + re-sync. + if (url.pathname === "/api/aigw/configure" && req.method === "DELETE") { + const next = listGateways(preferencesStore).filter((g) => g.name !== "aigw"); + saveGateways(preferencesStore, next); + await syncGatewaysModelsJson(preferencesStore); + invalidateModelCache(); + broadcastPreferencesChanged(); + json({ ok: true }); + return; + } + + // POST /api/aigw/refresh — legacy: re-discover the `aigw` gateway + re-sync. if (url.pathname === "/api/aigw/refresh" && req.method === "POST") { - const aigwUrl = getAigwUrl(preferencesStore); - if (!aigwUrl) { + const gw = getGatewayByName(preferencesStore, "aigw") + ?? listGateways(preferencesStore).find((g) => g.type === "aigw"); + if (!gw) { json({ error: "No AI Gateway configured" }, 400); return; } try { - const models = await configureAigw(aigwUrl, preferencesStore); + const raw = await discoverGatewayModels(gw); + await syncGatewaysModelsJson(preferencesStore); invalidateModelCache(); broadcastPreferencesChanged(); - json({ models }); + json({ models: shapeGatewayModelsForDisplay(gw, raw) }); } catch (err: any) { jsonError(502, err); } @@ -7001,17 +7133,15 @@ async function handleApiRoute( }, 404); return; } - if (provider !== "aigw") { + // Is this provider one of the configured gateways? If not, fall back to + // the standard provider-key / direct test path. + const gw = getGatewayByName(preferencesStore, provider); + if (!gw) { const result = await testModelPreference(preferencesStore, pref); json(result, result.status || (result.ok ? 200 : 502)); return; } - const aigwUrl = getAigwUrl(preferencesStore); - if (!aigwUrl) { - json({ ok: false, error: "No AI Gateway configured." }); - return; - } - const baseUrl = aigwUrl.replace(/\/+$/, ""); + const baseUrl = gw.url.replace(/\/+$/, ""); const chatUrl = baseUrl.endsWith("/v1") ? `${baseUrl}/chat/completions` : `${baseUrl}/v1/chat/completions`; // The aigw registry strips the provider prefix (e.g. "aws/") from Claude @@ -7070,13 +7200,36 @@ async function handleApiRoute( return; } - // Proxy: /api/aigw/v1/* → forward to configured aigw URL - if (url.pathname.startsWith("/api/aigw/v1/") && getAigwUrl(preferencesStore)) { - const aigwUrl = getAigwUrl(preferencesStore)!; - const subPath = url.pathname.replace("/api/aigw/v1/", "/v1/"); - const targetUrl = `${aigwUrl}${subPath}${url.search}`; - proxyRequest(targetUrl, req, res); - return; + // Proxy by name: /api/aigw/:name/v1/* → /v1/* for the named + // ENABLED gateway. The browser may not resolve the gateway host directly, so + // it routes model discovery / completions through here. + const namedProxyMatch = url.pathname.match(/^\/api\/aigw\/([^/]+)\/v1\/(.*)$/); + if (namedProxyMatch) { + const name = decodeURIComponent(namedProxyMatch[1]); + // `/api/aigw/v1/*` (legacy, name-less) is handled below — don't treat the + // literal "v1" segment as a gateway name. + if (name !== "v1") { + const gw = getGatewayByName(preferencesStore, name); + if (gw && gw.enabled) { + const targetUrl = `${gw.url.replace(/\/+$/, "")}/v1/${namedProxyMatch[2]}${url.search}`; + proxyRequest(targetUrl, req, res); + return; + } + // Unknown/disabled gateway — fall through to the 404 handler. + } + } + + // Legacy proxy: /api/aigw/v1/* → the gateway named `aigw` (or, failing that, + // the first enabled gateway) for back-compat with the single-URL client. + if (url.pathname.startsWith("/api/aigw/v1/")) { + const named = getGatewayByName(preferencesStore, "aigw"); + const gw = named && named.enabled ? named : getEnabledGateways(preferencesStore)[0]; + if (gw) { + const subPath = url.pathname.replace("/api/aigw/v1/", "/v1/"); + const targetUrl = `${gw.url.replace(/\/+$/, "")}${subPath}${url.search}`; + proxyRequest(targetUrl, req, res); + return; + } } // GET /api/roles/assistant/prompts — must come before :name route diff --git a/src/ui/dialogs/AigwModelsDialog.ts b/src/ui/dialogs/AigwModelsDialog.ts index 4cdc55e22..0de2997e9 100644 --- a/src/ui/dialogs/AigwModelsDialog.ts +++ b/src/ui/dialogs/AigwModelsDialog.ts @@ -11,22 +11,28 @@ export type AigwModelEntry = { reasoning: boolean; }; +/** Mirror of aigw-manager's GatewayType (kept local — UI must not import server types). */ +export type AigwGatewayType = "aigw" | "openai-compatible"; + /** - * Read-only modal that lists the raw AI Gateway model IDs so users can debug + * Read-only modal that lists the raw gateway model IDs so users can debug * provider-prefix drift. Mirrors ModelSelector's dialog chrome but is not - * selectable. Includes a footnote about the `aws/` prefix-stripping rule - * applied elsewhere in the UI. + * selectable. The `aws/` prefix-stripping footnote applies ONLY to `aigw`-type + * gateways (Claude→Bedrock routing); `openai-compatible` gateways return raw ids + * untouched, so the footnote is suppressed for them. */ @customElement("aigw-models-dialog") export class AigwModelsDialog extends DialogBase { @state() private models: AigwModelEntry[] = []; + @state() private gatewayType: AigwGatewayType = "aigw"; protected override modalWidth = "min(500px, 92vw)"; protected override modalHeight = "min(640px, 90vh)"; - static open(models: AigwModelEntry[]): AigwModelsDialog { + static open(models: AigwModelEntry[], type: AigwGatewayType = "aigw"): AigwModelsDialog { const dialog = new AigwModelsDialog(); dialog.models = Array.isArray(models) ? models.slice() : []; + dialog.gatewayType = type; dialog.open(); return dialog; } @@ -77,7 +83,7 @@ export class AigwModelsDialog extends DialogBase {
${m.id}
- ${this.strippedId(m.id) + ${this.gatewayType === "aigw" && this.strippedId(m.id) ? html`
UI pref ID: ${this.strippedId(m.id)}
` @@ -86,12 +92,14 @@ export class AigwModelsDialog extends DialogBase { `, )} -
- Claude model IDs have the aws/ (or similar) provider prefix stripped - when surfaced to the rest of Bobbit, so that Bedrock routing works transparently. - If a stored default model looks like aigw/aws/…, it is a stale - preference — clear and re-pick it. -
+ ${this.gatewayType === "aigw" + ? html`
+ Claude model IDs have the aws/ (or similar) provider prefix stripped + when surfaced to the rest of Bobbit, so that Bedrock routing works transparently. + If a stored default model looks like aigw/aws/…, it is a stale + preference — clear and re-pick it. +
` + : ""} `; } } diff --git a/src/ui/dialogs/ModelSelector.ts b/src/ui/dialogs/ModelSelector.ts index f6d2294c9..92befd4ee 100644 --- a/src/ui/dialogs/ModelSelector.ts +++ b/src/ui/dialogs/ModelSelector.ts @@ -14,6 +14,29 @@ import { Input } from "../components/Input.js"; import { formatModelCost } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; +/** + * localStorage key for the §9 "Has key" picker filter. Display-only — the + * value (`"1"` / `"0"`) lives entirely in the browser and is never sent to + * `/api/models` or used in server-side model resolution. + */ +export const HIDE_UNAUTHED_KEY = "bobbit.modelPicker.hideUnauthed"; + +function readHideUnauthedPref(): boolean { + try { + return localStorage.getItem(HIDE_UNAUTHED_KEY) === "1"; + } catch { + return false; + } +} + +function writeHideUnauthedPref(value: boolean): void { + try { + localStorage.setItem(HIDE_UNAUTHED_KEY, value ? "1" : "0"); + } catch { + /* localStorage unavailable (private mode / SSR) — filter is ephemeral */ + } +} + function claudeOpus4Minor(id: string): number | undefined { // Keep in lockstep with src/server/agent/model-registry.ts. Limit the minor // capture to version-looking values so date-only IDs like @@ -124,6 +147,7 @@ export class ModelSelector extends DialogBase { @state() searchQuery = ""; @state() filterThinking = false; @state() filterVision = false; + @state() filterHideUnauthed = false; @state() selectedIndex = 0; @state() private navigationMode: "mouse" | "keyboard" = "mouse"; @state() private serverModels: any[] = []; @@ -140,6 +164,8 @@ export class ModelSelector extends DialogBase { const selector = new ModelSelector(); selector.currentModel = currentModel; selector.onSelectCallback = onSelect; + // §9: restore the persisted, default-OFF "Has key" filter on every open. + selector.filterHideUnauthed = readHideUnauthedPref(); selector.open(); selector.loadModels(); } @@ -256,6 +282,16 @@ export class ModelSelector extends DialogBase { if (this.filterVision) { filteredModels = filteredModels.filter(({ model }) => model.input.includes("image")); } + if (this.filterHideUnauthed) { + // §9 display-only filter: hide built-in models that have no API key. + // Correct-by-construction scoped to built-ins — the server emits + // gateway models (model-registry.ts:201) and custom-provider rows with + // `authenticated:true`, so only built-in cloud models can ever be + // `authenticated:false`. Keep authenticated models OR the currently + // selected model (never hide the current one, even if unauthenticated). + filteredModels = filteredModels.filter(({ model }) => + (model.authenticated ?? false) || modelsAreEqual(this.currentModel, model)); + } // Sort: current model first, then authenticated, then by recency rank filteredModels.sort((a, b) => { @@ -340,6 +376,23 @@ export class ModelSelector extends DialogBase { className: "rounded-full", children: html`${icon(ImageIcon, "sm")} ${i18n("Vision")}`, })} + ${Button({ + variant: this.filterHideUnauthed ? "default" : "secondary", + size: "sm", + onClick: () => { + this.filterHideUnauthed = !this.filterHideUnauthed; + writeHideUnauthedPref(this.filterHideUnauthed); + this.selectedIndex = 0; + if (this.scrollContainerRef.value) { + this.scrollContainerRef.value.scrollTop = 0; + } + }, + className: "rounded-full", + // Label is a plain literal (not i18n) to keep this slice's edits + // confined to ModelSelector.ts — adding a catalog key would touch + // the shared src/ui/utils/i18n.ts, which another slice also edits. + children: html`${icon(KeyRound, "sm")} Has key`, + })} diff --git a/tests/aigw-headers.test.ts b/tests/aigw-headers.test.ts deleted file mode 100644 index 04eb51266..000000000 --- a/tests/aigw-headers.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Unit tests for the provider-level `headers` block emitted by - * `writeAigwModelsJson()` in `src/server/agent/aigw-manager.ts`. - * - * Contract: - * 1. The `aigw` provider entry contains `headers["x-opencode-session"]` - * equal to the documented `!node -e "..."` literal. - * 2. `headers` lives at the provider level, not on individual model entries. - * 3. Provider-level `headers` is emitted regardless of whether models are - * Claude (Bedrock) or non-Claude (openai-completions). Bedrock ignores - * `model.headers` in pi-ai 0.67.5 — provider-level is harmless there. - * 4. `removeAigwModelsJson()` drops the entire `aigw` provider, leaving no - * orphan `headers` block on other providers. - */ -import { describe, it, before, after, beforeEach } from "node:test"; -import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -const EXPECTED_HEADER_VALUE = - `!node -e "process.stdout.write(process.env.BOBBIT_SESSION_ID || '')"`; -const EXPECTED_USER_AGENT = `Bobbit/${JSON.parse(readFileSync(path.resolve("package.json"), "utf-8")).version}`; - -let tmp: string; -let previousAgentDir: string | undefined; - -before(() => { - tmp = mkdtempSync(path.join(tmpdir(), "bobbit-aigw-hdr-")); - previousAgentDir = process.env.BOBBIT_AGENT_DIR; - process.env.BOBBIT_AGENT_DIR = tmp; - mkdirSync(tmp, { recursive: true }); -}); - -after(() => { - if (previousAgentDir === undefined) delete process.env.BOBBIT_AGENT_DIR; - else process.env.BOBBIT_AGENT_DIR = previousAgentDir; - rmSync(tmp, { recursive: true, force: true }); -}); - -beforeEach(() => { - const f = path.join(tmp, "models.json"); - if (existsSync(f)) rmSync(f); -}); - -const { writeAigwModelsJson, removeAigwModelsJson } = await import("../src/server/agent/aigw-manager.js"); - -function readModels(): any { - const f = path.join(tmp, "models.json"); - if (!existsSync(f)) return null; - return JSON.parse(readFileSync(f, "utf-8")); -} - -const NON_CLAUDE_MODEL = { - id: "qwen3-coder", - name: "Qwen 3 Coder", - api: "openai-completions", - reasoning: false, - input: ["text"] as ("text" | "image")[], - contextWindow: 1_000_000, - maxTokens: 32_768, -}; - -const CLAUDE_MODEL = { - id: "aws/us.anthropic.claude-sonnet-4-5-v1:0", - name: "Claude Sonnet 4.5 (aws)", - api: "openai-completions", - reasoning: true, - input: ["text", "image"] as ("text" | "image")[], - contextWindow: 1_000_000, - maxTokens: 16_384, -}; - -describe("writeAigwModelsJson — provider-level AI Gateway headers", () => { - it("emits the documented header literals at provider level (non-Claude models)", () => { - writeAigwModelsJson("https://aigw.example/v1", [NON_CLAUDE_MODEL]); - const data = readModels(); - assert.ok(data?.providers?.aigw, "providers.aigw must exist"); - const aigw = data.providers.aigw; - assert.ok(aigw.headers, "providers.aigw.headers must exist"); - assert.equal( - aigw.headers["User-Agent"], - EXPECTED_USER_AGENT, - "User-Agent must use the current package version", - ); - assert.equal( - aigw.headers["x-opencode-session"], - EXPECTED_HEADER_VALUE, - "header value must match the documented `!node -e` literal exactly", - ); - }); - - it("does NOT add `headers` to any individual model entry", () => { - writeAigwModelsJson("https://aigw.example/v1", [NON_CLAUDE_MODEL, CLAUDE_MODEL]); - const data = readModels(); - const models = data.providers.aigw.models; - assert.ok(Array.isArray(models) && models.length === 2); - for (const m of models) { - assert.equal(m.headers, undefined, `model ${m.id} must not carry a per-model headers field`); - } - }); - - it("emits provider-level header even when only Claude (Bedrock-routed) models are present", () => { - // Bedrock provider in pi-ai ignores model.headers, but `headers` lives at - // the provider level — emitting it is harmless and keeps the provider - // shape uniform regardless of which models the gateway exposes. - writeAigwModelsJson("https://aigw.example/v1", [CLAUDE_MODEL]); - const data = readModels(); - assert.equal(data.providers.aigw.headers["User-Agent"], EXPECTED_USER_AGENT); - assert.equal( - data.providers.aigw.headers["x-opencode-session"], - EXPECTED_HEADER_VALUE, - ); - // And the Claude model is routed through bedrock-converse-stream. - const claudeEntry = data.providers.aigw.models[0]; - assert.equal(claudeEntry.api, "bedrock-converse-stream"); - assert.equal(claudeEntry.headers, undefined); - }); - - it("header literal JSON-encodes to the documented escaped form", () => { - writeAigwModelsJson("https://aigw.example/v1", [NON_CLAUDE_MODEL]); - const f = path.join(tmp, "models.json"); - const raw = readFileSync(f, "utf-8"); - // JSON-encoded literal as documented in the design doc: - assert.ok( - raw.includes(`"!node -e \\"process.stdout.write(process.env.BOBBIT_SESSION_ID || '')\\""`), - "file must contain the exact escaped JSON form", - ); - }); - - it("removeAigwModelsJson() drops the aigw block entirely (no orphan headers anywhere)", () => { - // Pre-seed an unrelated provider to confirm we don't touch it. - const seeded = { - providers: { - anthropic: { apiKey: "sk-test", models: [{ id: "claude-x" }] }, - }, - }; - writeFileSync(path.join(tmp, "models.json"), JSON.stringify(seeded, null, 2)); - writeAigwModelsJson("https://aigw.example/v1", [NON_CLAUDE_MODEL]); - removeAigwModelsJson(); - const data = readModels(); - assert.equal(data.providers.aigw, undefined, "aigw provider must be gone"); - // anthropic provider untouched + has no AI Gateway header leak. - assert.ok(data.providers.anthropic, "anthropic provider must survive"); - assert.equal(data.providers.anthropic.headers, undefined, "no orphan headers on other providers"); - }); - - it("does not leak AI Gateway headers onto non-aigw providers when re-written", () => { - // Seed an anthropic provider, then run aigw write. Confirm anthropic is - // left alone — no AI Gateway headers synthesised on it. - const seeded = { - providers: { - anthropic: { - apiKey: "sk-test", - headers: { "X-Existing": "keep-me" }, - }, - }, - }; - writeFileSync(path.join(tmp, "models.json"), JSON.stringify(seeded, null, 2)); - writeAigwModelsJson("https://aigw.example/v1", [NON_CLAUDE_MODEL]); - const data = readModels(); - assert.deepEqual(data.providers.anthropic.headers, { "X-Existing": "keep-me" }); - assert.equal(data.providers.anthropic.headers["User-Agent"], undefined); - assert.equal(data.providers.anthropic.headers["x-opencode-session"], undefined); - assert.equal(data.providers.aigw.headers["User-Agent"], EXPECTED_USER_AGENT); - assert.equal( - data.providers.aigw.headers["x-opencode-session"], - EXPECTED_HEADER_VALUE, - ); - }); -}); diff --git a/tests/aigw-pricing.test.ts b/tests/aigw-pricing.test.ts index c160bfdbe..cf7e1eb56 100644 --- a/tests/aigw-pricing.test.ts +++ b/tests/aigw-pricing.test.ts @@ -14,7 +14,7 @@ import path from "node:path"; const { PreferencesStore } = await import("../src/server/agent/preferences-store.ts"); const { getAvailableModels, invalidateModelCache } = await import("../src/server/agent/model-registry.ts"); -const { discoverAigwModels, writeAigwModelsJson } = await import("../src/server/agent/aigw-manager.ts"); +const { discoverAigwModels, saveGateways, syncGatewaysModelsJson } = await import("../src/server/agent/aigw-manager.ts"); const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; const GPT_52_COST = { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.5625 }; @@ -140,7 +140,9 @@ describe("AI Gateway pricing metadata", () => { const mock = await startMockAigw(); try { const prefs = new PreferencesStore(tmpDir); - prefs.set("aigw.url", mock.url); + // Migrated schema: a single `aigw`-type gateway named "aigw" behaves + // identically to the legacy single-URL config (provider key stays "aigw"). + saveGateways(prefs as any, [{ id: "g-aigw", name: "aigw", url: mock.url, type: "aigw", enabled: true }]); invalidateModelCache(); const models = await getAvailableModels(prefs as any); @@ -170,10 +172,14 @@ describe("AI Gateway pricing metadata", () => { try { process.env.BOBBIT_AGENT_DIR = tmpAgentDir; const mock = await startMockAigw([pricedOpenAiModel(), pricedClaudeModel()]); + const prefsDir = fs.mkdtempSync(path.join(os.tmpdir(), "bobbit-aigw-pricing-prefs-")); try { - const discoveredModels = await discoverAigwModels(mock.url); - - writeAigwModelsJson(`${mock.url}/v1`, discoveredModels); + // syncGatewaysModelsJson discovers the gateway itself and writes the + // providers["aigw"] block via the type-driven `aigw` writer — exercising + // the same cost propagation the old writeAigwModelsJson did. + const prefs = new PreferencesStore(prefsDir); + saveGateways(prefs as any, [{ id: "g-aigw", name: "aigw", url: `${mock.url}/v1`, type: "aigw", enabled: true }]); + await syncGatewaysModelsJson(prefs as any); const data = readModelsJson(tmpAgentDir); const persistedModels = data.providers.aigw.models; @@ -189,6 +195,7 @@ describe("AI Gateway pricing metadata", () => { assert.deepEqual(mock.requests(), ["/v1/models"]); } finally { await mock.close(); + fs.rmSync(prefsDir, { recursive: true, force: true }); } } finally { if (previousAgentDir === undefined) delete process.env.BOBBIT_AGENT_DIR; diff --git a/tests/e2e/aigw-configure.spec.ts b/tests/e2e/aigw-configure.spec.ts index fe110fb4c..817784979 100644 --- a/tests/e2e/aigw-configure.spec.ts +++ b/tests/e2e/aigw-configure.spec.ts @@ -223,7 +223,15 @@ test.describe("AI Gateway Configure Flow", () => { const res = await apiFetch("/api/preferences"); const prefs = await res.json(); - expect(prefs["aigw.url"]).toBe(`http://127.0.0.1:${mockPort}`); + // Post multi-gateway migration: the legacy single `aigw.url` pref is + // superseded by the `modelGateways` list (the configure shim upserts a + // `{ name: "aigw", type: "aigw", enabled: true }` entry). + const gateways = prefs["modelGateways"]; + expect(Array.isArray(gateways)).toBe(true); + expect(gateways.find((g: { name: string }) => g.name === "aigw")?.url).toBe( + `http://127.0.0.1:${mockPort}`, + ); + expect(prefs["aigw.url"]).toBeUndefined(); // aigw.models is no longer cached in preferences — models are discovered fresh via GET /api/models }); diff --git a/tests/e2e/models-api.spec.ts b/tests/e2e/models-api.spec.ts index c3a8b7421..00a96e0b2 100644 --- a/tests/e2e/models-api.spec.ts +++ b/tests/e2e/models-api.spec.ts @@ -38,11 +38,12 @@ test.describe("GET /api/models with AI Gateway", () => { }); test.afterEach(async () => { - await apiFetch("/api/aigw/configure", { method: "DELETE" }); - // Reset exclusive flag to default so it doesn't leak between tests. - await apiFetch("/api/preferences", { + // Full reset: clear ALL configured gateways (aigw + openai-compatible) + // so none leak between tests. Exclusivity is derived from the list now + // (no `aigw.exclusive` pref), so emptying the list is the canonical reset. + await apiFetch("/api/aigw/gateways", { method: "PUT", - body: JSON.stringify({ "aigw.exclusive": null }), + body: JSON.stringify({ gateways: [] }), }); }); @@ -138,28 +139,30 @@ test.describe("GET /api/models with AI Gateway", () => { expect(providers.has("openai")).toBe(false); }); - test("built-in providers return when aigw.exclusive is set to false", async () => { - await apiFetch("/api/aigw/configure", { - method: "POST", - body: JSON.stringify({ url: `http://127.0.0.1:${mockPort}` }), - }); - await apiFetch("/api/preferences", { + test("built-in providers return in merged mode with an openai-compatible gateway", async () => { + // Exclusivity is now derived from gateway TYPE, not a manual `aigw.exclusive` + // toggle. An `openai-compatible` gateway runs in MERGED mode: its models AND + // the built-in cloud providers both contribute. (Only an enabled `aigw`-type + // gateway forces exclusive mode.) + const putRes = await apiFetch("/api/aigw/gateways", { method: "PUT", - body: JSON.stringify({ "aigw.exclusive": false }), + body: JSON.stringify({ + gateways: [ + { name: "local-llm", url: `http://127.0.0.1:${mockPort}`, type: "openai-compatible", enabled: true }, + ], + }), }); + expect(putRes.status).toBe(200); const res = await apiFetch("/api/models"); const models = await res.json(); const providers = new Set(models.map((m: any) => m.provider)); - expect(providers.has("aigw")).toBe(true); + // The openai-compatible gateway surfaces under its own name (not "aigw"). + expect(providers.has("local-llm")).toBe(true); + expect(providers.has("aigw")).toBe(false); + // Built-ins are NOT suppressed in merged mode. const hasBuiltIn = providers.has("anthropic") || providers.has("amazon-bedrock"); expect(hasBuiltIn).toBe(true); - - // Reset to default for subsequent tests. - await apiFetch("/api/preferences", { - method: "PUT", - body: JSON.stringify({ "aigw.exclusive": null }), - }); }); test("built-in providers return once aigw is removed", async () => { diff --git a/tests/e2e/multi-gateway-api.spec.ts b/tests/e2e/multi-gateway-api.spec.ts new file mode 100644 index 000000000..43f2e3ba4 --- /dev/null +++ b/tests/e2e/multi-gateway-api.spec.ts @@ -0,0 +1,358 @@ +/** + * API E2E tests for the multi-gateway provider surface (Slice C). + * + * Exercises the §6 REST contract against TWO in-process stub gateways (NO + * NETWORK — never the LAN host, never tools/dummy-aigw): + * + * - PUT /api/aigw/gateways with one enabled `openai-compatible` gateway and a + * DISABLED `aigw` gateway → merged mode: /api/models surfaces provider + * "llama-swap" + built-ins, no "aigw"; models.json has the + * openai-compatible block (plain openai-completions, no headers) and NO + * aigw block (disabled ⇒ pruned). + * - Enable the `aigw` gateway → exclusive mode: /api/models surfaces ONLY + * "aigw" (built-ins + openai-compatible suppressed); on disk BOTH blocks + * exist — the aigw block carries the x-opencode-session header literal and + * Bedrock-routes the claude id, while the openai-compatible block's + * `claude-local` stays openai-completions (the latent-bug fix). + * - PUT [] → both gateway blocks pruned, a pre-seeded `anthropic` block + * untouched, /api/models shows built-ins only. + * - POST /api/aigw/test → ok + 502 on unreachable. + * - Legacy POST/DELETE /api/aigw/configure shims still work. + * + * "Binding resolves" is asserted at the models.json layer (provider block + + * correct `api` per type) since `set_model` is agent-subprocess-side and not + * reachable from the in-process REST harness — same de-scope rationale as + * tests/e2e/aigw-session-header.spec.ts. + */ + +import { test, expect } from "./in-process-harness.js"; +import http from "node:http"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { apiFetch } from "./e2e-setup.js"; + +// Tests share the worker-scoped gateway + a single on-disk models.json, so they +// must run serially and reset gateway state between each. +test.describe.configure({ mode: "serial" }); + +const EXPECTED_HEADER_VALUE = + `!node -e "process.stdout.write(process.env.BOBBIT_SESSION_ID || '')"`; + +interface StubGateway { + url: string; + close: () => Promise; +} + +/** A tiny OpenAI-compatible stub: GET /v1/models + POST /v1/chat/completions. */ +function startStub(modelIds: string[]): Promise { + const server = http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + if (req.url?.endsWith("/v1/models")) { + res.end(JSON.stringify({ + data: modelIds.map((id) => ({ id, object: "model", created: 1700000000, owned_by: "system" })), + })); + return; + } + if (req.url?.endsWith("/v1/chat/completions")) { + res.end(JSON.stringify({ choices: [{ message: { content: "OK" } }] })); + return; + } + res.writeHead(404); + res.end(JSON.stringify({ error: "not found" })); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as any).port; + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} + +function modelsJsonPath(bobbitDir: string): string { + return join(bobbitDir, "agent", "models.json"); +} + +function readModelsJson(bobbitDir: string): any { + const p = modelsJsonPath(bobbitDir); + if (!existsSync(p)) return { providers: {} }; + return JSON.parse(readFileSync(p, "utf-8")); +} + +async function putGateways(gateways: Array>): Promise { + return apiFetch("/api/aigw/gateways", { + method: "PUT", + body: JSON.stringify({ gateways }), + }); +} + +async function getModels(): Promise { + const res = await apiFetch("/api/models"); + expect(res.status).toBe(200); + return res.json(); +} + +// Stub gateways — stubA mimics a local llama-swap; stubB an enterprise aigw. +let stubA: StubGateway; // openai-compatible (llama-swap) +let stubB: StubGateway; // aigw + +test.beforeAll(async () => { + stubA = await startStub(["qwen-coder-medium", "claude-local"]); + stubB = await startStub(["openai/gpt-5.2", "aws/us.anthropic.claude-sonnet-4-6"]); +}); + +test.afterAll(async () => { + await stubA?.close(); + await stubB?.close(); +}); + +test.afterEach(async ({ gateway }) => { + // Reset gateway list (prunes managed gateway blocks) so tests don't leak. + await putGateways([]); + // Strip any sentinel `anthropic` block a test seeded into models.json. + try { + const p = modelsJsonPath(gateway.bobbitDir); + if (existsSync(p)) { + const data = JSON.parse(readFileSync(p, "utf-8")); + if (data?.providers?.anthropic?.apiKey === "sk-ant-e2e-sentinel") { + delete data.providers.anthropic; + writeFileSync(p, JSON.stringify(data, null, 2)); + } + } + } catch { /* best-effort cleanup */ } +}); + +test.describe("Multi-gateway provider API", () => { + test("GET /api/aigw/gateways is empty by default", async () => { + const res = await apiFetch("/api/aigw/gateways"); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.gateways)).toBe(true); + expect(data.gateways).toHaveLength(0); + }); + + test("merged mode: enabled openai-compatible + disabled aigw", async ({ gateway }) => { + const res = await putGateways([ + { name: "llama-swap", type: "openai-compatible", url: stubA.url, enabled: true }, + { name: "aigw", type: "aigw", url: stubB.url, enabled: false }, + ]); + expect(res.status).toBe(200); + const body = await res.json(); + // Canonical list round-trips (incl. the disabled row), with ids filled in. + expect(body.gateways).toHaveLength(2); + const llamaRow = body.gateways.find((g: any) => g.name === "llama-swap"); + expect(llamaRow).toBeTruthy(); + expect(typeof llamaRow.id).toBe("string"); + expect(llamaRow.id.length).toBeGreaterThan(0); + expect(body.gateways.find((g: any) => g.name === "aigw").enabled).toBe(false); + // Discovered models reported for the enabled gateway only. + expect(body.modelsByGateway["llama-swap"]).toHaveLength(2); + + // /api/models — merged: llama-swap present, built-ins present, no aigw. + const models = await getModels(); + const providers = new Set(models.map((m) => m.provider)); + expect(providers.has("llama-swap")).toBe(true); + expect(providers.has("aigw")).toBe(false); + // At least one built-in provider survives in merged mode. + expect(models.some((m) => m.provider !== "llama-swap" && m.provider !== "aigw")).toBe(true); + + // On disk: openai-compatible block, NO aigw block (disabled ⇒ pruned). + const data = readModelsJson(gateway.bobbitDir); + const llama = data.providers["llama-swap"]; + expect(llama, "llama-swap provider block must exist").toBeTruthy(); + expect(llama.api).toBe("openai-completions"); + expect(llama.baseUrl).toBe(`${stubA.url}/v1`); + expect(llama.headers, "openai-compatible block must carry NO headers").toBeUndefined(); + expect(data.providers.aigw, "disabled aigw must not be written").toBeUndefined(); + // claude-local on an openai-compatible gateway stays plain OpenAI. + const claudeLocal = llama.models.find((m: any) => m.id === "claude-local"); + expect(claudeLocal).toBeTruthy(); + expect(claudeLocal.api).toBe("openai-completions"); + expect(claudeLocal.baseUrl, "no per-model Bedrock baseUrl override").toBeUndefined(); + }); + + test("exclusive mode: enabling aigw suppresses built-ins + openai-compatible", async ({ gateway }) => { + const res = await putGateways([ + { name: "llama-swap", type: "openai-compatible", url: stubA.url, enabled: true }, + { name: "aigw", type: "aigw", url: stubB.url, enabled: true }, + ]); + expect(res.status).toBe(200); + + // /api/models — exclusive: ONLY provider "aigw"; built-ins + llama-swap gone. + const models = await getModels(); + const providers = new Set(models.map((m) => m.provider)); + expect(providers.has("aigw")).toBe(true); + expect(providers.has("llama-swap"), "openai-compatible suppressed in exclusive mode").toBe(false); + expect([...providers], "only the aigw provider contributes in exclusive mode").toEqual(["aigw"]); + + // On disk: BOTH blocks exist (the agent could bind either) ... + const data = readModelsJson(gateway.bobbitDir); + const aigw = data.providers.aigw; + expect(aigw, "aigw provider block must exist").toBeTruthy(); + expect(aigw.headers["x-opencode-session"]).toBe(EXPECTED_HEADER_VALUE); + // ... aigw Bedrock-routes the claude id (prefix stripped) ... + const aigwClaude = aigw.models.find((m: any) => m.id.includes("claude")); + expect(aigwClaude).toBeTruthy(); + expect(aigwClaude.id).toBe("us.anthropic.claude-sonnet-4-6"); + expect(aigwClaude.api).toBe("bedrock-converse-stream"); + + // ... while the openai-compatible block's claude-local stays OpenAI. + const llama = data.providers["llama-swap"]; + expect(llama, "llama-swap block must still exist on disk in exclusive mode").toBeTruthy(); + expect(llama.headers).toBeUndefined(); + const claudeLocal = llama.models.find((m: any) => m.id === "claude-local"); + expect(claudeLocal.api).toBe("openai-completions"); + expect(claudeLocal.baseUrl).toBeUndefined(); + }); + + test("disabled aigw is NOT exclusive (merged with built-ins)", async () => { + const res = await putGateways([ + { name: "aigw", type: "aigw", url: stubB.url, enabled: false }, + ]); + expect(res.status).toBe(200); + + const models = await getModels(); + const providers = new Set(models.map((m) => m.provider)); + expect(providers.has("aigw"), "disabled aigw contributes nothing").toBe(false); + // Built-ins are present (disabled aigw ⇒ not exclusive ⇒ merged mode). + expect(models.some((m) => m.provider !== "aigw"), "built-ins present").toBe(true); + }); + + test("PUT [] prunes gateway blocks but leaves unrelated providers untouched", async ({ gateway }) => { + // Seed two gateway blocks first. + const seed = await putGateways([ + { name: "llama-swap", type: "openai-compatible", url: stubA.url, enabled: true }, + { name: "aigw", type: "aigw", url: stubB.url, enabled: true }, + ]); + expect(seed.status).toBe(200); + + // Inject a sentinel `anthropic` provider block directly on disk to prove + // the sync never clobbers providers it does not manage. + const p = modelsJsonPath(gateway.bobbitDir); + const before = readModelsJson(gateway.bobbitDir); + const sentinelAnthropic = { apiKey: "sk-ant-e2e-sentinel", models: [{ id: "claude-x" }] }; + before.providers.anthropic = sentinelAnthropic; + writeFileSync(p, JSON.stringify(before, null, 2)); + + // Clear the gateway list. + const clear = await putGateways([]); + expect(clear.status).toBe(200); + + const after = readModelsJson(gateway.bobbitDir); + expect(after.providers["llama-swap"], "openai-compatible block pruned").toBeUndefined(); + expect(after.providers.aigw, "aigw block pruned").toBeUndefined(); + expect(after.providers.anthropic, "unrelated anthropic block untouched").toEqual(sentinelAnthropic); + + // /api/models — built-ins only, no gateway providers. + const models = await getModels(); + const providers = new Set(models.map((m) => m.provider)); + expect(providers.has("llama-swap")).toBe(false); + expect(providers.has("aigw")).toBe(false); + }); + + test("POST /api/aigw/test discovers a URL (ok) and 502s on unreachable", async () => { + const ok = await apiFetch("/api/aigw/test", { + method: "POST", + body: JSON.stringify({ url: stubA.url }), + }); + expect(ok.status).toBe(200); + const okData = await ok.json(); + expect(okData.ok).toBe(true); + expect(okData.models).toHaveLength(2); + + const bad = await apiFetch("/api/aigw/test", { + method: "POST", + body: JSON.stringify({ url: "http://127.0.0.1:19999" }), + }); + expect(bad.status).toBe(502); + + const missing = await apiFetch("/api/aigw/test", { + method: "POST", + body: JSON.stringify({}), + }); + expect(missing.status).toBe(400); + }); + + test("PUT rejects an invalid gateway list (400)", async () => { + // An aigw-type gateway must be named exactly "aigw" (§1 singleton constraint). + const res = await putGateways([ + { name: "enterprise", type: "aigw", url: stubB.url, enabled: true }, + ]); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBeTruthy(); + }); + + test("per-gateway refresh + status by name", async ({ gateway }) => { + await putGateways([ + { name: "llama-swap", type: "openai-compatible", url: stubA.url, enabled: true }, + ]); + + const refresh = await apiFetch("/api/aigw/gateways/llama-swap/refresh", { method: "POST" }); + expect(refresh.status).toBe(200); + expect((await refresh.json()).models).toHaveLength(2); + + const status = await apiFetch("/api/aigw/gateways/llama-swap/status"); + expect(status.status).toBe(200); + const sData = await status.json(); + expect(sData.configured).toBe(true); + expect(sData.name).toBe("llama-swap"); + expect(sData.type).toBe("openai-compatible"); + expect(sData.enabled).toBe(true); + expect(sData.models).toHaveLength(2); + + // Unknown gateway → 404 refresh / { configured:false } status. + const unknownRefresh = await apiFetch("/api/aigw/gateways/nope/refresh", { method: "POST" }); + expect(unknownRefresh.status).toBe(404); + const unknownStatus = await apiFetch("/api/aigw/gateways/nope/status"); + expect(unknownStatus.status).toBe(200); + expect((await unknownStatus.json()).configured).toBe(false); + + // Sanity: the block landed on disk. + const data = readModelsJson(gateway.bobbitDir); + expect(data.providers["llama-swap"]).toBeTruthy(); + }); + + test("legacy configure/status/DELETE shims still work", async ({ gateway }) => { + // configure upserts a singleton `aigw` gateway and returns transformed models. + const conf = await apiFetch("/api/aigw/configure", { + method: "POST", + body: JSON.stringify({ url: stubB.url }), + }); + expect(conf.status).toBe(200); + const confData = await conf.json(); + expect(confData.ok).toBe(true); + const ids = confData.models.map((m: any) => m.id); + expect(ids).toContain("openai/gpt-5.2"); + expect(ids).toContain("us.anthropic.claude-sonnet-4-6"); // claude prefix stripped + + // status reflects the configured aigw gateway. + const status = await apiFetch("/api/aigw/status"); + expect(status.status).toBe(200); + const statusData = await status.json(); + expect(statusData.configured).toBe(true); + expect(statusData.url).toBe(stubB.url); + + // The aigw gateway is also visible in the canonical list. + const list = await (await apiFetch("/api/aigw/gateways")).json(); + expect(list.gateways.find((g: any) => g.name === "aigw")).toBeTruthy(); + + // DELETE removes the aigw gateway + prunes its block. + const del = await apiFetch("/api/aigw/configure", { method: "DELETE" }); + expect(del.status).toBe(200); + expect((await del.json()).ok).toBe(true); + + const afterStatus = await (await apiFetch("/api/aigw/status")).json(); + expect(afterStatus.configured).toBe(false); + const data = readModelsJson(gateway.bobbitDir); + expect(data.providers.aigw).toBeUndefined(); + }); + + test("DELETE /api/aigw/configure succeeds even when nothing is configured", async () => { + const del = await apiFetch("/api/aigw/configure", { method: "DELETE" }); + expect(del.status).toBe(200); + expect((await del.json()).ok).toBe(true); + }); +}); diff --git a/tests/e2e/ui/model-picker-hide-filter.spec.ts b/tests/e2e/ui/model-picker-hide-filter.spec.ts new file mode 100644 index 000000000..6c29af7b0 --- /dev/null +++ b/tests/e2e/ui/model-picker-hide-filter.spec.ts @@ -0,0 +1,166 @@ +/** + * Browser E2E for the §9 "Has key" model-picker filter (Slice E). + * + * The model picker gains a persisted, default-OFF, DISPLAY-ONLY toggle that + * hides built-in models with no API key (`authenticated === false`). This test + * exercises the real `` dialog end-to-end: + * - default OFF shows everything; + * - toggling ON hides unauthenticated built-ins while keeping authenticated + * built-ins, gateway/custom models (always `authenticated:true`), and the + * currently-selected model (even when it is itself unauthenticated); + * - toggling OFF restores the hidden rows; + * - the choice persists across a full page reload (localStorage); + * - the toggle has NO server impact — every `/api/models` request is byte-for + * -byte identical regardless of toggle state (no query/body carries it), so + * the server response is necessarily identical with the toggle ON vs OFF. + * + * `/api/models` is stubbed with Playwright's `page.route()` so the model set is + * deterministic (the live discovery in the harness is disabled and would be + * env-dependent). The picker is driven through its real lazy-loaded component: + * the footer model button registers the custom element, then we open it with a + * controlled `currentModel` so the "never hide the current model" branch is + * covered with an unauthenticated current model. + */ +import { test, expect } from "../gateway-harness.js"; +import { openApp } from "./ui-helpers.js"; +import { createSession, deleteSession } from "../e2e-setup.js"; + +// Deterministic model set: two authenticated entries (an authed built-in + a +// gateway model that the server always emits `authenticated:true`), two +// unauthenticated built-ins (the ones that must vanish when the filter is ON), +// and one unauthenticated built-in that is also the *current* model (must stay +// visible regardless of the filter). +const FIXTURE = [ + { provider: "anthropic", id: "claude-authed-builtin", name: "Claude (authed)", api: "anthropic-messages", contextWindow: 200000, maxTokens: 64000, reasoning: true, input: ["text", "image"], cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, authenticated: true }, + { provider: "llama-swap", id: "qwen-coder-gateway", name: "Qwen Coder (gateway)", api: "openai-completions", baseUrl: "http://stub.local/v1", contextWindow: 32000, maxTokens: 8000, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, authenticated: true }, + { provider: "openai", id: "gpt-unauthed-builtin", name: "GPT (unauthed)", api: "openai-completions", contextWindow: 128000, maxTokens: 16000, reasoning: false, input: ["text"], cost: { input: 5, output: 15, cacheRead: 0, cacheWrite: 0 }, authenticated: false }, + { provider: "google", id: "gemini-unauthed-builtin", name: "Gemini (unauthed)", api: "openai-completions", contextWindow: 1000000, maxTokens: 8000, reasoning: false, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, authenticated: false }, + { provider: "xai", id: "grok-current-unauthed", name: "Grok (current, unauthed)", api: "openai-completions", contextWindow: 128000, maxTokens: 8000, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, authenticated: false }, +]; + +// The current model is an UNAUTHENTICATED built-in: it must never be hidden. +const CURRENT_MODEL = { id: "grok-current-unauthed", provider: "xai" }; + +const HIDE_KEY = "bobbit.modelPicker.hideUnauthed"; +const item = (id: string) => `[data-model-item][data-model-id="${id}"]`; +const FILTER_BTN = "[data-testid='model-filter-haskey']"; + +test.describe("model picker — hide-unconfigured filter (§9)", () => { + test("Has key toggle hides unauthenticated built-ins, persists, and never hits the server", async ({ page }) => { + // Cold-start budget: app load + session create + lazy dialog import. + test.setTimeout(120_000); + + // Record every /api/models request (regex matches with or without a + // query string, but NOT sub-paths like /api/models/test). The handler + // always returns the same FIXTURE — so the response is identical by + // construction and the only thing that could vary is the *request*. + const modelsRequests: Array<{ method: string; url: string; postData: string | null }> = []; + await page.route(/\/api\/models(\?.*)?$/, async (route) => { + const req = route.request(); + modelsRequests.push({ method: req.method(), url: req.url(), postData: req.postData() }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(FIXTURE), + }); + }); + + const sessionId = await createSession(); + try { + await openApp(page); + await page.evaluate((id) => { window.location.hash = `#/session/${id}`; }, sessionId); + await expect(page.locator("textarea").first()).toBeVisible({ timeout: 20_000 }); + + // Register the lazy-loaded by opening the real + // picker from the footer model button (its production entry point). + const footerModel = page.locator("[data-testid='footer-model-id']").first(); + await expect(footerModel).toBeVisible({ timeout: 20_000 }); + await footerModel.click(); + await page.waitForFunction(() => !!customElements.get("agent-model-selector"), null, { timeout: 20_000 }); + + // Drive the picker with a controlled currentModel. Closes any open + // instance first so re-opens don't stack dialogs. + const openPicker = async (current: { id: string; provider: string }) => { + await page.evaluate((cur) => { + document.querySelectorAll("agent-model-selector").forEach((el) => { + const close = (el as unknown as { close?: () => void }).close; + if (typeof close === "function") close.call(el); else el.remove(); + }); + const Cls = customElements.get("agent-model-selector") as + | { open: (m: unknown, cb: () => void) => void } + | undefined; + Cls?.open(cur, () => { /* selection no-op */ }); + }, current); + // Current model is never filtered out — wait for it to confirm the list rendered. + await expect(page.locator(item(current.id))).toBeVisible({ timeout: 10_000 }); + }; + + // ── Default OFF: nothing persisted yet, all five models present ── + expect(await page.evaluate((k) => localStorage.getItem(k), HIDE_KEY)).toBeNull(); + await openPicker(CURRENT_MODEL); + for (const m of FIXTURE) { + await expect(page.locator(item(m.id))).toBeVisible(); + } + const requestsAfterDefaultOpen = modelsRequests.length; + + // ── Toggle ON: unauthenticated built-ins (non-current) vanish ── + await page.locator(FILTER_BTN).click(); + await expect(page.locator(item("gpt-unauthed-builtin"))).toHaveCount(0); + await expect(page.locator(item("gemini-unauthed-builtin"))).toHaveCount(0); + // Authenticated built-in + gateway model + current (unauthed) remain. + await expect(page.locator(item("claude-authed-builtin"))).toBeVisible(); + await expect(page.locator(item("qwen-coder-gateway"))).toBeVisible(); + await expect(page.locator(item("grok-current-unauthed"))).toBeVisible(); + // Persisted to localStorage as "1". + expect(await page.evaluate((k) => localStorage.getItem(k), HIDE_KEY)).toBe("1"); + // Display-only: toggling did NOT trigger another /api/models fetch. + expect(modelsRequests.length).toBe(requestsAfterDefaultOpen); + + // ── Toggle OFF: hidden rows reappear, persisted as "0" ── + await page.locator(FILTER_BTN).click(); + await expect(page.locator(item("gpt-unauthed-builtin"))).toBeVisible(); + await expect(page.locator(item("gemini-unauthed-builtin"))).toBeVisible(); + expect(await page.evaluate((k) => localStorage.getItem(k), HIDE_KEY)).toBe("0"); + + // Leave the filter ON to verify persistence across reload. + await page.locator(FILTER_BTN).click(); + expect(await page.evaluate((k) => localStorage.getItem(k), HIDE_KEY)).toBe("1"); + + // ── Persistence across a full reload ── + await page.reload(); + await page.evaluate((id) => { window.location.hash = `#/session/${id}`; }, sessionId); + await expect(page.locator("textarea").first()).toBeVisible({ timeout: 20_000 }); + // localStorage survived the reload. + expect(await page.evaluate((k) => localStorage.getItem(k), HIDE_KEY)).toBe("1"); + // Re-register the lazy component (fresh JS context after reload). + const footerModel2 = page.locator("[data-testid='footer-model-id']").first(); + await expect(footerModel2).toBeVisible({ timeout: 20_000 }); + await footerModel2.click(); + await page.waitForFunction(() => !!customElements.get("agent-model-selector"), null, { timeout: 20_000 }); + + await openPicker(CURRENT_MODEL); + // Filter is restored ON: unauthenticated built-ins are hidden again. + await expect(page.locator(item("gpt-unauthed-builtin"))).toHaveCount(0); + await expect(page.locator(item("gemini-unauthed-builtin"))).toHaveCount(0); + await expect(page.locator(item("claude-authed-builtin"))).toBeVisible(); + await expect(page.locator(item("grok-current-unauthed"))).toBeVisible(); + + // ── No server impact: every /api/models request is identical ── + // The toggle value never appears in any request (no query, no body) + // and the method/path are constant — so the server returns the same + // payload whether the toggle is ON or OFF (it is a pure display filter). + expect(modelsRequests.length).toBeGreaterThanOrEqual(2); + for (const r of modelsRequests) { + const u = new URL(r.url); + expect(r.method).toBe("GET"); + expect(u.pathname).toBe("/api/models"); + expect(u.search).toBe(""); + expect(r.postData ?? "").toBe(""); + expect(r.url.toLowerCase()).not.toContain("hideunauthed"); + expect(r.url.toLowerCase()).not.toContain("authenticated"); + } + } finally { + await deleteSession(sessionId); + } + }); +}); diff --git a/tests/e2e/ui/multi-gateway-settings.spec.ts b/tests/e2e/ui/multi-gateway-settings.spec.ts new file mode 100644 index 000000000..629636086 --- /dev/null +++ b/tests/e2e/ui/multi-gateway-settings.spec.ts @@ -0,0 +1,205 @@ +/** + * Browser E2E for the Slice D gateway list editor (Settings → Models tab). + * + * See docs/design/multi-gateway-providers.md §7 + §11. Covers the full editor + * surface end-to-end against TWO self-hosted in-process stub gateways (no LAN + * dependency, per the NO-NETWORK constraint): + * - add an `openai-compatible` row and an `aigw` row (name / url / type) + Save; + * - the exclusivity warning banner appears whenever an ENABLED `aigw` row + * exists and disappears when it is disabled (purely client-side, pre-Save); + * - the model picker reflects the saved gateways: in merged mode the + * `openai-compatible` provider + built-ins are present, and in exclusive + * mode (enabled `aigw`) ONLY `aigw` models are surfaced (built-ins + + * openai-compatible suppressed); + * - persistence: a full page reload restores rows + enabled state from + * GET /api/aigw/gateways; + * - removal cleanup: removing a row + Save drops its provider from /api/models. + * + * Discovery runs server-side (model-registry → discoverGatewayModels), so the + * stub servers only need to be reachable from the in-process gateway (Node), + * not from the browser. The two stubs expose DISJOINT model id sets so the + * picker assertions are deterministic per-provider. + */ +import { test, expect } from "../gateway-harness.js"; +import { openApp, navigateToHash } from "./ui-helpers.js"; +import { apiFetch } from "../e2e-setup.js"; +import http from "node:http"; +import type { Page } from "@playwright/test"; + +type Stub = { server: http.Server; url: string }; + +/** Start a tiny OpenAI-compatible stub serving GET /v1/models + a canned completion. */ +async function startStub(modelIds: string[]): Promise { + const server = http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + if (req.url?.endsWith("/v1/models")) { + res.end(JSON.stringify({ data: modelIds.map((id) => ({ id, object: "model" })) })); + } else { + res.end(JSON.stringify({ choices: [{ message: { content: "OK" } }] })); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const port = (server.address() as { port: number }).port; + return { server, url: `http://127.0.0.1:${port}` }; +} + +const MODELS_TAB = "#/settings/system/models"; +const editor = "[data-testid='gateways-editor']"; +const rowsSel = "[data-testid='gateway-row']"; +const warning = "[data-testid='gateway-exclusivity-warning']"; +const saveBtn = "[data-testid='gateways-save-btn']"; + +// Disjoint model id sets — one per provider — so picker rows are unambiguous. +const LLAMA_MODEL = "qwen-coder-medium"; // only the llama-swap (openai-compatible) stub serves this +const AIGW_MODEL = "gpt-aigw-only"; // only the aigw stub serves this + +const modelItem = (id: string) => `[data-model-item][data-model-id="${id}"]`; + +/** + * Open the model picker from the Session default-model row and wait for it to + * load. The `` host is a zero-size element (the modal + * portals its content to the body), so we wait on a rendered model ROW becoming + * visible rather than the host itself. + */ +async function openPicker(page: Page): Promise { + await page.locator("[data-row-label='Session'] button[title='Choose model']").first().click(); + // Wait for the list to finish loading (the "Loading models..." placeholder clears). + await expect(page.locator(`${modelItem(LLAMA_MODEL)}, ${modelItem(AIGW_MODEL)}`).first()).toBeVisible({ timeout: 10_000 }); +} + +/** Close the model picker deterministically so the next open() doesn't stack dialogs. */ +async function closePicker(page: Page): Promise { + await page.evaluate(() => { + document.querySelectorAll("agent-model-selector").forEach((el) => { + const close = (el as unknown as { close?: () => void }).close; + if (typeof close === "function") close.call(el); + else el.remove(); + }); + }); + await expect(page.locator("agent-model-selector")).toHaveCount(0, { timeout: 10_000 }); +} + +async function readModels(): Promise> { + const res = await apiFetch("/api/models"); + expect(res.ok).toBe(true); + return res.json(); +} + +test.describe("Settings → Models → AI Gateways list editor (§7)", () => { + test("add/save, exclusivity warning, picker providers, persistence, removal", async ({ page }) => { + test.setTimeout(120_000); + + const llama = await startStub([LLAMA_MODEL]); + const aigw = await startStub([AIGW_MODEL, "aws/us.anthropic.claude-sonnet-4-6"]); + + // Always leave the worker's gateway prefs clean for sibling specs. + const resetGateways = async () => { + await apiFetch("/api/aigw/gateways", { method: "PUT", body: JSON.stringify({ gateways: [] }) }).catch(() => {}); + }; + + try { + await resetGateways(); + await openApp(page); + await navigateToHash(page, MODELS_TAB); + await expect(page.locator(editor)).toBeVisible({ timeout: 15_000 }); + + // ── Add an openai-compatible row (llama-swap) ── + await page.locator("[data-testid='gateways-add-btn']").click(); + await expect(page.locator(rowsSel)).toHaveCount(1); + const row0 = page.locator(rowsSel).nth(0); + await row0.locator("[data-testid='gateway-name-input']").fill("llama-swap"); + await row0.locator("[data-testid='gateway-url-input']").fill(llama.url); + // type defaults to openai-compatible. + await expect(row0.locator("[data-testid='gateway-type-select']")).toHaveValue("openai-compatible"); + + // ── Add an aigw row (must be named exactly "aigw") ── + await page.locator("[data-testid='gateways-add-btn']").click(); + await expect(page.locator(rowsSel)).toHaveCount(2); + const row1 = page.locator(rowsSel).nth(1); + await row1.locator("[data-testid='gateway-name-input']").fill("aigw"); + await row1.locator("[data-testid='gateway-url-input']").fill(aigw.url); + await row1.locator("[data-testid='gateway-type-select']").selectOption("aigw"); + + // Enabling an aigw-type row (default enabled) → exclusivity warning shows. + await expect(page.locator(warning)).toBeVisible(); + // Uncheck the aigw row → warning disappears (purely client-side, pre-Save). + await row1.locator("[data-testid='gateway-enabled-checkbox']").uncheck(); + await expect(page.locator(warning)).toHaveCount(0); + // Re-check → warning reappears. + await row1.locator("[data-testid='gateway-enabled-checkbox']").check(); + await expect(page.locator(warning)).toBeVisible(); + + // ── MERGED mode: save with the aigw row DISABLED ── + await row1.locator("[data-testid='gateway-enabled-checkbox']").uncheck(); + await expect(page.locator(warning)).toHaveCount(0); + await page.locator(saveBtn).click(); + await expect(page.locator(saveBtn)).toHaveText("Save", { timeout: 15_000 }); + await expect(page.locator("[data-testid='gateways-error']")).toHaveCount(0); + + // /api/models (merged): llama-swap present, no aigw, built-ins present. + let models = await readModels(); + expect(models.some((m) => m.provider === "llama-swap" && m.id === LLAMA_MODEL)).toBe(true); + expect(models.some((m) => m.provider === "aigw")).toBe(false); + expect(models.some((m) => m.provider !== "llama-swap")).toBe(true); // built-ins survive in merged mode + + // Picker UI shows the llama-swap model with its provider badge. + await openPicker(page); + await expect(page.locator(modelItem(LLAMA_MODEL))).toBeVisible({ timeout: 10_000 }); + await expect(page.locator(modelItem(LLAMA_MODEL))).toContainText("llama-swap"); + await closePicker(page); + + // ── EXCLUSIVE mode: enable the aigw row + Save ── + await page.locator(rowsSel).nth(1).locator("[data-testid='gateway-enabled-checkbox']").check(); + await expect(page.locator(warning)).toBeVisible(); + await page.locator(saveBtn).click(); + await expect(page.locator(saveBtn)).toHaveText("Save", { timeout: 15_000 }); + await expect(page.locator("[data-testid='gateways-error']")).toHaveCount(0); + + // /api/models (exclusive): ONLY provider "aigw" — built-ins + llama-swap suppressed. + models = await readModels(); + expect(models.length).toBeGreaterThan(0); + expect(models.every((m) => m.provider === "aigw")).toBe(true); + expect(models.some((m) => m.id === AIGW_MODEL)).toBe(true); + expect(models.some((m) => m.provider === "llama-swap")).toBe(false); + + // Picker UI (exclusive): aigw model present, llama-swap absent. + await openPicker(page); + await expect(page.locator(modelItem(AIGW_MODEL))).toBeVisible({ timeout: 10_000 }); + await expect(page.locator(modelItem(LLAMA_MODEL))).toHaveCount(0); + await closePicker(page); + + // ── Persistence: full reload restores both rows + enabled state ── + await page.reload(); + await navigateToHash(page, MODELS_TAB); + await expect(page.locator(editor)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(rowsSel)).toHaveCount(2); + // Order is preserved by the server list, so row0=llama-swap, row1=aigw. + const llamaRow = page.locator(rowsSel).nth(0); + const aigwRow = page.locator(rowsSel).nth(1); + await expect(llamaRow.locator("[data-testid='gateway-name-input']")).toHaveValue("llama-swap"); + await expect(aigwRow.locator("[data-testid='gateway-name-input']")).toHaveValue("aigw"); + await expect(llamaRow.locator("[data-testid='gateway-enabled-checkbox']")).toBeChecked(); + await expect(aigwRow.locator("[data-testid='gateway-enabled-checkbox']")).toBeChecked(); + await expect(aigwRow.locator("[data-testid='gateway-type-select']")).toHaveValue("aigw"); + // aigw still enabled after reload → warning restored. + await expect(page.locator(warning)).toBeVisible(); + + // ── Removal cleanup ── + // Disable aigw first (back to merged) so the removed openai-compatible + // provider's disappearance is observable in /api/models. + await aigwRow.locator("[data-testid='gateway-enabled-checkbox']").uncheck(); + await llamaRow.locator("[data-testid='gateway-remove-btn']").click(); + await expect(page.locator(rowsSel)).toHaveCount(1); + await page.locator(saveBtn).click(); + await expect(page.locator(saveBtn)).toHaveText("Save", { timeout: 15_000 }); + + models = await readModels(); + expect(models.some((m) => m.provider === "llama-swap")).toBe(false); + expect(models.some((m) => m.provider === "aigw")).toBe(false); // aigw disabled too + } finally { + await resetGateways(); + await new Promise((r) => llama.server.close(() => r())); + await new Promise((r) => aigw.server.close(() => r())); + } + }); +}); diff --git a/tests/multi-gateway-exclusivity.test.ts b/tests/multi-gateway-exclusivity.test.ts new file mode 100644 index 000000000..38a0c53bf --- /dev/null +++ b/tests/multi-gateway-exclusivity.test.ts @@ -0,0 +1,47 @@ +/** + * Unit tests for `isExclusiveMode()` — exclusivity is DERIVED from gateway type, + * not a manual toggle. Pins the §4 truth table: + * + * | Gateways enabled | Mode | + * |-------------------------------------------|-----------| + * | one `aigw` | exclusive | + * | `aigw` + `openai-compatible` | exclusive | + * | only `openai-compatible`(s) | merged | + * | `aigw` disabled + `openai-compatible` | merged | + * | none enabled | merged | + * + * (docs/design/multi-gateway-providers.md §4.) + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { isExclusiveMode, type ModelGateway } from "../src/server/agent/aigw-manager.ts"; + +const aigw = (enabled: boolean): ModelGateway => ({ id: "a", name: "aigw", url: "http://gw/v1", type: "aigw", enabled }); +const local = (name: string, enabled: boolean): ModelGateway => ({ id: name, name, url: "http://host:9292", type: "openai-compatible", enabled }); + +describe("isExclusiveMode (derived exclusivity)", () => { + it("one enabled aigw ⇒ exclusive", () => { + assert.equal(isExclusiveMode([aigw(true)]), true); + }); + + it("aigw + openai-compatible both enabled ⇒ exclusive", () => { + assert.equal(isExclusiveMode([aigw(true), local("llama-swap", true)]), true); + }); + + it("only openai-compatible(s) enabled ⇒ merged", () => { + assert.equal(isExclusiveMode([local("llama-swap", true), local("ollama", true)]), false); + }); + + it("aigw DISABLED + openai-compatible enabled ⇒ merged (disabled aigw is not exclusive)", () => { + assert.equal(isExclusiveMode([aigw(false), local("llama-swap", true)]), false); + }); + + it("none enabled ⇒ merged", () => { + assert.equal(isExclusiveMode([aigw(false), local("llama-swap", false)]), false); + }); + + it("empty list ⇒ merged", () => { + assert.equal(isExclusiveMode([]), false); + }); +}); diff --git a/tests/multi-gateway-migration.test.ts b/tests/multi-gateway-migration.test.ts new file mode 100644 index 000000000..4f3cc5526 --- /dev/null +++ b/tests/multi-gateway-migration.test.ts @@ -0,0 +1,108 @@ +/** + * Unit tests for `migrateGatewayPrefs()` — the idempotent boot-time migration + * of the legacy single-URL prefs (`aigw.url` [+ `aigw.exclusive`]) into the new + * ordered `modelGateways` list. + * + * Contract (docs/design/multi-gateway-providers.md §2): + * 1. A configured single URL migrates to one `{name:"aigw", type:"aigw"}` + * gateway; `aigw.url` / `aigw.exclusive` are removed; unrelated prefs + * (e.g. `default.sessionModel = "aigw/..."`) are left untouched. + * 2. Running it again is a no-op (`migrated:false`) and leaves the list + * byte-identical. + * 3. With nothing to migrate, prefs are untouched and `modelGateways` stays + * absent (readers treat absent as `[]`). + * 4. With `modelGateways` already present, a stale `aigw.url` is stripped + * defensively and the list is unchanged. + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const { PreferencesStore } = await import("../src/server/agent/preferences-store.ts"); +const { migrateGatewayPrefs, listGateways } = await import("../src/server/agent/aigw-manager.ts"); + +let stateDir: string; + +beforeEach(() => { + stateDir = mkdtempSync(path.join(tmpdir(), "bobbit-mg-migrate-")); +}); + +afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe("migrateGatewayPrefs", () => { + it("migrates a configured single URL into a one-element aigw gateway list", () => { + const prefs = new PreferencesStore(stateDir); + prefs.set("aigw.url", "http://gw/v1"); + prefs.set("aigw.exclusive", false); + prefs.set("default.sessionModel", "aigw/claude-sonnet-4-6"); + + const result = migrateGatewayPrefs(prefs as any); + + assert.equal(result.migrated, true, "should report a migration occurred"); + + const gateways = listGateways(prefs as any); + assert.equal(gateways.length, 1); + assert.equal(typeof gateways[0].id, "string"); + assert.ok(gateways[0].id.length > 0, "id must be a non-empty string"); + // deep-equal everything but the random id + const { id, ...rest } = gateways[0]; + assert.deepEqual(rest, { + name: "aigw", + url: "http://gw/v1", + type: "aigw", + enabled: true, + }); + + // Legacy keys gone; the result's gateways mirror the persisted list. + assert.equal(prefs.get("aigw.url"), undefined, "aigw.url must be removed"); + assert.equal(prefs.get("aigw.exclusive"), undefined, "aigw.exclusive must be removed"); + assert.deepEqual(result.gateways, gateways); + + // Unrelated session-model pref keyed on the "aigw" provider survives. + assert.equal(prefs.get("default.sessionModel"), "aigw/claude-sonnet-4-6"); + }); + + it("is idempotent — a second run is a no-op leaving the list byte-identical", () => { + const prefs = new PreferencesStore(stateDir); + prefs.set("aigw.url", "http://gw/v1"); + + const first = migrateGatewayPrefs(prefs as any); + assert.equal(first.migrated, true); + const afterFirst = JSON.stringify(prefs.get("modelGateways")); + + const second = migrateGatewayPrefs(prefs as any); + assert.equal(second.migrated, false, "second run must not migrate again"); + const afterSecond = JSON.stringify(prefs.get("modelGateways")); + + assert.equal(afterSecond, afterFirst, "modelGateways must be byte-identical after re-run"); + }); + + it("is a no-op when there is nothing to migrate", () => { + const prefs = new PreferencesStore(stateDir); + + const result = migrateGatewayPrefs(prefs as any); + + assert.equal(result.migrated, false); + assert.deepEqual(result.gateways, []); + assert.equal(prefs.get("modelGateways"), undefined, "modelGateways must stay absent"); + assert.deepEqual(listGateways(prefs as any), []); + }); + + it("defensively strips a stale aigw.url when modelGateways already exists", () => { + const prefs = new PreferencesStore(stateDir); + const seeded = [{ id: "fixed-id", name: "llama-swap", url: "http://host:9292", type: "openai-compatible", enabled: true }]; + prefs.set("modelGateways", seeded); + prefs.set("aigw.url", "http://stale/v1"); + const before = JSON.stringify(prefs.get("modelGateways")); + + const result = migrateGatewayPrefs(prefs as any); + + assert.equal(result.migrated, false); + assert.equal(prefs.get("aigw.url"), undefined, "stale aigw.url must be removed"); + assert.equal(JSON.stringify(prefs.get("modelGateways")), before, "modelGateways must be unchanged"); + }); +}); diff --git a/tests/multi-gateway-validation.test.ts b/tests/multi-gateway-validation.test.ts new file mode 100644 index 000000000..21f5e52ad --- /dev/null +++ b/tests/multi-gateway-validation.test.ts @@ -0,0 +1,100 @@ +/** + * Unit tests for `saveGateways()` validation (docs/design/multi-gateway-providers.md §1). + * + * Pins the naming constraints — in particular the singleton `aigw`-name rule + * that keeps the three literal `"aigw"` guards (pi-ai-bedrock-headers-patch.ts, + * model-completion.ts, shared/thinking-levels.ts) correct UNCHANGED: + * + * - accept an `aigw`-type gateway named exactly "aigw"; + * - reject an `aigw`-type gateway named anything else; + * - reject a list containing more than one `aigw`-type gateway; + * - accept arbitrarily-named `openai-compatible` gateways; + * - reject names colliding with a built-in provider id; + * - reject names violating `^[a-zA-Z0-9._-]+$`; + * - reject duplicate names; + * - fill a missing `id` with a generated UUID. + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const { PreferencesStore } = await import("../src/server/agent/preferences-store.ts"); +const { saveGateways, listGateways } = await import("../src/server/agent/aigw-manager.ts"); + +let stateDir: string; + +beforeEach(() => { + stateDir = mkdtempSync(path.join(tmpdir(), "bobbit-mg-valid-")); +}); + +afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); +}); + +const aigw = (name: string) => ({ id: "x", name, url: "http://gw/v1", type: "aigw" as const, enabled: true }); +const local = (name: string) => ({ id: name, name, url: "http://host:9292", type: "openai-compatible" as const, enabled: true }); + +describe("saveGateways validation", () => { + it("accepts an aigw-type gateway named exactly \"aigw\"", () => { + const prefs = new PreferencesStore(stateDir); + assert.doesNotThrow(() => saveGateways(prefs as any, [aigw("aigw")])); + const list = listGateways(prefs as any); + assert.equal(list.length, 1); + assert.equal(list[0].name, "aigw"); + assert.equal(list[0].type, "aigw"); + }); + + it("accepts arbitrarily-named openai-compatible gateways", () => { + const prefs = new PreferencesStore(stateDir); + assert.doesNotThrow(() => saveGateways(prefs as any, [local("llama-swap"), local("ollama"), local("my.gw_1")])); + assert.equal(listGateways(prefs as any).length, 3); + }); + + it("accepts one aigw plus openai-compatible gateways together", () => { + const prefs = new PreferencesStore(stateDir); + assert.doesNotThrow(() => saveGateways(prefs as any, [aigw("aigw"), local("llama-swap")])); + assert.equal(listGateways(prefs as any).length, 2); + }); + + it("rejects an aigw-type gateway named anything other than \"aigw\"", () => { + const prefs = new PreferencesStore(stateDir); + assert.throws(() => saveGateways(prefs as any, [aigw("enterprise")]), /aigw-type gateway must be named/i); + }); + + it("rejects more than one aigw-type gateway", () => { + const prefs = new PreferencesStore(stateDir); + // Two aigw rows: the first must be named "aigw"; the second is rejected + // either for the duplicate name or the >1 aigw rule — both are correct. + assert.throws(() => saveGateways(prefs as any, [aigw("aigw"), aigw("aigw")])); + }); + + it("rejects a name colliding with a built-in provider id", () => { + const prefs = new PreferencesStore(stateDir); + for (const builtin of ["openai", "anthropic", "amazon-bedrock", "google", "groq", "mistral", "xai"]) { + assert.throws(() => saveGateways(prefs as any, [local(builtin)]), /collides with a built-in provider id/i, `expected "${builtin}" to be rejected`); + } + }); + + it("rejects names violating ^[a-zA-Z0-9._-]+$", () => { + const prefs = new PreferencesStore(stateDir); + for (const bad of ["has space", "a/b", "café", "name!", ""]) { + assert.throws(() => saveGateways(prefs as any, [local(bad)]), `expected "${bad}" to be rejected`); + } + }); + + it("rejects duplicate names", () => { + const prefs = new PreferencesStore(stateDir); + assert.throws(() => saveGateways(prefs as any, [local("dup"), local("dup")]), /duplicate gateway name/i); + }); + + it("fills a missing id with a generated UUID", () => { + const prefs = new PreferencesStore(stateDir); + saveGateways(prefs as any, [{ name: "llama-swap", url: "http://host:9292", type: "openai-compatible", enabled: true } as any]); + const list = listGateways(prefs as any); + assert.equal(list.length, 1); + assert.equal(typeof list[0].id, "string"); + assert.ok(list[0].id.length > 0, "id must be filled in"); + }); +}); diff --git a/tests/multi-gateway-writer.test.ts b/tests/multi-gateway-writer.test.ts new file mode 100644 index 000000000..8abcd1e3f --- /dev/null +++ b/tests/multi-gateway-writer.test.ts @@ -0,0 +1,319 @@ +/** + * Unit tests for the type-driven models.json writers + sync orchestrator + * (docs/design/multi-gateway-providers.md §3). NO NETWORK — discovery is served + * by a tiny in-process http.Server; models.json is written under a tmp + * BOBBIT_AGENT_DIR. + * + * Covers: + * - aigw type, Claude id ⇒ Bedrock block + provider-level headers + env; + * - openai-compatible, claude-named id stays OpenAI (no Bedrock, no headers, + * no AWS env from this gateway); + * - provider key = gateway name (not the literal "aigw"); + * - multiple gateways ⇒ multiple blocks; + * - pruning of disabled/removed gateways, never clobbering anthropic; + * - openai-compatible baseUrl normalization (append /v1, no double /v1); + * - Bedrock env set then cleared. + * + * Also folds in the former tests/aigw-headers.test.ts pins (header literal, no + * per-model headers, exact JSON-escaped form on disk, no header leak onto + * non-aigw providers) now that the single-URL writeAigwModelsJson is gone. + */ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const EXPECTED_HEADER_VALUE = `!node -e "process.stdout.write(process.env.BOBBIT_SESSION_ID || '')"`; +const EXPECTED_USER_AGENT = `Bobbit/${JSON.parse(readFileSync(path.resolve("package.json"), "utf-8")).version}`; + +const AWS_VARS = [ + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME", + "AWS_BEDROCK_FORCE_HTTP1", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_REGION", + "AWS_BEDROCK_SKIP_AUTH", +]; + +let agentDir: string; +let prevAgentDir: string | undefined; +const prevAws: Record = {}; + +before(() => { + agentDir = mkdtempSync(path.join(tmpdir(), "bobbit-mg-writer-agent-")); + prevAgentDir = process.env.BOBBIT_AGENT_DIR; + process.env.BOBBIT_AGENT_DIR = agentDir; + for (const v of AWS_VARS) prevAws[v] = process.env[v]; +}); + +after(() => { + if (prevAgentDir === undefined) delete process.env.BOBBIT_AGENT_DIR; + else process.env.BOBBIT_AGENT_DIR = prevAgentDir; + for (const v of AWS_VARS) { + if (prevAws[v] === undefined) delete process.env[v]; + else process.env[v] = prevAws[v]!; + } + rmSync(agentDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + const f = path.join(agentDir, "models.json"); + if (existsSync(f)) rmSync(f); + for (const v of AWS_VARS) delete process.env[v]; +}); + +const { PreferencesStore } = await import("../src/server/agent/preferences-store.ts"); +const { + buildAigwProviderBlock, + buildOpenAiCompatibleProviderBlock, + saveGateways, + syncGatewaysModelsJson, +} = await import("../src/server/agent/aigw-manager.ts"); + +type AigwModel = Awaited>[number]; + +function model(id: string, over: Partial = {}): AigwModel { + return { + id, + name: id, + api: "openai-completions", + reasoning: false, + input: ["text"], + contextWindow: 128_000, + maxTokens: 16_384, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + ...over, + }; +} + +/** Tiny in-process stub gateway: GET /v1/models → { data: [{id}] }. */ +function startStub(ids: string[]): Promise<{ url: string; close: () => Promise }> { + const server = http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + if (req.url?.endsWith("/v1/models")) { + res.end(JSON.stringify({ data: ids.map(id => ({ id, object: "model" })) })); + } else { + res.statusCode = 404; + res.end("{}"); + } + }); + return new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as any).port; + resolve({ url: `http://127.0.0.1:${port}`, close: () => new Promise(r => server.close(() => r())) }); + }); + }); +} + +function newPrefs() { + const dir = mkdtempSync(path.join(tmpdir(), "bobbit-mg-writer-state-")); + return new PreferencesStore(dir) as any; +} + +function readModelsJson(): any { + const f = path.join(agentDir, "models.json"); + return existsSync(f) ? JSON.parse(readFileSync(f, "utf-8")) : null; +} + +// ── Pure writers (no network) ────────────────────────────────────── + +describe("buildAigwProviderBlock", () => { + const gw = { id: "1", name: "aigw", url: "http://gw/v1", type: "aigw" as const, enabled: true }; + + it("emits provider-level headers, Bedrock-routes Claude ids, keeps non-Claude on openai-completions", () => { + const block: any = buildAigwProviderBlock(gw, [ + model("aws/us.anthropic.claude-sonnet-4-6", { name: "Claude Sonnet 4.6 (aws)", reasoning: true, input: ["text", "image"] }), + model("openai/gpt-5.2", { name: "Gpt 5.2 (openai)" }), + ]); + + assert.equal(block.baseUrl, "http://gw/v1"); + assert.equal(block.headers["User-Agent"], EXPECTED_USER_AGENT); + assert.equal(block.headers["x-opencode-session"], EXPECTED_HEADER_VALUE); + + const claude = block.models.find((m: any) => m.id.includes("claude")); + assert.equal(claude.id, "us.anthropic.claude-sonnet-4-6", "Claude id must be prefix-stripped"); + assert.equal(claude.api, "bedrock-converse-stream"); + assert.ok(String(claude.baseUrl).endsWith("/aws"), `claude baseUrl must end /aws, got ${claude.baseUrl}`); + + const gpt = block.models.find((m: any) => m.id === "openai/gpt-5.2"); + assert.equal(gpt.api ?? "openai-completions", "openai-completions"); + assert.equal(gpt.baseUrl, undefined, "non-Claude must not get a per-model baseUrl"); + + for (const m of block.models) assert.equal(m.headers, undefined, "no per-model headers"); + }); +}); + +describe("buildOpenAiCompatibleProviderBlock", () => { + it("emits plain openai-completions for EVERY model incl. a claude-named id, with no headers or Bedrock", () => { + const gw = { id: "2", name: "llama-swap", url: "http://host:9292", type: "openai-compatible" as const, enabled: true }; + const block: any = buildOpenAiCompatibleProviderBlock(gw, [model("qwen-coder-medium"), model("claude-local")]); + + assert.equal(block.headers, undefined, "openai-compatible block must have NO headers"); + assert.equal(block.api, "openai-completions"); + + const claude = block.models.find((m: any) => m.id === "claude-local"); + assert.equal(claude.api, "openai-completions", "a claude-named id must stay openai-completions"); + assert.notEqual(claude.api, "bedrock-converse-stream"); + assert.equal(claude.baseUrl, undefined, "no per-model /aws baseUrl override"); + }); + + it("normalizes baseUrl: appends /v1 and never doubles it", () => { + const a: any = buildOpenAiCompatibleProviderBlock({ id: "a", name: "g", url: "http://host:9292", type: "openai-compatible", enabled: true }, []); + assert.equal(a.baseUrl, "http://host:9292/v1"); + const b: any = buildOpenAiCompatibleProviderBlock({ id: "b", name: "g", url: "http://host:9292/v1/", type: "openai-compatible", enabled: true }, []); + assert.equal(b.baseUrl, "http://host:9292/v1"); + }); +}); + +// ── Sync orchestrator (in-process stub) ──────────────────────────── + +describe("syncGatewaysModelsJson", () => { + it("aigw + Claude id ⇒ Bedrock block + headers + AWS env set; provider key = gateway name", async () => { + const stub = await startStub(["openai/gpt-5.2", "aws/us.anthropic.claude-sonnet-4-6"]); + try { + const prefs = newPrefs(); + saveGateways(prefs, [{ id: "1", name: "aigw", url: stub.url, type: "aigw", enabled: true }]); + await syncGatewaysModelsJson(prefs); + + const data = readModelsJson(); + assert.ok(data.providers.aigw, "block must live under providers.aigw"); + assert.equal(data.providers.aigw.headers["x-opencode-session"], EXPECTED_HEADER_VALUE); + assert.equal(data.providers.aigw.headers["User-Agent"], EXPECTED_USER_AGENT); + + const claude = data.providers.aigw.models.find((m: any) => m.id.includes("claude")); + assert.equal(claude.api, "bedrock-converse-stream"); + assert.ok(String(claude.baseUrl).endsWith("/aws")); + + assert.ok(process.env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME, "AWS env must be set for an enabled aigw gateway"); + } finally { + await stub.close(); + } + }); + + it("openai-compatible: claude-named id stays openai-completions; no headers; no AWS env; keyed by gateway name", async () => { + const stub = await startStub(["qwen-coder-medium", "claude-local"]); + try { + const prefs = newPrefs(); + saveGateways(prefs, [{ id: "2", name: "llama-swap", url: stub.url, type: "openai-compatible", enabled: true }]); + await syncGatewaysModelsJson(prefs); + + const data = readModelsJson(); + assert.ok(data.providers["llama-swap"], "block must live under providers['llama-swap']"); + assert.equal(data.providers.aigw, undefined, "must NOT create a providers.aigw block"); + assert.equal(data.providers["llama-swap"].headers, undefined, "no headers for openai-compatible"); + + const claude = data.providers["llama-swap"].models.find((m: any) => m.id === "claude-local"); + assert.equal(claude.api, "openai-completions"); + assert.equal(claude.baseUrl, undefined); + + assert.equal(process.env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME, undefined, "openai-compatible gateway must not set Bedrock env"); + } finally { + await stub.close(); + } + }); + + it("multiple enabled gateways ⇒ multiple provider blocks from one sync", async () => { + const stub = await startStub(["qwen-coder-medium", "aws/us.anthropic.claude-sonnet-4-6"]); + try { + const prefs = newPrefs(); + saveGateways(prefs, [ + { id: "1", name: "aigw", url: stub.url, type: "aigw", enabled: true }, + { id: "2", name: "llama-swap", url: stub.url, type: "openai-compatible", enabled: true }, + ]); + await syncGatewaysModelsJson(prefs); + + const data = readModelsJson(); + assert.ok(data.providers.aigw, "providers.aigw present"); + assert.ok(data.providers["llama-swap"], "providers['llama-swap'] present"); + } finally { + await stub.close(); + } + }); + + it("prunes disabled / removed gateways without clobbering unrelated providers", async () => { + const stub = await startStub(["qwen-coder-medium"]); + try { + // Pre-seed an unrelated anthropic provider. + writeFileSync(path.join(agentDir, "models.json"), JSON.stringify({ + providers: { anthropic: { apiKey: "sk-test", models: [{ id: "claude-x" }] } }, + }, null, 2)); + + const prefs = newPrefs(); + saveGateways(prefs, [ + { id: "1", name: "aigw", url: stub.url, type: "aigw", enabled: true }, + { id: "2", name: "llama-swap", url: stub.url, type: "openai-compatible", enabled: true }, + ]); + await syncGatewaysModelsJson(prefs); + assert.ok(readModelsJson().providers["llama-swap"], "both gateways present after first sync"); + + // Disable llama-swap → its block is pruned, aigw stays. + saveGateways(prefs, [ + { id: "1", name: "aigw", url: stub.url, type: "aigw", enabled: true }, + { id: "2", name: "llama-swap", url: stub.url, type: "openai-compatible", enabled: false }, + ]); + await syncGatewaysModelsJson(prefs); + let data = readModelsJson(); + assert.equal(data.providers["llama-swap"], undefined, "disabled gateway block pruned"); + assert.ok(data.providers.aigw, "aigw block intact"); + + // Remove everything → both gateway blocks gone, anthropic survives. + saveGateways(prefs, []); + await syncGatewaysModelsJson(prefs); + data = readModelsJson(); + assert.equal(data.providers.aigw, undefined, "aigw pruned"); + assert.equal(data.providers["llama-swap"], undefined, "llama-swap pruned"); + assert.ok(data.providers.anthropic, "unrelated anthropic provider untouched"); + } finally { + await stub.close(); + } + }); + + it("sets Bedrock env for an enabled aigw, then clears the four AWS_* vars when disabled", async () => { + const stub = await startStub(["aws/us.anthropic.claude-sonnet-4-6"]); + try { + const prefs = newPrefs(); + saveGateways(prefs, [{ id: "1", name: "aigw", url: stub.url, type: "aigw", enabled: true }]); + await syncGatewaysModelsJson(prefs); + assert.ok(process.env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME, "env set while aigw enabled"); + assert.equal(process.env.AWS_ACCESS_KEY_ID, "anything"); + + saveGateways(prefs, [{ id: "1", name: "aigw", url: stub.url, type: "aigw", enabled: false }]); + await syncGatewaysModelsJson(prefs); + assert.equal(process.env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME, undefined); + assert.equal(process.env.AWS_BEDROCK_FORCE_HTTP1, undefined); + assert.equal(process.env.AWS_ACCESS_KEY_ID, undefined); + assert.equal(process.env.AWS_SECRET_ACCESS_KEY, undefined); + } finally { + await stub.close(); + } + }); + + it("writes the exact JSON-escaped x-opencode-session literal and never leaks headers onto non-aigw providers", async () => { + const stub = await startStub(["openai/gpt-5.2"]); + try { + // Pre-seed anthropic with its own headers — must be left untouched. + writeFileSync(path.join(agentDir, "models.json"), JSON.stringify({ + providers: { anthropic: { apiKey: "sk-test", headers: { "X-Existing": "keep-me" } } }, + }, null, 2)); + + const prefs = newPrefs(); + saveGateways(prefs, [{ id: "1", name: "aigw", url: stub.url, type: "aigw", enabled: true }]); + await syncGatewaysModelsJson(prefs); + + const raw = readFileSync(path.join(agentDir, "models.json"), "utf-8"); + assert.ok( + raw.includes(`"!node -e \\"process.stdout.write(process.env.BOBBIT_SESSION_ID || '')\\""`), + "file must contain the exact escaped JSON form of the header literal", + ); + + const data = readModelsJson(); + assert.deepEqual(data.providers.anthropic.headers, { "X-Existing": "keep-me" }, "anthropic headers untouched"); + assert.equal(data.providers.anthropic.headers["User-Agent"], undefined, "no User-Agent leak"); + assert.equal(data.providers.anthropic.headers["x-opencode-session"], undefined, "no x-opencode-session leak"); + } finally { + await stub.close(); + } + }); +});