diff --git a/README.md b/README.md index cad8bb4..9c2b9eb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![npm downloads](https://img.shields.io/npm/dm/@zhafron/opencode-kiro-auth)](https://www.npmjs.com/package/@zhafron/opencode-kiro-auth) [![license](https://img.shields.io/npm/l/@zhafron/opencode-kiro-auth)](https://www.npmjs.com/package/@zhafron/opencode-kiro-auth) -OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude Sonnet and Haiku +OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude and GPT-5.6 models with substantial trial quotas. ## Features @@ -23,7 +23,7 @@ models with substantial trial quotas. 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. + levels automatically, using each model's supported effort ladder. - **Automated Recovery**: Exponential backoff for rate limits and automated token refresh. @@ -92,9 +92,19 @@ reachable from a budget alone: 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. +Kiro's GPT-5.6 tiers are advertised directly under their base IDs as native reasoning +models: + +| Model | Rate | Advertised context | Variants | +| ----- | ---- | ------------------ | -------- | +| `gpt-5.6-sol` | `2.4x` | `272K` | `low`, `medium`, `high`, `xhigh` | +| `gpt-5.6-terra` | `1.0x` | `272K` | `low`, `medium`, `high`, `xhigh` | +| `gpt-5.6-luna` | `0.1x` | `272K` | `low`, `medium`, `high`, `xhigh` | + +GPT requests send effort as `reasoning.effort`, while Claude requests retain +`output_config.effort`. GPT does not accept the plugin's `max` level, so a global +`max` override is clamped to `xhigh`. Unlike Claude, GPT does not use a separate +`-thinking` companion or receive the legacy `` system-prompt tags. 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` diff --git a/src/__tests__/effort.test.ts b/src/__tests__/effort.test.ts index 263b8dd..6b75f39 100644 --- a/src/__tests__/effort.test.ts +++ b/src/__tests__/effort.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test' import { budgetToEffort, getEffectiveEffort, + getEffortSchemaPath, + getSupportedEffortLevels, resolveEffort, supportsEffort, supportsXHighEffort @@ -17,6 +19,9 @@ describe('effort module', () => { expect(supportsEffort('claude-sonnet-5')).toBe(true) expect(supportsEffort('claude-sonnet-5-1m')).toBe(true) expect(supportsEffort('claude-opus-5')).toBe(true) + expect(supportsEffort('gpt-5.6-sol')).toBe(true) + expect(supportsEffort('gpt-5.6-terra')).toBe(true) + expect(supportsEffort('gpt-5.6-luna')).toBe(true) }) test('returns false for unsupported models', () => { @@ -90,6 +95,20 @@ describe('effort module', () => { }) }) + describe('GPT reasoning contract', () => { + test('uses the reasoning schema path and low-through-xhigh levels', () => { + expect(getEffortSchemaPath('gpt-5.6-sol')).toBe('reasoning') + expect(getEffortSchemaPath('claude-opus-5')).toBe('output_config') + expect(getSupportedEffortLevels('gpt-5.6-sol')).toEqual(['low', 'medium', 'high', 'xhigh']) + }) + + test('clamps the plugin-wide max setting to GPT xhigh', () => { + expect(resolveEffort('gpt-5.6-sol', 'max')).toBe('xhigh') + expect(budgetToEffort(128000, 'gpt-5.6-sol')).toBe('xhigh') + expect(getEffectiveEffort('gpt-5.6-sol', true, 98304)).toBe('xhigh') + }) + }) + describe('getEffectiveEffort', () => { test('returns undefined for unsupported models', () => { expect(getEffectiveEffort('claude-haiku-4.5', true, 100000)).toBeUndefined() diff --git a/src/__tests__/gpt-request.test.ts b/src/__tests__/gpt-request.test.ts new file mode 100644 index 0000000..265afc8 --- /dev/null +++ b/src/__tests__/gpt-request.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'bun:test' +import { transformToSdkRequest } from '../plugin/request.js' + +const auth: any = { + access: 'access-token', + refresh: 'refresh-token', + expires: Date.now() + 60_000, + authMethod: 'idc', + region: 'us-east-1' +} + +const body = { + messages: [ + { role: 'system', content: 'Follow the instructions.' }, + { role: 'user', content: 'Solve this.' } + ] +} + +describe('GPT request preparation', () => { + test('uses reasoning.effort semantics without Claude thinking tags', () => { + const prepared = transformToSdkRequest(body, 'gpt-5.6-sol', auth, true, 128000) + + expect(prepared.effectiveModel).toBe('gpt-5.6-sol') + expect(prepared.effort).toBe('xhigh') + expect(prepared.effortSchemaPath).toBe('reasoning') + expect(JSON.stringify(prepared.conversationState)).not.toContain('') + expect(JSON.stringify(prepared.conversationState)).not.toContain('') + }) + + test('does not replay assistant reasoning as Claude thinking tags', () => { + const priorAssistant = transformToSdkRequest( + { + messages: [ + { role: 'user', content: 'First question' }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'hidden prior reasoning' }, + { type: 'text', text: 'Prior answer' } + ] + }, + { role: 'user', content: 'Follow up' } + ] + }, + 'gpt-5.6-sol', + auth, + true, + 65536 + ) + const trailingAssistant = transformToSdkRequest( + { + messages: [ + { role: 'user', content: 'First question' }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'hidden current reasoning' }, + { type: 'text', text: 'Current answer' } + ] + } + ] + }, + 'gpt-5.6-sol', + auth, + true, + 65536 + ) + + const priorSerialized = JSON.stringify(priorAssistant.conversationState) + const trailingSerialized = JSON.stringify(trailingAssistant.conversationState) + expect(priorSerialized).not.toContain('') + expect(priorSerialized).not.toContain('hidden prior reasoning') + expect(priorSerialized).toContain('Prior answer') + expect(trailingSerialized).not.toContain('') + expect(trailingSerialized).not.toContain('hidden current reasoning') + expect(trailingSerialized).toContain('Current answer') + }) + + test('preserves Claude output_config effort and compatibility tags', () => { + const prepared = transformToSdkRequest(body, 'claude-opus-5-thinking', auth, true, 98304) + const serialized = JSON.stringify(prepared.conversationState) + + expect(prepared.effectiveModel).toBe('claude-opus-5') + expect(prepared.effort).toBe('xhigh') + expect(prepared.effortSchemaPath).toBe('output_config') + expect(serialized).toContain('enabled') + expect(serialized).toContain('98304') + }) +}) diff --git a/src/__tests__/model-registry.test.ts b/src/__tests__/model-registry.test.ts index 81242fa..04662db 100644 --- a/src/__tests__/model-registry.test.ts +++ b/src/__tests__/model-registry.test.ts @@ -8,11 +8,17 @@ import { resolveKiroModel } from '../plugin/models.js' const registry = buildModelRegistry() as Record const thinkingIDs = Object.keys(registry).filter((id) => id.endsWith('-thinking')) +const reasoningIDs = Object.entries(registry) + .filter(([, model]) => model.reasoning === true) + .map(([id]) => id) const XHIGH_MODELS = [ 'claude-opus-4-7-thinking', 'claude-opus-4-8-thinking', 'claude-opus-5-thinking', - 'claude-sonnet-5-thinking' + 'claude-sonnet-5-thinking', + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna' ] describe('model registry', () => { @@ -37,9 +43,33 @@ describe('model registry', () => { ) }) - 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) + test('advertises exact GPT-5.6 base IDs as native reasoning models', () => { + expect(registry['gpt-5.6-sol']).toMatchObject({ + name: 'GPT-5.6 Sol (2.4x)', + limit: { context: 272000, output: 64000 }, + reasoning: true, + interleaved: { field: 'reasoning_content' } + }) + expect(registry['gpt-5.6-terra']).toMatchObject({ + name: 'GPT-5.6 Terra (1.0x)', + limit: { context: 272000, output: 64000 }, + reasoning: true + }) + expect(registry['gpt-5.6-luna']).toMatchObject({ + name: 'GPT-5.6 Luna (0.1x)', + limit: { context: 272000, output: 64000 }, + reasoning: true + }) + + for (const modelID of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { + expect(registry[`${modelID}-thinking`]).toBeUndefined() + expect(Object.keys(registry[modelID].variants ?? {})).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh' + ]) + expect(registry[modelID].variants?.max).toBeUndefined() } }) @@ -47,32 +77,32 @@ describe('model registry', () => { // 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) { + test('every reasoning model declares the reasoning_content field', () => { + for (const id of reasoningIDs) { 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 + test('non-reasoning models declare neither', () => { + for (const model of Object.values(registry)) { + if (model.reasoning) continue expect(model.reasoning).toBeUndefined() expect(model.interleaved).toBeUndefined() } }) }) - describe('thinking variants', () => { + describe('reasoning variants', () => { test('offers xhigh only on models Kiro documents as xhigh-capable', () => { - for (const id of thinkingIDs) { + for (const id of reasoningIDs) { 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) { + for (const id of reasoningIDs) { const kiroModel = resolveKiroModel(id) for (const [name, variant] of Object.entries(registry[id].variants)) { const level = name as Effort @@ -84,7 +114,7 @@ describe('model registry', () => { }) test('variants are ordered low to max', () => { - for (const id of thinkingIDs) { + for (const id of reasoningIDs) { const budgets = Object.values(registry[id].variants).map( (v) => v.thinkingConfig.thinkingBudget ) diff --git a/src/__tests__/model-resolution.test.ts b/src/__tests__/model-resolution.test.ts index 16a92e6..28b9bc4 100644 --- a/src/__tests__/model-resolution.test.ts +++ b/src/__tests__/model-resolution.test.ts @@ -9,6 +9,9 @@ describe('resolveKiroModel', () => { expect(resolveKiroModel('minimax-m2.5')).toBe('minimax-m2.5') expect(resolveKiroModel('minimax-m2.1')).toBe('minimax-m2.1') expect(resolveKiroModel('qwen3-coder-next')).toBe('qwen3-coder-next') + expect(resolveKiroModel('gpt-5.6-sol')).toBe('gpt-5.6-sol') + expect(resolveKiroModel('gpt-5.6-terra')).toBe('gpt-5.6-terra') + expect(resolveKiroModel('gpt-5.6-luna')).toBe('gpt-5.6-luna') }) test('keeps existing supported Claude slugs intact', () => { diff --git a/src/__tests__/native-reasoning.test.ts b/src/__tests__/native-reasoning.test.ts index b016061..b4e35d4 100644 --- a/src/__tests__/native-reasoning.test.ts +++ b/src/__tests__/native-reasoning.test.ts @@ -11,9 +11,9 @@ function streamOf(events: any[]) { } } -async function collect(events: any[]) { +async function collect(events: any[], model = MODEL) { const chunks: any[] = [] - for await (const chunk of transformSdkStream(streamOf(events), MODEL, 'conversation-1')) { + for await (const chunk of transformSdkStream(streamOf(events), model, 'conversation-1')) { chunks.push(chunk) } @@ -92,6 +92,24 @@ describe('native reasoning stream', () => { expect(text).toBe('Final answer.') }) + test('uses GPT-5.6 272K context size for streamed usage accounting', async () => { + const chunks: any[] = [] + for await (const chunk of transformSdkStream( + streamOf([ + { assistantResponseEvent: { content: 'Answer.' } }, + { contextUsageEvent: { contextUsagePercentage: 10 } } + ]), + 'gpt-5.6-sol', + 'conversation-1' + )) { + chunks.push(chunk) + } + + const usage = chunks.find((chunk) => chunk.usage)?.usage + expect(usage).toBeDefined() + expect(usage.prompt_tokens + usage.completion_tokens).toBe(27200) + }) + test('ignores event types the transformer does not consume', async () => { const { reasoning, text } = await collect([ { meteringEvent: {} }, diff --git a/src/__tests__/sdk-client.test.ts b/src/__tests__/sdk-client.test.ts index 6c7a150..fe684f9 100644 --- a/src/__tests__/sdk-client.test.ts +++ b/src/__tests__/sdk-client.test.ts @@ -102,15 +102,68 @@ describe('SDK client', () => { clearSdkClientCache() }) - test('does not reuse a cached client across different effort levels', () => { + test('injects GPT effort under reasoning before content-length is computed', async () => { + clearSdkClientCache() + + const client = createSdkClient(auth(), 'us-east-1', 'high', 'reasoning') + const { body, request } = await captureRequest(client) + + expect(body.additionalModelRequestFields.reasoning.effort).toBe('high') + expect(body.additionalModelRequestFields.output_config).toBeUndefined() + expect(Number(request.headers['content-length'])).toBe(Buffer.byteLength(request.bodyText)) + + clearSdkClientCache() + }) + + test('fails explicitly when effort injection cannot rewrite the SDK body', async () => { + clearSdkClientCache() + + const client = createSdkClient(auth(), 'us-east-1', 'high', 'reasoning') + client.middlewareStack.addRelativeTo( + (next: any) => async (args: any) => { + args.request.body = '{invalid-json' + return next(args) + }, + { + name: 'corruptBodyBeforeEffort', + relation: 'before', + toMiddleware: 'addEffortConfig' + } + ) + + const command = new GenerateAssistantResponseCommand({ + conversationState: { + chatTriggerType: 'MANUAL', + conversationId: 'test-conversation', + currentMessage: { + userInputMessage: { + content: 'hello', + modelId: 'gpt-5.6-sol', + origin: 'AI_EDITOR' + } + } + } + }) + + await expect(client.send(command)).rejects.toThrow('Failed to inject Kiro effort configuration') + + clearSdkClientCache() + }) + + test('does not reuse a cached client across different effort levels or schema paths', () => { 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') + const outputConfig = createSdkClient(auth(), 'us-east-1', 'high', 'output_config') + const reasoning = createSdkClient(auth(), 'us-east-1', 'high', 'reasoning') + const reasoningAgain = createSdkClient(auth(), 'us-east-1', 'high', 'reasoning') expect(xhigh).not.toBe(max) expect(maxAgain).toBe(max) + expect(reasoning).not.toBe(outputConfig) + expect(reasoningAgain).toBe(reasoning) clearSdkClientCache() }) diff --git a/src/constants.ts b/src/constants.ts index fdb25f3..89e5d3e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -82,6 +82,10 @@ export const MODEL_MAPPING: Record = { 'claude-opus-5-thinking': 'claude-opus-5', // Auto auto: 'auto', + // OpenAI GPT-5.6 + 'gpt-5.6-sol': 'gpt-5.6-sol', + 'gpt-5.6-terra': 'gpt-5.6-terra', + 'gpt-5.6-luna': 'gpt-5.6-luna', // Open weight models 'deepseek-3.2': 'deepseek-3.2', 'glm-5': 'glm-5', diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index ace5cc3..87be3c8 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -2,6 +2,7 @@ import { GenerateAssistantResponseCommand } from '@aws/codewhisperer-streaming-c import type { AccountRepository } from '../../infrastructure/database/account-repository' import type { AccountManager } from '../../plugin/accounts' import type { KiroConfig } from '../../plugin/config' +import { buildEffortRequestFields } from '../../plugin/effort' import { isPermanentError } from '../../plugin/health' import * as logger from '../../plugin/logger' import { transformToSdkRequest } from '../../plugin/request' @@ -139,7 +140,12 @@ export class RequestHandler { this.logSdkRequest(sdkPrep, acc, apiTimestamp) } try { - const client = createSdkClient(auth, sdkPrep.region, sdkPrep.effort) + const client = createSdkClient( + auth, + sdkPrep.region, + sdkPrep.effort, + sdkPrep.effortSchemaPath + ) const command = new GenerateAssistantResponseCommand({ conversationState: sdkPrep.conversationState as any, profileArn: sdkPrep.profileArn @@ -278,9 +284,10 @@ 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 + const additionalModelRequestFields = + prep.effort && prep.effortSchemaPath + ? buildEffortRequestFields(prep.effort, prep.effortSchemaPath) + : undefined logger.logApiRequest( { diff --git a/src/infrastructure/transformers/history-builder.ts b/src/infrastructure/transformers/history-builder.ts index 951b914..4699d30 100644 --- a/src/infrastructure/transformers/history-builder.ts +++ b/src/infrastructure/transformers/history-builder.ts @@ -79,7 +79,11 @@ export function collapseAgenticLoops(history: CodeWhispererMessage[]): CodeWhisp return result } -export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessage[] { +export function buildHistory( + msgs: any[], + resolved: string, + includeThinkingTags = true +): CodeWhispererMessage[] { let history: CodeWhispererMessage[] = [] for (let i = 0; i < msgs.length - 1; i++) { const m = msgs[i] @@ -152,7 +156,7 @@ export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessag if (Array.isArray(m.content)) { for (const p of m.content) { if (p.type === 'text') arm.content += p.text || '' - else if (p.type === 'thinking') th += p.thinking || p.text || '' + else if (p.type === 'thinking' && includeThinkingTags) th += p.thinking || p.text || '' else if (p.type === 'tool_use') tus.push({ input: p.input, name: p.name, toolUseId: p.id }) } diff --git a/src/plugin/effort.ts b/src/plugin/effort.ts index 4a21e6c..08c763a 100644 --- a/src/plugin/effort.ts +++ b/src/plugin/effort.ts @@ -1,18 +1,14 @@ import type { Effort } from './config/schema' -/** - * Effort levels ordered from lowest to highest reasoning depth. - */ +export type EffortSchemaPath = 'output_config' | 'reasoning' + +/** Effort levels ordered from lowest to highest reasoning depth. */ 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. + * Reference thinking budget for each effort level. The values also define the + * inclusive bands used by budgetToEffort, keeping advertised variants and wire + * effort values in sync. */ export const THINKING_BUDGETS: Readonly> = { low: 16384, @@ -22,22 +18,20 @@ export const THINKING_BUDGETS: Readonly> = { max: 128000 } -/** - * Models that support the 5-value effort enum (including xhigh). - * Per Kiro's effort docs, this is opus-4.7/4.8/5 and sonnet-5. - */ +/** GPT-5.6 uses `reasoning.effort` and supports low through xhigh, but not max. */ +const GPT_REASONING_MODELS = new Set(['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) + +/** Models whose advertised effort enum includes xhigh. */ const XHIGH_CAPABLE_MODELS = new Set([ 'claude-opus-4.7', 'claude-opus-4.8', 'claude-opus-5', 'claude-sonnet-5', - 'claude-sonnet-5-1m' + 'claude-sonnet-5-1m', + ...GPT_REASONING_MODELS ]) -/** - * Models that support the 4-value effort enum (no xhigh). - * xhigh requests on these models are clamped to max. - */ +/** Models that accept an effort parameter through either supported schema path. */ const EFFORT_CAPABLE_MODELS = new Set([ 'claude-opus-4.5', 'claude-opus-4.6', @@ -49,64 +43,55 @@ const EFFORT_CAPABLE_MODELS = new Set([ ...XHIGH_CAPABLE_MODELS ]) -/** - * Check if a model supports the effort parameter. - */ export function supportsEffort(kiroModel: string): boolean { return EFFORT_CAPABLE_MODELS.has(kiroModel) } -/** - * Check if a model supports xhigh effort level. - */ export function supportsXHighEffort(kiroModel: string): boolean { return XHIGH_CAPABLE_MODELS.has(kiroModel) } -/** - * Resolve effort level for a given model. - * - Returns undefined if model doesn't support effort - * - Clamps xhigh to max for models that don't support it - */ -export function resolveEffort(kiroModel: string, requested: Effort): Effort | undefined { - if (!supportsEffort(kiroModel)) { - return undefined - } +export function usesReasoningEffortSchema(kiroModel: string): boolean { + return GPT_REASONING_MODELS.has(kiroModel) +} - // xhigh is only supported on the models in XHIGH_CAPABLE_MODELS - if (requested === 'xhigh' && !supportsXHighEffort(kiroModel)) { - return 'max' - } +/** Match Kiro CLI's schema-driven additionalModelRequestFields selection. */ +export function getEffortSchemaPath(kiroModel: string): EffortSchemaPath | undefined { + if (!supportsEffort(kiroModel)) return undefined + return usesReasoningEffortSchema(kiroModel) ? 'reasoning' : 'output_config' +} - return requested +/** Return only effort levels that the selected model advertises. */ +export function getSupportedEffortLevels(kiroModel: string): readonly Effort[] { + if (!supportsEffort(kiroModel)) return [] + if (usesReasoningEffortSchema(kiroModel)) return EFFORT_LEVELS.filter((level) => level !== 'max') + if (!supportsXHighEffort(kiroModel)) return EFFORT_LEVELS.filter((level) => level !== 'xhigh') + return EFFORT_LEVELS } /** - * Map OpenCode thinking budget to Kiro effort level. - * - * 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 - * - * 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 + * Resolve an effort level against model-specific capabilities. + * GPT maps the plugin's global `max` setting to its highest valid value, + * `xhigh`; Claude models without xhigh clamp xhigh to max. */ +export function resolveEffort(kiroModel: string, requested: Effort): Effort | undefined { + if (!supportsEffort(kiroModel)) return undefined + if (usesReasoningEffortSchema(kiroModel) && requested === 'max') return 'xhigh' + if (requested === 'xhigh' && !supportsXHighEffort(kiroModel)) return 'max' + return requested +} + +export function buildEffortRequestFields( + effort: Effort, + schemaPath: EffortSchemaPath +): Record { + return schemaPath === 'reasoning' ? { reasoning: { effort } } : { output_config: { effort } } +} + +/** Map an OpenCode thinking budget to a valid Kiro effort level. */ export function budgetToEffort(budget: number, kiroModel: string): Effort | undefined { - if (!supportsEffort(kiroModel)) { - return undefined - } + if (!supportsEffort(kiroModel)) return undefined - // 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]! @@ -115,13 +100,8 @@ export function budgetToEffort(budget: number, kiroModel: string): Effort | unde } /** - * Get the effective effort level based on config, budget, and model. - * - * Priority: - * 1. Explicit effort config (if set) - always applied regardless of thinking state - * 2. Budget-to-effort mapping (if auto_effort_mapping enabled and thinking) - * 3. 'medium' default (if thinking enabled) - * 4. undefined (if not thinking) + * Resolve effort by priority: explicit config, mapped thinking budget, medium + * fallback, or no field when reasoning is not enabled. */ export function getEffectiveEffort( kiroModel: string, @@ -130,25 +110,9 @@ export function getEffectiveEffort( configEffort?: Effort, autoEffortMapping = true ): Effort | undefined { - if (!supportsEffort(kiroModel)) { - return undefined - } - - // Explicit config takes precedence - always applied even without thinking - if (configEffort) { - return resolveEffort(kiroModel, configEffort) - } - - // If not thinking, no effort needed - if (!thinking) { - return undefined - } - - // Auto-map budget to effort - if (autoEffortMapping) { - return budgetToEffort(budget, kiroModel) - } - - // Default to medium when thinking without auto-mapping + if (!supportsEffort(kiroModel)) return undefined + if (configEffort) return resolveEffort(kiroModel, configEffort) + if (!thinking) return undefined + if (autoEffortMapping) return budgetToEffort(budget, kiroModel) return 'medium' } diff --git a/src/plugin/model-registry.ts b/src/plugin/model-registry.ts index b91936e..f281d51 100644 --- a/src/plugin/model-registry.ts +++ b/src/plugin/model-registry.ts @@ -1,4 +1,4 @@ -import { EFFORT_LEVELS, supportsEffort, supportsXHighEffort, THINKING_BUDGETS } from './effort.js' +import { getSupportedEffortLevels, supportsEffort, THINKING_BUDGETS } from './effort.js' import { resolveKiroModel } from './models.js' type Modalities = { @@ -21,19 +21,15 @@ interface ModelSpec { 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. + * `companion` emits a `-thinking` model alongside the base entry. `native` + * marks the base model itself as reasoning-capable (used by GPT-5.6). */ - thinking?: boolean + effort?: 'companion' | 'native' } /** - * 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. + * Models Kiro exposes, keyed by the OpenCode-facing model ID. Claude reasoning + * uses `output_config.effort`; GPT-5.6 reasoning uses `reasoning.effort`. */ const MODEL_SPECS: Record = { auto: { name: 'Auto', rate: '1.0x', limit: CONTEXT_200K, modalities: MULTIMODAL }, @@ -50,21 +46,21 @@ const MODEL_SPECS: Record = { rate: '1.3x', limit: CONTEXT_200K, modalities: MULTIMODAL, - thinking: true + effort: 'companion' }, 'claude-sonnet-4-6': { name: 'Claude Sonnet 4.6', rate: '1.3x', limit: CONTEXT_1M, modalities: MULTIMODAL, - thinking: true + effort: 'companion' }, 'claude-sonnet-5': { name: 'Claude Sonnet 5', rate: '1.3x', limit: CONTEXT_1M, modalities: MULTIMODAL, - thinking: true + effort: 'companion' }, // Claude Haiku @@ -81,35 +77,58 @@ const MODEL_SPECS: Record = { rate: '2.2x', limit: CONTEXT_200K, modalities: MULTIMODAL, - thinking: true + effort: 'companion' }, 'claude-opus-4-6': { name: 'Claude Opus 4.6', rate: '2.2x', limit: CONTEXT_1M, modalities: MULTIMODAL, - thinking: true + effort: 'companion' }, 'claude-opus-4-7': { name: 'Claude Opus 4.7', rate: '2.2x', limit: CONTEXT_1M, modalities: MULTIMODAL, - thinking: true + effort: 'companion' }, 'claude-opus-4-8': { name: 'Claude Opus 4.8', rate: '2.2x', limit: CONTEXT_1M, modalities: MULTIMODAL, - thinking: true + effort: 'companion' }, 'claude-opus-5': { name: 'Claude Opus 5', rate: '2.2x', limit: CONTEXT_1M, modalities: MULTIMODAL, - thinking: true + effort: 'companion' + }, + + // OpenAI GPT-5.6 + 'gpt-5.6-sol': { + name: 'GPT-5.6 Sol', + rate: '2.4x', + limit: { context: 272000, output: 64000 }, + modalities: TEXT_ONLY, + effort: 'native' + }, + 'gpt-5.6-terra': { + name: 'GPT-5.6 Terra', + rate: '1.0x', + limit: { context: 272000, output: 64000 }, + modalities: TEXT_ONLY, + effort: 'native' + }, + 'gpt-5.6-luna': { + name: 'GPT-5.6 Luna', + rate: '0.1x', + limit: { context: 272000, output: 64000 }, + modalities: TEXT_ONLY, + effort: 'native' }, // Open weight models @@ -149,8 +168,7 @@ const MODEL_SPECS: Record = { function buildVariants(kiroModel: string): Record { const variants: Record = {} - for (const level of EFFORT_LEVELS) { - if (level === 'xhigh' && !supportsXHighEffort(kiroModel)) continue + for (const level of getSupportedEffortLevels(kiroModel)) { variants[level] = { thinkingConfig: { thinkingBudget: THINKING_BUDGETS[level] } } } @@ -170,27 +188,40 @@ export function buildModelRegistry(): Record { const models: Record = {} for (const [modelID, spec] of Object.entries(MODEL_SPECS)) { - models[modelID] = { + const base: Record = { name: `${spec.name} (${spec.rate})`, limit: spec.limit, modalities: spec.modalities } - if (!spec.thinking) continue + if (!spec.effort) { + models[modelID] = base + 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 + if (!supportsEffort(kiroModel)) { + models[modelID] = base + continue + } - models[`${modelID}-thinking`] = { - name: `${spec.name} Thinking (${spec.rate})`, - limit: spec.limit, - modalities: spec.modalities, + const reasoning = { reasoning: true, interleaved: { field: 'reasoning_content' }, variants: buildVariants(kiroModel) } + + if (spec.effort === 'native') { + models[modelID] = { ...base, ...reasoning } + continue + } + + models[modelID] = base + models[`${modelID}-thinking`] = { + ...base, + name: `${spec.name} Thinking (${spec.rate})`, + ...reasoning + } } return models diff --git a/src/plugin/models.ts b/src/plugin/models.ts index c9aac7a..7867786 100644 --- a/src/plugin/models.ts +++ b/src/plugin/models.ts @@ -8,6 +8,9 @@ export function resolveKiroModel(model: string): string { return resolved } +const GPT_272K_MODELS = new Set(['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) + export function getContextWindowSize(model: string): number { + if (GPT_272K_MODELS.has(model)) return 272000 return isLongContextModel(model) ? 1000000 : 200000 } diff --git a/src/plugin/request.ts b/src/plugin/request.ts index 86b5bf9..2677e5d 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -17,7 +17,7 @@ import { createToolNameRegistry, deduplicateToolResults } from '../infrastructure/transformers/tool-transformer.js' -import { getEffectiveEffort } from './effort.js' +import { getEffectiveEffort, getEffortSchemaPath, usesReasoningEffortSchema } from './effort.js' import { convertImagesToKiroFormat, extractAllImages, @@ -67,7 +67,7 @@ function buildCodeWhispererRequest( const extractedSystem = systemMsgs.map((m: any) => getContentText(m)).join('\n\n') sys = sys ? `${sys}\n\n${extractedSystem}` : extractedSystem } - if (think) { + if (think && !usesReasoningEffortSchema(resolved)) { const pfx = `enabled${budget}` sys = sys.includes('') ? sys : sys ? `${pfx}\n${sys}` : pfx } @@ -77,7 +77,7 @@ function buildCodeWhispererRequest( const normalizedTools = Array.isArray(tools) ? tools : [] const toolNameRegistry = createToolNameRegistry(normalizedTools) const cwTools = convertToolsToCodeWhisperer(normalizedTools, toolNameRegistry) - let history = buildHistory(msgs, resolved) + let history = buildHistory(msgs, resolved, !usesReasoningEffortSchema(resolved)) const curMsg = msgs[msgs.length - 1] if (!curMsg) throw new Error('Empty') @@ -116,7 +116,8 @@ function buildCodeWhispererRequest( if (Array.isArray(curMsg.content)) { for (const p of curMsg.content) { if (p.type === 'text') arm.content += p.text || '' - else if (p.type === 'thinking') th += p.thinking || p.text || '' + else if (p.type === 'thinking' && !usesReasoningEffortSchema(resolved)) + th += p.thinking || p.text || '' else if (p.type === 'tool_use') { if (!arm.toolUses) arm.toolUses = [] arm.toolUses.push({ input: p.input, name: p.name, toolUseId: p.id }) @@ -367,6 +368,8 @@ export function transformToSdkRequest( effortConfig?.autoEffortMapping ?? true ) + const effortSchemaPath = effort ? getEffortSchemaPath(resolved) : undefined + return { conversationState: request.conversationState, profileArn: request.profileArn, @@ -375,6 +378,7 @@ export function transformToSdkRequest( conversationId: convId, region: extractRegionFromArn(auth.profileArn) ?? auth.region, toolNameMap, - effort + effort, + effortSchemaPath } } diff --git a/src/plugin/sdk-client.ts b/src/plugin/sdk-client.ts index 9ec4d56..3b40d4f 100644 --- a/src/plugin/sdk-client.ts +++ b/src/plugin/sdk-client.ts @@ -1,5 +1,6 @@ import { CodeWhispererStreamingClient } from '@aws/codewhisperer-streaming-client' import { KIRO_CONSTANTS } from '../constants.js' +import { buildEffortRequestFields, type EffortSchemaPath } from './effort.js' import type { Effort, KiroAuthDetails } from './types' /** @@ -10,6 +11,7 @@ interface ClientCacheEntry { client: CodeWhispererStreamingClient token: string effort?: Effort + effortSchemaPath?: EffortSchemaPath } const clientCache = new Map() @@ -18,12 +20,19 @@ const KIRO_CLI_MAX_ATTEMPTS = 3 export function createSdkClient( auth: KiroAuthDetails, region: string, - effort?: Effort + effort?: Effort, + effortSchemaPath?: EffortSchemaPath ): CodeWhispererStreamingClient { - const cacheKey = `${region}:${auth.email || 'default'}:${effort || 'none'}` + const resolvedSchemaPath = effort ? (effortSchemaPath ?? 'output_config') : undefined + const cacheKey = `${region}:${auth.email || 'default'}:${effort || 'none'}:${resolvedSchemaPath || 'none'}` const cached = clientCache.get(cacheKey) - if (cached && cached.token === auth.access && cached.effort === effort) { + if ( + cached && + cached.token === auth.access && + cached.effort === effort && + cached.effortSchemaPath === resolvedSchemaPath + ) { return cached.client } @@ -46,23 +55,18 @@ export function createSdkClient( { step: 'build', name: 'addKiroHeaders' } ) - // Inject additionalModelRequestFields for effort-based thinking control - if (effort) { + // Inject additionalModelRequestFields using the model's advertised schema path. + if (effort && resolvedSchemaPath) { client.middlewareStack.add( (next: any) => async (args: any) => { - // The SDK serializes input to args.input, we need to modify the body - // before it's sent. The body is in args.request.body as a string. if (args.request?.body) { try { const body = JSON.parse(args.request.body) - body.additionalModelRequestFields = { - output_config: { - effort - } - } + body.additionalModelRequestFields = buildEffortRequestFields(effort, resolvedSchemaPath) args.request.body = JSON.stringify(body) - } catch { - // If body parsing fails, continue without modification + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to inject Kiro effort configuration: ${detail}`) } } return next(args) @@ -71,7 +75,12 @@ export function createSdkClient( ) } - clientCache.set(cacheKey, { client, token, effort }) + clientCache.set(cacheKey, { + client, + token, + effort, + effortSchemaPath: resolvedSchemaPath + }) return client } diff --git a/src/plugin/types.ts b/src/plugin/types.ts index 9e7ae4e..2a39d56 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -124,8 +124,10 @@ export interface SdkPreparedRequest { conversationId: string region: string toolNameMap?: ToolNameMap - /** Resolved effort level for thinking models */ + /** Resolved effort level for thinking/reasoning models */ effort?: Effort + /** Kiro additionalModelRequestFields object that accepts the effort value. */ + effortSchemaPath?: 'output_config' | 'reasoning' } export type AccountSelectionStrategy = 'sticky' | 'round-robin' | 'lowest-usage'