diff --git a/README.md b/README.md index 48a87f0..cad8bb4 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ 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. +- **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. + levels automatically, across the full `low`–`max` ladder. - **Automated Recovery**: Exponential backoff for rate limits and automated token refresh. @@ -32,229 +33,68 @@ 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-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. + +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 +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` @@ -404,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__/effort.test.ts b/src/__tests__/effort.test.ts index 9aad0ec..263b8dd 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,14 +26,18 @@ describe('effort module', () => { }) describe('supportsXHighEffort', () => { - test('returns true for opus 4.7 and 4.8', () => { + 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) }) }) @@ -45,6 +50,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', () => { @@ -58,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') }) }) @@ -88,6 +107,9 @@ 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, 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') }) test('falls back to medium when auto-mapping disabled', () => { 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/__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/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/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.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/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/effort.ts b/src/plugin/effort.ts index 4c2ab54..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']) +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 +} 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)) { {