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
48 changes: 47 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,53 @@ Two built-in identities: **netgate / netGate** for SSO-issued keys (the "Get a n

The load-bearing consequence: the provider id **is** CoDev's authorship marker for codex/opencode/codev-code. It gates `detectConfiguredTools` (whose configs `codevhub model` rewrites), `isCodevAuthored` (restore's delete-vs-`kept-live` decision), and `readAgentConfig`'s base_url readback. Since it's no longer a compile-time constant, `codevProviderIds()` returns the candidate set — the id saved in `~/.codev-hub/auth.json`, then `netgate`, `ai-gateway`, and the pre-rename `aigateway` — and `firstNestedKey` resolves the live entry against it. Nothing writes `aigateway` any more; it stays recognized so installs predating the rename keep working, and they converge on the new id at the next config rewrite. Detection is deliberately *saved-id-first*: without auth.json a custom-id config is unattributable and restore keeps it rather than deleting it, matching the module's standing rule that a config we can't attribute is one we don't delete.

`Credentials`/`ApiKeyCreds` carry `providerId`/`providerName` (persisted as `provider_id`/`provider_name`), and `resolveProvider` supplies the netGate 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 netGate on the next model switch or launch-time key refresh.
`Credentials`/`ApiKeyCreds` carry `providerId`/`providerName` (persisted as `provider_id`/`provider_name`), and `resolveProvider` supplies the netGate 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 netGate 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.

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

`ModelLimits` is `{ context, trigger, output? }`: the true window, the absolute token count where auto-compaction should fire, and an optional output cap. **`trigger` is explicit rather than a percentage** — the gap between window and trigger is a per-model judgement call, not a constant. `limitsFor(modelId)` resolves **remote → table → `DEFAULT_LIMITS`**, where remote is the gateway's own numbers cached in auth.json and `DEFAULT_LIMITS` (200K/160K) covers anything unrecognized. `MiniMax/MiniMax-M2.7` is deliberately *absent* from the table: the default already describes it, and an entry that merely restates the default is one more thing to keep in sync.

Each agent takes a different shape, and the differences are the whole reason this module exists:

- **Claude Code** — `CLAUDE_CODE_AUTO_COMPACT_WINDOW` + `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`, via `claudeWindow()` / `claudeCompactPct()`. The one agent that will **not** accept an arbitrary window; see below.
- **Codex** — `model_context_window` + `model_auto_compact_token_limit` (an absolute count). Also single-model, also exact.
- **Continue** — per-model `defaultCompletionOptions.contextLength` / `maxTokens`. Continue has no compaction of its own; it prunes history to fit `contextLength`, so the window is all it needs and there is no trigger to express.
- **OpenCode / CoDev Code** — the hard one, below.

**`limit.input` is what makes OpenCode's trigger per-model, and it is not optional.** The decompiled threshold (identical in both the `opencode` and `codev` binaries) is:

```js
const reserved = cfg.compaction?.reserved ?? Math.min(20000, maxOutputTokens(model));
return model.limit.input
? Math.max(0, model.limit.input - reserved) // reserved IS used
: Math.max(0, ctx - maxOutputTokens(model)); // reserved is DISCARDED
```

Two consequences. First, **`compaction.reserved` is dead unless `limit.input` is present** — CoDev wrote `{context, output}` for a while and the configured reserve did nothing; the real trigger was `context − maxOutputTokens`, ~36K tokens earlier than intended. Second, `reserved` is a single **top-level** value with no per-model variant in the config schema, so it alone cannot put a 1M model and a 200K model on different triggers: sized for the big one it drives the small one's trigger negative, sized for the small one the big one fires at ~96%.

`declaredInput()` resolves this by solving `input − reserved = trigger`, i.e. `input = trigger + reserved`, per model, against one global reserve. `limit.context` therefore stays the **true** window — the TUI's "% context used" gauge divides by it, so understating it there would misreport every session. The result is clamped to `context`: `trigger + reserved` above the real window would overstate the budget and let a session run past the model's ceiling before compacting, and clamping can only move a trigger earlier, never later.

**Claude Code cannot be told a window larger than 200000, and three separate ceilings enforce it.** All three were read out of the shipped binary (2.1.220); none is documented.

1. `nc()` resolves the window as `Math.min(nativeWindow, envValue)`, so `CLAUDE_CODE_AUTO_COMPACT_WINDOW` can only ever **shrink** it. For a model Claude Code doesn't recognize — every gateway model — `w37()` falls through to `_Z_ = 200000`. A 1M-token model is a 200K-token model to Claude Code, and there is no way around it: `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is read only when `DISABLE_COMPACT` is set, which turns compaction off.
2. `Rzq = Math.min(T − round(T × precomputeBufferFraction), qB6(T, opts))` with `precomputeBufferFraction` defaulting to `0.2`, so the trigger is capped at **80% of the effective window**. `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` above 80 is inert — the `Math.min` discards it. Hence `CLAUDE_MAX_COMPACT_PCT`.
3. **Pinning a window *below* 200000 disables auto-compaction outright.** Setting the variable makes `nc()` report `source: "env"`, which puts `aiK` on the branch reading `if (window < 200000) return false`. Omitting it leaves `source: "auto"`, which skips that gate and resolves to the same 200000 anyway. So `claudeWindow()` returns `null` below the ceiling and the writer omits the variable — the pre-existing `196608` was tripping exactly this.

The percentage is therefore taken against the **clamped** window, not the model's true one (`800000/1000000` = 80 is a coincidence; `800000/200000` = 400 is what the raw ratio would give), and bounded to `[1, 80]` — Claude Code's own guard is `K > 0 && K <= 100`, so a 0 would be ignored silently.

Net effect: Claude Code compacts at `0.8 × (200000 − min(modelMaxOutput, 20000))`, i.e. **~144–160K regardless of the model**. `S$H` (the model's max output) is not statically resolvable in the binary, so the exact point inside that range is unverified. Claude Code is the one agent where CoDev's per-model windows genuinely cannot take effect — don't "fix" it by raising the numbers.

`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` is also read into a field named **`testPctOverride`**. It is honored on the production path, but the name says test hook: treat it as unsupported and expect it to disappear.

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

One test-hygiene note: `ModelSelect` now fetches on mount, so **every test that renders it must stub `fetchModelWindows`**. Left unmocked, the `baseUrl` cases issue real HTTPS requests, and a non-empty result writes to the developer's actual `~/.codev-hub/auth.json` — `tests/components/ModelSelect.test.tsx` stubs neither `$HOME` nor the network on its own.

## Config refresh and upload self-healing

Expand Down
24 changes: 23 additions & 1 deletion src/components/ModelSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { Box, Text, useInput } from "ink";
import Spinner from "ink-spinner";
import { useEffect, useRef, useState } from "react";
import { fetchModels, isInvalidKeyError } from "@/lib/backend.js";
import { saveModelLimits } from "@/lib/auth.js";
import {
fetchModels,
fetchModelWindows,
isInvalidKeyError,
} from "@/lib/backend.js";
import { limitsFromWindow, resetModelLimitsCache } from "@/lib/model-limits.js";

interface ModelSelectProps {
apiKey: string;
Expand Down Expand Up @@ -58,6 +64,22 @@ export function ModelSelect({
errorReported.current = false;
setPhase("loading");
setError(null);
// Refresh the cached per-model windows alongside the list. Fire-and-forget
// and deliberately not awaited: fetchModelWindows never rejects, an empty
// result is the norm (the gateway leaves max_input_tokens unset), and
// lib/model-limits.ts falls back to its static table either way — so
// nothing here should delay the picker or fail the flow. The cache reset
// makes a fresh map visible to configure*, which runs later in the flow.
fetchModelWindows(apiKey, baseUrl).then((windows) => {
const limits = Object.fromEntries(
Object.entries(windows).map(([id, w]) => [
id,
limitsFromWindow(w.context, w.output),
]),
);
saveModelLimits(limits);
resetModelLimitsCache();
});
fetchModels(apiKey, baseUrl)
.then((ids) => {
if (cancelled) return;
Expand Down
30 changes: 30 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import open from "open";
import { fetchCodevConfig } from "@/lib/backend.js";
import { LOGIN_SUCCESS_URL, SSO_URL } from "@/lib/const.js";
import { logDebug, logError, loggedFetch, logWarn } from "@/lib/log.js";
// Type-only: lib/model-limits.ts imports loadModelLimits from here, so a value
// import would close a runtime cycle. `import type` is erased at compile time.
import type { ModelLimits } from "@/lib/model-limits.js";

const CLIENT_ID = atob("bGl0ZWxsbS10ZXN0");
const REVOKE_TIMEOUT_MS = 3_000;
Expand Down Expand Up @@ -99,6 +102,12 @@ interface AuthFileContents {
supabase_url?: string;
supabase_anon_key?: string;
gateway_url?: string;
// Per-model context windows as reported by the gateway, cached at the
// model-choice step. Its own block rather than a field on the api-key block
// above, precisely because saveApiKey rewrites that block wholesale — a
// field there would be cleared by every re-save site that didn't thread it
// through. Absent ⇒ lib/model-limits.ts falls back to its static table.
model_limits?: Record<string, ModelLimits>;
// SkillHub session cookie (`skill-hub-session=…`), captured by
// `codevhub login --admin` for local ADMIN/SUPERADMIN accounts that can't use
// SSO. SSO users don't have one — skillhubFetch falls back to a Bearer token.
Expand Down Expand Up @@ -233,6 +242,19 @@ export function saveCodevConfig(config: CodevConfig): void {
});
}

// Cache the gateway's per-model windows. Skips the write entirely for an empty
// map so a gateway that reports nothing (every max_input_tokens null, which is
// the case today) doesn't churn auth.json on every model-choice step.
export function saveModelLimits(limits: Record<string, ModelLimits>): void {
if (Object.keys(limits).length === 0) return;
const existing = readAuthFile() ?? {};
writeAuthFile({ ...existing, model_limits: limits });
}

export function loadModelLimits(): Record<string, ModelLimits> | null {
return readAuthFile()?.model_limits ?? null;
}

export function loadApiKey(): ApiKeyCreds | null {
const raw = readAuthFile();
if (!raw?.api_key) return null;
Expand Down Expand Up @@ -287,10 +309,18 @@ export async function logout(): Promise<boolean> {
api_key: raw.api_key,
base_url: raw.base_url,
model: raw.model,
// The provider pair belongs to the api-key block above and must travel
// with it. Dropping it here silently re-labels a manually-named
// provider as the netGate default on the next config write — the same
// failure saveApiKey's whole-block rewrite is documented to cause,
// reached by a different route.
provider_id: raw.provider_id,
provider_name: raw.provider_name,
supabase_url: raw.supabase_url,
supabase_anon_key: raw.supabase_anon_key,
gateway_url: raw.gateway_url,
skillhub_cookie: raw.skillhub_cookie,
model_limits: raw.model_limits,
};
const hasAnything = Object.values(preserved).some((v) => v !== undefined);
if (hasAnything) {
Expand Down
72 changes: 72 additions & 0 deletions src/lib/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,78 @@ 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.
function modelGroupInfoUrl(baseUrl?: string): string {
const base = baseUrl ?? AI_GATEWAY_URL();
const stripped = base.replace(/\/?v1\/?$/, "");
const trailing = stripped.endsWith("/") ? stripped : `${stripped}/`;
return `${trailing}model_group/info`;
}

interface ModelGroupInfo {
model_group?: string;
max_input_tokens?: number | null;
max_output_tokens?: number | null;
}

// A window as the gateway reports it. Deliberately NOT lib/model-limits.ts's
// ModelLimits: that module reads auth.json, which imports this one, and taking
// its type as a value import would close a require cycle. Callers convert with
// limitsFromWindow.
export interface ModelWindow {
context: number;
output?: number;
}

// Per-model context windows, straight from the gateway. Entries whose
// max_input_tokens the gateway hasn't been told are skipped, which today is all
// of them — the field is nullable in LiteLLM and only populated when an admin
// sets it on the model. So this returns {} on the current deployment and the
// static table in lib/model-limits.ts carries every model; the moment an admin
// fills the field in, that model becomes gateway-driven with no CoDev release.
//
// Unlike fetchModels, this NEVER throws and never fail-stops the caller: a
// window is an optimization over a sane default, and install must not break
// because a metadata endpoint 404s on some other gateway build.
export async function fetchModelWindows(
apiKey: string,
baseUrl?: string,
): Promise<Record<string, ModelWindow>> {
try {
const res = await loggedFetch(
"gateway.model-limits",
modelGroupInfoUrl(baseUrl),
{
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(MODELS_TIMEOUT_MS),
},
);
if (!res.ok) return {};
const data = (await res.json()) as { data?: ModelGroupInfo[] };
const out: Record<string, ModelWindow> = {};
for (const entry of data.data ?? []) {
const id = entry.model_group;
const context = entry.max_input_tokens;
if (!id || typeof context !== "number" || context <= 0) continue;
const output =
typeof entry.max_output_tokens === "number" &&
entry.max_output_tokens > 0
? entry.max_output_tokens
: undefined;
out[id] = { context, ...(output ? { output } : {}) };
}
return out;
} catch {
return {};
}
}

// 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
Expand Down
Loading
Loading