#18408 · @GyuminJack · opened Mar 20, 2026 at 2:54 PM UTC · last updated Mar 21, 2026 at 12:36 PM UTC

feat: add Kiro provider with @ai-sdk/kiro SDK

appfeat
35
+43091323 files

Score breakdown

Impact

8.0

Clarity

8.0

Urgency

2.0

Ease Of Review

2.0

Guidelines

7.0

Readiness

3.0

Size

0.0

Trust

5.0

Traction

0.0

Summary

This PR adds Kiro as a new AI provider, integrating its SDK and handling Builder ID SSO authentication for several Claude models. It's a large feature that significantly expands OpenCode's capabilities.

Open in GitHub

Description

Issue for this PR

N/A — new feature, no existing issue.

Type of change

  • [ ] Bug fix
  • [x] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Adds Kiro as a provider in OpenCode. Kiro uses AWS CodeWhisperer-backed Claude models via Builder ID SSO auth.

Changes:

  • New @ai-sdk/kiro SDK under src/provider/kiro/ (converters, streaming, tokenizer, language model, provider factory)
  • Kiro plugin (src/plugin/kiro.ts) handling Builder ID SSO auth + token refresh
  • 7 models registered in provider database (Opus 4.5, Opus 4.6, Sonnet 4.5, Sonnet 4.5 v2, Sonnet 4.6, Haiku 4.5, Haiku 3.5)
  • Reasoning variants support in transform.ts

Built on top of work from:

Two main gotchas with Kiro's API: context window is ~200K tokens (exceeding it causes rejection), and there's no official docs on the exact request payload format — the opencode-kiro-auth repo was essential for figuring that out.

Thanks to both authors for their work 🙏 This is a baseline implementation — some edge cases may not be fully covered, so feedback is welcome.

How did you verify your code works?

  • bun typecheck passes (only pre-existing branded type warnings)
  • bun test — 1450 pass, 8 skip, 1 fail (pre-existing cowsay package issue, unrelated)
  • Manually tested model selection and chat with Kiro provider locally

Screenshots / recordings

N/A — no UI changes, provider shows up in existing model selection UI.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

None.

Comments

PR comments

duckida

Will it have support for other Kiro logins such as Sign in with Google?

Changed Files

bun.lock

+30
@@ -364,6 +364,7 @@
"hono": "catalog:",
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"js-tiktoken": "1.0.21",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
@@ -3334,6 +3335,8 @@
"js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="],
"js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],

packages/opencode/package.json

+10
@@ -129,6 +129,7 @@
"hono": "catalog:",
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"js-tiktoken": "1.0.21",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",

packages/opencode/src/acp/agent.ts

+140
@@ -1738,6 +1738,20 @@ export namespace ACP {
}
}
// Fallback: try extracting variant from hyphen-joined modelID (e.g., "claude-opus-4-6-high" -> model: "claude-opus-4-6", variant: "high")
const lastHyphen = parsed.modelID.lastIndexOf("-")
if (lastHyphen > 0) {
const candidateVariant = parsed.modelID.slice(lastHyphen + 1)
const baseModelId = parsed.modelID.slice(0, lastHyphen)
const baseModelInfo = provider.models[baseModelId]
if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) {
return {
model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) },
variant: candidateVariant,
}
}
}
return { model: parsed, variant: undefined }
}
}

packages/opencode/src/plugin/index.ts

+21
@@ -12,12 +12,13 @@ import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
import { KiroAuthPlugin } from "./kiro"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, KiroAuthPlugin]
const state = Instance.state(async () => {
const client = createOpencodeClient({

packages/opencode/src/plugin/kiro.ts

+2910
@@ -0,0 +1,291 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as path from "path"
import * as os from "os"
interface KiroToken {
access_token: string
expires_at: string
refresh_token: string
region: string
start_url: string
oauth_flow: string
scopes: string[]
}
interface KiroDeviceRegistration {
client_id: string
client_secret: string
client_secret_expires_at: string
region: string
oauth_flow: string
scopes: string[]
}
interface RefreshTokenResponse {
accessToken: string
expiresIn: number
refreshToken?: string
}
export function getKiroDbPath(): string {
switch (process.platform) {
case "darwin":
return path.join(os.homedir(), "Library/Application Support/kiro-cli/data.sqlite3")
case "win32":
return path.join(process.env.APPDATA || "", "kiro-cli/data.sqlite3")
default:
return path.join(os.homedir(), ".local/share/kiro-cli/data.sqlite3")
}
}
async function getKiroToken(): Promise<KiroToken | null> {
const dbPath = getKiroDbPath()
const file = Bun.file(dbPath)
if (!(await file.exists())) return null
try {
const { Database } = awa

packages/opencode/src/provider/provider.ts

+2936
@@ -30,6 +30,7 @@ import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
import { createKiro } from "./sdk/kiro/src"
import { createXai } from "@ai-sdk/xai"
import { createMistral } from "@ai-sdk/mistral"
import { createGroq } from "@ai-sdk/groq"
@@ -46,6 +47,7 @@ import { GoogleAuth } from "google-auth-library"
import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
import { ModelID, ProviderID } from "./schema"
import { getKiroDbPath } from "../plugin/kiro"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -127,6 +129,8 @@ export namespace Provider {
"@gitlab/gitlab-ai-provider": createGitLab,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
// @ts-ignore
"@ai-sdk/kiro": createKiro,
}
type CustomModelLoader = (sdk: any, modelID: string, options?: Reco

packages/opencode/src/provider/schema.ts

+10
@@ -23,6 +23,7 @@ export const ProviderID = providerIdSchema.pipe(
openrouter: schema.makeUnsafe("openrouter"),
mistral: schema.makeUnsafe("mistral"),
gitlab: schema.makeUnsafe("gitlab"),
kiro: schema.makeUnsafe("kiro"),
})),
)

packages/opencode/src/provider/sdk/kiro/src/converters.ts

+7330
@@ -0,0 +1,733 @@
import type {
LanguageModelV2FunctionTool,
LanguageModelV2Prompt,
LanguageModelV2ToolCallPart,
LanguageModelV2ToolResultPart,
} from "@ai-sdk/provider"
import { estimatePayloadTokens, countTokens } from "./tokenizer"
export interface KiroTool {
toolSpecification: {
name: string
description: string
inputSchema: { json: object }
}
}
export interface KiroToolResult {
content: Array<{ text: string }>
status: "success" | "error"
toolUseId: string
}
export interface KiroEnvState {
operatingSystem: string
currentWorkingDirectory: string
}
export interface KiroHistoryItem {
userInputMessage?: {
content: string
modelId: string
origin: string
userInputMessageContext?: {
tools?: KiroTool[]
toolResults?: KiroToolResult[]
envState?: KiroEnvState
}
}
assistantResponseMessage?: {
content: string
messageId?: string
modelId?: string
toolUses?: Array<{
name: string
toolUseId: string
input: unknown
}>
reasoning?: {
thinking?: string
}
}
}
export interface KiroPayload {
conversationState: {

packages/opencode/src/provider/sdk/kiro/src/index.ts

+20
@@ -0,0 +1,2 @@
export { createKiro } from "./kiro-provider"
export type { KiroProvider, KiroProviderSettings } from "./kiro-provider"

packages/opencode/src/provider/sdk/kiro/src/kiro-language-model.ts

+4690
@@ -0,0 +1,469 @@
import type {
LanguageModelV2,
LanguageModelV2CallWarning,
LanguageModelV2Content,
LanguageModelV2FinishReason,
LanguageModelV2FunctionTool,
LanguageModelV2StreamPart,
LanguageModelV2Usage,
} from "@ai-sdk/provider"
import type { FetchFunction } from "@ai-sdk/provider-utils"
import { convertToKiroPayload, type KiroProviderOptions } from "./converters"
import { normalizeModelName } from "./model-resolver"
import { parseAwsEventStream, type KiroEvent } from "./streaming"
import { estimatePayloadTokens, countTokens } from "./tokenizer"
export interface KiroLanguageModelConfig {
provider: string
apiKey?: string
baseURL: string
headers?: Record<string, string>
fetch?: FetchFunction
}
function headersToRecord(headers: Headers): Record<string, string> {
const result: Record<string, string> = {}
headers.forEach((value, key) => {
result[key] = value
})
return result
}
export class KiroLanguageModel implements LanguageModelV2 {
readonly specificationVersion = "v2"
readonly modelId: string
private readonly config: KiroLanguageModelConfig
readonly supportedUrls: Record<string, RegExp[]> = {

packages/opencode/src/provider/sdk/kiro/src/kiro-provider.ts

+360
@@ -0,0 +1,36 @@
import type { LanguageModelV2 } from "@ai-sdk/provider"
import type { FetchFunction } from "@ai-sdk/provider-utils"
import { KiroLanguageModel } from "./kiro-language-model"
export interface KiroProviderSettings {
apiKey?: string
baseURL?: string
region?: string
headers?: Record<string, string>
fetch?: FetchFunction
}
export interface KiroProvider {
(modelId: string): LanguageModelV2
languageModel(modelId: string): LanguageModelV2
}
export function createKiro(options: KiroProviderSettings = {}): KiroProvider {
const region = options.region ?? "us-east-1"
const baseURL = options.baseURL ?? `https://codewhisperer.${region}.amazonaws.com`
const createLanguageModel = (modelId: string): LanguageModelV2 => {
return new KiroLanguageModel(modelId, {
provider: "kiro",
apiKey: options.apiKey,
baseURL,
headers: options.headers,
fetch: options.fetch,
})
}
const provider = (modelId: string): LanguageModelV2 => createLanguageModel(modelId)
provider.languageModel = createLanguageModel
return provider as KiroProvider
}

packages/opencode/src/provider/sdk/kiro/src/model-resolver.ts

+150
@@ -0,0 +1,15 @@
const HIDDEN_MODELS: Record<string, string> = {
"claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0",
"claude-3-7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0",
}
export function normalizeModelName(name: string): string {
// Convert model names like claude-sonnet-4-5 → claude-sonnet-4.5
// or claude-haiku-4-5-20251001 → claude-haiku-4.5
const normalized = name
.toLowerCase()
.replace(/-(\d+)-(\d{1,2})(?:-(?:\d{8}|latest))?$/, "-$1.$2") // 4-5 → 4.5
.replace(/-(\d+)(?:-\d{8})?$/, "-$1") // 4-20250514 → 4
return HIDDEN_MODELS[normalized] ?? normalized
}

packages/opencode/src/provider/sdk/kiro/src/streaming.ts

+6310
@@ -0,0 +1,631 @@
export type KiroEventType =
| "content"
| "tool_start"
| "tool_input"
| "tool_stop"
| "thinking_start"
| "thinking"
| "thinking_stop"
| "usage"
| "context_usage"
| "done"
| "error"
export interface KiroContentEvent {
type: "content"
content: string
}
export interface KiroToolStartEvent {
type: "tool_start"
name: string
toolUseId: string
}
export interface KiroToolInputEvent {
type: "tool_input"
toolUseId: string
input: string
}
export interface KiroToolStopEvent {
type: "tool_stop"
toolUseId: string
input: unknown
}
export interface KiroThinkingStartEvent {
type: "thinking_start"
}
export interface KiroThinkingEvent {
type: "thinking"
thinking: string
}
export interface KiroThinkingStopEvent {
type: "thinking_stop"
}
export interface KiroUsageEvent {
type: "usage"
inputTokens: number
outputTokens: number
}
export interface KiroContextUsageEvent {
type: "context_usage"
percentage: number
}
export interface KiroDoneEvent {
type: "done"
}
export interface KiroErrorEvent {
type: "error"
error: string
}
export type KiroEv

packages/opencode/src/provider/sdk/kiro/src/tokenizer.ts

+1070
@@ -0,0 +1,107 @@
import { encodingForModel } from "js-tiktoken"
import type { KiroPayload } from "./converters"
let encoding: ReturnType<typeof encodingForModel> | undefined
function getEncoding() {
if (!encoding) encoding = encodingForModel("gpt-4o")
return encoding
}
// Raw GPT-4o token count — no correction factor applied.
// Correction is applied at the payload level in estimatePayloadTokens.
export function countTokens(text: string) {
if (!text) return 0
return getEncoding().encode(text).length
}
// Empirical constants derived from binary-search boundary tests against the Kiro API.
// The server rejected payloads at these exact byte thresholds:
// 0 tools → 812,219 bytes 10 tools → 808,395 bytes
// Server limit ≈ 200K tokens (server-side tokenizer, not publicly available).
//
// Tool overhead (server-side) is non-linear:
// 1 tool: 531 server tokens for 54 raw tokens (9.8x)
// 10 tools: 2,443 server tokens for 540 raw tokens (4.5x)
// Model: TOOL_FIXED_OVERHEAD + TOOL_PER_OVERHEAD * rawToolTokens
const TOOL_FIXED_OVERHEAD = 350
const TOOL_PER_MULTIPLIER = 3.5
// The server tokenizer differs from gpt-4o by content type:
//

packages/opencode/src/provider/transform.ts

+160
@@ -707,6 +707,22 @@ export namespace ProviderTransform {
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
}
return {}
case "@ai-sdk/kiro":
return {
high: {
thinking: {
type: "enabled",
budgetTokens: 16000,
},
},
max: {
thinking: {
type: "enabled",
budgetTokens: 31999,
},
},
}
}
return {}
}

packages/opencode/src/session/message-v2.ts

+75
@@ -295,9 +295,9 @@ export namespace MessageV2 {
.object({
status: z.literal("completed"),
input: z.record(z.string(), z.any()),
output: z.string(),
title: z.string(),
metadata: z.record(z.string(), z.any()),
output: z.string().default(""),
title: z.string().default(""),
metadata: z.record(z.string(), z.any()).default({}),
time: z.object({
start: z.number(),
end: z.number(),
@@ -697,8 +697,10 @@ export namespace MessageV2 {
if (part.type === "tool") {
toolNames.add(part.tool)
if (part.state.status === "completed") {
const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
const outputText = part.state.time.compacted
? "[Tool output removed during compaction. Re-run this tool if you need the full result.]"
: part.state.output
const attachments = part.state.time.compacted ? [] : (part.state.attachments ?? [])

packages/opencode/test/plugin/kiro.test.ts

+400
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { getKiroDbPath } from "../../src/plugin/kiro"
describe("plugin.kiro", () => {
describe("getKiroDbPath", () => {
test("returns correct path for macOS", () => {
// Note: This test will only pass on macOS
if (process.platform === "darwin") {
const path = getKiroDbPath()
expect(path).toContain("Library/Application Support/kiro-cli/data.sqlite3")
expect(path).toMatch(/^\/Users\//)
}
})
test("returns correct path for Windows", () => {
// Note: This test will only pass on Windows
if (process.platform === "win32") {
const path = getKiroDbPath()
expect(path).toContain("kiro-cli/data.sqlite3")
expect(path).toContain("AppData")
}
})
test("returns correct path for Linux", () => {
// Note: This test will only pass on Linux
if (process.platform === "linux") {
const path = getKiroDbPath()
expect(path).toContain(".local/share/kiro-cli/data.sqlite3")
}
})
test("returns a non-empty string", () => {
const path = getKiroDbPath()
expe

packages/opencode/test/provider/kiro-compaction.test.ts

+2370
@@ -0,0 +1,237 @@
import { describe, expect, test } from "bun:test"
import { convertToKiroPayload } from "../../src/provider/sdk/kiro/src/converters"
describe("kiro compaction: tool calls in history + no tools", () => {
const modelId = "claude-opus-4.6"
function buildCompactionPrompt() {
return [
{ role: "system" as const, content: "You are a helpful assistant" },
{ role: "user" as const, content: [{ type: "text" as const, text: "List files in current directory" }] },
{
role: "assistant" as const,
content: [
{ type: "text" as const, text: "I'll list the files for you." },
{ type: "tool-call" as const, toolCallId: "call_001", toolName: "bash", input: { command: "ls" } },
],
},
{
role: "tool" as const,
content: [
{
type: "tool-result" as const,
toolCallId: "call_001",
toolName: "bash",
output: { type: "text" as const, value: "file1.txt\nfile2.txt\nREADME.md" },
},
],
},
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "Found 3 file

packages/opencode/test/provider/kiro-provider.test.ts

+3690
@@ -0,0 +1,369 @@
import { test, expect, mock } from "bun:test"
import path from "path"
// === Mocks ===
mock.module("../../src/bun/index", () => ({
BunProc: {
install: async (pkg: string, _version?: string) => {
const lastAtIndex = pkg.lastIndexOf("@")
return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
},
run: async () => {
throw new Error("BunProc.run should not be called in tests")
},
which: () => process.execPath,
InstallFailedError: class extends Error {},
},
}))
const mockPlugin = async () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin, gitlabAuthPlugin: mockPlugin }))
const { tmpdir } = await import("../fixture/fixture")
const { Instance } = await import("../../src/project/instance")
const { Provider } = await import("../../src/provider/provider")
const { ProviderID, ModelID } = await import("../../src/provider/schema")
test("Kiro: provider is registered in database with correct models", async () => {
await usin

packages/opencode/test/provider/kiro-tool-pairing.test.ts

+4680
@@ -0,0 +1,468 @@
import { describe, expect, test } from "bun:test"
import { convertToKiroPayload } from "../../src/provider/sdk/kiro/src/converters"
/**
* Validates that every toolUse in history has a matching toolResult and vice versa.
* This is what Kiro API enforces — any mismatch causes 400 "Improperly formed request".
*/
function assertToolPairing(payload: ReturnType<typeof convertToKiroPayload>) {
const history = payload.conversationState.history
const current = payload.conversationState.currentMessage
for (let i = 0; i < history.length; i++) {
const item = history[i]
const uses = item.assistantResponseMessage?.toolUses ?? []
if (uses.length === 0) continue
// Find the next user message (should be i+1 in alternating structure)
const next = history[i + 1]
const results = next?.userInputMessage?.userInputMessageContext?.toolResults ?? []
// Every toolUse must have a matching toolResult
for (const use of uses) {
const match = results.find((r) => r.toolUseId === use.toolUseId)
if (!match) {
// Check if it's the last history item and results are in currentMessage
if (i === history.l

packages/opencode/test/provider/kiro.test.ts

+5690
@@ -0,0 +1,569 @@
import { describe, expect, test } from "bun:test"
import { convertToKiroPayload } from "../../src/provider/sdk/kiro/src/converters"
import { normalizeModelName } from "../../src/provider/sdk/kiro/src/model-resolver"
import { parseAwsEventStream } from "../../src/provider/sdk/kiro/src/streaming"
describe("normalizeModelName", () => {
test("converts claude-sonnet-4-5 to claude-sonnet-4.5", () => {
expect(normalizeModelName("claude-sonnet-4-5")).toBe("claude-sonnet-4.5")
})
test("converts claude-haiku-4-5 to claude-haiku-4.5", () => {
expect(normalizeModelName("claude-haiku-4-5")).toBe("claude-haiku-4.5")
})
test("converts claude-opus-4-5 to claude-opus-4.5", () => {
expect(normalizeModelName("claude-opus-4-5")).toBe("claude-opus-4.5")
})
test("converts claude-sonnet-4 to claude-sonnet-4", () => {
expect(normalizeModelName("claude-sonnet-4")).toBe("claude-sonnet-4")
})
test("handles model with date suffix", () => {
expect(normalizeModelName("claude-sonnet-4-5-20251001")).toBe("claude-sonnet-4.5")
})
test("maps claude-3-7-sonnet to hidden model ID", () => {
expect(normalizeModelName("claud

packages/opencode/test/session/compaction.test.ts

+10
@@ -6,6 +6,7 @@ import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
import { Session } from "../../src/session"
import { Identifier } from "../../src/id/id"
import type { Provider } from "../../src/provider/provider"
Log.init({ print: false })

packages/opencode/test/session/message-v2.test.ts

+41
@@ -494,7 +494,10 @@ describe("session.message-v2.toModelMessage", () => {
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
output: { type: "text", value: "[Old tool result content cleared]" },
output: {
type: "text",
value: "[Tool output removed during compaction. Re-run this tool if you need the full result.]",
},
},
],
},