diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6b8bae0c65..ecbbf5862a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -856,6 +856,7 @@ "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", + "context-window-seed-repair.test.ts": "providers", "model-selection-guidance.test.ts": "cli", "model-visibility-management-api.test.ts": "codex-integration", "models-feedback-callback.test.ts": "gui", diff --git a/src/adapters/devin-cli/models.ts b/src/adapters/devin-cli/models.ts index ea614c5d66..5d22a049b0 100644 --- a/src/adapters/devin-cli/models.ts +++ b/src/adapters/devin-cli/models.ts @@ -21,3 +21,37 @@ export const DEVIN_CLI_MODELS = [ "glm-5-3-low", "kimi-k3-high", ] as const; + +/** + * Context windows for the CLI roster, in the same effort-suffixed ids the CLI + * accepts. + * + * Without this the picker fell back to the 128k default for every Devin CLI + * model, including `swe-2`, which is the roster's own default — so the one + * model most sessions ran reported less than half its real window. + * + * The numbers come from Cognition's `GetCascadeModelConfigs` catalog + * (`ClientModelConfig` field #18), which is the only first-party source: the + * Devin CLI and Desktop model pages, the SWE-2 announcement, and the Windsurf + * model reference all list these models without a window. The CLI is a separate + * product from the cloud, but Cognition documents the same models on both and + * describes no per-surface difference — the SWE-2 announcement ships it to + * Desktop, CLI, Web, and Fusion in one sentence — so the catalog's figure is + * used for both rather than inventing a second table. + * + * ACP has no discovery call, so unlike the cloud provider this cannot be + * refreshed live; it needs updating when the roster above does. + */ +export const DEVIN_CLI_MODEL_CONTEXT_WINDOWS: Record = { + "swe-2": 262_000, + "swe-2-high": 262_000, + "claude-opus-5-medium": 1_000_000, + "claude-fable-5-1-medium": 1_000_000, + "claude-sonnet-5-medium": 1_000_000, + "gpt-6-astra-medium": 1_000_000, + "gpt-5-6-sol-medium": 1_000_000, + "gemini-3-8-flash-medium": 1_048_576, + "glm-5-3-high": 1_048_576, + "glm-5-3-low": 1_048_576, + "kimi-k3-high": 1_048_576, +}; diff --git a/src/adapters/devin/cloud-direct/catalog.ts b/src/adapters/devin/cloud-direct/catalog.ts index 665b896780..f7638d1660 100644 --- a/src/adapters/devin/cloud-direct/catalog.ts +++ b/src/adapters/devin/cloud-direct/catalog.ts @@ -63,6 +63,21 @@ export interface ModelCatalogEntry { label: string; /** True when the caller's account tier cannot use this UID for chat. */ disabled: boolean; + /** + * Maximum input tokens the account may send this model, from + * `ClientModelConfig` field #18. + * + * Cognition publishes no context-window numbers anywhere: not in the Devin + * CLI or Desktop model pages, not in the SWE-2 or SWE-1.7 announcements, and + * not in the Windsurf model reference, which has no such table. The only + * numbers on those pages are long-context PRICING thresholds, which are a + * different quantity. That makes this field the single first-party source, + * and it is per account rather than per model id. + * + * Absent when the entry omits the field, which is how a future schema change + * degrades: the caller keeps its static fallback instead of reporting zero. + */ + contextWindow?: number; } export interface CacheEntry { @@ -89,7 +104,7 @@ function flightKey(apiKey: string, host: string): string { * Parse a GetCascadeModelConfigsResponse buffer into a UID-keyed map. * A malformed catalog returns an empty map. */ -function parseCatalogBuffer(buf: Buffer, apiKey: string, host: string): CacheEntry { +export function parseCatalogBuffer(buf: Buffer, apiKey: string, host: string): CacheEntry { // GetCascadeModelConfigsResponse #1 (repeated ClientModelConfig) const byUid = new Map(); for (const f of iterFields(buf)) { @@ -97,18 +112,30 @@ function parseCatalogBuffer(buf: Buffer, apiKey: string, host: string): CacheEnt let label = ''; let modelUid = ''; let disabled = false; + let contextWindow = 0; for (const sf of iterFields(f.value as Buffer)) { if (sf.num === 1 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { label = (sf.value as Buffer).toString('utf8'); } else if (sf.num === 4 && sf.wire === 0) { // #4 = disabled (bool, varint 0/1) disabled = sf.value === 1n; + } else if (sf.num === 18 && sf.wire === 0) { + // #18 = max input tokens. Identified by dumping a live catalog and + // reading the varints back against models whose windows are known from + // their upstream vendors: 1000000 on the Claude and GPT rows, 1048576 + // on Gemini/Kimi/GLM, 500000 on Grok, 262000 on swe-2. + contextWindow = Number(sf.value); } else if (sf.num === 22 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { modelUid = (sf.value as Buffer).toString('utf8'); } } if (modelUid.length > 0) { - byUid.set(modelUid, { modelUid, label: label || modelUid, disabled }); + byUid.set(modelUid, { + modelUid, + label: label || modelUid, + disabled, + ...(contextWindow > 0 ? { contextWindow } : {}), + }); } } return { byUid, fetchedAt: Date.now(), apiKey, host }; diff --git a/src/adapters/devin/live-models.ts b/src/adapters/devin/live-models.ts index de44ae0a27..08160213dc 100644 --- a/src/adapters/devin/live-models.ts +++ b/src/adapters/devin/live-models.ts @@ -29,19 +29,43 @@ export const DEVIN_STATIC_MODELS = [ "grok-4-5", ] as const; -/** Per-model context windows for Devin/Cognition models. Source: Cognition model catalog. */ +/** + * Degraded-mode context windows, used only when live discovery cannot run. + * + * Every number here was read from a live `GetCascadeModelConfigs` response + * (`ClientModelConfig` field #18) rather than from documentation, because + * Cognition publishes none: the Devin CLI and Desktop model pages, the SWE-2 + * and SWE-1.7 announcements, and the Windsurf model reference all state model + * names without a context window. The only published numbers are long-context + * pricing thresholds, which are a different quantity and were not used. + * + * The previous copy of this table was wrong for nine of its eleven rows — the + * three Claude models were listed at 200k against an actual 1M, `grok-4-5` at + * 256k against 500k, and the GPT rows at 1.05M against 1M — because it was + * assembled from each model's upstream vendor window instead of what Cognition + * actually serves. Measure the catalog when updating this; do not carry a + * number over from the model's original vendor. + */ export const DEVIN_MODEL_CONTEXT_WINDOWS: Record = { - "swe-1-7": 256_000, - "swe-1-7-lightning": 256_000, - "gpt-5-6-sol": 1_050_000, - "gpt-5-6-luna": 1_050_000, - "gpt-5-6-terra": 1_050_000, - "claude-opus-4-8": 200_000, - "claude-fable-5-1": 200_000, - "claude-sonnet-5": 200_000, + "swe-2": 262_000, + "swe-1-7": 262_000, + "swe-1-7-lightning": 202_752, + "swe-1-6": 200_000, + "gpt-5-6-sol": 1_000_000, + "gpt-5-6-luna": 1_000_000, + "gpt-5-6-terra": 1_000_000, + "gpt-6-astra": 1_000_000, + "claude-opus-4-8": 1_000_000, + "claude-opus-5": 1_000_000, + "claude-fable-5-1": 1_000_000, + "claude-sonnet-5": 1_000_000, "glm-5-2": 200_000, - "kimi-k2-7": 256_000, - "grok-4-5": 256_000, + "glm-5-3": 1_048_576, + "kimi-k2-7": 262_144, + "kimi-k3": 1_048_576, + "gemini-3-8-flash": 1_048_576, + "grok-4-5": 500_000, + "grok-4-6": 500_000, }; /** @@ -62,7 +86,7 @@ export function collapseDevinModelUid(uid: string): string { } export type DevinUsableModelsResult = - | { ok: true; models: string[] } + | { ok: true; models: string[]; contextWindows: Record } | { ok: false; error: "auth" | "http" | "empty" | "unknown"; detail?: string }; /** @@ -80,15 +104,27 @@ export async function fetchDevinUsableModels(opts: { const catalog = await getCachedCatalog(opts.apiKey, host, opts.signal); if (!catalog) return { ok: false, error: "empty" }; const bases = new Set(); + const contextWindows: Record = {}; for (const entry of catalog.byUid.values()) { if (entry.disabled) continue; // Skip internal enum constants (e.g. MODEL_GPT_5_2_LOW, MODEL_PRIVATE_*). // Real chat model UIDs are lowercase dashed strings (swe-1-7, gpt-5-6-sol). if (entry.modelUid.startsWith("MODEL_")) continue; - bases.add(collapseDevinModelUid(entry.modelUid)); + const base = collapseDevinModelUid(entry.modelUid); + bases.add(base); + if (entry.contextWindow && entry.contextWindow > 0) { + // Variants of one base can disagree: the opt-in `-1m` rows report a + // larger window than the plain row of the same base, and both collapse + // here because `1m` is an effort token. Keep the smallest, because the + // base id routes to the plain variant — advertising the long-context + // number would promise a window the request the picker actually sends + // cannot use. + const seen = contextWindows[base]; + contextWindows[base] = seen === undefined ? entry.contextWindow : Math.min(seen, entry.contextWindow); + } } if (bases.size === 0) return { ok: false, error: "empty" }; - return { ok: true, models: [...bases].sort() }; + return { ok: true, models: [...bases].sort(), contextWindows }; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/unauth|401|invalid token|login/i.test(message)) return { ok: false, error: "auth", detail: message }; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index cb85ef36d2..68910ce0eb 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1722,11 +1722,23 @@ async function fetchProviderModelsWithAuth( if (liveResult.ok) { // Live catalog is the source of truth — use the discovered base models // directly, not a filtered subset of the static seed. - const result = liveResult.models.map((id) => ({ - id, - provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), - }) as CatalogModel); + // + // That extends to the context window. Cognition publishes no window + // anywhere, so the per-account catalog is the only first-party number, + // and the shipped static table is a degraded-mode guess that was wrong + // for nine of its eleven rows. The live value is applied first and the + // config hints run after it, so an explicit per-model override and an + // enabled Context cap still win — this only replaces the number nobody + // chose. + const result = liveResult.models.map((id) => { + const liveWindow = liveResult.contextWindows[id]; + return { + id, + provider: name, + ...(liveWindow ? { contextWindow: liveWindow } : {}), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + } as CatalogModel; + }); const forCache = withConfiguredRetention(result, { retainComboTargets: false }); if (!setCached(name, forCache, Date.now(), cacheGeneration)) { return observed(withConfiguredRetention(configured), "degraded"); diff --git a/src/providers/model-rename-startup.ts b/src/providers/model-rename-startup.ts index 7112e7cdd8..c4ab5938b4 100644 --- a/src/providers/model-rename-startup.ts +++ b/src/providers/model-rename-startup.ts @@ -1,7 +1,25 @@ import { mutatePersistedConfig } from "../config"; import { projectModelRenames } from "./model-rename-migration"; +import { projectStaleContextWindows } from "./stale-context-window-migration"; import type { OcxConfig } from "../types"; +/** + * The startup projection: registry model renames, then the context-window + * repair. Both fix a saved row the registry can no longer reach on its own — + * `enrichProviderFromRegistry` backfills a missing field and never rewrites a + * present one — so they share this pass rather than adding a second boot step + * with its own persistence, adopt, and failure handling. + */ +export function projectStartupConfigRepairs(config: OcxConfig): ReturnType { + const renames = projectModelRenames(config); + const windows = projectStaleContextWindows(renames.config); + return { + config: windows.config, + changed: renames.changed || windows.changed, + warnings: [...renames.warnings, ...windows.warnings], + }; +} + export interface ModelRenameStartupDeps { project: typeof projectModelRenames; /** Injected writer for tests and callers that own their own persistence. */ @@ -59,7 +77,7 @@ function adoptConfig(target: OcxConfig, source: OcxConfig): void { */ export function runModelRenameStartupMigration( config: OcxConfig, - deps: ModelRenameStartupDeps = { project: projectModelRenames }, + deps: ModelRenameStartupDeps = { project: projectStartupConfigRepairs }, ): OcxConfig { const projection = deps.project(structuredClone(config)); if (!projection.changed) { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index a46e357390..85badbddd1 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,7 +1,7 @@ import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; -import { DEVIN_CLI_DEFAULT_MODEL, DEVIN_CLI_MODELS } from "../adapters/devin-cli/models"; +import { DEVIN_CLI_DEFAULT_MODEL, DEVIN_CLI_MODEL_CONTEXT_WINDOWS, DEVIN_CLI_MODELS } from "../adapters/devin-cli/models"; import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; @@ -1343,6 +1343,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ note: "Drives the locally installed Devin CLI over the Agent Client Protocol (`devin acp`, newline-delimited JSON-RPC on stdio). Requires the CLI on PATH and a completed `devin auth login`; no API key is stored by opencodex. Set OPENCODEX_DEVIN_CLI_BIN to point at a specific build, and OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1 to let the CLI read and write files — the default is to refuse.", models: [...DEVIN_CLI_MODELS], defaultModel: DEVIN_CLI_DEFAULT_MODEL, + modelContextWindows: DEVIN_CLI_MODEL_CONTEXT_WINDOWS, }, { id: "devin", diff --git a/src/providers/stale-context-window-migration.ts b/src/providers/stale-context-window-migration.ts new file mode 100644 index 0000000000..5fb1eea2ec --- /dev/null +++ b/src/providers/stale-context-window-migration.ts @@ -0,0 +1,92 @@ +/** + * Repair context windows that a saved config inherited from a wrong registry seed. + * + * `enrichProviderFromRegistry` is fill-only: it seeds `modelContextWindows` when + * the row has none and never rewrites it afterwards. That posture is right — a + * hand-tuned window must survive an upgrade — but it means a registry table that + * shipped WRONG numbers is frozen into every config that was saved while those + * numbers were current. Correcting the registry alone fixes new installs and + * leaves existing ones reporting the old figure forever. + * + * This rewrites one thing: a window whose saved value is still byte-for-byte the + * wrong number this file names, on a provider that still carries the registry's + * adapter. A value the user changed does not match `from` and is left alone, and + * nothing else in the row is touched. Same shape and the same restraint as + * `model-rename-migration`, for the case where the id was right and the number + * was not. + */ +import { PROVIDER_REGISTRY } from "./registry"; +import type { OcxConfig } from "../types"; + +export interface StaleContextWindow { + /** Registry provider id whose saved rows may carry the wrong window. */ + provider: string; + model: string; + /** The wrong value this migration is allowed to replace, and nothing else. */ + from: number; + to: number; +} + +export interface StaleContextWindowProjection { + config: OcxConfig; + changed: boolean; + warnings: string[]; +} + +/** + * Cognition windows corrected against a live `GetCascadeModelConfigs` response. + * + * The shipped table had been assembled from each model's ORIGINAL vendor window + * rather than from what Cognition serves, so the Claude rows claimed 200k against + * an actual 1M and Grok claimed 256k against 500k. Cognition documents no window + * anywhere, so the per-account catalog is the only first-party source; these are + * the degraded-mode figures, and live discovery supersedes them when it runs. + */ +export const STALE_CONTEXT_WINDOWS: readonly StaleContextWindow[] = [ + { provider: "devin", model: "swe-1-7", from: 256_000, to: 262_000 }, + { provider: "devin", model: "swe-1-7-lightning", from: 256_000, to: 202_752 }, + { provider: "devin", model: "gpt-5-6-sol", from: 1_050_000, to: 1_000_000 }, + { provider: "devin", model: "gpt-5-6-luna", from: 1_050_000, to: 1_000_000 }, + { provider: "devin", model: "gpt-5-6-terra", from: 1_050_000, to: 1_000_000 }, + { provider: "devin", model: "claude-opus-4-8", from: 200_000, to: 1_000_000 }, + { provider: "devin", model: "claude-fable-5-1", from: 200_000, to: 1_000_000 }, + { provider: "devin", model: "claude-sonnet-5", from: 200_000, to: 1_000_000 }, + { provider: "devin", model: "kimi-k2-7", from: 256_000, to: 262_144 }, + { provider: "devin", model: "grok-4-5", from: 256_000, to: 500_000 }, +]; + +function providerStillMatchesRegistry(id: string, adapter: unknown): boolean { + const entry = PROVIDER_REGISTRY.find(row => row.id === id); + return entry !== undefined && entry.adapter === adapter; +} + +/** Pure projection. The caller decides whether to persist. */ +export function projectStaleContextWindows( + config: OcxConfig, + entries: readonly StaleContextWindow[] = STALE_CONTEXT_WINDOWS, +): StaleContextWindowProjection { + const warnings: string[] = []; + const repaired = new Map(); + + for (const entry of entries) { + const prov = config.providers?.[entry.provider]; + if (!prov) continue; + if (!providerStillMatchesRegistry(entry.provider, prov.adapter)) continue; + const windows = prov.modelContextWindows; + if (!windows || windows[entry.model] !== entry.from) continue; + windows[entry.model] = entry.to; + const list = repaired.get(entry.provider) ?? []; + list.push(`${entry.model} ${entry.from} -> ${entry.to}`); + repaired.set(entry.provider, list); + } + + for (const [provider, list] of repaired) { + warnings.push( + `corrected ${list.length} context window(s) on "${provider}" that the saved config ` + + `inherited from a wrong registry seed: ${list.join(", ")}.`, + ); + } + + return { config, changed: repaired.size > 0, warnings }; +} + diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 048826e42c..b92e85757c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -690,6 +690,7 @@ "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", + "context-window-seed-repair.test.ts": "providers", "model-selection-guidance.test.ts": "cli", "model-visibility-management-api.test.ts": "codex-integration", "models-feedback-callback.test.ts": "gui", diff --git a/tests/providers/context-window-seed-repair.test.ts b/tests/providers/context-window-seed-repair.test.ts new file mode 100644 index 0000000000..55063afa4e --- /dev/null +++ b/tests/providers/context-window-seed-repair.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { projectStaleContextWindows, STALE_CONTEXT_WINDOWS } from "../../src/providers/stale-context-window-migration"; +import type { OcxConfig } from "../../src/types"; + +function devinConfig(windows: Record, adapter = "devin"): OcxConfig { + return { + providers: { + devin: { adapter, baseUrl: "https://server.codeium.com", modelContextWindows: { ...windows } }, + }, + } as unknown as OcxConfig; +} + +describe("stale context window migration", () => { + test("repairs a window the config inherited from the wrong registry seed", () => { + // `enrichProviderFromRegistry` is fill-only, so a config saved while the + // registry shipped 256k for Grok keeps reporting 256k forever. Correcting + // the registry fixes new installs only; this is what reaches the old ones. + const config = devinConfig({ "grok-4-5": 256_000, "claude-sonnet-5": 200_000 }); + const projection = projectStaleContextWindows(config); + expect(projection.changed).toBe(true); + expect(projection.config.providers!.devin!.modelContextWindows).toMatchObject({ + "grok-4-5": 500_000, + "claude-sonnet-5": 1_000_000, + }); + expect(projection.warnings.join(" ")).toContain("grok-4-5 256000 -> 500000"); + }); + + test("leaves a value the user chose alone", () => { + // The guard is an exact match on the wrong number. Anything else is a + // deliberate override and outranks this migration. + const config = devinConfig({ "grok-4-5": 300_000 }); + const projection = projectStaleContextWindows(config); + expect(projection.changed).toBe(false); + expect(projection.config.providers!.devin!.modelContextWindows!["grok-4-5"]).toBe(300_000); + }); + + test("skips a row that no longer carries the registry adapter", () => { + // A `devin` row retargeted at another transport is not the provider these + // numbers describe, so rewriting its windows would be a guess. + const projection = projectStaleContextWindows(devinConfig({ "grok-4-5": 256_000 }, "openai-chat")); + expect(projection.changed).toBe(false); + }); + + test("is a no-op on a config with no such provider", () => { + const projection = projectStaleContextWindows({ providers: {} } as unknown as OcxConfig); + expect(projection.changed).toBe(false); + expect(projection.warnings).toEqual([]); + }); + + test("every entry names a real correction", () => { + // A from/to pair that is equal would make the migration claim a change it + // never performs, and an entry for another provider would silently do nothing. + for (const entry of STALE_CONTEXT_WINDOWS) { + expect(entry.from).not.toBe(entry.to); + expect(entry.provider).toBe("devin"); + } + }); +}); + diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 86f3c66a2d..c31e9dda4d 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; import { createDevinAdapter, mapOcxMessagesToDevin, mapOcxToolsToDevin } from "../../src/adapters/devin"; import { sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; -import { DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; +import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; +import { parseCatalogBuffer } from "../../src/adapters/devin/cloud-direct/catalog"; +import { encodeMessage, encodeString, encodeVarintField } from "../../src/adapters/devin/cloud-direct/wire"; import { OAUTH_PROVIDERS } from "../../src/oauth"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import type { OcxParsedRequest } from "../../src/types"; @@ -116,4 +118,45 @@ describe("devin adapter", () => { const nearMiss = "Runs a command in a terminal, returning output or a session ID for ongoing interaction."; expect(sanitizeToolDescriptionForCognitionForTests(nearMiss)).toBe(nearMiss); }); + + test("the catalog parser reads the per-account context window", () => { + // ClientModelConfig #18 is the max input tokens, and it is the only + // first-party context-window figure Cognition exposes: the Devin CLI and + // Desktop model pages, the SWE-2 announcement and the Windsurf model + // reference all list these models without a window. + const withWindow = Buffer.concat([ + encodeString(1, "SWE-2 High"), + encodeVarintField(18, 262_000), + encodeString(22, "swe-2-high"), + ]); + const withoutWindow = Buffer.concat([ + encodeString(1, "Mystery"), + encodeString(22, "mystery-model"), + ]); + const catalog = parseCatalogBuffer( + Buffer.concat([encodeMessage(1, withWindow), encodeMessage(1, withoutWindow)]), + "key", + "https://server.codeium.com", + ); + expect(catalog.byUid.get("swe-2-high")?.contextWindow).toBe(262_000); + // Absent rather than zero, so a caller can tell "not reported" from + // "reported as nothing" and keep its fallback. + expect(catalog.byUid.get("mystery-model")?.contextWindow).toBeUndefined(); + }); + + test("the degraded-mode windows match what Cognition serves", () => { + // This table was wrong for nine of its eleven rows because it had been + // copied from each model's ORIGINAL vendor rather than measured against + // Cognition's catalog. The spot-checks are the three shapes of that error: + // a Claude row five times too small, a Grok row about half its real size, + // and a GPT row rounded up past what the service accepts. + expect(DEVIN_MODEL_CONTEXT_WINDOWS["claude-sonnet-5"]).toBe(1_000_000); + expect(DEVIN_MODEL_CONTEXT_WINDOWS["grok-4-5"]).toBe(500_000); + expect(DEVIN_MODEL_CONTEXT_WINDOWS["gpt-5-6-sol"]).toBe(1_000_000); + expect(DEVIN_MODEL_CONTEXT_WINDOWS["swe-2"]).toBe(262_000); + // Every statically advertised model needs one, or the picker reports 128k. + for (const model of DEVIN_STATIC_MODELS) { + expect(DEVIN_MODEL_CONTEXT_WINDOWS[model]).toBeGreaterThan(0); + } + }); }); diff --git a/tests/providers/devin-cli-adapter.test.ts b/tests/providers/devin-cli-adapter.test.ts index 91bb4c2464..c89fb99f4b 100644 --- a/tests/providers/devin-cli-adapter.test.ts +++ b/tests/providers/devin-cli-adapter.test.ts @@ -13,6 +13,7 @@ import { import { DEVIN_CLI_BIN_ENV, resolveDevinCliBinary } from "../../src/adapters/devin-cli/binary"; import { createDevinCliAdapter } from "../../src/adapters/devin-cli/adapter"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { DEVIN_CLI_MODELS, DEVIN_CLI_MODEL_CONTEXT_WINDOWS, DEVIN_CLI_DEFAULT_MODEL } from "../../src/adapters/devin-cli/models"; import { formatProviderDisplayName, providerIconSrc } from "../../gui/src/provider-icons"; import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; import { EventEmitter } from "node:events"; @@ -43,6 +44,22 @@ describe("devin-cli registration", () => { expect(formatProviderDisplayName("devin", englishT)).toBe("Cognition (Devin/Windsurf)"); expect(formatProviderDisplayName("devin-cli", englishT)).toBe("Devin CLI"); }); + + test("every CLI model carries its context window", () => { + // The provider shipped without a window table at all, so the picker used the + // 128k default for the whole roster — including `swe-2`, the default model, + // whose real window is 262k. A model added to the roster without a window + // silently reintroduces that, so the table is checked against the roster + // rather than by spot-checking one id. + for (const model of DEVIN_CLI_MODELS) { + expect(DEVIN_CLI_MODEL_CONTEXT_WINDOWS[model]).toBeGreaterThan(0); + } + expect(Object.keys(DEVIN_CLI_MODEL_CONTEXT_WINDOWS).sort()).toEqual([...DEVIN_CLI_MODELS].sort()); + expect(DEVIN_CLI_MODEL_CONTEXT_WINDOWS[DEVIN_CLI_DEFAULT_MODEL]).toBe(262_000); + + const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin-cli"); + expect(entry?.modelContextWindows).toBe(DEVIN_CLI_MODEL_CONTEXT_WINDOWS); + }); }); describe("acp handshake frames", () => {