Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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 `<thinking_mode>` 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`
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/effort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test'
import {
budgetToEffort,
getEffectiveEffort,
getEffortSchemaPath,
getSupportedEffortLevels,
resolveEffort,
supportsEffort,
supportsXHighEffort
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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()
Expand Down
89 changes: 89 additions & 0 deletions src/__tests__/gpt-request.test.ts
Original file line number Diff line number Diff line change
@@ -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('<thinking_mode>')
expect(JSON.stringify(prepared.conversationState)).not.toContain('<max_thinking_length>')
})

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('<thinking>')
expect(priorSerialized).not.toContain('hidden prior reasoning')
expect(priorSerialized).toContain('Prior answer')
expect(trailingSerialized).not.toContain('<thinking>')
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('<thinking_mode>enabled</thinking_mode>')
expect(serialized).toContain('<max_thinking_length>98304</max_thinking_length>')
})
})
56 changes: 43 additions & 13 deletions src/__tests__/model-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ import { resolveKiroModel } from '../plugin/models.js'
const registry = buildModelRegistry() as Record<string, any>

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', () => {
Expand All @@ -37,42 +43,66 @@ 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()
}
})

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) {
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<any>(registry[id].variants)) {
const level = name as Effort
Expand All @@ -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<any>(registry[id].variants).map(
(v) => v.thinkingConfig.thinkingBudget
)
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/model-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 20 additions & 2 deletions src/__tests__/native-reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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: {} },
Expand Down
55 changes: 54 additions & 1 deletion src/__tests__/sdk-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down
Loading