From c3fab9ffe5ead7c18d5ee6539ccecdb618ea9a30 Mon Sep 17 00:00:00 2001 From: GuidanNick <224735395+guidan-nick@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:22:30 +0200 Subject: [PATCH 1/4] add Claude Opus 5 support with thinking mode variants --- README.md | 15 +++++++++++++++ src/__tests__/effort.test.ts | 8 +++++++- src/constants.ts | 2 ++ src/plugin/effort.ts | 2 +- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 48a87f0..1c2cb94 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,21 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: "max": { "thinkingConfig": { "thinkingBudget": 32768 } } } }, + "claude-opus-5": { + "name": "Claude Opus 5", + "limit": { "context": 1000000, "output": 64000 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } + }, + "claude-opus-5-thinking": { + "name": "Claude Opus 5 Thinking", + "limit": { "context": 1000000, "output": 64000 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, + "variants": { + "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, + "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, + "max": { "thinkingConfig": { "thinkingBudget": 32768 } } + } + }, "claude-sonnet-4-5-1m": { "name": "Claude Sonnet 4.5 (1M Context)", "limit": { "context": 1000000, "output": 64000 }, diff --git a/src/__tests__/effort.test.ts b/src/__tests__/effort.test.ts index 9aad0ec..760b9a3 100644 --- a/src/__tests__/effort.test.ts +++ b/src/__tests__/effort.test.ts @@ -16,6 +16,7 @@ describe('effort module', () => { expect(supportsEffort('claude-sonnet-4.6-1m')).toBe(true) expect(supportsEffort('claude-sonnet-5')).toBe(true) expect(supportsEffort('claude-sonnet-5-1m')).toBe(true) + expect(supportsEffort('claude-opus-5')).toBe(true) }) test('returns false for unsupported models', () => { @@ -25,9 +26,10 @@ describe('effort module', () => { }) describe('supportsXHighEffort', () => { - test('returns true for opus 4.7 and 4.8', () => { + test('returns true for opus 4.7, 4.8 and 5', () => { expect(supportsXHighEffort('claude-opus-4.8')).toBe(true) expect(supportsXHighEffort('claude-opus-4.7')).toBe(true) + expect(supportsXHighEffort('claude-opus-5')).toBe(true) }) test('returns false for other models', () => { @@ -45,6 +47,8 @@ describe('effort module', () => { expect(resolveEffort('claude-opus-4.8', 'low')).toBe('low') expect(resolveEffort('claude-opus-4.8', 'max')).toBe('max') expect(resolveEffort('claude-opus-4.8', 'xhigh')).toBe('xhigh') + expect(resolveEffort('claude-opus-5', 'xhigh')).toBe('xhigh') + expect(resolveEffort('claude-opus-5', 'max')).toBe('max') }) test('clamps xhigh to max for models without xhigh support', () => { @@ -88,6 +92,8 @@ describe('effort module', () => { test('uses budget mapping when thinking and auto-mapping enabled', () => { expect(getEffectiveEffort('claude-opus-4.8', true, 128000, undefined, true)).toBe('max') expect(getEffectiveEffort('claude-opus-4.8', true, 20000, undefined, true)).toBe('medium') + expect(getEffectiveEffort('claude-opus-5', true, 32768, undefined, true)).toBe('max') + expect(getEffectiveEffort('claude-opus-5', true, 8192, undefined, true)).toBe('low') }) test('falls back to medium when auto-mapping disabled', () => { diff --git a/src/constants.ts b/src/constants.ts index 8ed3cb0..fdb25f3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -78,6 +78,8 @@ export const MODEL_MAPPING: Record = { 'claude-opus-4-7-thinking': 'claude-opus-4.7', 'claude-opus-4-8': 'claude-opus-4.8', 'claude-opus-4-8-thinking': 'claude-opus-4.8', + 'claude-opus-5': 'claude-opus-5', + 'claude-opus-5-thinking': 'claude-opus-5', // Auto auto: 'auto', // Open weight models diff --git a/src/plugin/effort.ts b/src/plugin/effort.ts index 4c2ab54..d3babe1 100644 --- a/src/plugin/effort.ts +++ b/src/plugin/effort.ts @@ -9,7 +9,7 @@ export const EFFORT_LEVELS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh * Models that support the 5-value effort enum (including xhigh). * These models support up to 128k thinking tokens with max effort. */ -const XHIGH_CAPABLE_MODELS = new Set(['claude-opus-4.7', 'claude-opus-4.8']) +const XHIGH_CAPABLE_MODELS = new Set(['claude-opus-4.7', 'claude-opus-4.8', 'claude-opus-5']) /** * Models that support the 4-value effort enum (no xhigh). From 24c7b546aa9412b4c69e17af38ec9e3439cad9b6 Mon Sep 17 00:00:00 2001 From: GuidanNick <224735395+guidan-nick@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:34:27 +0200 Subject: [PATCH 2/4] fix: advertise reasoning flags and reach every effort level Thinking blocks never rendered in OpenCode, and xhigh effort was unreachable. Two independent causes. OpenCode drops reasoning deltas unless the model declares both `reasoning: true` and `interleaved.field`. The plugin emits reasoning as `reasoning_content` (streaming/openai-converter.ts) but never declared either field, so every reasoning chunk was discarded before rendering. Every model in OpenCode's own registry that carries reasoning in a non-standard field declares both; this brings the plugin in line. budgetToEffort had no branch returning xhigh, so the level could only be reached by setting `effort` explicitly in kiro.json. With auto_effort_mapping on by default, xhigh was dead configuration and adding models to XHIGH_CAPABLE_MODELS had no effect on the auto path. Move the model list into plugin/model-registry.ts and derive the thinking entries from effort capability, so a model's advertised variants and the budget mapping cannot drift apart. Adds the missing claude-opus-5, claude-opus-5-thinking and claude-opus-4-7-thinking entries. Kiro's GPT-5.6 tiers stay unadvertised: they configure reasoning through `reasoning.effort` / `reasoning.mode` rather than `output_config.effort`, so they need their own request path. BREAKING: budget bands are rescaled to Kiro's real thinking range (1024-128000) instead of OpenCode's conventional 32768 cap, so xhigh and max are reachable from a budget. A budget of 32768 now maps to medium rather than max. Users depending on the old bands should set an explicit `effort` in kiro.json. Verified: effort=xhigh confirmed on the wire against opus-5 (HTTP 200). --- src/__tests__/effort.test.ts | 36 +++-- src/__tests__/model-registry.test.ts | 103 ++++++++++++++ src/plugin.ts | 88 +----------- src/plugin/effort.ts | 73 ++++++---- src/plugin/model-registry.ts | 197 +++++++++++++++++++++++++++ 5 files changed, 374 insertions(+), 123 deletions(-) create mode 100644 src/__tests__/model-registry.test.ts create mode 100644 src/plugin/model-registry.ts diff --git a/src/__tests__/effort.test.ts b/src/__tests__/effort.test.ts index 760b9a3..263b8dd 100644 --- a/src/__tests__/effort.test.ts +++ b/src/__tests__/effort.test.ts @@ -26,15 +26,18 @@ describe('effort module', () => { }) describe('supportsXHighEffort', () => { - test('returns true for opus 4.7, 4.8 and 5', () => { + test('returns true for opus 4.7/4.8/5 and sonnet 5', () => { expect(supportsXHighEffort('claude-opus-4.8')).toBe(true) expect(supportsXHighEffort('claude-opus-4.7')).toBe(true) expect(supportsXHighEffort('claude-opus-5')).toBe(true) + expect(supportsXHighEffort('claude-sonnet-5')).toBe(true) + expect(supportsXHighEffort('claude-sonnet-5-1m')).toBe(true) }) test('returns false for other models', () => { expect(supportsXHighEffort('claude-opus-4.6')).toBe(false) expect(supportsXHighEffort('claude-sonnet-4.6')).toBe(false) + expect(supportsXHighEffort('claude-opus-4.5')).toBe(false) }) }) @@ -62,16 +65,28 @@ describe('effort module', () => { expect(budgetToEffort(100000, 'claude-haiku-4.5')).toBeUndefined() }) - test('maps budget ranges correctly', () => { - expect(budgetToEffort(5000, 'claude-opus-4.8')).toBe('low') - expect(budgetToEffort(16384, 'claude-opus-4.8')).toBe('medium') - expect(budgetToEffort(24576, 'claude-opus-4.8')).toBe('high') - expect(budgetToEffort(32768, 'claude-opus-4.8')).toBe('max') - expect(budgetToEffort(80000, 'claude-opus-4.8')).toBe('max') + test('maps reference budgets to their effort level', () => { + expect(budgetToEffort(16384, 'claude-opus-4.8')).toBe('low') + expect(budgetToEffort(32768, 'claude-opus-4.8')).toBe('medium') + expect(budgetToEffort(65536, 'claude-opus-4.8')).toBe('high') + expect(budgetToEffort(98304, 'claude-opus-4.8')).toBe('xhigh') + expect(budgetToEffort(128000, 'claude-opus-4.8')).toBe('max') }) - test('maps to max instead of xhigh for non-xhigh models', () => { - expect(budgetToEffort(80000, 'claude-sonnet-4.6')).toBe('max') + test('maps sub-band and over-ceiling budgets', () => { + expect(budgetToEffort(1024, 'claude-opus-4.8')).toBe('low') + expect(budgetToEffort(20000, 'claude-opus-4.8')).toBe('medium') + expect(budgetToEffort(200000, 'claude-opus-4.8')).toBe('max') + }) + + test('reaches xhigh on every xhigh-capable model', () => { + expect(budgetToEffort(98304, 'claude-opus-4.7')).toBe('xhigh') + expect(budgetToEffort(98304, 'claude-opus-5')).toBe('xhigh') + }) + + test('clamps the xhigh band to max for non-xhigh models', () => { + expect(budgetToEffort(98304, 'claude-sonnet-4.6')).toBe('max') + expect(budgetToEffort(98304, 'claude-opus-4.6')).toBe('max') }) }) @@ -92,7 +107,8 @@ describe('effort module', () => { test('uses budget mapping when thinking and auto-mapping enabled', () => { expect(getEffectiveEffort('claude-opus-4.8', true, 128000, undefined, true)).toBe('max') expect(getEffectiveEffort('claude-opus-4.8', true, 20000, undefined, true)).toBe('medium') - expect(getEffectiveEffort('claude-opus-5', true, 32768, undefined, true)).toBe('max') + expect(getEffectiveEffort('claude-opus-5', true, 98304, undefined, true)).toBe('xhigh') + expect(getEffectiveEffort('claude-opus-5', true, 32768, undefined, true)).toBe('medium') expect(getEffectiveEffort('claude-opus-5', true, 8192, undefined, true)).toBe('low') }) diff --git a/src/__tests__/model-registry.test.ts b/src/__tests__/model-registry.test.ts new file mode 100644 index 0000000..81242fa --- /dev/null +++ b/src/__tests__/model-registry.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test' +import { SUPPORTED_MODELS } from '../constants.js' +import type { Effort } from '../plugin/config/schema.js' +import { budgetToEffort, THINKING_BUDGETS } from '../plugin/effort.js' +import { buildModelRegistry } from '../plugin/model-registry.js' +import { resolveKiroModel } from '../plugin/models.js' + +const registry = buildModelRegistry() as Record + +const thinkingIDs = Object.keys(registry).filter((id) => id.endsWith('-thinking')) +const XHIGH_MODELS = [ + 'claude-opus-4-7-thinking', + 'claude-opus-4-8-thinking', + 'claude-opus-5-thinking', + 'claude-sonnet-5-thinking' +] + +describe('model registry', () => { + test('every advertised model is resolvable to a Kiro model ID', () => { + for (const modelID of Object.keys(registry)) { + expect(SUPPORTED_MODELS).toContain(modelID) + } + }) + + test('advertises a thinking companion for each effort-capable Claude model', () => { + expect(thinkingIDs.sort()).toEqual( + [ + 'claude-opus-4-5-thinking', + 'claude-opus-4-6-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-5-thinking' + ].sort() + ) + }) + + test('does not advertise Kiro GPT tiers, which use a different reasoning contract', () => { + for (const id of Object.keys(registry)) { + expect(id.startsWith('gpt-')).toBe(false) + } + }) + + describe('reasoning capability flags', () => { + // Both are required: `reasoning` declares the capability, `interleaved.field` + // tells OpenCode reasoning arrives as `reasoning_content` deltas. Missing + // either one means reasoning chunks are silently dropped. + test('every thinking model declares reasoning and the reasoning_content field', () => { + for (const id of thinkingIDs) { + expect(registry[id].reasoning).toBe(true) + expect(registry[id].interleaved).toEqual({ field: 'reasoning_content' }) + } + }) + + test('non-thinking models declare neither', () => { + for (const [id, model] of Object.entries(registry)) { + if (id.endsWith('-thinking')) continue + expect(model.reasoning).toBeUndefined() + expect(model.interleaved).toBeUndefined() + } + }) + }) + + describe('thinking variants', () => { + test('offers xhigh only on models Kiro documents as xhigh-capable', () => { + for (const id of thinkingIDs) { + const hasXHigh = Object.keys(registry[id].variants).includes('xhigh') + expect(hasXHigh).toBe(XHIGH_MODELS.includes(id)) + } + }) + + test('variant budgets map back to the effort level they are named for', () => { + for (const id of thinkingIDs) { + const kiroModel = resolveKiroModel(id) + for (const [name, variant] of Object.entries(registry[id].variants)) { + const level = name as Effort + const budget = variant.thinkingConfig.thinkingBudget + expect(budget).toBe(THINKING_BUDGETS[level]) + expect(budgetToEffort(budget, kiroModel)).toBe(level) + } + } + }) + + test('variants are ordered low to max', () => { + for (const id of thinkingIDs) { + const budgets = Object.values(registry[id].variants).map( + (v) => v.thinkingConfig.thinkingBudget + ) + expect(budgets).toEqual([...budgets].sort((a, b) => a - b)) + } + }) + }) + + test('carries limit and modalities through to both entries', () => { + expect(registry['claude-opus-5'].limit).toEqual({ context: 1000000, 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 + ) + }) +}) diff --git a/src/plugin.ts b/src/plugin.ts index bd58704..06283c3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,6 +7,7 @@ import { AccountRepository } from './infrastructure/database/account-repository. import { AccountManager } from './plugin/accounts.js' import { bootstrapAuthIfNeeded } from './plugin/auth-bootstrap.js' import { loadConfig } from './plugin/config/index.js' +import { buildModelRegistry } from './plugin/model-registry.js' import { formatWebSearchResults, kiroWebSearch } from './plugin/web-search.js' type ToastFunction = (message: string, variant: string) => void @@ -105,92 +106,7 @@ export const createKiroPlugin = input.provider[id].api = baseURL } if (!input.provider[id].models) { - input.provider[id].models = { - auto: { - name: 'Auto (1.0x)', - limit: { context: 200000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - // Claude Sonnet - 'claude-sonnet-4': { - name: 'Claude Sonnet 4.0 (1.3x)', - limit: { context: 200000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - 'claude-sonnet-4-5': { - name: 'Claude Sonnet 4.5 (1.3x)', - limit: { context: 200000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - 'claude-sonnet-4-6': { - name: 'Claude Sonnet 4.6 (1.3x)', - limit: { context: 1000000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - 'claude-sonnet-5': { - name: 'Claude Sonnet 5 (1.3x)', - limit: { context: 1000000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - // Claude Haiku - 'claude-haiku-4-5': { - name: 'Claude Haiku 4.5 (0.4x)', - limit: { context: 200000, output: 64000 }, - modalities: { input: ['text', 'image'], output: ['text'] } - }, - // Claude Opus - 'claude-opus-4-5': { - name: 'Claude Opus 4.5 (2.2x)', - limit: { context: 200000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - 'claude-opus-4-6': { - name: 'Claude Opus 4.6 (2.2x)', - limit: { context: 1000000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - 'claude-opus-4-7': { - name: 'Claude Opus 4.7 (2.2x)', - limit: { context: 1000000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - 'claude-opus-4-8': { - name: 'Claude Opus 4.8 (2.2x)', - limit: { context: 1000000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - 'claude-opus-4-8-thinking': { - name: 'Claude Opus 4.8 Thinking (2.2x)', - limit: { context: 1000000, output: 64000 }, - modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } - }, - // Open weight models - 'deepseek-3.2': { - name: 'DeepSeek 3.2 (0.25x)', - limit: { context: 128000, output: 64000 }, - modalities: { input: ['text'], output: ['text'] } - }, - 'glm-5': { - name: 'GLM-5 (0.5x)', - limit: { context: 200000, output: 64000 }, - modalities: { input: ['text'], output: ['text'] } - }, - 'minimax-m2.5': { - name: 'MiniMax M2.5 (0.25x)', - limit: { context: 200000, output: 64000 }, - modalities: { input: ['text'], output: ['text'] } - }, - 'minimax-m2.1': { - name: 'MiniMax M2.1 (0.15x)', - limit: { context: 200000, output: 64000 }, - modalities: { input: ['text'], output: ['text'] } - }, - 'qwen3-coder-next': { - name: 'Qwen3 Coder Next (0.05x)', - limit: { context: 256000, output: 64000 }, - modalities: { input: ['text'], output: ['text'] } - } - } + input.provider[id].models = buildModelRegistry() } }, auth: { diff --git a/src/plugin/effort.ts b/src/plugin/effort.ts index d3babe1..4a21e6c 100644 --- a/src/plugin/effort.ts +++ b/src/plugin/effort.ts @@ -5,11 +5,34 @@ import type { Effort } from './config/schema' */ export const EFFORT_LEVELS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max'] as const +/** + * Reference thinking budget for each effort level. + * + * Scaled to Kiro's real thinking range (1024–128000 on opus-4.8/opus-5) rather + * than OpenCode's conventional 32768 cap, so every effort level is reachable + * from a budget alone. These double as the upper bound of each mapping band in + * budgetToEffort, and as the variant budgets the plugin advertises, so the two + * cannot drift apart. + */ +export const THINKING_BUDGETS: Readonly> = { + low: 16384, + medium: 32768, + high: 65536, + xhigh: 98304, + max: 128000 +} + /** * Models that support the 5-value effort enum (including xhigh). - * These models support up to 128k thinking tokens with max effort. + * Per Kiro's effort docs, this is opus-4.7/4.8/5 and sonnet-5. */ -const XHIGH_CAPABLE_MODELS = new Set(['claude-opus-4.7', 'claude-opus-4.8', 'claude-opus-5']) +const XHIGH_CAPABLE_MODELS = new Set([ + 'claude-opus-4.7', + 'claude-opus-4.8', + 'claude-opus-5', + 'claude-sonnet-5', + 'claude-sonnet-5-1m' +]) /** * Models that support the 4-value effort enum (no xhigh). @@ -23,8 +46,6 @@ const EFFORT_CAPABLE_MODELS = new Set([ 'claude-sonnet-4.5-1m', 'claude-sonnet-4.6', 'claude-sonnet-4.6-1m', - 'claude-sonnet-5', - 'claude-sonnet-5-1m', ...XHIGH_CAPABLE_MODELS ]) @@ -52,7 +73,7 @@ export function resolveEffort(kiroModel: string, requested: Effort): Effort | un return undefined } - // xhigh is only supported on opus-4.7 and opus-4.8 + // xhigh is only supported on the models in XHIGH_CAPABLE_MODELS if (requested === 'xhigh' && !supportsXHighEffort(kiroModel)) { return 'max' } @@ -63,36 +84,34 @@ export function resolveEffort(kiroModel: string, requested: Effort): Effort | un /** * Map OpenCode thinking budget to Kiro effort level. * - * OpenCode sends thinkingBudget from its variant config. Standard values: - * - low: 8192 - * - medium: 16384 - * - high: 24576 - * - max: 32768 + * Budget bands are scaled to Kiro's real thinking ceiling (1024–128000 for + * opus-4.8/opus-5), not OpenCode's conventional 32768 cap, so the full effort + * enum is reachable. Reference budgets: + * - low: 16384 + * - medium: 32768 + * - high: 65536 + * - xhigh: 98304 + * - max: 128000 * - * We map these ranges to Kiro effort levels: - * - ≤10000 → low - * - ≤20000 → medium - * - ≤28000 → high - * - ≤32768 → max (or xhigh on opus-4.7/4.8, max otherwise) - * - >32768 → max + * Each THINKING_BUDGETS value is the inclusive upper bound of its band, so a + * variant configured with a reference budget maps back to the same level: + * - ≤16384 → low + * - ≤32768 → medium + * - ≤65536 → high + * - ≤98304 → xhigh (clamped to max on models without xhigh support) + * - >98304 → max */ export function budgetToEffort(budget: number, kiroModel: string): Effort | undefined { if (!supportsEffort(kiroModel)) { return undefined } - let effort: Effort - if (budget <= 10000) { - effort = 'low' - } else if (budget <= 20000) { - effort = 'medium' - } else if (budget <= 28000) { - effort = 'high' - } else { - effort = 'max' - } + // EFFORT_LEVELS is ordered low→max, so the first band the budget fits wins. + const effort = + EFFORT_LEVELS.find((level) => budget <= THINKING_BUDGETS[level]) ?? + EFFORT_LEVELS[EFFORT_LEVELS.length - 1]! - return effort + return resolveEffort(kiroModel, effort) } /** diff --git a/src/plugin/model-registry.ts b/src/plugin/model-registry.ts new file mode 100644 index 0000000..b91936e --- /dev/null +++ b/src/plugin/model-registry.ts @@ -0,0 +1,197 @@ +import { EFFORT_LEVELS, supportsEffort, supportsXHighEffort, THINKING_BUDGETS } from './effort.js' +import { resolveKiroModel } from './models.js' + +type Modalities = { + input: Array<'text' | 'image' | 'pdf'> + output: ['text'] +} + +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 + * `output_config.effort`; the effort ladder is derived from the model's own + * capabilities in effort.ts. + */ + thinking?: boolean +} + +/** + * Models Kiro exposes, keyed by the OpenCode-facing model ID. + * + * 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. + */ +const MODEL_SPECS: Record = { + auto: { name: 'Auto', rate: '1.0x', limit: CONTEXT_200K, 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-5': { + name: 'Claude Sonnet 5', + rate: '1.3x', + limit: CONTEXT_1M, + modalities: MULTIMODAL, + thinking: true + }, + + // Claude Haiku + '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-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 + }, + + // Open weight models + '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 }, + '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 + } +} + +/** + * Build the thinking variants a model supports. + * + * Levels come from the model's own effort capabilities, so xhigh only appears on + * models that accept it and the budgets stay in step with budgetToEffort. + */ +function buildVariants(kiroModel: string): Record { + const variants: Record = {} + + for (const level of EFFORT_LEVELS) { + if (level === 'xhigh' && !supportsXHighEffort(kiroModel)) continue + variants[level] = { thinkingConfig: { thinkingBudget: THINKING_BUDGETS[level] } } + } + + return variants +} + +/** + * Model registry advertised to OpenCode. + * + * `-thinking` entries carry `reasoning` and `interleaved`. Both are required: + * `reasoning` declares the capability, and `interleaved.field` tells OpenCode + * that reasoning arrives in the non-standard `reasoning_content` delta this + * plugin emits (see streaming/openai-converter.ts). Without them OpenCode + * silently drops every reasoning chunk and no thinking block is rendered. + */ +export function buildModelRegistry(): Record { + const models: Record = {} + + for (const [modelID, spec] of Object.entries(MODEL_SPECS)) { + models[modelID] = { + name: `${spec.name} (${spec.rate})`, + limit: spec.limit, + modalities: spec.modalities + } + + if (!spec.thinking) continue + + // Effort capability is keyed on the resolved Kiro model ID, not the + // OpenCode-facing one (e.g. claude-opus-5 vs claude-opus-4-6). + const kiroModel = resolveKiroModel(modelID) + if (!supportsEffort(kiroModel)) continue + + models[`${modelID}-thinking`] = { + name: `${spec.name} Thinking (${spec.rate})`, + limit: spec.limit, + modalities: spec.modalities, + reasoning: true, + interleaved: { field: 'reasoning_content' }, + variants: buildVariants(kiroModel) + } + } + + return models +} From d296b623502a617b54b58f05d68a8e5413a5fca7 Mon Sep 17 00:00:00 2001 From: GuidanNick <224735395+guidan-nick@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:36:27 +0200 Subject: [PATCH 3/4] docs: document zero-config models and the reasoning flags The install example told users to hand-write the whole model registry, which is what left thinking models without reasoning flags in the first place. The plugin advertises every model itself, so the example is now just the plugin entry. Document that reasoning + interleaved.field are both required, and warn that overriding provider.kiro.models replaces the plugin registry wholesale, so anyone doing that has to carry the flags themselves. --- README.md | 272 +++++++++--------------------------------------------- 1 file changed, 46 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index 1c2cb94..f092384 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ models with substantial trial quotas. - **High-Performance Storage**: Efficient account and usage management using native Bun SQLite. - **Native Thinking Mode**: Full support for Claude reasoning capabilities via virtual - model mappings. + model mappings. Thinking models are advertised with the reasoning flags OpenCode + needs, so reasoning renders without any model configuration. - **Kiro Effort Mapping**: Maps OpenCode thinking budgets to Kiro's native effort - levels automatically. + levels automatically, across the full `low`–`max` ladder. - **Automated Recovery**: Exponential backoff for rate limits and automated token refresh. @@ -32,244 +33,63 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: ```json { - "plugin": ["@zhafron/opencode-kiro-auth"], - "provider": { - "kiro": { - "models": { - "claude-sonnet-4-5": { - "name": "Claude Sonnet 4.5", - "limit": { "context": 200000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-sonnet-4-5-thinking": { - "name": "Claude Sonnet 4.5 Thinking", - "limit": { "context": 200000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-sonnet-4-6": { - "name": "Claude Sonnet 4.6", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-sonnet-4-6-thinking": { - "name": "Claude Sonnet 4.6 Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-sonnet-5": { - "name": "Claude Sonnet 5", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-sonnet-5-thinking": { - "name": "Claude Sonnet 5 Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-sonnet-5-1m": { - "name": "Claude Sonnet 5 (1M Context)", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-sonnet-5-1m-thinking": { - "name": "Claude Sonnet 5 (1M Context) Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-haiku-4-5": { - "name": "Claude Haiku 4.5", - "limit": { "context": 200000, "output": 64000 }, - "modalities": { "input": ["text", "image"], "output": ["text"] } - }, - "claude-opus-4-5": { - "name": "Claude Opus 4.5", - "limit": { "context": 200000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-opus-4-5-thinking": { - "name": "Claude Opus 4.5 Thinking", - "limit": { "context": 200000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-opus-4-6": { - "name": "Claude Opus 4.6", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-opus-4-6-thinking": { - "name": "Claude Opus 4.6 Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-opus-4-6-1m": { - "name": "Claude Opus 4.6 (1M Context)", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-opus-4-6-1m-thinking": { - "name": "Claude Opus 4.6 (1M Context) Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-opus-4-7": { - "name": "Claude Opus 4.7", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-opus-4-7-thinking": { - "name": "Claude Opus 4.7 Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-opus-5": { - "name": "Claude Opus 5", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-opus-5-thinking": { - "name": "Claude Opus 5 Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-sonnet-4-5-1m": { - "name": "Claude Sonnet 4.5 (1M Context)", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-sonnet-4-6-1m": { - "name": "Claude Sonnet 4.6 (1M Context)", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-sonnet-4-6-1m-thinking": { - "name": "Claude Sonnet 4.6 (1M Context) Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "auto": { "name": "Auto (1.0x)" }, - "claude-sonnet-4": { - "name": "Claude Sonnet 4.0 (1.3x)", - "limit": { "context": 200000, "output": 64000 } - }, - "deepseek-3.2": { - "name": "DeepSeek 3.2 (0.25x)", - "limit": { "context": 128000, "output": 64000 } - }, - "glm-5": { "name": "GLM-5 (0.5x)", "limit": { "context": 200000, "output": 64000 } }, - "minimax-m2.5": { - "name": "MiniMax 2.5 (0.25x)", - "limit": { "context": 200000, "output": 64000 } - }, - "minimax-m2.1": { - "name": "MiniMax 2.1 (0.15x)", - "limit": { "context": 200000, "output": 64000 } - }, - "qwen3-coder-next": { - "name": "Qwen3 Coder Next (0.05x)", - "limit": { "context": 256000, "output": 64000 } - } - } - } - } + "plugin": ["@zhafron/opencode-kiro-auth"] } ``` +That is the whole configuration. The plugin registers the `kiro` provider and +advertises every model Kiro exposes, including a `-thinking` companion for each +model that supports reasoning effort. Run `/models` to pick one. + +Defining `provider.kiro.models` yourself replaces the plugin's registry entirely. +Only do that to rename or restrict models, and see the reasoning flags below if +any of them are `-thinking` models. + ### Thinking Effort Configuration -Configure Kiro effort per model in your OpenCode provider model definitions by setting -`thinkingConfig.thinkingBudget` on each model variant. The plugin automatically maps -those budgets to Kiro's native `effort` field for supported Claude models, so you do -not need to hardcode a global `effort` value in `~/.config/opencode/kiro.json`. +Every effort-capable Claude model gets a `-thinking` companion, already carrying +the reasoning flags and an effort ladder as variants. Nothing to configure: pick a +`-thinking` model and cycle its variants to change reasoning depth. + +Each `-thinking` entry declares two fields that OpenCode needs in order to render +reasoning: ```json { - "provider": { - "kiro": { - "models": { - "claude-opus-4-7-thinking": { - "name": "Claude Opus 4.7 Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - } - } - } - } + "reasoning": true, + "interleaved": { "field": "reasoning_content" } } ``` -Budget mapping: +Both are required. `reasoning` declares the capability, and `interleaved.field` +tells OpenCode that reasoning arrives in the non-standard `reasoning_content` +delta this plugin emits. If either is missing, OpenCode silently drops every +reasoning chunk and no thinking block appears. + +If you override `provider.kiro.models` in your own config, you replace the +plugin's registry wholesale — copy both fields onto any `-thinking` model you +define, or reasoning will stop rendering. + +Variants set `thinkingConfig.thinkingBudget`, which the plugin maps to Kiro's +native `effort` field. Bands are scaled to Kiro's real thinking ceiling +(1024-128000 on opus-4.8/opus-5), so every effort level including `xhigh` is +reachable from a budget alone: | OpenCode budget | Kiro effort | | --------------- | ----------- | -| `<= 10000` | `low` | -| `<= 20000` | `medium` | -| `<= 28000` | `high` | -| `> 28000` | `max` | +| `<= 16384` | `low` | +| `<= 32768` | `medium` | +| `<= 65536` | `high` | +| `<= 98304` | `xhigh` | +| `> 98304` | `max` | + +`xhigh` is only available on opus-4.7, opus-4.8, opus-5 and sonnet-5. Those models +get a five-variant ladder; the rest get four, and a budget in the `xhigh` band is +clamped to `max`. + +Kiro's GPT-5.6 tiers are not advertised. They configure reasoning through +`reasoning.effort` / `reasoning.mode` instead of `output_config.effort`, so they +need a separate request path. Use `~/.config/opencode/kiro.json` for plugin-wide behavior such as auth sync, account selection, retry limits, and `auto_effort_mapping`. A top-level `effort` From 1cd6f081a86ea53d2ad5f6c2e9a57b836d039c95 Mon Sep 17 00:00:00 2001 From: GuidanNick <224735395+guidan-nick@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:09:42 +0200 Subject: [PATCH 4/4] fix: stream native reasoning instead of discarding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeWhisperer sends reasoning on thinking models as reasoningContentEvent, a member of the ChatResponseStream union. transformSdkStream handled assistantResponseEvent, toolUseEvent, metadataEvent and contextUsageEvent and fell through on everything else, so every reasoning event was dropped. Measured against opus-5 before this change: 17 reasoning events received, 0 reasoning deltas emitted. After: 20 events in, 19 reasoning_content chunks out; 36 in, 35 out. The one-event gap per response is the trailing event carrying signature/redactedContent rather than text, which is skipped deliberately — redactedContent is encrypted by the provider and has no renderable form. Until now the only thinking that ever surfaced came from injecting into the system prompt and scraping tags out of the assistant text. Opus 5 does not emit those tags at all, so on that model thinking was invisible regardless of configuration. The scraper stays as a fallback for models that do inline reasoning in their text; when native reasoning arrives, the scraper is bypassed and its block closed so literal tag mentions in the answer are not re-parsed. logSdkRequest now records the resolved additionalModelRequestFields, since effort was previously invisible in the logs and could not be confirmed without patching the plugin. --- README.md | 15 ++- src/__tests__/native-reasoning.test.ts | 106 +++++++++++++++++ src/__tests__/sdk-client.test.ts | 110 ++++++++++++------ src/core/request/request-handler.ts | 8 +- src/plugin/config/schema.ts | 6 +- .../streaming/sdk-stream-transformer.ts | 28 ++++- 6 files changed, 231 insertions(+), 42 deletions(-) create mode 100644 src/__tests__/native-reasoning.test.ts diff --git a/README.md b/README.md index f092384..cad8bb4 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ models with substantial trial quotas. available quota. - **High-Performance Storage**: Efficient account and usage management using native Bun SQLite. -- **Native Thinking Mode**: Full support for Claude reasoning capabilities via virtual - model mappings. Thinking models are advertised with the reasoning flags OpenCode - needs, so reasoning renders without any model configuration. +- **Native Thinking Mode**: Streams Kiro's native reasoning to OpenCode's thinking + block, with the reasoning flags declared on every thinking model, so it renders + without any model configuration. - **Kiro Effort Mapping**: Maps OpenCode thinking budgets to Kiro's native effort levels automatically, across the full `low`–`max` ladder. - **Automated Recovery**: Exponential backoff for rate limits and automated token @@ -70,6 +70,11 @@ If you override `provider.kiro.models` in your own config, you replace the plugin's registry wholesale — copy both fields onto any `-thinking` model you define, or reasoning will stop rendering. +Reasoning itself comes from the API: Kiro streams `reasoningContentEvent` on +thinking models, and the plugin forwards each one as a `reasoning_content` delta. +Nothing needs to be enabled for that. Models that instead inline reasoning as +`` tags in their answer are still handled, via a fallback scraper. + Variants set `thinkingConfig.thinkingBudget`, which the plugin maps to Kiro's native `effort` field. Bands are scaled to Kiro's real thinking ceiling (1024-128000 on opus-4.8/opus-5), so every effort level including `xhigh` is @@ -239,7 +244,9 @@ Edit `~/.config/opencode/kiro.json`: - `usage_tracking_enabled`: Enable usage tracking and toast notifications. - `auto_effort_mapping`: Automatically map OpenCode thinking budgets to Kiro effort levels for supported models (default: `true`). -- `enable_log_api_request`: Enable detailed API request logging. +- `enable_log_api_request`: Enable detailed API request logging. Request logs + include the resolved `additionalModelRequestFields`, so this is how you confirm + which effort level actually went out on the wire. ## Storage diff --git a/src/__tests__/native-reasoning.test.ts b/src/__tests__/native-reasoning.test.ts new file mode 100644 index 0000000..b016061 --- /dev/null +++ b/src/__tests__/native-reasoning.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test' +import { transformSdkStream } from '../plugin/streaming/sdk-stream-transformer.js' + +const MODEL = 'claude-opus-5' + +function streamOf(events: any[]) { + return { + generateAssistantResponseResponse: (async function* () { + for (const event of events) yield event + })() + } +} + +async function collect(events: any[]) { + const chunks: any[] = [] + for await (const chunk of transformSdkStream(streamOf(events), MODEL, 'conversation-1')) { + chunks.push(chunk) + } + + let reasoning = '' + let text = '' + for (const chunk of chunks) { + const delta = chunk.choices?.[0]?.delta + if (delta?.reasoning_content) reasoning += delta.reasoning_content + if (delta?.content) text += delta.content + } + + return { reasoning, text } +} + +describe('native reasoning stream', () => { + test('surfaces reasoningContentEvent text as reasoning_content', async () => { + const { reasoning, text } = await collect([ + { reasoningContentEvent: { text: 'weighing ' } }, + { reasoningContentEvent: { text: 'the options' } }, + { assistantResponseEvent: { content: 'Answer.' } } + ]) + + expect(reasoning).toBe('weighing the options') + expect(text).toBe('Answer.') + }) + + test('does not scrape thinking tags once native reasoning arrived', async () => { + // A model echoing the tag literally must not have it re-parsed as a + // reasoning block when the API already streamed native reasoning. + const { reasoning, text } = await collect([ + { reasoningContentEvent: { text: 'native' } }, + { assistantResponseEvent: { content: 'literal mention stays in text' } } + ]) + + expect(reasoning).toBe('native') + expect(text).toContain('') + }) + + test('ignores reasoning events with no readable text', async () => { + const { reasoning, text } = await collect([ + { reasoningContentEvent: { redactedContent: new Uint8Array([1, 2, 3]) } }, + { reasoningContentEvent: { text: '' } }, + { assistantResponseEvent: { content: 'Answer.' } } + ]) + + expect(reasoning).toBe('') + expect(text).toBe('Answer.') + }) + + test('still scrapes thinking tags when no native reasoning is sent', async () => { + const { reasoning, text } = await collect([ + { assistantResponseEvent: { content: 'scraped\n\nAnswer.' } } + ]) + + expect(reasoning).toBe('scraped') + expect(text).toBe('Answer.') + }) + + // Observed shape from a live opus-5 response: the API interleaves many small + // reasoningContentEvents before any assistant text, and emits no + // tags at all. Reasoning must survive without the scraper having anything to + // find, and without anything being requested on the way out. + test('handles the live event shape: many reasoning events, then text, no tags', async () => { + const events = [ + ...Array.from({ length: 17 }, (_, i) => ({ + reasoningContentEvent: { text: `step${i} ` } + })), + { assistantResponseEvent: { content: 'Final answer.' } }, + { contextUsageEvent: { contextUsagePercentage: 12 } }, + { meteringEvent: {} } + ] + + const { reasoning, text } = await collect(events) + + expect(reasoning).toBe(Array.from({ length: 17 }, (_, i) => `step${i} `).join('')) + expect(text).toBe('Final answer.') + }) + + test('ignores event types the transformer does not consume', async () => { + const { reasoning, text } = await collect([ + { meteringEvent: {} }, + { reasoningContentEvent: { text: 'thought' } }, + { contextUsageEvent: { contextUsagePercentage: 3 } }, + { assistantResponseEvent: { content: 'Answer.' } } + ]) + + expect(reasoning).toBe('thought') + expect(text).toBe('Answer.') + }) +}) diff --git a/src/__tests__/sdk-client.test.ts b/src/__tests__/sdk-client.test.ts index 4a7c825..6c7a150 100644 --- a/src/__tests__/sdk-client.test.ts +++ b/src/__tests__/sdk-client.test.ts @@ -14,6 +14,46 @@ function auth(): KiroAuthDetails { } } +async function captureRequest(client: ReturnType) { + let capturedRequest: any + + client.middlewareStack.add( + () => async (args: any) => { + capturedRequest = args.request + throw new Error('captured-request') + }, + { step: 'finalizeRequest', name: 'captureRequest', priority: 'high' } + ) + + const command = new GenerateAssistantResponseCommand({ + conversationState: { + chatTriggerType: 'MANUAL', + conversationId: 'test-conversation', + currentMessage: { + userInputMessage: { + content: 'hello', + modelId: 'claude-opus-4.7', + origin: 'AI_EDITOR' + } + } + } + }) + + await client.send(command).catch((error) => { + if (error.message !== 'captured-request') throw error + }) + + const bodyText = + typeof capturedRequest.body === 'string' + ? capturedRequest.body + : Buffer.from(capturedRequest.body).toString('utf8') + + return { + body: JSON.parse(bodyText), + request: { headers: capturedRequest.headers, bodyText } + } +} + describe('SDK client', () => { test('uses Kiro CLI-style standard SDK retries for throttling', async () => { clearSdkClientCache() @@ -31,42 +71,46 @@ describe('SDK client', () => { clearSdkClientCache() const client = createSdkClient(auth(), 'us-east-1', 'max') - let capturedRequest: any - - client.middlewareStack.add( - () => async (args: any) => { - capturedRequest = args.request - throw new Error('captured-request') - }, - { step: 'finalizeRequest', name: 'captureRequest', priority: 'high' } - ) - - const command = new GenerateAssistantResponseCommand({ - conversationState: { - chatTriggerType: 'MANUAL', - conversationId: 'test-conversation', - currentMessage: { - userInputMessage: { - content: 'hello', - modelId: 'claude-opus-4.7', - origin: 'AI_EDITOR' - } - } - } - }) + const { body, request } = await captureRequest(client) - await client.send(command).catch((error) => { - if (error.message !== 'captured-request') throw error - }) + expect(body.additionalModelRequestFields.output_config.effort).toBe('max') + expect(Number(request.headers['content-length'])).toBe(Buffer.byteLength(request.bodyText)) - const bodyText = - typeof capturedRequest.body === 'string' - ? capturedRequest.body - : Buffer.from(capturedRequest.body).toString('utf8') - const body = JSON.parse(bodyText) + clearSdkClientCache() + }) - expect(body.additionalModelRequestFields.output_config.effort).toBe('max') - expect(Number(capturedRequest.headers['content-length'])).toBe(Buffer.byteLength(bodyText)) + test('omits additionalModelRequestFields when no effort is set', async () => { + clearSdkClientCache() + + const client = createSdkClient(auth(), 'us-east-1') + const { body } = await captureRequest(client) + + expect(body.additionalModelRequestFields).toBeUndefined() + + clearSdkClientCache() + }) + + test('injects xhigh, the level that was previously unreachable', async () => { + clearSdkClientCache() + + const client = createSdkClient(auth(), 'us-east-1', 'xhigh') + const { body, request } = await captureRequest(client) + + expect(body.additionalModelRequestFields.output_config.effort).toBe('xhigh') + expect(Number(request.headers['content-length'])).toBe(Buffer.byteLength(request.bodyText)) + + clearSdkClientCache() + }) + + test('does not reuse a cached client across different effort levels', () => { + clearSdkClientCache() + + const max = createSdkClient(auth(), 'us-east-1', 'max') + const xhigh = createSdkClient(auth(), 'us-east-1', 'xhigh') + const maxAgain = createSdkClient(auth(), 'us-east-1', 'max') + + expect(xhigh).not.toBe(max) + expect(maxAgain).toBe(max) clearSdkClientCache() }) diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index b9999d9..ace5cc3 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -277,6 +277,11 @@ export class RequestHandler { } private logSdkRequest(prep: SdkPreparedRequest, acc: ManagedAccount, timestamp: string): void { + // Mirrors what the sdk-client middleware injects, so logs reflect the wire body. + const additionalModelRequestFields = prep.effort + ? { output_config: { effort: prep.effort } } + : undefined + logger.logApiRequest( { url: `https://q.${prep.region}.amazonaws.com/generateAssistantResponse`, @@ -289,7 +294,8 @@ export class RequestHandler { historyLength: (prep.conversationState as any).history?.length || 0, currentMessage: prep.conversationState.currentMessage }, - profileArn: prep.profileArn + profileArn: prep.profileArn, + ...(additionalModelRequestFields ? { additionalModelRequestFields } : {}) }, conversationId: prep.conversationId, model: prep.effectiveModel, diff --git a/src/plugin/config/schema.ts b/src/plugin/config/schema.ts index f6acfa9..3ba8330 100644 --- a/src/plugin/config/schema.ts +++ b/src/plugin/config/schema.ts @@ -8,8 +8,8 @@ export type AccountSelectionStrategy = z.infer @@ -86,7 +86,7 @@ export const KiroConfigSchema = z.object({ /** * Default effort level for thinking models. Controls reasoning depth. * When set, this overrides the automatic budget-based mapping. - * Values: 'low', 'medium', 'high', 'xhigh' (opus-4.7/4.8 only), 'max' + * Values: 'low', 'medium', 'high', 'xhigh' (see XHIGH_CAPABLE_MODELS), 'max' */ effort: EffortSchema.optional(), diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index c49ffc0..877aa58 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -33,6 +33,11 @@ export async function* transformSdkStream( stoppedBlocks: new Set() } + // Set when the API returns native reasoning via reasoningContentEvent. The + // tag scraper below stays as a fallback for responses that only + // carry reasoning inline in the assistant text. + let sawNativeReasoning = false + let totalContent = '' let textOnlyContent = '' let outputTokens = 0 @@ -48,11 +53,32 @@ export async function* transformSdkStream( try { for await (const event of eventStream) { - if (event.assistantResponseEvent?.content) { + if (event.reasoningContentEvent) { + // Native reasoning stream. redactedContent is encrypted by the provider + // and has no readable form, so only text is surfaced. + const reasoning = event.reasoningContentEvent.text + if (typeof reasoning === 'string' && reasoning.length > 0) { + sawNativeReasoning = true + for (const ev of createThinkingDeltaEvents(reasoning, streamState)) { + const chunk = convertToOpenAI(ev, conversationId, model) + if (chunk !== null) yield chunk + } + } + } else if (event.assistantResponseEvent?.content) { const text = event.assistantResponseEvent.content totalContent += text textOnlyContent += text + // Native reasoning already streamed, so close that block and route the + // remaining content straight to text instead of scraping for tags. + if (sawNativeReasoning && !streamState.thinkingExtracted) { + streamState.thinkingExtracted = true + for (const ev of stopBlock(streamState.thinkingBlockIndex, streamState)) { + const chunk = convertToOpenAI(ev, conversationId, model) + if (chunk !== null) yield chunk + } + } + if (!thinkingRequested) { for (const ev of createTextDeltaEvents(text, streamState)) { {