Skip to content

Commit eaac664

Browse files
quickbeardclaude
andauthored
fix(configure): patch CoDev Code's config in place, honor its env flags, validate keys via /v1/models (#265)
The hub's CoDev Code configuration stopped fitting how the agent works. Three mismatches with codev-code main (packages/codev-gateway, the config layer, the desktop gateway controller), each fixed here: 1. The writer whole-file-replaced ~/.config/codev/codev.json(c), carrying only `mcp` across. CoDev Code is a standalone product now: its TUI connects custom providers, its desktop app writes theme/keybind/ permission settings, users keep comments in codev.jsonc — and the hub rewrites this file on every gateway-key auto-refresh and model switch, not just at install. All of that was deleted on each run. `configureOpenCodeKind` now PATCHes the file through jsonc-parser the way the agent's own `Config.updateGlobal` does: `$schema` seeded only when absent, `compaction.auto`/`reserved` set key-by-key, the CoDev `provider.<id>` block replaced wholesale (stale models and any inline apiKey go), everything else byte-identical. The agent's PATCH cannot delete, so convergence after the AIGW rename is the hub's job: other CoDev provider ids (`netgate`, `aigateway`, …) and a CoDev-authored top-level `model` pin are removed; a user's own provider never is. Syntax errors or a non-object root still fall back to a fresh file, with the backup holding the original. `readPreservedMcp` is gone — nothing needs carrying over any more. 2. `runAgent("codev")` set OPENCODE_DISABLE_AUTOUPDATE, but the fork renamed every OPENCODE_* env var to CODEV_* (codev-code #41), so the agent's self-updater was never disabled and raced `codevhub update`. Both spellings are set now. 3. `validateApiKey` probed LiteLLM's /key/info at the gateway ROOT. That root is now fronted by a web app whose catch-all answers HTTP 200 + HTML to any bearer — verified with a bogus key — so the probe could never return false: the launch-time refresh never fired on an expired key and SetupApp's "reuse existing key" offered dead keys as valid. It now lists /v1/models (authenticated by the key itself, 401s properly) and treats a non-JSON 200 as "can't tell", never "valid". `doctor` reports the same URL. Verified against the real agent: patching a copy of a live codev.jsonc that carried duplicate netgate + aigw blocks, a comment, a theme, a custom provider and the CodeGraph mcp entry, then running `codev models` under that HOME, lists each aigw model once plus the custom provider — with the comment and settings intact. Tests: writer tests now assert preservation (settings, comments, in-TUI providers, compaction siblings, $schema), legacy-block and pin removal, and the corrupt-file fallback; validateApiKey tests target /v1/models and pin the HTML-200 case; run tests assert both autoupdate flags. Claude-Session: https://claude.ai/code/session_0178nPaQqSGVe7Y1fR2ovDTK Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent e54e899 commit eaac664

10 files changed

Lines changed: 410 additions & 116 deletions

File tree

AGENTS.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,23 @@ The load-bearing consequence: the provider id **is** CoDev's authorship marker f
138138

139139
`Credentials`/`ApiKeyCreds` carry `providerId`/`providerName` (persisted as `provider_id`/`provider_name`), and `resolveProvider` supplies the AIGW default when they're absent. **`saveApiKey` writes the whole api-key block, so an omitted provider pair clears it** — every re-save site (`ModelApp`'s two re-auth branches and its model switch, `refresh.ts#ensureFreshGatewayKey`, `SetupApp`'s model-choice) must thread it through, or a manually-named provider silently reverts to AIGW on the next model switch or launch-time key refresh. `logout()` is the same hazard by a different route: it rebuilds the surviving file field-by-field rather than deleting SSO keys from it, so anything not listed in its `preserved` object is dropped. The provider pair was missing there and had to be added back — a field added to `AuthFileContents` is not automatically a field that survives sign-out.
140140

141+
## CoDev Code config (`~/.config/codev/codev.json(c)`)
142+
143+
CoDev Code is a standalone product with its own configure flows (the TUI's `/configure` and `codev configure`, the desktop sign-in), all built on its `packages/codev-gateway` package. The hub mirrors that package rather than the other way round, and three facts about it decide how the hub writes the file:
144+
145+
1. **The agent PATCHes its config; it never rewrites it.** `Config.updateGlobal` (`packages/opencode/src/config/config.ts`) edits `codev.json(c)` through jsonc-parser, preserving comments and every key it doesn't own. The file also carries things that are not the hub's: providers the user connected in-TUI (`provider.<custom id>` + an auth-store entry), desktop settings (`theme`, `keybinds`, `permission`, …), `disabled_providers`, `mcp`. So `configureOpenCodeKind` is a **surgical patch too** (`patchOpenCodeConfig`, same jsonc-parser `modify`/`applyEdits` as `lib/codegraph.ts`): it seeds `$schema` only when absent, sets `compaction.auto`/`compaction.reserved` key-by-key (a user's `prune` survives), replaces `provider.<id>` wholesale (stale models and any inline `apiKey` go), and leaves everything else byte-identical. It used to whole-file-replace and carry only `mcp` across — which is exactly how a user's custom providers and desktop settings vanished on every key refresh. A file with syntax errors or a non-object root is still replaced from scratch; the backup taken first keeps the original. This runs for legacy OpenCode too; the two kinds share the writer.
146+
147+
2. **The agent's PATCH cannot delete, so convergence is the hub's job.** After the AIGW rename (`aigw`, was `netgate`, before that `aigateway`) a re-run of the agent's own configure leaves the legacy block beside the new one and the model picker lists every model twice; the fork's own notes say "a hub-managed install converges on its next hub configure/refresh". The patch therefore removes `provider.<id>` for every *other* id in `codevProviderIds()` and a top-level `model` pin on a CoDev provider (an older hub wrote one; it outranks the TUI's saved selection every startup). Only CoDev-written ids are candidates — a user's own provider is never touched.
148+
149+
3. **The credential lives in the agent's auth store, not the config.** `~/.local/share/codev/auth.json` (`XDG_DATA_HOME`-aware; `Global.Path.data` in `packages/core/src/global.ts`), one `{ type: "api", key }` entry per provider id, merged into `options.apiKey` by the provider registry at load. The hub writes the entry *before* the keyless config block (a keyless block with no entry 401s on the first chat). The agent's startup migration (`config/gateway-key-migration.ts`) scrubs any inline key an older hub left, so the writer must never re-add one. The SSO session and gateway key are shared through `~/.codev-hub/auth.json` — the agent reads *and writes* the hub's file when it exists (same `AuthFileContents` shape), so a field added to the hub's file must be tolerated by, and ideally mirrored in, `packages/codev-gateway/src/auth-file.ts`.
150+
151+
Two related contracts, both broken silently when they drift:
152+
153+
- **Env flags are `CODEV_*`.** The fork renamed every `OPENCODE_*` variable (codev-code #41). `runAgent("codev")` sets `CODEV_DISABLE_AUTOUPDATE=1` (and the old spelling for pre-rename builds); setting only `OPENCODE_DISABLE_AUTOUPDATE` disabled nothing, and the agent's self-updater raced `codevhub update`.
154+
- **Key validation goes through `/v1/models`, never the gateway root.** `backend.ts#validateApiKey` used to probe LiteLLM's `/key/info` at the root; the root is now fronted by a web app whose catch-all answers **HTTP 200 + HTML to any bearer, a bogus one included**, so the probe could never say "invalid": the launch-time refresh never fired and `SetupApp`'s "reuse existing key" offered dead keys. `/v1/models` is authenticated by the key itself and 401s properly. A 200 whose body isn't JSON is treated as "can't tell" (throw), not "valid" — that guard is what stops the same failure recurring behind another catch-all. `doctor`'s `llm` group reports the same URL.
155+
156+
The gateway also mints **short-lived keys** now: the desktop's `ensureKey` (`packages/desktop/src/main/gateway-sso/controller.ts`) silently re-mints one from the SSO session when validation fails and re-keys the legacy ids too. `refresh.ts#ensureFreshGatewayKey` is the hub's counterpart at `codevhub codev` launch; it needs a `refresh_token` in `~/.codev-hub/auth.json`, and whether the IdP issues one is server-side (the hub already requests `offline_access`).
157+
141158
## Context windows and auto-compaction
142159

143160
Every agent CoDev configures has to be *told* the window of the model it's talking to. The gateway serves custom models none of them recognize, and each guesses differently when unconfigured: Codex assumes a 272K fallback, OpenCode assumes context `0` (which disables compaction outright), Continue falls back to a generic default. `src/lib/model-limits.ts` is the single source of truth; the four writers in `configure.ts` translate it into each agent's own knob and hold no window constants of their own. The flat `GATEWAY_CONTEXT_WINDOW` / `GATEWAY_COMPACT_*` constants that used to live in `const.ts` are gone — they encoded the assumption that every gateway model shares one 196608-token window, which stopped being true once the gateway served both a 1M-token and a 200K-token model.
@@ -180,7 +197,7 @@ Net effect: Claude Code compacts at `0.8 × (200000 − min(modelMaxOutput, 2000
180197
181198
**Verify OpenCode-family behavior against the shipped binary, not the published schema.** `https://opencode.ai/config.json` documents `reserved` only as "token buffer for compaction" and says nothing about the `limit.input` branch that decides whether it is read at all. The threshold function is greppable in the binary (`grep -aob "cfg.compaction?.reserved"`, then read the surrounding bytes).
182199

183-
The remote source is wired but currently inert: `backend.ts#fetchModelWindows` reads LiteLLM's `/model_group/info` (at the gateway **root**, next to `/key/info`, not under `/v1`) and keeps entries with a numeric `max_input_tokens`. The live gateway reports `null` for every model, so it returns `{}` and the static table carries everything — the moment an admin populates the field, that model becomes gateway-driven with no CoDev release. Unlike `fetchModels`, it **never throws**: a window is an optimization over a sane default, and install must not break because a metadata endpoint 404s on some other gateway build. `ModelSelect` refreshes it fire-and-forget alongside the model list, so it can never delay or fail the picker.
200+
The remote source is wired but currently inert: `backend.ts#fetchModelWindows` reads LiteLLM's `/model_group/info` (at the gateway **root**, not under `/v1` — and that root is now fronted by a web app whose catch-all answers HTML 200, so on the live gateway the JSON parse fails and it returns `{}`) and keeps entries with a numeric `max_input_tokens`. The live gateway reports `null` for every model, so it returns `{}` and the static table carries everything — the moment an admin populates the field, that model becomes gateway-driven with no CoDev release. Unlike `fetchModels`, it **never throws**: a window is an optimization over a sane default, and install must not break because a metadata endpoint 404s on some other gateway build. `ModelSelect` refreshes it fire-and-forget alongside the model list, so it can never delay or fail the picker.
184201
185202
The cache is its **own top-level `model_limits` block** in auth.json with its own `saveModelLimits`/`loadModelLimits`, deliberately *not* a field on the api-key block — see the `saveApiKey` hazard above; a field there would be cleared by every re-save site that didn't thread it through. `limitsFor` memoizes the read once per process (configure* runs once per selected agent and once per model in the OpenCode map), so tests that write the cache must call `resetModelLimitsCache()`.
186203

@@ -341,7 +358,7 @@ CoDev Code is the flagship: `ALWAYS_AGENT` is in every target set, the picker re
341358
342359
**CoDev Code wiring is special.** codegraph has no built-in target for the fork, so `setupCodegraph` wires it per run via one of two paths that produce byte-identical config (the `mcp.codegraph` entry in `codevCodeConfigPath()`, i.e. `~/.config/codev/codev.json(c)`): **Path A** — when `supportsCustomTargets()` (probe: `codegraph targets list`, read-only; the capability is codegraph PR #1459) reports support, register `CODEV_TARGET_SPEC` via `codegraph targets add` and append `codev` to the one install CSV; **Path B** — otherwise `wireCodevCodeMcp()` edits the config directly (surgical jsonc-parser `modify`/`applyEdits`; idempotent — an already-correct entry writes nothing; refuses malformed files). A registration failure on a capable binary silently falls back to the shim. Because codev-hub npm-installs the latest codegraph right before wiring, Path A activates machine-by-machine the moment upstream releases custom targets — once the released floor supports them, delete Path B and the probe (pure code removal, no migration). Gating uses `codegraphEligible(tools)` (built-in targets **or** codev-code), never `codegraphTargets(...).length` — codev-code is the always-on flagship tool, and gating on targets alone would write an MCP entry referencing a binary that was never installed.
343360
344-
**Config-rewrite preservation.** `configureOpenCodeKind` (OpenCode + the fork) whole-file-replaces its config, and runs not just at install time but on every gateway-key auto-refresh (`refresh.ts#ensureFreshGatewayKey`) and `codevhub model` switch. It therefore carries the existing top-level `mcp` map (via `readPreservedMcp` — best-effort, only object-valued maps) across the rewrite; without that, every refresh would silently unwire CodeGraph (either path) and any MCP servers the user added. The `mcp` entry never lands in `*.backup` (taken before CoDev's first write), so restore still returns the true pre-CoDev state.
361+
**Config-rewrite preservation.** `configureOpenCodeKind` (OpenCode + the fork) patches its config **in place** (see "CoDev Code config" below), so the `mcp` map — CodeGraph's entry and any servers the user added — survives every gateway-key auto-refresh (`refresh.ts#ensureFreshGatewayKey`) and `codevhub model` switch without being carried over by hand. The `mcp` entry never lands in `*.backup` (taken before CoDev's first write), so restore still returns the true pre-CoDev state.
345362
346363
2. **Command passthrough.** `codevhub codegraph <args>` forwards verbatim to `codegraph <args>` via `forwardToCodegraph` (e.g. `codevhub codegraph init -y`). It mirrors `src/lib/run.ts#runAgent` (inherited stdio, SIGINT/SIGTERM swallowing, win32 `shell:true`) minus the shim-dir stripping and upload daemon — CodeGraph isn't a chat agent and isn't shimmed. ENOENT prints an install hint.
347364

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ It is read-only (it installs and configures nothing) and checks, in order:
4040
backend (used by `codevhub upload`).
4141
- **LLM access** — the key is valid, models are listable, and a real one-token
4242
completion succeeds. Only the last of these proves inference is permitted;
43-
`/key/info` and `/v1/models` both pass for a key that is then 403'd on every
44-
completion.
43+
`/v1/models` passes for a key that is then 403'd on every completion.
4544
- **This machine** — what is already installed, configured, and backed up.
4645

4746
Failures expand in place into what happened, the most likely cause given your

src/lib/backend.ts

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -102,34 +102,41 @@ export async function fetchCodevConfig(
102102
};
103103
}
104104

105-
// Manual creds may include a `/v1` suffix (OpenAI-style); /key/info lives at
106-
// the gateway root, so strip a trailing v1 segment before joining. Falls
107-
// back to AI_GATEWAY_URL when the saved key has no base_url (SSO-fetched
108-
// keys don't store one).
109-
function keyInfoUrl(baseUrl?: string): string {
110-
const base = baseUrl ?? AI_GATEWAY_URL();
111-
const stripped = base.replace(/\/?v1\/?$/, "");
112-
const trailing = stripped.endsWith("/") ? stripped : `${stripped}/`;
113-
return `${trailing}key/info`;
114-
}
115-
116-
// Validates a key against the gateway's /key/info endpoint (LiteLLM): a single
117-
// hash-based lookup against the key table. Returns true on 2xx, false on
118-
// 401/403 (invalid/revoked), throws on network errors so the caller can
119-
// distinguish "key is bad" from "couldn't reach the gateway".
105+
// Validates a key by listing models through the OpenAI-compatible
106+
// `/v1/models` endpoint — the same call fetchModels makes, and the one path the
107+
// gateway is guaranteed to authenticate with the key itself. Returns true on a
108+
// JSON 2xx, false on 401/403 (invalid/revoked), throws on network errors and
109+
// anything else so the caller can tell "key is bad" from "couldn't tell".
110+
//
111+
// This used to hit LiteLLM's `/key/info` at the gateway ROOT. The root is now
112+
// fronted by a web app whose catch-all answers **HTTP 200 with an HTML page to
113+
// any bearer, including a bogus one**, so that probe could never return false:
114+
// the launch-time refresh (refresh.ts) never fired and the "reuse existing
115+
// key" path offered dead keys as valid. `/v1/models` 401s properly. The
116+
// content-type guard is what keeps the same failure from recurring behind
117+
// some other catch-all — a 200 that isn't JSON is "can't tell", never "valid".
120118
export async function validateApiKey(
121119
apiKey: string,
122120
baseUrl?: string,
123121
): Promise<boolean> {
124-
const res = await loggedFetch("gateway.key-info", keyInfoUrl(baseUrl), {
122+
const res = await loggedFetch("gateway.key-check", modelsUrl(baseUrl), {
125123
method: "GET",
126-
headers: { Authorization: `Bearer ${apiKey}` },
124+
headers: {
125+
accept: "application/json",
126+
Authorization: `Bearer ${apiKey}`,
127+
},
127128
signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS),
128129
});
129130
if (res.status === 401 || res.status === 403) return false;
130131
if (!res.ok) {
131132
throw new Error(`Validation failed (${res.status}): ${res.statusText}`);
132133
}
134+
const type = res.headers.get("content-type") ?? "";
135+
if (!type.toLowerCase().includes("json")) {
136+
throw new Error(
137+
`Validation failed: the gateway answered with ${type || "a non-JSON body"} instead of JSON`,
138+
);
139+
}
133140
return true;
134141
}
135142

@@ -185,9 +192,11 @@ export async function fetchModels(
185192
return ids;
186193
}
187194

188-
// LiteLLM's aggregated per-model-name view. Lives at the gateway root next to
189-
// /key/info, not under /v1, so it reuses the root-stripping join rather than
190-
// gatewayV1Url.
195+
// LiteLLM's aggregated per-model-name view. Lives at the gateway ROOT, not
196+
// under /v1, so it uses a root-stripping join rather than gatewayV1Url. Note
197+
// the root is now fronted by a web app (see validateApiKey), so on the live
198+
// gateway this answers an HTML 200 — the JSON parse fails and the catch below
199+
// returns {}, which is the documented "gateway reports nothing" outcome.
191200
function modelGroupInfoUrl(baseUrl?: string): string {
192201
const base = baseUrl ?? AI_GATEWAY_URL();
193202
const stripped = base.replace(/\/?v1\/?$/, "");
@@ -258,8 +267,8 @@ export async function fetchModelWindows(
258267
}
259268

260269
// Confirms the configured key can actually RUN the chosen model through the
261-
// gateway. validateApiKey (/key/info) and fetchModels (/v1/models) only prove
262-
// the key exists and that models are listable — neither proves inference is
270+
// gateway. validateApiKey and fetchModels (both /v1/models) only prove the key
271+
// authenticates and that models are listable — neither proves inference is
263272
// permitted. This 1-token chat completion catches the gateway 403s ("key not
264273
// allowed to access model", over-budget, edge/WAF blocks) that otherwise stay
265274
// hidden until the agent's first message. Returns null on success, or a short

0 commit comments

Comments
 (0)