From 4a833fc77666a26f77e8533d2e2d5acefba08820 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sun, 16 Aug 2026 18:17:16 +0800 Subject: [PATCH] fix: reconcile modelContextLimit on model switch (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Within one LLM request the host fires experimental.chat.messages.transform BEFORE experimental.chat.system.transform (sst/opencode: session/prompt.ts triggers messages.transform, then llm/request.ts triggers system.transform inside handle.process). state.modelContextLimit is written only by the system hook, so the first request after a model switch computed every percentage threshold against the PREVIOUS model's window — e.g. a 50% emergencyThresholdPercent fired at ~26% real usage after switching 200K -> 1M (issue #312), and 1M -> 200K missed a real emergency. Fix: SessionStateRegistry keeps a `${providerID}/${modelID}` -> context limit catalog. The system hook records every observed model's limit into it (before the session-state guard, so it works even without state); the messages hook reconciles state.modelContextLimit from the catalog entry for the model named on the request's user message; plugin init seeds the catalog best-effort from GET /config/providers. Unknown models keep the previous fallback (stale limit for one request, corrected by the system hook later in the same request). Tests: 7 new regression tests in tests/model-switch-limits.test.ts; full suite 988 pass, 0 fail. --- .../REQ.md | 74 ++++ .../WORKLOG.md | 57 +++ index.ts | 7 + lib/hooks.ts | 35 +- lib/state/state.ts | 65 ++++ tests/model-switch-limits.test.ts | 324 ++++++++++++++++++ tests/registry-stub.ts | 16 + 7 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 devlog/2026-08-16_model-switch-limit-catalog/REQ.md create mode 100644 devlog/2026-08-16_model-switch-limit-catalog/WORKLOG.md create mode 100644 tests/model-switch-limits.test.ts diff --git a/devlog/2026-08-16_model-switch-limit-catalog/REQ.md b/devlog/2026-08-16_model-switch-limit-catalog/REQ.md new file mode 100644 index 00000000..c552b76e --- /dev/null +++ b/devlog/2026-08-16_model-switch-limit-catalog/REQ.md @@ -0,0 +1,74 @@ +# REQ - Fix wrong context-level math after model switch (issue #312) + +- Task ID: `2026-08-16_model-switch-limit-catalog` +- Home Repo: `opencode-acp` +- Created: 2026-08-16 +- Status: Done +- Priority: P1 +- Owner: ranxianglei +- References: [issue #312](https://github.com/ranxianglei/opencode-acp/issues/312) + +## 1. Background & Problem Statement + +- **Context**: `state.modelContextLimit` is written exclusively by the + `experimental.chat.system.transform` hook (lib/hooks.ts). Every percentage + threshold derives from it: `compress.emergencyThresholdPercent`, + `compress.min/maxContextLimit` in `"N%"` form, adaptive `nudgeGrowthTokens` + (5% of limit clamped [6K, 50K]), GC tier thresholds, and + `buildCompressedBlockGuidance` context hints. +- **Root cause** (host-source-confirmed, sst/opencode dev HEAD): within one LLM + request the host fires `experimental.chat.messages.transform` FIRST + (packages/opencode/src/session/prompt.ts:1255, before `handle.process`) and + `experimental.chat.system.transform` SECOND + (packages/opencode/src/session/llm/request.ts:69-73, inside `prepare()` under + `handle.process`). The messages hook runs all nudge/threshold math BEFORE the + system hook writes the new model's limit. +- **Current behavior (symptom)**: switch mid-session from a 200K model to a 1M + model with `emergencyThresholdPercent: "50%"` → the first request(s) after the + switch compute the threshold as 50% × 200K = 100K. At ~260K actual context the + user is told the context is at "emergency" while it is really at 26% of the 1M + window. Conversely, switching 1M → 200K misses a real emergency until the + system hook catches up on the following request. +- **Expected behavior**: threshold math on the FIRST request after a model + switch uses the new model's context window. +- **Impact**: premature (or missed) emergency compression nudges, distorted + adaptive nudge growth, wrong GC tier evaluation for one request after every + model switch. + +## 2. Reproduction + +- **Minimal reproduction steps**: + 1) Use a session on a 200K-context model; let `state.modelContextLimit = 200000`. + 2) Set `compress.emergencyThresholdPercent: "50%"`. + 3) Switch the model to a 1M-context model; send a message bringing context to ~260K. + 4) First request after the switch: emergency nudge "Context limit reached" fires + (260K ≥ 0.5 × 200K stale limit) even though usage is 26% of 1M. +- **Relevant configuration**: `compress.emergencyThresholdPercent: "50%"`; any + model switch between different context windows (user report: 20w → 100w). + +## 3. Constraints & Non-Goals + +- **Non-Goals**: changing host dispatch order; persisting per-model limits to + disk; changing `modelContextLimit` semantics for same-model requests. +- **Constraints**: no `any` (repo lint rule); no new dependencies; tests must + pass under `node --import tsx --test tests/*.test.ts`. + +## 4. Acceptance Criteria + +- [x] First request after a model switch resolves the new model's context limit + before any threshold math runs. +- [x] Unknown model (no catalog entry) falls back to previous behavior (stale + limit for one request, corrected by system hook later in that request). +- [x] Catalog populated both live (per request via system hook) and at plugin + init (GET /config/providers). +- [x] All existing tests pass (988 pass, 0 fail) + new regression tests. + +## 5. Proposed Approach + +`SessionStateRegistry` gains a `${providerID}/${modelID}` → context-limit +catalog. The system hook records every observed model's limit into it +(statelessly, before the session-state guard). The messages hook, right after +resolving the session state, reconciles `state.modelContextLimit` from the +catalog entry for the model named on the request's last user message. At plugin +init the catalog is seeded best-effort from `client.config.providers()` +(returns all providers + models with `limit.context`). diff --git a/devlog/2026-08-16_model-switch-limit-catalog/WORKLOG.md b/devlog/2026-08-16_model-switch-limit-catalog/WORKLOG.md new file mode 100644 index 00000000..e5a710c0 --- /dev/null +++ b/devlog/2026-08-16_model-switch-limit-catalog/WORKLOG.md @@ -0,0 +1,57 @@ +# WORKLOG - Fix wrong context-level math after model switch (issue #312) + +- Task ID: `2026-08-16_model-switch-limit-catalog` +- Home Repo: `opencode-acp` +- Status: Done +- Updated: 2026-08-16 + +## 1. Summary + +- **What was done**: `SessionStateRegistry` now keeps a `${providerID}/${modelID}` → context-limit catalog. The system hook records every observed model's limit into it; the messages hook reconciles `state.modelContextLimit` from the catalog (keyed by the model on the request's user message) before any threshold math; plugin init seeds the catalog from `GET /config/providers`. +- **Why**: Within one LLM request the host fires `messages.transform` BEFORE `system.transform`, and only the latter wrote `modelContextLimit` — so the first request after a model switch computed every percentage against the previous model's window (issue #312: 50% emergency fired at 26% after 200K → 1M). +- **Behavior / compatibility changes**: Yes — threshold math now uses the new model's limit on the first request after a switch. Unknown models keep the previous fallback (stale limit for one request). No persisted-state, config-schema, or exported-API changes. +- **Risk level**: Low + +## 2. Change Log + +### Key Files + +- `lib/state/state.ts` — `SessionStateRegistry.modelLimits: Map` + `recordModelLimit(providerId, modelId, limit)` + `resolveModelLimit(providerId, modelId)` + `hydrateModelLimitsFromClient(client): Promise` (best-effort, never throws). +- `lib/hooks.ts` — + - `createSystemPromptHandler`: records `input.model.limit.context` into the catalog keyed by `providerID`/`id`, placed BEFORE the `registry.get` guard so it works even when the session state has not been created; system-hook input type widened to include optional `model.id` / `model.providerID`. + - `createChatMessageTransformHandler`: after `registry.getOrCreate`, reconciles `state.modelContextLimit` from `registry.resolveModelLimit` for the model on `lastUserMessage.info.model` (undefined-safe; unknown model keeps the previous value). +- `index.ts` — fire-and-forget `registry.hydrateModelLimitsFromClient(ctx.client).catch(() => {})` at init, so the FIRST switch in an instance resolves even for models never used before. +- `tests/registry-stub.ts` — `createTestRegistry` now exposes the same `recordModelLimit`/`resolveModelLimit` surface backed by a local `Map`. +- `tests/model-switch-limits.test.ts` — new: 7 regression tests. + +## 3. Design & Implementation Notes + +- **Why a catalog instead of just persisting the last limit**: the state carries ONE `modelContextLimit`; a switch needs the NEW model's limit before the system hook runs. A per-model map recorded from every system.transform call (plus an init-time seed from the host's provider catalog) resolves the lookup synchronously in the messages hook with no extra round-trip on the hot path. +- **Unknown-model fallback**: if the catalog misses (e.g. hydration failed and the model was never used in this instance), reconciliation is a no-op — exactly the pre-fix behavior, and the system hook still corrects the state later in the same request. This keeps the fix purely additive. +- **`hydrateModelLimitsFromClient` shape**: `client.config.providers()` returns `{ data: { providers: [{ id, models: { [modelId]: { limit: { context } } } }] } }` (SDK `ConfigProvidersResponses` / host `ConfigProvidersResult`). All field access is `unknown`-guarded; any failure returns 0. +- **Hot-path cost**: one `Map.get` per messages.transform. The hydration is one HTTP call per plugin init. + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +npx tsc --noEmit # clean +node --import tsx --test tests/model-switch-limits.test.ts # 7/7 pass +node --import tsx --test tests/*.test.ts # 988 pass, 0 fail +``` + +### Test Scenarios (tests/model-switch-limits.test.ts) + +1. **200K → 1M switch, 260K tokens, `"50%"` emergency**: reconciled limit = 1M; no "Context limit reached" nudge; no nudge baseline recorded. (The issue #312 scenario.) +2. **Unknown model in catalog**: limit stays 200K; emergency fires at 260K — documents the pre-fix fallback path. +3. **1M → 200K switch, 150K tokens**: reconciled limit = 200K; emergency DOES fire (75% ≥ 50%) — stale 1M limit would have missed it. +4. **system.transform records into catalog even when session state is absent** (`sessionID: "never-seen"`). +5. **Catalog rejects invalid entries** (undefined ids, limit ≤ 0) and unknown lookups. +6. **`hydrateModelLimitsFromClient` seeds from a mocked `/config/providers` payload** (2 valid + 1 broken model). +7. **`hydrateModelLimitsFromClient` tolerates missing and throwing clients** (returns 0). + +## 5. Follow-ups + +- None blocking. Potential (not done, out of scope): debounced re-hydration if + providers change at runtime. diff --git a/index.ts b/index.ts index bb94a1d1..0433d09c 100644 --- a/index.ts +++ b/index.ts @@ -45,6 +45,13 @@ const server: Plugin = (async (ctx) => { // logger.info("Secure mode detected, configured client authentication") } + // [FIX #312] Seed the model-limit catalog so the FIRST request after a + // model switch resolves the new model's context window (the per-request + // system.transform refresh only fills entries for models already used in + // this instance). Fire-and-forget: failures fall back to per-request + // refresh, which is the pre-fix behavior. + registry.hydrateModelLimitsFromClient(ctx.client).catch(() => {}) + logger.info("DCP initialized") startAutoUpdate(ctx, config.autoUpdate) diff --git a/lib/hooks.ts b/lib/hooks.ts index 12b4b481..f1507e18 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -75,10 +75,24 @@ export function createSystemPromptHandler( return async ( input: { sessionID?: string - model: { limit: { context: number; input?: number; output?: number } } + model: { + id?: string + providerID?: string + limit: { context: number; input?: number; output?: number } + } }, output: { system: string[] }, ) => { + // [FIX #312] Record the live limit for this model BEFORE the state + // guard below: the catalog is stateless and must keep accepting + // entries even when the session state has not been created yet, so + // the messages hook can reconcile a model switch on its next call. + registry.recordModelLimit( + input.model?.providerID, + input.model?.id, + input.model?.limit?.context, + ) + // messages.transform creates the session state before this fires; if // absent (internal-agent early-return), there is nothing to attribute. const state = input.sessionID ? registry.get(input.sessionID) : undefined @@ -158,6 +172,25 @@ export function createChatMessageTransformHandler( messages, config, ) + + // [FIX #312] system.transform (the only writer of + // state.modelContextLimit) fires AFTER messages.transform within + // one request, so on the first request after a model switch the + // value still reflects the previous model. Reconcile it from the + // catalog entry for the model named on this request's user message + // before any consumer (filters, GC, nudge thresholds) reads it. + // Unknown model → keep the previous value; the system hook + // refreshes it later in this same request anyway. + const requestModel = ( + lastUserMessage.info as { model?: { providerID?: string; modelID?: string } } + ).model + const requestModelLimit = registry.resolveModelLimit( + requestModel?.providerID, + requestModel?.modelID, + ) + if (requestModelLimit !== undefined) { + state.modelContextLimit = requestModelLimit + } await updatePerTurnState(state, logger, messages) } diff --git a/lib/state/state.ts b/lib/state/state.ts index 4b8962e5..14983c6f 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -69,8 +69,73 @@ export class SessionStateRegistry { pendingByCallId: new Map(), } + // [FIX #312] Catalog of per-model context limits, keyed `${providerID}/${modelID}`. + // Within one LLM request the host fires experimental.chat.messages.transform + // BEFORE experimental.chat.system.transform (sst/opencode: session/prompt.ts + // triggers messages.transform, then llm/request.ts triggers system.transform + // during handle.process). state.modelContextLimit is written only by the + // system hook, so on the first request after a model switch every percentage + // threshold (emergencyThresholdPercent, min/maxContextLimit "%", adaptive + // nudge growth, GC tiers) is still computed against the PREVIOUS model's + // limit. This catalog lets the messages hook reconcile against the model + // named on the request's user message instead of waiting one turn. + // Entries are recorded live by the system hook every request and seeded once + // at plugin init from the host's /config/providers catalog. + private readonly modelLimits = new Map() + constructor(private readonly logger: Logger) {} + recordModelLimit( + providerId: string | undefined, + modelId: string | undefined, + limit: number | undefined, + ): void { + if (!providerId || !modelId || typeof limit !== "number" || limit <= 0) return + this.modelLimits.set(`${providerId}/${modelId}`, limit) + } + + resolveModelLimit( + providerId: string | undefined, + modelId: string | undefined, + ): number | undefined { + if (!providerId || !modelId) return undefined + return this.modelLimits.get(`${providerId}/${modelId}`) + } + + /** + * Best-effort one-time seed from the host's provider catalog + * (`client.config.providers()` → GET /config/providers). Never throws; + * returns the number of model-limit entries recorded. + */ + async hydrateModelLimitsFromClient(client: unknown): Promise { + try { + const config = client as { config?: { providers?: () => Promise<{ data?: unknown }> } } + const result = await config.config?.providers?.() + const payload = result as { data?: { providers?: unknown } } | undefined + const providers = payload?.data?.providers + if (!Array.isArray(providers)) return 0 + let recorded = 0 + for (const provider of providers) { + const { id, models } = (provider ?? {}) as { + id?: unknown + models?: Record + } + if (typeof id !== "string" || !models) continue + for (const [modelId, model] of Object.entries(models)) { + const limit = (model as { limit?: { context?: unknown } } | null)?.limit + const context = limit?.context + if (typeof context === "number" && context > 0) { + this.modelLimits.set(`${id}/${modelId}`, context) + recorded++ + } + } + } + return recorded + } catch { + return 0 + } + } + get(sessionId: string): SessionState | undefined { return this.states.get(sessionId) } diff --git a/tests/model-switch-limits.test.ts b/tests/model-switch-limits.test.ts new file mode 100644 index 00000000..1f186095 --- /dev/null +++ b/tests/model-switch-limits.test.ts @@ -0,0 +1,324 @@ +/** + * Regression tests for issue #312: switching models caused wrong context-level + * math — a 50% emergency threshold fired at ~26% because every percentage was + * still computed against the PREVIOUS model's context window. + * + * Root cause: within one LLM request the host fires + * experimental.chat.messages.transform BEFORE experimental.chat.system.transform + * (sst/opencode: session/prompt.ts → llm/request.ts), and + * state.modelContextLimit is written only by the system hook. So the first + * request after a model switch runs all threshold math against the old limit. + * + * Fix: SessionStateRegistry keeps a `${providerID}/${modelID}` → context limit + * catalog (recorded live by the system hook, seeded at init from + * GET /config/providers), and the messages hook reconciles + * state.modelContextLimit from the model named on the request's user message. + */ + +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import type { PluginConfig } from "../lib/config" +import { createChatMessageTransformHandler, createSystemPromptHandler } from "../lib/hooks" +import { Logger } from "../lib/logger" +import { SessionStateRegistry, createSessionState, type SessionState, type WithParts } from "../lib/state" +import { createTestRegistry } from "./registry-stub" + +const SID = "session-model-switch" +const OLD_MODEL = "model-200k" +const NEW_MODEL = "model-1m" +const PROVIDER = "test-provider" + +const OLD_LIMIT = 200_000 +const NEW_LIMIT = 1_000_000 +const EMERGENCY_PERCENT: `${number}%` = "50%" + +function buildConfig(): PluginConfig { + return { + enabled: true, + autoUpdate: true, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { enabled: true, protectedTools: [] }, + experimental: { allowSubAgents: false, customPrompts: false }, + protectedFilePatterns: [], + compress: { + mode: "message", + permission: "allow", + showCompression: false, + summaryBuffer: true, + maxContextLimit: 5_000_000, + minContextLimit: 5_000, + nudgeFrequency: 5, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + protectedTools: ["task"], + protectTags: false, + protectUserMessages: false, + emergencyThresholdPercent: EMERGENCY_PERCENT, + }, + gc: { + algorithm: "truncate", + promotionThreshold: 5, + maxBlockAge: 15, + maxOldGenSummaryLength: 3000, + majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, + }, + } +} + +function makeUserMessage(id: string, text: string, modelId: string): WithParts { + return { + info: { + id, + sessionID: SID, + role: "user", + agent: "assistant", + time: { created: Date.now() }, + model: { providerID: PROVIDER, modelID: modelId }, + } as WithParts["info"], + parts: [{ type: "text", text, id: `${id}-p1`, sessionID: SID, messageID: id }], + } +} + +function makeAssistantMessage(id: string, text: string, inputTokens: number): WithParts { + return { + info: { + id, + sessionID: SID, + role: "assistant", + agent: "assistant", + parentID: "parent-placeholder", + modelID: OLD_MODEL, + providerID: PROVIDER, + mode: "normal", + path: { cwd: "/", root: "/" }, + summary: false, + cost: 0, + tokens: { input: inputTokens, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + } as WithParts["info"], + parts: [ + { type: "step-start", id: `${id}-ss`, sessionID: SID, messageID: id }, + { type: "text", text, id: `${id}-p1`, sessionID: SID, messageID: id }, + ], + } +} + +function createMockClient() { + return { + session: { + get: async () => ({ data: { parentID: null } }), + }, + } +} + +function createMockPrompts() { + return { + reload() {}, + getRuntimePrompts() { + return { + system: "ACP system", + compressRange: "compress range", + compressMessage: "compress message", + contextLimitNudge: "nudge", + turnNudge: "turn nudge", + iterationNudge: "iteration nudge", + manualExtension: "", + subagentExtension: "", + } + }, + } +} + +function collectText(messages: WithParts[]): string { + return messages + .flatMap((m) => (m.parts ?? [])) + .filter((p) => p.type === "text") + .map((p) => (p as { text?: string }).text ?? "") + .join("\n") +} + +/** + * Runs one messages.transform with `currentTokens` of context while the user + * message names `modelId`. The session state starts with `initialLimit` + * (simulating the previous model's window written by the last request's + * system.transform). `catalog` optionally seeds the registry catalog the way + * the system hook / init bootstrap would have. + */ +async function runTransform(opts: { + currentTokens: number + modelId: string + initialLimit: number + catalog?: Array<[providerId: string, modelId: string, limit: number]> +}): Promise<{ text: string; state: SessionState }> { + const tempDir = mkdtempSync(join(tmpdir(), "acp-model-switch-")) + process.env.XDG_DATA_HOME = tempDir + process.env.XDG_CONFIG_HOME = tempDir + + try { + const state = createSessionState() + state.sessionId = SID + state.modelContextLimit = opts.initialLimit + + const registry = createTestRegistry(state) + for (const [providerId, modelId, limit] of opts.catalog ?? []) { + registry.recordModelLimit(providerId, modelId, limit) + } + + const handler = createChatMessageTransformHandler( + createMockClient(), + registry, + new Logger(false), + buildConfig(), + createMockPrompts(), + { global: undefined, agents: {} }, + ) + + const messages: WithParts[] = [ + makeUserMessage("msg-u1", "earlier question", opts.modelId), + makeAssistantMessage("msg-a1", "earlier answer", 1_000), + makeUserMessage("msg-u2", "current question", opts.modelId), + ] + // Token usage is read from the LAST assistant message with token data. + messages.splice(2, 0, makeAssistantMessage("msg-a2", "big answer", opts.currentTokens)) + + await handler({}, { messages }) + + return { text: collectText(messages), state } + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +} + +// ─── Issue #312 scenario: 200K → 1M switch, 50% emergency threshold ───────── + +test("model switch to larger window: 26% usage must NOT fire a 50% emergency nudge", async () => { + // 260K tokens on a 1M window = 26%. Against the stale 200K window the + // 50% threshold is 100K and the emergency nudge (wrongly) fires. + const currentTokens = 260_000 + const { text, state } = await runTransform({ + currentTokens, + modelId: NEW_MODEL, + initialLimit: OLD_LIMIT, + catalog: [[PROVIDER, NEW_MODEL, NEW_LIMIT]], + }) + + assert.equal(state.modelContextLimit, NEW_LIMIT, "limit must reconcile to the new model") + assert.ok(!text.includes("Context limit reached"), "emergency nudge must not fire at 26%") + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "no nudge baseline recorded") +}) + +test("unknown model in catalog keeps previous limit (documents pre-fix path)", async () => { + // No catalog entry for the new model: reconciliation cannot run and the + // stale 200K limit computes a 100K threshold → 260K fires. This is exactly + // the issue #312 symptom and the fallback behavior when the catalog misses. + const { text, state } = await runTransform({ + currentTokens: 260_000, + modelId: NEW_MODEL, + initialLimit: OLD_LIMIT, + }) + + assert.equal(state.modelContextLimit, OLD_LIMIT) + assert.ok(text.includes("Context limit reached"), "stale-limit path still fires (bug symptom)") +}) + +test("model switch to smaller window: emergency fires when actually over threshold", async () => { + // 150K tokens on a 200K window = 75% ≥ 50% → must fire. With the stale 1M + // window the threshold would be 500K and the emergency would be missed. + const { text, state } = await runTransform({ + currentTokens: 150_000, + modelId: OLD_MODEL, + initialLimit: NEW_LIMIT, + catalog: [[PROVIDER, OLD_MODEL, OLD_LIMIT]], + }) + + assert.equal(state.modelContextLimit, OLD_LIMIT) + assert.ok(text.includes("Context limit reached"), "emergency must fire at 75% of 200K") +}) + +// ─── Catalog population ────────────────────────────────────────────────────── + +test("system.transform records model limit even when session state is absent", async () => { + const registry = new SessionStateRegistry(new Logger(false)) + const handler = createSystemPromptHandler( + registry, + new Logger(false), + buildConfig(), + createMockPrompts(), + ) + + await handler( + { + sessionID: "never-seen", + model: { + id: NEW_MODEL, + providerID: PROVIDER, + limit: { context: NEW_LIMIT }, + }, + }, + { system: ["base system prompt"] }, + ) + + assert.equal(registry.resolveModelLimit(PROVIDER, NEW_MODEL), NEW_LIMIT) + assert.equal(registry.resolveModelLimit(PROVIDER, "other"), undefined) +}) + +test("registry catalog ignores invalid entries and unknown lookups", () => { + const registry = new SessionStateRegistry(new Logger(false)) + registry.recordModelLimit(PROVIDER, NEW_MODEL, NEW_LIMIT) + registry.recordModelLimit(undefined, NEW_MODEL, 123) + registry.recordModelLimit(PROVIDER, undefined, 123) + registry.recordModelLimit(PROVIDER, "zero", 0) + registry.recordModelLimit(PROVIDER, "negative", -5) + + assert.equal(registry.resolveModelLimit(PROVIDER, NEW_MODEL), NEW_LIMIT) + assert.equal(registry.resolveModelLimit(PROVIDER, "zero"), undefined) + assert.equal(registry.resolveModelLimit(undefined, NEW_MODEL), undefined) + assert.equal(registry.resolveModelLimit(PROVIDER, undefined), undefined) +}) + +test("hydrateModelLimitsFromClient seeds the catalog from /config/providers", async () => { + const registry = new SessionStateRegistry(new Logger(false)) + const client = { + config: { + providers: async () => ({ + data: { + providers: [ + { + id: PROVIDER, + models: { + [NEW_MODEL]: { limit: { context: NEW_LIMIT } }, + [OLD_MODEL]: { limit: { context: OLD_LIMIT } }, + broken: { limit: {} }, + }, + }, + { id: "no-models-provider" }, + ], + }, + }), + }, + } + + const recorded = await registry.hydrateModelLimitsFromClient(client) + assert.equal(recorded, 2) + assert.equal(registry.resolveModelLimit(PROVIDER, NEW_MODEL), NEW_LIMIT) + assert.equal(registry.resolveModelLimit(PROVIDER, OLD_MODEL), OLD_LIMIT) + assert.equal(registry.resolveModelLimit(PROVIDER, "broken"), undefined) +}) + +test("hydrateModelLimitsFromClient tolerates missing and throwing clients", async () => { + const registry = new SessionStateRegistry(new Logger(false)) + + assert.equal(await registry.hydrateModelLimitsFromClient({}), 0) + assert.equal( + await registry.hydrateModelLimitsFromClient({ + config: { providers: async () => { throw new Error("offline") } }, + }), + 0, + ) +}) diff --git a/tests/registry-stub.ts b/tests/registry-stub.ts index 79268abd..30fdd1f4 100644 --- a/tests/registry-stub.ts +++ b/tests/registry-stub.ts @@ -28,6 +28,7 @@ export function createTestRegistry(seedState: SessionState) { states.set(seedState.sessionId, seedState) } const sharedTiming = seedState.compressionTiming + const modelLimits = new Map() return { compressionTiming: sharedTiming, get size() { @@ -52,5 +53,20 @@ export function createTestRegistry(seedState: SessionState) { all() { return [...states.values()] }, + recordModelLimit( + providerId: string | undefined, + modelId: string | undefined, + limit: number | undefined, + ) { + if (!providerId || !modelId || typeof limit !== "number" || limit <= 0) return + modelLimits.set(`${providerId}/${modelId}`, limit) + }, + resolveModelLimit( + providerId: string | undefined, + modelId: string | undefined, + ): number | undefined { + if (!providerId || !modelId) return undefined + return modelLimits.get(`${providerId}/${modelId}`) + }, } }