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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,23 @@ The load-bearing consequence: the provider id **is** CoDev's authorship marker f

`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.

## CoDev Code config (`~/.config/codev/codev.json(c)`)

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:

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.

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.

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`.

Two related contracts, both broken silently when they drift:

- **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`.
- **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.

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`).

## Context windows and auto-compaction

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.
Expand Down Expand Up @@ -180,7 +197,7 @@ Net effect: Claude Code compacts at `0.8 × (200000 − min(modelMaxOutput, 2000

**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).

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.
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.

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()`.

Expand Down Expand Up @@ -341,7 +358,7 @@ CoDev Code is the flagship: `ALWAYS_AGENT` is in every target set, the picker re

**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.

**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.
**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.

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.

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

Failures expand in place into what happened, the most likely cause given your
Expand Down
53 changes: 31 additions & 22 deletions src/lib/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,34 +102,41 @@ export async function fetchCodevConfig(
};
}

// Manual creds may include a `/v1` suffix (OpenAI-style); /key/info lives at
// the gateway root, so strip a trailing v1 segment before joining. Falls
// back to AI_GATEWAY_URL when the saved key has no base_url (SSO-fetched
// keys don't store one).
function keyInfoUrl(baseUrl?: string): string {
const base = baseUrl ?? AI_GATEWAY_URL();
const stripped = base.replace(/\/?v1\/?$/, "");
const trailing = stripped.endsWith("/") ? stripped : `${stripped}/`;
return `${trailing}key/info`;
}

// Validates a key against the gateway's /key/info endpoint (LiteLLM): a single
// hash-based lookup against the key table. Returns true on 2xx, false on
// 401/403 (invalid/revoked), throws on network errors so the caller can
// distinguish "key is bad" from "couldn't reach the gateway".
// Validates a key by listing models through the OpenAI-compatible
// `/v1/models` endpoint — the same call fetchModels makes, and the one path the
// gateway is guaranteed to authenticate with the key itself. Returns true on a
// JSON 2xx, false on 401/403 (invalid/revoked), throws on network errors and
// anything else so the caller can tell "key is bad" from "couldn't tell".
//
// This used to hit LiteLLM's `/key/info` at the gateway ROOT. The root is now
// fronted by a web app whose catch-all answers **HTTP 200 with an HTML page to
// any bearer, including a bogus one**, so that probe could never return false:
// the launch-time refresh (refresh.ts) never fired and the "reuse existing
// key" path offered dead keys as valid. `/v1/models` 401s properly. The
// content-type guard is what keeps the same failure from recurring behind
// some other catch-all — a 200 that isn't JSON is "can't tell", never "valid".
export async function validateApiKey(
apiKey: string,
baseUrl?: string,
): Promise<boolean> {
const res = await loggedFetch("gateway.key-info", keyInfoUrl(baseUrl), {
const res = await loggedFetch("gateway.key-check", modelsUrl(baseUrl), {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
headers: {
accept: "application/json",
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS),
});
if (res.status === 401 || res.status === 403) return false;
if (!res.ok) {
throw new Error(`Validation failed (${res.status}): ${res.statusText}`);
}
const type = res.headers.get("content-type") ?? "";
if (!type.toLowerCase().includes("json")) {
throw new Error(
`Validation failed: the gateway answered with ${type || "a non-JSON body"} instead of JSON`,
);
}
return true;
}

Expand Down Expand Up @@ -185,9 +192,11 @@ export async function fetchModels(
return ids;
}

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

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