Skip to content
Open
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: 48 additions & 0 deletions src/__tests__/context-windows.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
14 changes: 13 additions & 1 deletion src/__tests__/model-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Expand Down Expand Up @@ -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)
})
})
83 changes: 83 additions & 0 deletions src/plugin/context-windows.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, number>> = {
'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
}
48 changes: 26 additions & 22 deletions src/plugin/model-registry.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
Expand All @@ -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<string, ModelSpec> = {
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
},
Expand All @@ -71,43 +74,43 @@ const MODEL_SPECS: Record<string, ModelSpec> = {
'claude-haiku-4-5': {
name: 'Claude Haiku 4.5',
rate: '0.4x',
limit: CONTEXT_200K,
modalities: TEXT_IMAGE
},

// Claude Opus
'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
},
Expand All @@ -116,26 +119,22 @@ const MODEL_SPECS: Record<string, ModelSpec> = {
'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
}
}
Expand Down Expand Up @@ -170,9 +169,14 @@ export function buildModelRegistry(): Record<string, unknown> {
const models: Record<string, unknown> = {}

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
}

Expand All @@ -185,7 +189,7 @@ export function buildModelRegistry(): Record<string, unknown> {

models[`${modelID}-thinking`] = {
name: `${spec.name} Thinking (${spec.rate})`,
limit: spec.limit,
limit,
modalities: spec.modalities,
reasoning: true,
interleaved: { field: 'reasoning_content' },
Expand Down
14 changes: 11 additions & 3 deletions src/plugin/models.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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)
}