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
55 changes: 51 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,51 @@ models with substantial trial quotas.

## Installation

Add the plugin to your `opencode.json` or `opencode.jsonc`:
The same package works on OpenCode V1 (1.18.29 or newer) and OpenCode V2. Add it
to your `opencode.json` or `opencode.jsonc`.

OpenCode V2:

```json
{
"plugins": ["@zhafron/opencode-kiro-auth"]
}
```

OpenCode V1:

```json
{
"plugin": ["@zhafron/opencode-kiro-auth"]
}
```

V2 also accepts the V1 `plugin` key, so an existing config keeps working after
upgrading OpenCode.

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.
### OpenCode V2 differences

- Pick a thinking level with the model reference, e.g.
`kiro/claude-opus-5-thinking#xhigh`, or cycle variants in the UI.
- The `reasoning` / `interleaved` flags described below are V1-only. V2 ignores
them and renders Kiro's reasoning without any model flags.
- V2 server plugins cannot show toasts. Usage summaries and re-authentication
notices go to `~/.config/opencode/kiro-logs/plugin.log` instead.
- Automatic re-authentication from inside a request is V1-only. On V2, run
`opencode auth login kiro` (or `kiro-cli login`) when the plugin reports that
all accounts need re-authentication.
- Leave `npm` / `package` out of any `provider.kiro` entry. On V2 the plugin
pins its own package per model, but older docs suggested setting `npm`, and
you no longer need it on either version.

On V1, 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. On V2, models you define are merged
onto the plugin's registry instead.

### Thinking Effort Configuration

Expand All @@ -70,6 +100,23 @@ 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.

On OpenCode V2, a native `providers.kiro.models` override needs no reasoning
flags. Put the budget in each variant's `settings`:

```json
{
"providers": {
"kiro": {
"models": {
"claude-opus-5-thinking": {
"variants": [{ "id": "high", "settings": { "thinkingConfig": { "thinkingBudget": 65536 } } }]
}
}
}
}
}
```

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
Expand Down
726 changes: 722 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node scripts/fix-esm-imports.mjs",
"format": "prettier --write 'src/**/*.ts'",
Expand Down Expand Up @@ -32,13 +40,15 @@
"access": "public"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.87",
"@aws/codewhisperer-streaming-client": "^1.0.34",
"@opencode-ai/plugin": "^1.15.11",
"libsql": "^0.5.29",
"proper-lockfile": "^4.1.2",
"zod": "^3.24.0"
"zod": "^3.25.76"
},
"devDependencies": {
"@opencode/plugin": "^2.2.1",
"@types/node": "^20.0.0",
"@types/proper-lockfile": "^4.1.4",
"bun-types": "^1.3.6",
Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/plugin-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ describe('package plugin module', () => {
test('uses the kiro provider id in the default export', () => {
expect(pluginModule.id).toBe('kiro')
})

test('exposes both the V2 setup and the V1 server entrypoints', () => {
expect(typeof pluginModule.setup).toBe('function')
expect(typeof pluginModule.server).toBe('function')
})
})
172 changes: 172 additions & 0 deletions src/__tests__/plugin-v2-setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, expect, test } from 'bun:test'
import { KIRO_V2_PACKAGE, setupKiroV2 } from '../plugin-v2.js'
import type { KiroRuntime } from '../runtime.js'

const BASE_URL = 'https://q.us-east-1.amazonaws.com'

/** A plugin context that records every transform and hook callback. */
function fakeContext() {
const recorded = {
integration: [] as Array<(editor: any) => void>,
provider: [] as Array<(editor: any) => void>,
tool: [] as Array<(editor: any) => void>,
sdkHooks: [] as Array<{ callback: (event: any) => void; options: any }>,
toolReloads: 0
}
const registration = { dispose: async () => {} }
const ctx = {
location: { directory: '/tmp/project' },
integration: {
transform: async (cb: any) => (recorded.integration.push(cb), registration)
},
provider: { transform: async (cb: any) => (recorded.provider.push(cb), registration) },
tool: {
transform: async (cb: any) => (recorded.tool.push(cb), registration),
reload: async () => {
recorded.toolReloads++
}
},
aisdk: {
hook: async (name: string, callback: any, options: any) => {
if (name === 'sdk') recorded.sdkHooks.push({ callback, options })
return registration
}
}
}
return { ctx: ctx as any, recorded }
}

function fakeRuntime(input: { profileArn?: string; webSearch?: boolean } = {}) {
const requests: Array<{ url: string; body: any }> = []
const runtime = {
config: { web_search_enabled: input.webSearch ?? true, default_region: 'us-east-1' },
repository: {},
accountManager: {
getCurrentOrNext: () => (input.profileArn ? { profileArn: input.profileArn } : { id: 'a' })
},
authHandler: { initialize: async () => {} },
requestHandler: {
handle: async (url: any, init: any) => {
requests.push({ url: String(url), body: JSON.parse(init.body) })
return new Response('{}', { status: 500 })
}
},
baseURL: BASE_URL
} as unknown as KiroRuntime
return { runtime, requests }
}

/** Replay a recorded transform against a minimal editor and capture its writes. */
function runEditor(callbacks: Array<(editor: any) => void>) {
const added: any[] = []
const methods: any[] = []
const integrations = new Map<string, any>()
const editor = {
add: (value: any) => added.push(value),
update: (id: string, update: (value: any) => void) => {
const current = integrations.get(id) ?? { id, name: id }
update(current)
integrations.set(id, current)
},
method: { update: (value: any) => methods.push(value) }
}
for (const cb of callbacks) cb(editor)
return { added, methods, integrations }
}

const flush = () => new Promise((resolve) => setTimeout(resolve, 0))

describe('setupKiroV2', () => {
test('registers the kiro provider with every model pinned to the plugin SDK', async () => {
const { ctx, recorded } = fakeContext()
await setupKiroV2(ctx, async () => fakeRuntime().runtime)

const { added } = runEditor(recorded.provider)
expect(added).toHaveLength(1)
expect(added[0].info).toMatchObject({
id: 'kiro',
activation: 'enabled',
package: KIRO_V2_PACKAGE
})
expect(added[0].models.length).toBeGreaterThan(0)
for (const model of added[0].models) expect(model.package).toBe(KIRO_V2_PACKAGE)
})

test('registers both login methods with forms V2 can validate', async () => {
const { ctx, recorded } = fakeContext()
await setupKiroV2(ctx, async () => fakeRuntime().runtime)

const { methods, integrations } = runEditor(recorded.integration)
expect(integrations.get('kiro')?.name).toBe('Kiro')
expect(methods.map((m) => m.method.id)).toEqual(['idc', 'idc-profile'])

const keys = (m: any) => m.method.form.map((f: any) => f.key)
expect(keys(methods[0])).toEqual(['start_url', 'idc_region'])
expect(keys(methods[1])).toEqual(['start_url', 'idc_region', 'profile_arn'])

for (const method of methods) {
expect(method.integrationID).toBe('kiro')
for (const field of method.method.form) {
expect(field.type).toBe('string')
// Every pattern must accept a blank answer, which means "use the default".
expect(new RegExp(field.pattern).test('')).toBe(true)
}
}
})

test('rejects invalid login input before starting a device flow', async () => {
const { ctx, recorded } = fakeContext()
await setupKiroV2(ctx, async () => fakeRuntime().runtime)
const [idc] = runEditor(recorded.integration).methods

const field = (key: string) => idc.method.form.find((f: any) => f.key === key)
expect(new RegExp(field('start_url').pattern).test('not-a-url')).toBe(false)
expect(new RegExp(field('idc_region').pattern).test('mars')).toBe(false)
await expect(idc.authorize({ start_url: 'not-a-url' })).rejects.toThrow('valid URL')
await expect(idc.authorize({ idc_region: 'mars' })).rejects.toThrow('valid AWS region')
})

test('the sdk hook only claims kiro models and routes them through the request handler', async () => {
const { ctx, recorded } = fakeContext()
const { runtime, requests } = fakeRuntime()
await setupKiroV2(ctx, async () => runtime)

expect(recorded.sdkHooks).toHaveLength(1)
const [{ callback, options }] = recorded.sdkHooks as [any]
expect(options).toEqual({ providerID: 'kiro' })

const other = { model: { providerID: 'anthropic' }, options: {} } as any
callback(other)
expect(other.sdk).toBeUndefined()

const event = {
model: { providerID: 'kiro', body: { thinkingConfig: { thinkingBudget: 1024 } } },
options: { baseURL: BASE_URL }
} as any
callback(event)
expect(typeof event.sdk?.languageModel).toBe('function')

await event.sdk
.languageModel('claude-opus-5-thinking')
.doGenerate({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }] })
.catch(() => {})
expect(requests).toHaveLength(1)
expect(requests[0]!.url).toBe(`${BASE_URL}/chat/completions`)
expect(requests[0]!.body.thinkingConfig).toEqual({ thinkingBudget: 1024 })
})

test('offers web search only to Pro accounts and reloads tools once accounts sync', async () => {
const free = fakeContext()
await setupKiroV2(free.ctx, async () => fakeRuntime().runtime)
expect(runEditor(free.recorded.tool).added).toHaveLength(0)

const pro = fakeContext()
await setupKiroV2(pro.ctx, async () => fakeRuntime({ profileArn: 'arn:aws:x' }).runtime)
const tools = runEditor(pro.recorded.tool).added
expect(tools.map((t) => t.name)).toEqual(['kiro_web_search'])
expect(tools[0].input.required).toEqual(['query'])

await flush()
expect(pro.recorded.toolReloads).toBe(1)
})
})
Loading