diff --git a/src/__tests__/context-windows.test.ts b/src/__tests__/context-windows.test.ts new file mode 100644 index 00000000..013844c1 --- /dev/null +++ b/src/__tests__/context-windows.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { getModelContextWindow } from '../plugin/context-windows.js' +import { getContextWindowSize } from '../plugin/models.js' + +describe('getModelContextWindow', () => { + test('returns the measured 600K family regardless of the -thinking suffix', () => { + for (const id of [ + 'auto', + 'claude-opus-4-7', + 'claude-opus-4-8', + 'claude-opus-5', + 'claude-opus-5-5', + 'claude-sonnet-5', + 'claude-sonnet-5-5', + 'claude-fable-5-1' + ]) { + expect(getModelContextWindow(id)).toBe(600_000) + expect(getModelContextWindow(`${id}-thinking`)).toBe(600_000) + } + }) + + test('returns 1M only for the models the service really serves at 1M', () => { + expect(getModelContextWindow('claude-opus-4-6')).toBe(1_000_000) + expect(getModelContextWindow('claude-sonnet-4-6')).toBe(1_000_000) + expect(getModelContextWindow('claude-opus-4-6-1m')).toBe(1_000_000) + expect(getModelContextWindow('claude-sonnet-4-6-1m-thinking')).toBe(1_000_000) + }) + + test('returns 200K for the older Claude models and unknown ids', () => { + expect(getModelContextWindow('claude-sonnet-4-5')).toBe(200_000) + expect(getModelContextWindow('claude-haiku-4-5')).toBe(200_000) + expect(getModelContextWindow('claude-opus-4-5')).toBe(200_000) + expect(getModelContextWindow('brand-new-model')).toBe(200_000) + }) + + test('keeps the open-weight windows', () => { + expect(getModelContextWindow('deepseek-3.2')).toBe(128_000) + expect(getModelContextWindow('minimax-m2.5')).toBe(196_000) + expect(getModelContextWindow('qwen3-coder-next')).toBe(256_000) + }) + + test('getContextWindowSize delegates to the measured table', () => { + expect(getContextWindowSize('claude-opus-5-5')).toBe(600_000) + expect(getContextWindowSize('claude-opus-5')).toBe(600_000) + expect(getContextWindowSize('claude-opus-4-6')).toBe(1_000_000) + expect(getContextWindowSize('claude-sonnet-4-5')).toBe(200_000) + }) +}) \ No newline at end of file diff --git a/src/__tests__/model-registry.test.ts b/src/__tests__/model-registry.test.ts index 81242fa4..8026375a 100644 --- a/src/__tests__/model-registry.test.ts +++ b/src/__tests__/model-registry.test.ts @@ -27,11 +27,13 @@ describe('model registry', () => { [ 'claude-opus-4-5-thinking', 'claude-opus-4-6-thinking', + 'claude-opus-4-6-1m-thinking', 'claude-opus-4-7-thinking', 'claude-opus-4-8-thinking', 'claude-opus-5-thinking', 'claude-sonnet-4-5-thinking', 'claude-sonnet-4-6-thinking', + 'claude-sonnet-4-6-1m-thinking', 'claude-sonnet-5-thinking' ].sort() ) @@ -94,10 +96,20 @@ describe('model registry', () => { }) test('carries limit and modalities through to both entries', () => { - expect(registry['claude-opus-5'].limit).toEqual({ context: 1000000, output: 64000 }) + // 5.x is advertised as 1M but the service caps it at 600K (measured 2026-11-03). + expect(registry['claude-opus-5'].limit).toEqual({ context: 600000, output: 64000 }) expect(registry['claude-opus-5-thinking'].limit).toEqual(registry['claude-opus-5'].limit) expect(registry['claude-opus-5-thinking'].modalities).toEqual( registry['claude-opus-5'].modalities ) }) + + test('advertises the measured served window per model family', () => { + expect(registry['claude-sonnet-4-5'].limit.context).toBe(200000) + expect(registry['claude-opus-4-6'].limit.context).toBe(1000000) + expect(registry['claude-opus-4-6-1m'].limit.context).toBe(1000000) + expect(registry['claude-opus-4-7'].limit.context).toBe(600000) + expect(registry['claude-opus-4-8'].limit.context).toBe(600000) + expect(registry['auto'].limit.context).toBe(600000) + }) }) diff --git a/src/plugin/context-windows.ts b/src/plugin/context-windows.ts new file mode 100644 index 00000000..101e11d4 --- /dev/null +++ b/src/plugin/context-windows.ts @@ -0,0 +1,83 @@ +/** + * Context windows the Kiro backend actually serves. + * + * The numbers in Kiro's marketing material are not what the Q layer enforces. + * The only reliable way to find the real ceiling is to send an oversized + * `generateAssistantResponse` payload and watch `contextUsagePercentage` climb + * to ~100% before the service answers + * `{"message":"Input is too long.","reason":"CONTENT_LENGTH_EXCEEDS_THRESHOLD"}`. + * + * Measured 2026-11-03 against a Kiro Pro Max (Q_DEVELOPER_STANDALONE_PRO_MAX) + * account, growing a single prompt through 1.8M / 2.1M / 2.4M / 2.7M chars: + * + * - claude-sonnet-4-5, claude-haiku-4-5, claude-opus-4-5 : 200K + * - claude-opus-4-6, claude-sonnet-4-6 (and -1m) : 1M + * - claude-opus-4-7, claude-opus-4-8, claude-opus-5, + * claude-opus-5-5, claude-sonnet-5, claude-sonnet-5-5, + * claude-fable-5-1, auto : 600K + * + * (claude-sonnet-5-1m and claude-opus-4-7-1m are rejected by the service with + * INVALID_MODEL_ID today, so they are not advertised in the registry.) + * + * This table is the single source of truth for BOTH: + * - the `limit.context` advertised to OpenCode, and + * - the denominator used to turn `contextUsagePercentage` into real tokens. + * + * Splitting the two was the original bug: the registry advertised 1M while + * `getContextWindowSize()` fell back to 200K for any id without a `-1m` + * suffix, so reported input tokens were ~2.75x too low on 5.x models and + * auto-compaction never fired. + * + * Keyed by the OpenCode-facing model id. + */ +export const DEFAULT_CONTEXT_WINDOW = 200_000 + +/** Output cap shared by every model the plugin advertises (Kiro's P_T max). */ +export const OUTPUT_TOKEN_LIMIT = 64_000 + +/** Served at the full 1M. */ +const LONG_CONTEXT_MODELS = new Set(['claude-opus-4-6', 'claude-sonnet-4-6']) + +/** Advertised as 1M, actually capped at 600K by the service. */ +const SIX_HUNDRED_K_MODELS = new Set([ + 'auto', + 'claude-opus-4-7', + 'claude-opus-4-8', + 'claude-opus-5', + 'claude-opus-5-5', + 'claude-sonnet-5', + 'claude-sonnet-5-5', + 'claude-fable-5-1' +]) + +/** Open-weight and legacy models with per-model windows instead of the Claude defaults. */ +const EXPLICIT_WINDOWS: Readonly> = { + 'deepseek-3.2': 128_000, + 'minimax-m2.1': 196_000, + 'minimax-m2.5': 196_000, + 'qwen3-coder-next': 256_000, + 'glm-5': 200_000 +} + +function stripThinkingSuffix(model: string): string { + return model.endsWith('-thinking') ? model.slice(0, -'-thinking'.length) : model +} + +/** + * Resolve the input-token window Kiro serves for an OpenCode-facing model id. + * + * Accepts both the base id (`claude-opus-5-5`) and its `-thinking` companion. + * Unknown ids fall back to the conservative 200K so a newly added model never + * overflows before we have measured it. + */ +export function getModelContextWindow(model: string): number { + const base = stripThinkingSuffix(model) + + const explicit = EXPLICIT_WINDOWS[base] + if (explicit !== undefined) return explicit + + if (LONG_CONTEXT_MODELS.has(base) || base.includes('-1m')) return 1_000_000 + if (SIX_HUNDRED_K_MODELS.has(base)) return 600_000 + + return DEFAULT_CONTEXT_WINDOW +} diff --git a/src/plugin/model-registry.ts b/src/plugin/model-registry.ts index b91936e3..44b8b0d5 100644 --- a/src/plugin/model-registry.ts +++ b/src/plugin/model-registry.ts @@ -1,3 +1,4 @@ +import { OUTPUT_TOKEN_LIMIT, getModelContextWindow } from './context-windows.js' import { EFFORT_LEVELS, supportsEffort, supportsXHighEffort, THINKING_BUDGETS } from './effort.js' import { resolveKiroModel } from './models.js' @@ -10,15 +11,11 @@ const TEXT_ONLY: Modalities = { input: ['text'], output: ['text'] } const TEXT_IMAGE: Modalities = { input: ['text', 'image'], output: ['text'] } const MULTIMODAL: Modalities = { input: ['text', 'image', 'pdf'], output: ['text'] } -const CONTEXT_200K = { context: 200000, output: 64000 } -const CONTEXT_1M = { context: 1000000, output: 64000 } - interface ModelSpec { /** Display name, without the credit multiplier suffix. */ name: string /** Kiro credit multiplier, rendered into the display name. */ rate: string - limit: { context: number; output: number } modalities: Modalities /** * Emit a companion `-thinking` entry. Only set for Claude models that accept @@ -34,35 +31,41 @@ interface ModelSpec { * Anthropic and open-weight models only. Kiro's GPT-5.6 tiers are deliberately * absent: they configure reasoning through `reasoning.effort` / `reasoning.mode` * rather than `output_config.effort`, so they need their own request path. + * + * The context window is NOT declared here — `getModelContextWindow()` owns it, + * because the advertised limit and the denominator for `contextUsagePercentage` + * must always agree (see context-windows.ts). */ const MODEL_SPECS: Record = { - auto: { name: 'Auto', rate: '1.0x', limit: CONTEXT_200K, modalities: MULTIMODAL }, + auto: { name: 'Auto', rate: '1.0x', modalities: MULTIMODAL }, // Claude Sonnet 'claude-sonnet-4': { name: 'Claude Sonnet 4.0', rate: '1.3x', - limit: CONTEXT_200K, modalities: MULTIMODAL }, 'claude-sonnet-4-5': { name: 'Claude Sonnet 4.5', rate: '1.3x', - limit: CONTEXT_200K, modalities: MULTIMODAL, thinking: true }, 'claude-sonnet-4-6': { name: 'Claude Sonnet 4.6', rate: '1.3x', - limit: CONTEXT_1M, + modalities: MULTIMODAL, + thinking: true + }, + 'claude-sonnet-4-6-1m': { + name: 'Claude Sonnet 4.6 1M', + rate: '1.3x', modalities: MULTIMODAL, thinking: true }, 'claude-sonnet-5': { name: 'Claude Sonnet 5', rate: '1.3x', - limit: CONTEXT_1M, modalities: MULTIMODAL, thinking: true }, @@ -71,7 +74,6 @@ const MODEL_SPECS: Record = { 'claude-haiku-4-5': { name: 'Claude Haiku 4.5', rate: '0.4x', - limit: CONTEXT_200K, modalities: TEXT_IMAGE }, @@ -79,35 +81,36 @@ const MODEL_SPECS: Record = { 'claude-opus-4-5': { name: 'Claude Opus 4.5', rate: '2.2x', - limit: CONTEXT_200K, modalities: MULTIMODAL, thinking: true }, 'claude-opus-4-6': { name: 'Claude Opus 4.6', rate: '2.2x', - limit: CONTEXT_1M, + modalities: MULTIMODAL, + thinking: true + }, + 'claude-opus-4-6-1m': { + name: 'Claude Opus 4.6 1M', + rate: '2.2x', modalities: MULTIMODAL, thinking: true }, 'claude-opus-4-7': { name: 'Claude Opus 4.7', rate: '2.2x', - limit: CONTEXT_1M, modalities: MULTIMODAL, thinking: true }, 'claude-opus-4-8': { name: 'Claude Opus 4.8', rate: '2.2x', - limit: CONTEXT_1M, modalities: MULTIMODAL, thinking: true }, 'claude-opus-5': { name: 'Claude Opus 5', rate: '2.2x', - limit: CONTEXT_1M, modalities: MULTIMODAL, thinking: true }, @@ -116,26 +119,22 @@ const MODEL_SPECS: Record = { 'deepseek-3.2': { name: 'DeepSeek 3.2', rate: '0.25x', - limit: { context: 128000, output: 64000 }, modalities: TEXT_ONLY }, - 'glm-5': { name: 'GLM-5', rate: '0.5x', limit: CONTEXT_200K, modalities: TEXT_ONLY }, + 'glm-5': { name: 'GLM-5', rate: '0.5x', modalities: TEXT_ONLY }, 'minimax-m2.5': { name: 'MiniMax M2.5', rate: '0.25x', - limit: { context: 196000, output: 64000 }, modalities: TEXT_ONLY }, 'minimax-m2.1': { name: 'MiniMax M2.1', rate: '0.15x', - limit: { context: 196000, output: 64000 }, modalities: TEXT_ONLY }, 'qwen3-coder-next': { name: 'Qwen3 Coder Next', rate: '0.05x', - limit: { context: 256000, output: 64000 }, modalities: TEXT_ONLY } } @@ -170,9 +169,14 @@ export function buildModelRegistry(): Record { const models: Record = {} for (const [modelID, spec] of Object.entries(MODEL_SPECS)) { + const limit = { + context: getModelContextWindow(modelID), + output: OUTPUT_TOKEN_LIMIT + } + models[modelID] = { name: `${spec.name} (${spec.rate})`, - limit: spec.limit, + limit, modalities: spec.modalities } @@ -185,7 +189,7 @@ export function buildModelRegistry(): Record { models[`${modelID}-thinking`] = { name: `${spec.name} Thinking (${spec.rate})`, - limit: spec.limit, + limit, modalities: spec.modalities, reasoning: true, interleaved: { field: 'reasoning_content' }, diff --git a/src/plugin/models.ts b/src/plugin/models.ts index c9aac7a9..ff90fcf7 100644 --- a/src/plugin/models.ts +++ b/src/plugin/models.ts @@ -1,4 +1,5 @@ -import { MODEL_MAPPING, SUPPORTED_MODELS, isLongContextModel } from '../constants' +import { MODEL_MAPPING, SUPPORTED_MODELS } from '../constants' +import { getModelContextWindow } from './context-windows.js' export function resolveKiroModel(model: string): string { const resolved = MODEL_MAPPING[model] @@ -8,6 +9,13 @@ export function resolveKiroModel(model: string): string { return resolved } +/** + * Input-token window Kiro serves for this model. + * + * Delegates to the measured table in `context-windows.ts`; the old + * `-1m`-suffix heuristic returned 200K for the 5.x models, which are actually + * served at 600K, so every reported token count was ~2.75x too low. + */ export function getContextWindowSize(model: string): number { - return isLongContextModel(model) ? 1000000 : 200000 -} + return getModelContextWindow(model) +} \ No newline at end of file