From ccf7466c7434b57a68e130d3617aa140c8e41ca6 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Tue, 19 May 2026 11:02:34 +0200 Subject: [PATCH 01/20] fix: last character(s) missing at end of streamed response TextDecoder in streaming mode buffers incomplete multi-byte sequences. Added explicit flush after the read loop so nothing gets dropped. --- src/plugin/streaming/stream-transformer.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/plugin/streaming/stream-transformer.ts b/src/plugin/streaming/stream-transformer.ts index afa9f81..404c94b 100644 --- a/src/plugin/streaming/stream-transformer.ts +++ b/src/plugin/streaming/stream-transformer.ts @@ -43,7 +43,13 @@ export async function* transformKiroStream( try { while (true) { const { done, value } = await reader.read() - if (done) break + if (done) { + // Flush any bytes buffered inside the TextDecoder for incomplete + // multi-byte UTF-8 sequences at the end of the stream. + const tail = decoder.decode() + if (tail) rawBuffer += tail + break + } const chunk = decoder.decode(value, { stream: true }) rawBuffer += chunk From 5d0bc568e735511c08a79e074ada9a8973c21f1e Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Tue, 19 May 2026 11:02:41 +0200 Subject: [PATCH 02/20] feat: stable conversationId scoped to workspace and thread Was using crypto.randomUUID() on every request, so each tool call counted as a new Kiro session. Now persisting the ID in kiro.db keyed on workspace + first user message, so threads survive restarts. Also trigger reauth when all accounts are permanently unhealthy instead of throwing immediately. --- src/core/request/request-handler.ts | 10 ++++- src/plugin.ts | 2 +- src/plugin/request.ts | 59 ++++++++++++++++++++++++--- src/plugin/storage/migrations.ts | 19 +++++++++ src/plugin/storage/sqlite.ts | 63 +++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 9 deletions(-) diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index 2f05af7..915a52b 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -34,7 +34,8 @@ export class RequestHandler { private accountManager: AccountManager, private config: KiroConfig, private repository: AccountRepository, - private client?: any + private client?: any, + private workspace = '' ) { this.accountSelector = new AccountSelector(accountManager, config, syncFromKiroCli, repository) this.tokenRefresher = new TokenRefresher(config, accountManager, syncFromKiroCli, repository) @@ -172,6 +173,11 @@ export class RequestHandler { continue } + if (this.allAccountsPermanentlyUnhealthy()) { + const reauthed = await this.triggerReauth(showToast) + if (reauthed) continue + } + throw new Error(`Kiro Error: ${httpStatus}`) } @@ -201,7 +207,7 @@ export class RequestHandler { budget: number, showToast?: (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void ): SdkPreparedRequest { - return transformToSdkRequest(body, model, auth, think, budget, showToast) + return transformToSdkRequest(body, model, auth, think, budget, showToast, this.workspace) } private handleSuccessfulRequest(acc: ManagedAccount): void { diff --git a/src/plugin.ts b/src/plugin.ts index 8006ea8..a1cc96b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -26,7 +26,7 @@ export const createKiroPlugin = const accountManager = await AccountManager.loadFromDisk(config.account_selection_strategy) authHandler.setAccountManager(accountManager) - const requestHandler = new RequestHandler(accountManager, config, repository, client) + const requestHandler = new RequestHandler(accountManager, config, repository, client, directory) return { config: async (input: any) => { diff --git a/src/plugin/request.ts b/src/plugin/request.ts index 71542f7..999110e 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -22,6 +22,7 @@ import { extractTextFromParts } from './image-handler.js' import { resolveKiroModel } from './models.js' +import { kiroDb } from './storage/sqlite.js' import type { CodeWhispererRequest, KiroAuthDetails, @@ -29,6 +30,29 @@ import type { SdkPreparedRequest } from './types' +// Stable conversationId per thread — fingerprint is SHA-256(workspace + firstUserContent). +// New thread always gets a fresh UUID; continuation reuses what's in the DB. +function deriveConversationId( + workspace: string, + firstUserContent: string, + isNewThread: boolean +): string { + const fingerprint = crypto + .createHash('sha256') + .update(workspace + '\0' + (firstUserContent || '_empty_')) + .digest('hex') + .slice(0, 32) + + if (!isNewThread) { + const existing = kiroDb.getConversationId(workspace, fingerprint) + if (existing) return existing + } + + const id = crypto.randomUUID() + kiroDb.setConversationId(workspace, fingerprint, id) + return id +} + interface TransformResult { request: CodeWhispererRequest resolved: string @@ -43,12 +67,24 @@ function buildCodeWhispererRequest( auth: KiroAuthDetails, think = false, budget = 20000, - showToast?: ToastFunction + showToast?: ToastFunction, + workspace = '' ): TransformResult { const req = typeof body === 'string' ? JSON.parse(body) : body const { messages, tools, system } = req - const convId = crypto.randomUUID() if (!messages || messages.length === 0) throw new Error('No messages') + + // Keep the same conversationId across all turns in a thread (including tool calls) + // so Kiro treats them as one session. New thread = fresh UUID, continuation = reuse from DB. + const nonSystemMessages = messages.filter((m: any) => m.role !== 'system') + const isNewThread = nonSystemMessages.length === 1 + const firstUserMsg = nonSystemMessages.find((m: any) => m.role === 'user') + const firstUserContent = firstUserMsg + ? typeof firstUserMsg.content === 'string' + ? firstUserMsg.content + : JSON.stringify(firstUserMsg.content) + : '' + const convId = deriveConversationId(workspace, firstUserContent, isNewThread) const resolved = resolveKiroModel(model) const systemMsgs = messages.filter((m: any) => m.role === 'system') const otherMsgs = messages.filter((m: any) => m.role !== 'system') @@ -279,9 +315,18 @@ export function transformToCodeWhisperer( model: string, auth: KiroAuthDetails, think = false, - budget = 20000 + budget = 20000, + workspace = '' ): PreparedRequest { - const { request, resolved, convId } = buildCodeWhispererRequest(body, model, auth, think, budget) + const { request, resolved, convId } = buildCodeWhispererRequest( + body, + model, + auth, + think, + budget, + undefined, + workspace + ) const osP = os.platform(), osR = os.release(), nodeV = process.version.replace('v', '') @@ -317,7 +362,8 @@ export function transformToSdkRequest( auth: KiroAuthDetails, think = false, budget = 20000, - showToast?: ToastFunction + showToast?: ToastFunction, + workspace = '' ): SdkPreparedRequest { const { request, resolved, convId } = buildCodeWhispererRequest( body, @@ -325,7 +371,8 @@ export function transformToSdkRequest( auth, think, budget, - showToast + showToast, + workspace ) return { conversationState: request.conversationState, diff --git a/src/plugin/storage/migrations.ts b/src/plugin/storage/migrations.ts index b892b64..21ca1b8 100644 --- a/src/plugin/storage/migrations.ts +++ b/src/plugin/storage/migrations.ts @@ -7,6 +7,25 @@ export function runMigrations(db: Database): void { migrateStartUrlColumn(db) migrateOidcRegionColumn(db) migrateDropRefreshTokenUniqueIndex(db) + migrateConversationsTable(db) +} + +function migrateConversationsTable(db: Database): void { + const hasTable = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'") + .get() + if (hasTable) return + + db.run(` + CREATE TABLE conversations ( + workspace TEXT NOT NULL, + fingerprint TEXT NOT NULL, + conv_id TEXT NOT NULL, + last_used INTEGER NOT NULL, + PRIMARY KEY (workspace, fingerprint) + ) + `) + db.run('CREATE INDEX idx_conversations_last_used ON conversations(last_used)') } function migrateToUniqueRefreshToken(db: Database): void { diff --git a/src/plugin/storage/sqlite.ts b/src/plugin/storage/sqlite.ts index eefc69a..b986909 100644 --- a/src/plugin/storage/sqlite.ts +++ b/src/plugin/storage/sqlite.ts @@ -137,6 +137,34 @@ export class KiroDatabase { }) } + async deleteStaleIdcDuplicates( + canonicalId: string, + email: string, + profileArn: string + ): Promise { + await withDatabaseLock(this.path, async () => { + this.db + .prepare( + `DELETE FROM accounts + WHERE auth_method = 'idc' + AND email = ? + AND profile_arn = ? + AND id != ?` + ) + .run(email, profileArn, canonicalId) + // Also clean up placeholder rows for the same profileArn. + this.db + .prepare( + `DELETE FROM accounts + WHERE auth_method = 'idc' + AND profile_arn = ? + AND email LIKE 'placeholder-%' + AND id != ?` + ) + .run(profileArn, canonicalId) + }) + } + private rowToAccount(row: any): ManagedAccount { return { id: row.id, @@ -166,6 +194,41 @@ export class KiroDatabase { close() { this.db.close() } + + getConversationId(workspace: string, fingerprint: string): string | undefined { + const row = this.db + .prepare('SELECT conv_id FROM conversations WHERE workspace = ? AND fingerprint = ?') + .get(workspace, fingerprint) as { conv_id: string } | undefined + if (row) { + this.db + .prepare('UPDATE conversations SET last_used = ? WHERE workspace = ? AND fingerprint = ?') + .run(Date.now(), workspace, fingerprint) + } + return row?.conv_id + } + + /** + * Persist a conversationId and clean up entries older than ttlDays (default 7). + */ + setConversationId(workspace: string, fingerprint: string, convId: string, ttlDays = 7): void { + const now = Date.now() + const cutoff = now - ttlDays * 24 * 60 * 60 * 1000 + this.db.run('BEGIN TRANSACTION') + try { + this.db + .prepare( + `INSERT INTO conversations (workspace, fingerprint, conv_id, last_used) + VALUES (?, ?, ?, ?) + ON CONFLICT(workspace, fingerprint) DO UPDATE SET conv_id = excluded.conv_id, last_used = excluded.last_used` + ) + .run(workspace, fingerprint, convId, now) + this.db.prepare('DELETE FROM conversations WHERE last_used < ?').run(cutoff) + this.db.run('COMMIT') + } catch (e) { + this.db.run('ROLLBACK') + throw e + } + } } export function createDatabase(path?: string): KiroDatabase { From 3163adab678772c2b32a3f87683c02b0fe2a708c Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Tue, 19 May 2026 11:02:49 +0200 Subject: [PATCH 03/20] fix: IDC re-auth kept adding duplicate accounts to kiro.db Every re-auth rotates the clientId, so the account hash changed and a new row was inserted. Fixed the hash to use profileArn+email only, and added cleanup of stale rows on each upsert. --- src/plugin/accounts.ts | 9 ++++++--- src/plugin/sync/kiro-cli.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index d0a623e..099c23c 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -17,9 +17,12 @@ export function createDeterministicAccountId( clientId?: string, profileArn?: string ): string { - return createHash('sha256') - .update(`${email}:${method}:${clientId || ''}:${profileArn || ''}`) - .digest('hex') + // IDC clientId rotates on every re-auth; use profileArn + email as the stable identity. + const key = + method === 'idc' + ? `${email}:${method}:${profileArn || ''}` + : `${email}:${method}:${clientId || ''}:${profileArn || ''}` + return createHash('sha256').update(key).digest('hex') } export class AccountManager { diff --git a/src/plugin/sync/kiro-cli.ts b/src/plugin/sync/kiro-cli.ts index 6e46b01..c5ab5b9 100644 --- a/src/plugin/sync/kiro-cli.ts +++ b/src/plugin/sync/kiro-cli.ts @@ -122,8 +122,34 @@ export async function syncFromKiroCli() { } } - const resolvedEmail = + // If fetchUsageLimits failed, reuse a known real email for this profileArn/clientId + // to avoid creating a new placeholder row with a different hash. + let resolvedEmail: string = email || makePlaceholderEmail(authMethod, serviceRegion, clientId, profileArn) + if (resolvedEmail.startsWith('placeholder-')) { + let existingReal: any | undefined + if (profileArn) { + existingReal = all.find( + (a) => + a.auth_method === authMethod && + a.profile_arn === profileArn && + a.email && + !a.email.startsWith('placeholder-') + ) + } + if (!existingReal && authMethod === 'idc' && clientId) { + existingReal = all.find( + (a) => + a.auth_method === 'idc' && + a.client_id === clientId && + a.email && + !a.email.startsWith('placeholder-') + ) + } + if (existingReal) { + resolvedEmail = existingReal.email + } + } const id = createDeterministicAccountId(resolvedEmail, authMethod, clientId, profileArn) const existingById = all.find((a) => a.id === id) @@ -196,6 +222,12 @@ export async function syncFromKiroCli() { limitCount, lastSync: Date.now() }) + + // Remove stale rows for the same logical account that were created with + // the old hash key (which included the rotating clientId for IDC accounts). + if (authMethod === 'idc' && profileArn) { + await kiroDb.deleteStaleIdcDuplicates(id, resolvedEmail, profileArn) + } } } cliDb.close() From 9ee5110d9eb44a322df1f805a908e5d4f0ee129f Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Tue, 19 May 2026 11:02:57 +0200 Subject: [PATCH 04/20] chore: log HTTP errors to plugin.log for easier diagnosis HTTP errors (400/401/403/429/500) were only visible as UI toasts. Added logger.warn calls so they land in plugin.log as well. --- src/core/request/error-handler.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/core/request/error-handler.ts b/src/core/request/error-handler.ts index c9f968f..803a185 100644 --- a/src/core/request/error-handler.ts +++ b/src/core/request/error-handler.ts @@ -1,5 +1,6 @@ import type { AccountRepository } from '../../infrastructure/database/account-repository' import type { AccountManager } from '../../plugin/accounts' +import * as logger from '../../plugin/logger' import type { ManagedAccount } from '../../plugin/types' type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void @@ -38,12 +39,16 @@ export class ErrorHandler { if (response.status === 400) { const reason = await readBody() + logger.warn(`HTTP 400 on ${account.email}: ${reason || 'unknown'}`) showToast(`400: ${reason || 'unknown'}`, 'error') return { shouldRetry: false } } if (response.status === 401 && context.retry < this.config.rate_limit_max_retries) { const reason = await readBody() + logger.warn( + `HTTP 401 on ${account.email} (retry ${context.retry}): ${reason || 'Unauthorized'}` + ) showToast(`401: ${reason || 'Unauthorized'}. Retrying...`, 'warning') return { shouldRetry: true, @@ -64,6 +69,7 @@ export class ErrorHandler { } } catch (e) {} + logger.warn(`HTTP 500 on ${account.email} (failCount ${account.failCount}): ${errorMessage}`) if (account.failCount < 5) { const delay = 1000 * Math.pow(2, account.failCount - 1) showToast(`500: ${errorMessage}. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warning') @@ -82,6 +88,7 @@ export class ErrorHandler { if (response.status === 429) { const w = parseInt(response.headers.get('retry-after') || '60') * 1000 + logger.warn(`HTTP 429 on ${account.email}: rate limited, retry-after=${Math.ceil(w / 1000)}s`) this.accountManager.markRateLimited(account, w) await this.repository.batchSave(this.accountManager.getAccounts()) const count = this.accountManager.getAccountCount() @@ -124,6 +131,12 @@ export class ErrorHandler { account.failCount = 10 } + logger.warn(`HTTP ${response.status} on ${account.email}: ${errorReason}`, { + isPermanent, + retry: context.retry, + reason: errorData?.reason + }) + if (this.accountManager.getAccountCount() > 1) { showToast(`${response.status}: ${errorReason}. Switching account...`, 'warning') this.accountManager.markUnhealthy(account, errorReason) @@ -150,6 +163,7 @@ export class ErrorHandler { } const reason = await readBody() + logger.warn(`HTTP ${response.status} on ${account.email}: ${reason || response.statusText}`) showToast(`${response.status}: ${reason || response.statusText}`, 'error') return { shouldRetry: false } } From 51731f522ce69ab62bedc53035abd3c359001c92 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Wed, 20 May 2026 11:39:25 +0200 Subject: [PATCH 05/20] fix: auth reliability (reauth, bearer token, single account) - Reauth: cross-process lock via SQLite, timeout after 90s, poll when another instance holds the lock - Bearer token invalid: force token refresh instead of permanent failure - Token refresher: use error.code for markUnhealthy - Health: remove HTTP_403 and bearer token from isPermanentError - Accounts: null-guard on round-robin, cursor anchored to full list - IDC: deterministic account ID ignores rotating clientId - Tests: 178 tests across 13 files --- src/__tests__/accounts.test.ts | 457 +++++++++++++++++++++ src/__tests__/error-handler.test.ts | 200 +++++++++ src/__tests__/event-stream-parser.test.ts | 89 ++++ src/__tests__/health.test.ts | 71 ++++ src/__tests__/kiro-cli-parser.test.ts | 131 ++++++ src/__tests__/kiro-cli-profile.test.ts | 70 ++++ src/__tests__/response.test.ts | 69 ++++ src/__tests__/sqlite.test.ts | 217 ++++++++++ src/__tests__/stream-transformer.test.ts | 248 +++++++++++ src/__tests__/tool-call-parser.test.ts | 88 ++++ src/__tests__/usage.test.ts | 135 ++++++ src/core/auth/token-refresher.ts | 2 +- src/core/request/error-handler.ts | 22 +- src/plugin/accounts.ts | 28 +- src/plugin/health.ts | 2 +- src/plugin/request.ts | 59 ++- src/plugin/sdk-client.ts | 7 +- src/plugin/storage/migrations.ts | 20 + src/plugin/storage/sqlite.ts | 96 ++++- src/plugin/streaming/stream-transformer.ts | 3 +- src/plugin/sync/kiro-cli.ts | 5 +- src/plugin/types.ts | 2 + 22 files changed, 1966 insertions(+), 55 deletions(-) create mode 100644 src/__tests__/accounts.test.ts create mode 100644 src/__tests__/error-handler.test.ts create mode 100644 src/__tests__/event-stream-parser.test.ts create mode 100644 src/__tests__/health.test.ts create mode 100644 src/__tests__/kiro-cli-parser.test.ts create mode 100644 src/__tests__/kiro-cli-profile.test.ts create mode 100644 src/__tests__/response.test.ts create mode 100644 src/__tests__/sqlite.test.ts create mode 100644 src/__tests__/stream-transformer.test.ts create mode 100644 src/__tests__/tool-call-parser.test.ts create mode 100644 src/__tests__/usage.test.ts diff --git a/src/__tests__/accounts.test.ts b/src/__tests__/accounts.test.ts new file mode 100644 index 0000000..c94f9ae --- /dev/null +++ b/src/__tests__/accounts.test.ts @@ -0,0 +1,457 @@ +import { describe, expect, mock, test } from 'bun:test' +import { AccountManager, createDeterministicAccountId } from '../plugin/accounts.js' +import type { ManagedAccount } from '../plugin/types.js' + +// Mock DB and external dependencies +mock.module('../plugin/storage/sqlite.js', () => ({ + kiroDb: { + getAccounts: () => [], + upsertAccount: () => Promise.resolve(), + deleteAccount: () => Promise.resolve(), + batchUpsertAccounts: () => Promise.resolve() + } +})) +mock.module('../plugin/sync/kiro-cli.js', () => ({ + writeToKiroCli: () => Promise.resolve() +})) +mock.module('../kiro/auth.js', () => ({ + decodeRefreshToken: (t: string) => ({ refreshToken: t }), + encodeRefreshToken: (p: any) => p.refreshToken +})) + +function makeAccount(overrides: Partial = {}): ManagedAccount { + return { + id: 'test-id', + email: 'test@example.com', + authMethod: 'idc', + region: 'eu-central-1', + refreshToken: 'refresh', + accessToken: 'access', + expiresAt: Date.now() + 3600000, + isHealthy: true, + failCount: 0, + lastUsed: 0, + usedCount: 0, + limitCount: 0, + ...overrides + } +} + +// ── createDeterministicAccountId ────────────────────────────────────────────── + +describe('createDeterministicAccountId', () => { + test('IDC uses email + method + profileArn, ignores clientId', () => { + const id1 = createDeterministicAccountId('a@b.com', 'idc', 'client-1', 'arn:aws:123') + const id2 = createDeterministicAccountId('a@b.com', 'idc', 'client-2', 'arn:aws:123') + expect(id1).toBe(id2) + }) + + test('non-IDC uses email + method + clientId + profileArn', () => { + const id1 = createDeterministicAccountId('a@b.com', 'builderid', 'client-1') + const id2 = createDeterministicAccountId('a@b.com', 'builderid', 'client-2') + expect(id1).not.toBe(id2) + }) + + test('different emails produce different IDs', () => { + const id1 = createDeterministicAccountId('a@b.com', 'idc', 'c', 'arn') + const id2 = createDeterministicAccountId('x@b.com', 'idc', 'c', 'arn') + expect(id1).not.toBe(id2) + }) + + test('returns 64-char hex string', () => { + const id = createDeterministicAccountId('a@b.com', 'idc', 'c', 'arn') + expect(id).toMatch(/^[a-f0-9]{64}$/) + }) +}) + +// ── AccountManager ──────────────────────────────────────────────────────────── + +describe('AccountManager.getCurrentOrNext', () => { + test('returns healthy account', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + expect(mgr.getCurrentOrNext()).not.toBeNull() + }) + + test('returns null when no accounts', () => { + expect(new AccountManager([]).getCurrentOrNext()).toBeNull() + }) + + test('skips permanently unhealthy accounts', () => { + const acc = makeAccount({ isHealthy: false, unhealthyReason: 'HTTP_403', failCount: 10 }) + expect(new AccountManager([acc]).getCurrentOrNext()).toBeNull() + }) + + test('skips rate-limited accounts', () => { + const acc = makeAccount({ rateLimitResetTime: Date.now() + 60000 }) + expect(new AccountManager([acc]).getCurrentOrNext()).toBeNull() + }) + + test('recovers unhealthy account past recoveryTime', () => { + const acc = makeAccount({ + isHealthy: false, + unhealthyReason: 'temporary', + failCount: 3, + recoveryTime: Date.now() - 1000 + }) + const mgr = new AccountManager([acc]) + const selected = mgr.getCurrentOrNext() + expect(selected).not.toBeNull() + expect(selected!.isHealthy).toBe(true) + }) + + test('does NOT recover permanently unhealthy account past recoveryTime', () => { + const acc = makeAccount({ + isHealthy: false, + unhealthyReason: 'HTTP_403', + failCount: 10, + recoveryTime: Date.now() - 1000 + }) + expect(new AccountManager([acc]).getCurrentOrNext()).toBeNull() + }) + + test('increments usedCount and sets lastUsed on selection', () => { + const acc = makeAccount({ usedCount: 5 }) + const mgr = new AccountManager([acc]) + mgr.getCurrentOrNext() + expect(acc.usedCount).toBe(6) + expect(acc.lastUsed).toBeGreaterThan(0) + }) + + test('round-robin cycles through multiple accounts', () => { + const a = makeAccount({ id: 'a', email: 'a@x.com' }) + const b = makeAccount({ id: 'b', email: 'b@x.com' }) + const mgr = new AccountManager([a, b], 'round-robin') + const first = mgr.getCurrentOrNext() + const second = mgr.getCurrentOrNext() + expect(first!.id).not.toBe(second!.id) + }) + + test('round-robin skips rate-limited account and resumes correct position', () => { + const a = makeAccount({ id: 'a', email: 'a@x.com' }) + const b = makeAccount({ id: 'b', email: 'b@x.com' }) + const c = makeAccount({ id: 'c', email: 'c@x.com' }) + const mgr = new AccountManager([a, b, c], 'round-robin') + // a→b→c normal cycle + expect(mgr.getCurrentOrNext()!.id).toBe('a') + expect(mgr.getCurrentOrNext()!.id).toBe('b') + // Now rate-limit b before the next call + b.rateLimitResetTime = Date.now() + 60_000 + // cursor is at c — c is still available + expect(mgr.getCurrentOrNext()!.id).toBe('c') + // cursor wraps: a is next (b skipped) + expect(mgr.getCurrentOrNext()!.id).toBe('a') + // b's rate limit expires + b.rateLimitResetTime = 0 + // cursor is at b — b is available again + expect(mgr.getCurrentOrNext()!.id).toBe('b') + }) + + test('lowest-usage picks account with fewer usedCount', () => { + const a = makeAccount({ id: 'a', email: 'a@x.com', usedCount: 10 }) + const b = makeAccount({ id: 'b', email: 'b@x.com', usedCount: 2 }) + const mgr = new AccountManager([a, b], 'lowest-usage') + expect(mgr.getCurrentOrNext()!.id).toBe('b') + }) +}) + +describe('AccountManager.markUnhealthy', () => { + test('permanent error sets isHealthy=false, failCount=10, no recoveryTime', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + mgr.markUnhealthy(acc, 'ExpiredTokenException') + expect(acc.isHealthy).toBe(false) + expect(acc.failCount).toBe(10) + expect(acc.recoveryTime).toBeUndefined() + }) + + test('non-permanent error increments failCount', () => { + const acc = makeAccount({ failCount: 2 }) + const mgr = new AccountManager([acc]) + mgr.markUnhealthy(acc, 'Server Error') + expect(acc.failCount).toBe(3) + expect(acc.isHealthy).toBe(true) + }) + + test('non-permanent error sets isHealthy=false after 10 failures', () => { + const acc = makeAccount({ failCount: 9 }) + const mgr = new AccountManager([acc]) + mgr.markUnhealthy(acc, 'Server Error') + expect(acc.failCount).toBe(10) + expect(acc.isHealthy).toBe(false) + expect(acc.recoveryTime).toBeGreaterThan(Date.now()) + }) + + test('expired token exception is treated as permanent', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + mgr.markUnhealthy(acc, 'ExpiredTokenException') + expect(acc.isHealthy).toBe(false) + expect(acc.failCount).toBe(10) + expect(acc.recoveryTime).toBeUndefined() + }) + + test('does nothing for unknown account id', () => { + const acc = makeAccount({ id: 'known' }) + const mgr = new AccountManager([acc]) + const unknown = makeAccount({ id: 'unknown' }) + mgr.markUnhealthy(unknown, 'HTTP_403') + expect(acc.isHealthy).toBe(true) // unchanged + }) +}) + +describe('AccountManager.removeAccount', () => { + test('removes account from list', () => { + const a = makeAccount({ id: 'a' }) + const b = makeAccount({ id: 'b' }) + const mgr = new AccountManager([a, b]) + mgr.removeAccount(a) + expect(mgr.getAccountCount()).toBe(1) + expect(mgr.getAccounts()[0].id).toBe('b') + }) + + test('cursor resets to 0 when list becomes empty', () => { + const a = makeAccount() + const mgr = new AccountManager([a]) + mgr.removeAccount(a) + expect(mgr.getAccountCount()).toBe(0) + expect(mgr.getCurrentOrNext()).toBeNull() + }) + + test('ignores unknown account', () => { + const a = makeAccount({ id: 'a' }) + const mgr = new AccountManager([a]) + mgr.removeAccount(makeAccount({ id: 'unknown' })) + expect(mgr.getAccountCount()).toBe(1) + }) +}) + +describe('AccountManager.addAccount', () => { + test('adds new account', () => { + const mgr = new AccountManager([]) + mgr.addAccount(makeAccount({ id: 'new' })) + expect(mgr.getAccountCount()).toBe(1) + }) + + test('replaces existing account with same id', () => { + const original = makeAccount({ id: 'x', email: 'old@x.com' }) + const updated = makeAccount({ id: 'x', email: 'new@x.com' }) + const mgr = new AccountManager([original]) + mgr.addAccount(updated) + expect(mgr.getAccountCount()).toBe(1) + expect(mgr.getAccounts()[0].email).toBe('new@x.com') + }) +}) + +describe('AccountManager.getMinWaitTime', () => { + test('returns 0 with no rate-limited accounts', () => { + const mgr = new AccountManager([makeAccount()]) + expect(mgr.getMinWaitTime()).toBe(0) + }) + + test('returns minimum wait across rate-limited accounts', () => { + const a = makeAccount({ id: 'a', rateLimitResetTime: Date.now() + 5000 }) + const b = makeAccount({ id: 'b', rateLimitResetTime: Date.now() + 10000 }) + const mgr = new AccountManager([a, b]) + expect(mgr.getMinWaitTime()).toBeGreaterThan(0) + expect(mgr.getMinWaitTime()).toBeLessThanOrEqual(5000) + }) +}) + +// ── updateUsage ─────────────────────────────────────────────────────────────── + +describe('AccountManager.updateUsage', () => { + test('updates usedCount and limitCount', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + mgr.updateUsage(acc.id, { usedCount: 50, limitCount: 500 }) + expect(acc.usedCount).toBe(50) + expect(acc.limitCount).toBe(500) + }) + + test('updates email when provided', () => { + const acc = makeAccount({ email: 'old@example.com' }) + const mgr = new AccountManager([acc]) + mgr.updateUsage(acc.id, { usedCount: 0, limitCount: 0, email: 'new@example.com' }) + expect(acc.email).toBe('new@example.com') + }) + + test('resets failCount and marks healthy for non-permanent error', () => { + const acc = makeAccount({ failCount: 5, isHealthy: false, unhealthyReason: 'transient' }) + const mgr = new AccountManager([acc]) + mgr.updateUsage(acc.id, { usedCount: 0, limitCount: 0 }) + expect(acc.failCount).toBe(0) + expect(acc.isHealthy).toBe(true) + expect(acc.unhealthyReason).toBeUndefined() + }) + + test('does not reset health for permanent error', () => { + const acc = makeAccount({ + failCount: 10, + isHealthy: false, + unhealthyReason: 'ExpiredTokenException' + }) + const mgr = new AccountManager([acc]) + mgr.updateUsage(acc.id, { usedCount: 0, limitCount: 0 }) + expect(acc.isHealthy).toBe(false) + }) + + test('no-ops on unknown id', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + expect(() => mgr.updateUsage('unknown', { usedCount: 99, limitCount: 99 })).not.toThrow() + expect(acc.usedCount).toBe(0) // unchanged + }) +}) + +// ── addAccount / removeAccount ───────────────────────────────────────────────── + +describe('AccountManager.addAccount / removeAccount', () => { + test('addAccount appends new account', () => { + const mgr = new AccountManager([]) + const acc = makeAccount() + mgr.addAccount(acc) + expect(mgr.getAccountCount()).toBe(1) + }) + + test('addAccount replaces existing account with same id', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + mgr.addAccount({ ...acc, email: 'updated@example.com' }) + expect(mgr.getAccountCount()).toBe(1) + expect(mgr.getAccounts()[0].email).toBe('updated@example.com') + }) + + test('removeAccount removes the account', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + mgr.removeAccount(acc) + expect(mgr.getAccountCount()).toBe(0) + }) + + test('removeAccount is no-op for unknown account', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + mgr.removeAccount(makeAccount({ id: 'unknown' })) + expect(mgr.getAccountCount()).toBe(1) + }) + + test('cursor adjusts after removeAccount', () => { + const a = makeAccount({ id: 'a' }) + const b = makeAccount({ id: 'b', email: 'b@example.com' }) + const mgr = new AccountManager([a, b]) + mgr.removeAccount(a) + expect(mgr.getAccountCount()).toBe(1) + // Should not throw selecting next account + expect(mgr.getCurrentOrNext()).toBeDefined() + }) +}) + +// ── lowest-usage strategy ───────────────────────────────────────────────────── + +describe('AccountManager: lowest-usage strategy', () => { + test('selects account with lowest usedCount', () => { + const a = makeAccount({ id: 'a', usedCount: 100 }) + const b = makeAccount({ id: 'b', email: 'b@example.com', usedCount: 10 }) + const mgr = new AccountManager([a, b], 'lowest-usage') + const selected = mgr.getCurrentOrNext() + expect(selected?.id).toBe('b') + }) + + test('breaks ties by lastUsed', () => { + const a = makeAccount({ id: 'a', usedCount: 5, lastUsed: 1000 }) + const b = makeAccount({ id: 'b', email: 'b@example.com', usedCount: 5, lastUsed: 500 }) + const mgr = new AccountManager([a, b], 'lowest-usage') + const selected = mgr.getCurrentOrNext() + expect(selected?.id).toBe('b') // lower lastUsed = used less recently + }) +}) + +// ── markRateLimited ─────────────────────────────────────────────────────────── + +describe('AccountManager.markRateLimited', () => { + test('sets rateLimitResetTime in the future', () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + mgr.markRateLimited(acc, 30000) + expect(acc.rateLimitResetTime).toBeGreaterThan(Date.now()) + }) + + test('rate-limited account is excluded from getCurrentOrNext', () => { + const a = makeAccount({ id: 'a' }) + const mgr = new AccountManager([a]) + mgr.markRateLimited(a, 60000) + expect(mgr.getCurrentOrNext()).toBeNull() + }) +}) + +// ── shouldShowToast / shouldShowUsageToast ───────────────────────────────────── + +describe('AccountManager.shouldShowToast', () => { + test('returns true on first call', () => { + const mgr = new AccountManager([makeAccount()]) + expect(mgr.shouldShowToast()).toBe(true) + }) + + test('returns false within debounce window', () => { + const mgr = new AccountManager([makeAccount()]) + mgr.shouldShowToast(5000) + expect(mgr.shouldShowToast(5000)).toBe(false) + }) + + test('shouldShowUsageToast returns true on first call', () => { + const mgr = new AccountManager([makeAccount()]) + expect(mgr.shouldShowUsageToast()).toBe(true) + }) +}) + +// ── recovery after unhealthy ────────────────────────────────────────────────── + +describe('AccountManager: recovery from temporary unhealthy', () => { + test('account with past recoveryTime becomes available again', () => { + const acc = makeAccount({ + isHealthy: false, + failCount: 3, + recoveryTime: Date.now() - 1000 // past + }) + const mgr = new AccountManager([acc]) + const selected = mgr.getCurrentOrNext() + expect(selected).not.toBeNull() + expect(acc.isHealthy).toBe(true) + }) + + test('account with future recoveryTime is not returned (respects the wait)', () => { + const acc = makeAccount({ + isHealthy: false, + failCount: 3, + recoveryTime: Date.now() + 3_600_000 + }) + const mgr = new AccountManager([acc]) + expect(mgr.getCurrentOrNext()).toBeNull() + }) + + test('fallback returns unhealthy account with no recoveryTime (limbo state)', () => { + // An account can end up isHealthy=false with no recoveryTime due to incremental + // failCount increases that haven't yet hit the threshold. The fallback rescues it. + const acc = makeAccount({ + isHealthy: false, + failCount: 3 + // no recoveryTime + }) + const mgr = new AccountManager([acc]) + const selected = mgr.getCurrentOrNext() + expect(selected).not.toBeNull() + expect(acc.isHealthy).toBe(true) + expect(acc.recoveryTime).toBeUndefined() + }) + + test('permanently unhealthy account is never returned', () => { + const acc = makeAccount({ + isHealthy: false, + failCount: 10, + unhealthyReason: 'bearer token included in the request is invalid' + }) + const mgr = new AccountManager([acc]) + expect(mgr.getCurrentOrNext()).toBeNull() + }) +}) diff --git a/src/__tests__/error-handler.test.ts b/src/__tests__/error-handler.test.ts new file mode 100644 index 0000000..7004ec6 --- /dev/null +++ b/src/__tests__/error-handler.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, mock, test } from 'bun:test' +import { ErrorHandler } from '../core/request/error-handler.js' +import { AccountManager } from '../plugin/accounts.js' +import type { ManagedAccount } from '../plugin/types.js' + +mock.module('../plugin/storage/sqlite.js', () => ({ + kiroDb: { + getAccounts: () => [], + upsertAccount: () => Promise.resolve(), + deleteAccount: () => Promise.resolve(), + batchUpsertAccounts: () => Promise.resolve() + } +})) +mock.module('../plugin/sync/kiro-cli.js', () => ({ writeToKiroCli: () => Promise.resolve() })) +mock.module('../kiro/auth.js', () => ({ + decodeRefreshToken: (t: string) => ({ refreshToken: t }), + encodeRefreshToken: (p: any) => p.refreshToken +})) + +const defaultConfig = { rate_limit_max_retries: 3, rate_limit_retry_delay_ms: 100 } +const noToast = () => {} + +function makeAccount(overrides: Partial = {}): ManagedAccount { + return { + id: 'acc-1', + email: 'test@example.com', + authMethod: 'idc', + region: 'eu-central-1', + refreshToken: 'r', + accessToken: 'a', + expiresAt: Date.now() + 3600000, + isHealthy: true, + failCount: 0, + lastUsed: 0, + usedCount: 0, + limitCount: 0, + ...overrides + } +} + +function makeRepo(accounts: ManagedAccount[]) { + return { + findAll: async () => accounts, + batchSave: async () => {}, + save: async () => {}, + invalidateCache: () => {} + } as any +} + +function makeResponse(status: number, body: any, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers } + }) +} + +// ── 400 ─────────────────────────────────────────────────────────────────────── + +describe('ErrorHandler: 400', () => { + test('returns shouldRetry=false', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(400, { message: 'Bad Request' }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + expect(result.shouldRetry).toBe(false) + }) +}) + +// ── 401 ─────────────────────────────────────────────────────────────────────── + +describe('ErrorHandler: 401', () => { + test('retries when under max retries', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(401, { message: 'Unauthorized' }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + expect(result.shouldRetry).toBe(true) + expect(result.newContext?.retry).toBe(1) + }) + + test('stops retrying at max retries', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(401, { message: 'Unauthorized' }) + const result = await handler.handle(null, res, acc, { retry: 3 }, noToast) + expect(result.shouldRetry).toBe(false) + }) +}) + +// ── 403 single account ──────────────────────────────────────────────────────── + +describe('ErrorHandler: 403 single account', () => { + test('bearer token invalid 403 forces token refresh (sets expiresAt=0) and retries', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(403, { + message: 'The bearer token included in the request is invalid' + }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + // Should retry so the token refresher can get a fresh token + expect(result.shouldRetry).toBe(true) + // Account stays healthy — refresh will handle it + expect(acc.isHealthy).toBe(true) + // expiresAt zeroed so refreshIfNeeded triggers on next iteration + expect(acc.expiresAt).toBe(0) + }) + + test('TEMPORARILY_SUSPENDED marks account unhealthy', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(403, { reason: 'TEMPORARILY_SUSPENDED', message: 'Suspended' }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + expect(result.shouldRetry).toBe(false) + expect(acc.isHealthy).toBe(false) + }) + + test('non-permanent 403 retries with backoff', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(403, { message: 'Forbidden' }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + expect(result.shouldRetry).toBe(true) + expect(result.newContext?.retry).toBe(1) + }) + + test('INVALID_MODEL_ID throws immediately', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(403, { reason: 'INVALID_MODEL_ID', message: 'bad model' }) + await expect(handler.handle(null, res, acc, { retry: 0 }, noToast)).rejects.toThrow( + 'Invalid model: bad model' + ) + }) +}) + +// ── 403 multi account ───────────────────────────────────────────────────────── + +describe('ErrorHandler: 403 multi account', () => { + test('switches account on any 403, increments failCount', async () => { + const a = makeAccount({ id: 'a' }) + const b = makeAccount({ id: 'b', email: 'b@example.com' }) + const mgr = new AccountManager([a, b]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([a, b])) + const res = makeResponse(403, { message: 'Forbidden' }) + const result = await handler.handle(null, res, a, { retry: 0 }, noToast) + expect(result.shouldRetry).toBe(true) + expect(result.switchAccount).toBe(true) + // non-permanent 403: failCount incremented but account still available + expect(a.failCount).toBe(1) + expect(a.isHealthy).toBe(true) + }) +}) + +// ── 429 ─────────────────────────────────────────────────────────────────────── + +describe('ErrorHandler: 429', () => { + test('marks account rate-limited', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = new Response('', { + status: 429, + headers: { 'retry-after': '1' } // 1s not 30s + }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + expect(result.shouldRetry).toBe(true) + expect(acc.rateLimitResetTime).toBeGreaterThan(Date.now() - 100) + }) +}) + +// ── 500 ─────────────────────────────────────────────────────────────────────── + +describe('ErrorHandler: 500', () => { + test('retries with backoff on first failure', async () => { + const acc = makeAccount({ failCount: 0 }) + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(500, { message: 'Internal Server Error' }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + expect(result.shouldRetry).toBe(true) + expect(acc.failCount).toBe(1) + }) + + test('marks unhealthy after 5 failures', async () => { + const acc = makeAccount({ failCount: 4 }) + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = makeResponse(500, { message: 'Internal Server Error' }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + expect(result.switchAccount).toBe(true) + expect(acc.isHealthy).toBe(false) + }) +}) diff --git a/src/__tests__/event-stream-parser.test.ts b/src/__tests__/event-stream-parser.test.ts new file mode 100644 index 0000000..f0f343a --- /dev/null +++ b/src/__tests__/event-stream-parser.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'bun:test' +import { + parseAwsEventStreamBuffer, + parseEventLine +} from '../infrastructure/transformers/event-stream-parser.js' + +// ── parseEventLine ──────────────────────────────────────────────────────────── + +describe('parseEventLine', () => { + test('parses valid JSON', () => { + expect(parseEventLine('{"content":"hello"}')).toEqual({ content: 'hello' }) + }) + + test('returns null on invalid JSON', () => { + expect(parseEventLine('not json')).toBeNull() + expect(parseEventLine('{')).toBeNull() + }) +}) + +// ── parseAwsEventStreamBuffer ───────────────────────────────────────────────── + +describe('parseAwsEventStreamBuffer', () => { + test('empty buffer returns empty array', () => { + expect(parseAwsEventStreamBuffer('')).toEqual([]) + }) + + test('parses content event', () => { + const result = parseAwsEventStreamBuffer('{"content":"Hello"}') + expect(result).toHaveLength(1) + expect(result[0].type).toBe('content') + expect(result[0].data).toBe('Hello') + }) + + test('skips followupPrompt as not content', () => { + const result = parseAwsEventStreamBuffer('{"content":"x","followupPrompt":"y"}') + expect(result).toHaveLength(0) + }) + + test('parses toolUse event', () => { + const result = parseAwsEventStreamBuffer( + '{"name":"bash","toolUseId":"t-1","input":"ls","stop":false}' + ) + expect(result).toHaveLength(1) + expect(result[0].type).toBe('toolUse') + expect(result[0].data.name).toBe('bash') + expect(result[0].data.toolUseId).toBe('t-1') + }) + + test('parses toolUseInput event', () => { + const result = parseAwsEventStreamBuffer('{"input":"-la"}') + expect(result).toHaveLength(1) + expect(result[0].type).toBe('toolUseInput') + expect(result[0].data.input).toBe('-la') + }) + + test('parses toolUseStop event', () => { + const result = parseAwsEventStreamBuffer('{"stop":true}') + expect(result).toHaveLength(1) + expect(result[0].type).toBe('toolUseStop') + expect(result[0].data.stop).toBe(true) + }) + + test('parses contextUsage event', () => { + const result = parseAwsEventStreamBuffer('{"contextUsagePercentage":42}') + expect(result).toHaveLength(1) + expect(result[0].type).toBe('contextUsage') + expect(result[0].data.contextUsagePercentage).toBe(42) + }) + + test('parses multiple events from a single buffer', () => { + const buffer = '{"content":"Hello"}\n{"content":" world"}\n{"contextUsagePercentage":25}' + const result = parseAwsEventStreamBuffer(buffer) + expect(result).toHaveLength(3) + expect(result[0].data).toBe('Hello') + expect(result[1].data).toBe(' world') + expect(result[2].type).toBe('contextUsage') + }) + + test('handles incomplete JSON at end (no jsonEnd)', () => { + const result = parseAwsEventStreamBuffer('{"content":"truncated') + expect(result).toHaveLength(0) + }) + + test('handles escaped strings inside JSON values', () => { + const result = parseAwsEventStreamBuffer('{"content":"say \\"hello\\""}') + expect(result).toHaveLength(1) + expect(result[0].data).toBe('say "hello"') + }) +}) diff --git a/src/__tests__/health.test.ts b/src/__tests__/health.test.ts new file mode 100644 index 0000000..86604b2 --- /dev/null +++ b/src/__tests__/health.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test' +import { isPermanentError } from '../plugin/health.js' + +describe('isPermanentError', () => { + test('returns false for undefined', () => { + expect(isPermanentError(undefined)).toBe(false) + }) + + test('returns false for empty string', () => { + expect(isPermanentError('')).toBe(false) + }) + + test('returns false for generic error', () => { + expect(isPermanentError('Internal Server Error')).toBe(false) + expect(isPermanentError('Rate limited')).toBe(false) + expect(isPermanentError('Network timeout')).toBe(false) + }) + + test('detects Invalid refresh token', () => { + expect(isPermanentError('Invalid refresh token')).toBe(true) + expect(isPermanentError('Error: Invalid refresh token provided')).toBe(true) + }) + + test('detects Invalid grant provided', () => { + expect(isPermanentError('Invalid grant provided')).toBe(true) + }) + + test('detects invalid_grant', () => { + expect(isPermanentError('invalid_grant')).toBe(true) + expect(isPermanentError('error: invalid_grant')).toBe(true) + }) + + test('detects ExpiredTokenException', () => { + expect(isPermanentError('ExpiredTokenException')).toBe(true) + expect(isPermanentError('AWS: ExpiredTokenException: token expired')).toBe(true) + }) + + test('detects InvalidTokenException', () => { + expect(isPermanentError('InvalidTokenException')).toBe(true) + }) + + test('detects ExpiredClientException', () => { + expect(isPermanentError('ExpiredClientException')).toBe(true) + }) + + test('detects Client is expired', () => { + expect(isPermanentError('Client is expired')).toBe(true) + }) + + test('detects HTTP_401', () => { + expect(isPermanentError('HTTP_401')).toBe(true) + expect(isPermanentError('error HTTP_401 Unauthorized')).toBe(true) + }) + + test('does not treat HTTP_403 as permanent (token expiry — should refresh, not reauth)', () => { + // HTTP_403 from Kiro means the access token expired mid-request. + // This is recoverable via token refresh, not a permanent error. + expect(isPermanentError('HTTP_403')).toBe(false) + expect(isPermanentError('error HTTP_403 Forbidden')).toBe(false) + }) + + test('does not treat bearer token invalid as permanent (handled in error-handler with refresh)', () => { + // bearer token invalid triggers a forced token refresh in ErrorHandler, not permanent unhealthy. + expect(isPermanentError('The bearer token included in the request is invalid')).toBe(false) + expect(isPermanentError('bearer token included in the request is invalid')).toBe(false) + }) + + test('detects Account Suspended', () => { + expect(isPermanentError('Account Suspended')).toBe(true) + }) +}) diff --git a/src/__tests__/kiro-cli-parser.test.ts b/src/__tests__/kiro-cli-parser.test.ts new file mode 100644 index 0000000..36aecf5 --- /dev/null +++ b/src/__tests__/kiro-cli-parser.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'bun:test' +import { + findClientCredsRecursive, + getCliDbPath, + makePlaceholderEmail, + normalizeExpiresAt, + safeJsonParse +} from '../plugin/sync/kiro-cli-parser.js' + +// ── getCliDbPath ────────────────────────────────────────────────────────────── + +describe('getCliDbPath', () => { + test('respects KIROCLI_DB_PATH override', () => { + process.env.KIROCLI_DB_PATH = '/custom/path.db' + expect(getCliDbPath()).toBe('/custom/path.db') + delete process.env.KIROCLI_DB_PATH + }) + + test('returns a string path without override', () => { + delete process.env.KIROCLI_DB_PATH + const path = getCliDbPath() + expect(typeof path).toBe('string') + expect(path.length).toBeGreaterThan(0) + }) +}) + +// ── safeJsonParse ───────────────────────────────────────────────────────────── + +describe('safeJsonParse', () => { + test('parses valid JSON string', () => { + expect(safeJsonParse('{"key":"value"}')).toEqual({ key: 'value' }) + }) + + test('returns null for invalid JSON', () => { + expect(safeJsonParse('not json')).toBeNull() + expect(safeJsonParse('{')).toBeNull() + }) + + test('returns null for non-string input', () => { + expect(safeJsonParse(42)).toBeNull() + expect(safeJsonParse(null)).toBeNull() + expect(safeJsonParse(undefined)).toBeNull() + expect(safeJsonParse({})).toBeNull() + }) +}) + +// ── normalizeExpiresAt ──────────────────────────────────────────────────────── + +describe('normalizeExpiresAt', () => { + test('ms timestamp stays as-is', () => { + const ms = 1700000000000 + expect(normalizeExpiresAt(ms)).toBe(ms) + }) + + test('seconds timestamp is converted to ms', () => { + const sec = 1700000000 // < 10_000_000_000 + expect(normalizeExpiresAt(sec)).toBe(sec * 1000) + }) + + test('ISO date string is converted to ms', () => { + const iso = '2024-01-01T00:00:00.000Z' + const expected = new Date(iso).getTime() + expect(normalizeExpiresAt(iso)).toBe(expected) + }) + + test('numeric string is converted', () => { + expect(normalizeExpiresAt('1700000000')).toBe(1700000000 * 1000) + }) + + test('returns 0 for invalid input', () => { + expect(normalizeExpiresAt(null)).toBe(0) + expect(normalizeExpiresAt('')).toBe(0) + expect(normalizeExpiresAt('not-a-date')).toBe(0) + }) +}) + +// ── findClientCredsRecursive ────────────────────────────────────────────────── + +describe('findClientCredsRecursive', () => { + test('finds flat clientId/clientSecret', () => { + const result = findClientCredsRecursive({ client_id: 'cid', client_secret: 'csec' }) + expect(result).toEqual({ clientId: 'cid', clientSecret: 'csec' }) + }) + + test('finds camelCase variant', () => { + const result = findClientCredsRecursive({ clientId: 'cid', clientSecret: 'csec' }) + expect(result).toEqual({ clientId: 'cid', clientSecret: 'csec' }) + }) + + test('finds nested credentials', () => { + const result = findClientCredsRecursive({ + nested: { deeper: { client_id: 'n-id', client_secret: 'n-sec' } } + }) + expect(result).toEqual({ clientId: 'n-id', clientSecret: 'n-sec' }) + }) + + test('finds credentials inside array', () => { + const result = findClientCredsRecursive([ + { unrelated: true }, + { client_id: 'arr-id', client_secret: 'arr-sec' } + ]) + expect(result).toEqual({ clientId: 'arr-id', clientSecret: 'arr-sec' }) + }) + + test('returns empty object when not found', () => { + expect(findClientCredsRecursive({})).toEqual({}) + expect(findClientCredsRecursive(null)).toEqual({}) + expect(findClientCredsRecursive('string')).toEqual({}) + }) +}) + +// ── makePlaceholderEmail ────────────────────────────────────────────────────── + +describe('makePlaceholderEmail', () => { + test('returns a valid placeholder email', () => { + const email = makePlaceholderEmail('idc', 'eu-central-1', 'cid', 'arn') + expect(email).toMatch(/^idc-placeholder\+[a-f0-9]+@awsapps\.local$/) + }) + + test('same inputs produce same email (deterministic)', () => { + const a = makePlaceholderEmail('idc', 'us-east-1', 'c1', 'arn1') + const b = makePlaceholderEmail('idc', 'us-east-1', 'c1', 'arn1') + expect(a).toBe(b) + }) + + test('different inputs produce different emails', () => { + const a = makePlaceholderEmail('idc', 'us-east-1', 'c1', 'arn1') + const b = makePlaceholderEmail('idc', 'eu-central-1', 'c1', 'arn1') + expect(a).not.toBe(b) + }) +}) diff --git a/src/__tests__/kiro-cli-profile.test.ts b/src/__tests__/kiro-cli-profile.test.ts new file mode 100644 index 0000000..1a21f91 --- /dev/null +++ b/src/__tests__/kiro-cli-profile.test.ts @@ -0,0 +1,70 @@ +import { Database } from 'bun:sqlite' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +let dir: string +let dbPath: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kiro-profile-test-')) + dbPath = join(dir, 'data.sqlite3') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +describe('readActiveProfileArnFromKiroCli', () => { + test('returns undefined when DB file does not exist', async () => { + process.env.KIROCLI_DB_PATH = join(dir, 'nonexistent.sqlite3') + const { readActiveProfileArnFromKiroCli } = await import('../plugin/sync/kiro-cli-profile.js') + expect(readActiveProfileArnFromKiroCli()).toBeUndefined() + delete process.env.KIROCLI_DB_PATH + }) + + test('returns profileArn from state table', async () => { + process.env.KIROCLI_DB_PATH = dbPath + const db = new Database(dbPath) + db.run('CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT)') + db.run( + 'INSERT INTO state (key, value) VALUES (?, ?)', + 'api.codewhisperer.profile', + JSON.stringify({ arn: 'arn:aws:codewhisperer:eu-central-1:123:profile/ABC' }) + ) + db.close() + + const { readActiveProfileArnFromKiroCli } = await import('../plugin/sync/kiro-cli-profile.js') + const result = readActiveProfileArnFromKiroCli() + expect(result).toBe('arn:aws:codewhisperer:eu-central-1:123:profile/ABC') + delete process.env.KIROCLI_DB_PATH + }) + + test('returns undefined when row is missing', async () => { + process.env.KIROCLI_DB_PATH = dbPath + const db = new Database(dbPath) + db.run('CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT)') + db.close() + + const { readActiveProfileArnFromKiroCli } = await import('../plugin/sync/kiro-cli-profile.js') + expect(readActiveProfileArnFromKiroCli()).toBeUndefined() + delete process.env.KIROCLI_DB_PATH + }) + + test('returns undefined when JSON has no arn field', async () => { + process.env.KIROCLI_DB_PATH = dbPath + const db = new Database(dbPath) + db.run('CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT)') + db.run( + 'INSERT INTO state (key, value) VALUES (?, ?)', + 'api.codewhisperer.profile', + JSON.stringify({ other: 'field' }) + ) + db.close() + + const { readActiveProfileArnFromKiroCli } = await import('../plugin/sync/kiro-cli-profile.js') + expect(readActiveProfileArnFromKiroCli()).toBeUndefined() + delete process.env.KIROCLI_DB_PATH + }) +}) diff --git a/src/__tests__/response.test.ts b/src/__tests__/response.test.ts new file mode 100644 index 0000000..8351d18 --- /dev/null +++ b/src/__tests__/response.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test' +import { estimateTokens, parseEventStream } from '../plugin/response.js' + +// ── estimateTokens ──────────────────────────────────────────────────────────── + +describe('estimateTokens', () => { + test('empty string returns 0', () => { + expect(estimateTokens('')).toBe(0) + }) + + test('4-char string returns 1 token', () => { + expect(estimateTokens('abcd')).toBe(1) + }) + + test('5-char string returns 2 tokens (ceil)', () => { + expect(estimateTokens('abcde')).toBe(2) + }) + + test('100-char string returns 25 tokens', () => { + expect(estimateTokens('a'.repeat(100))).toBe(25) + }) +}) + +// ── parseEventStream ────────────────────────────────────────────────────────── + +describe('parseEventStream', () => { + test('parses plain text content', () => { + const result = parseEventStream('{"content":"Hello world"}') + expect(result.content).toBe('Hello world') + expect(result.toolCalls).toHaveLength(0) + expect(result.stopReason).toBe('end_turn') + }) + + test('parses multiple content chunks', () => { + const result = parseEventStream('{"content":"Hello "}{"content":"world"}') + expect(result.content).toBe('Hello world') + }) + + test('parses tool use', () => { + const raw = [ + '{"name":"bash","toolUseId":"t-1","input":"","stop":false}', + '{"input":"{\\\"command\\\":\\\"ls\\\"}"}', + '{"stop":true}' + ].join('\n') + const result = parseEventStream(raw) + expect(result.toolCalls).toHaveLength(1) + expect(result.toolCalls[0].name).toBe('bash') + expect(result.stopReason).toBe('tool_use') + }) + + test('parses context usage into token counts', () => { + const raw = '{"content":"hi"}{"contextUsagePercentage":50}' + const result = parseEventStream(raw, 'CLAUDE_SONNET_4_5') + // With a known context window, inputTokens should be set + expect(result.outputTokens).toBeDefined() + expect(result.inputTokens).toBeDefined() + }) + + test('returns end_turn when no tool calls', () => { + const result = parseEventStream('{"content":"answer"}') + expect(result.stopReason).toBe('end_turn') + }) + + test('empty input returns empty content', () => { + const result = parseEventStream('') + expect(result.content).toBe('') + expect(result.toolCalls).toHaveLength(0) + }) +}) diff --git a/src/__tests__/sqlite.test.ts b/src/__tests__/sqlite.test.ts new file mode 100644 index 0000000..bb7b2ca --- /dev/null +++ b/src/__tests__/sqlite.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { KiroDatabase } from '../plugin/storage/sqlite.js' +import type { ManagedAccount } from '../plugin/types.js' + +function makeAccount(overrides: Partial = {}): ManagedAccount { + return { + id: 'acc-1', + email: 'test@example.com', + authMethod: 'idc', + region: 'eu-central-1', + refreshToken: 'r', + accessToken: 'a', + expiresAt: Date.now() + 3600000, + isHealthy: true, + failCount: 0, + lastUsed: 0, + usedCount: 0, + limitCount: 0, + ...overrides + } +} + +let dir: string +let dbPath: string +let db: KiroDatabase + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kiro-test-')) + dbPath = join(dir, 'test.db') + db = new KiroDatabase(dbPath) +}) + +afterEach(() => { + db.close() + rmSync(dir, { recursive: true, force: true }) +}) + +// ── accounts CRUD ───────────────────────────────────────────────────────────── + +describe('KiroDatabase: accounts', () => { + test('starts empty', () => { + expect(db.getAccounts()).toHaveLength(0) + }) + + test('upsertAccount stores and retrieves account', async () => { + const acc = makeAccount() + await db.upsertAccount(acc) + const rows = db.getAccounts() + expect(rows).toHaveLength(1) + expect(rows[0].email).toBe('test@example.com') + expect(rows[0].is_healthy).toBe(1) + }) + + test('upsertAccount updates token fields on existing account', async () => { + const acc = makeAccount() + await db.upsertAccount(acc) + await db.upsertAccount({ ...acc, accessToken: 'new-token' }) + const rows = db.getAccounts() + expect(rows).toHaveLength(1) + expect(rows[0].access_token).toBe('new-token') + }) + + test('upsertAccount with permanent error sets isHealthy=false', async () => { + const acc = makeAccount() + await db.upsertAccount(acc) + await db.upsertAccount({ + ...acc, + isHealthy: false, + unhealthyReason: 'ExpiredTokenException' + }) + const rows = db.getAccounts() + expect(rows).toHaveLength(1) + expect(rows[0].is_healthy).toBe(0) + }) + + test('batchUpsertAccounts stores multiple accounts', async () => { + const a = makeAccount({ id: 'a', email: 'a@example.com' }) + const b = makeAccount({ id: 'b', email: 'b@example.com' }) + await db.batchUpsertAccounts([a, b]) + expect(db.getAccounts()).toHaveLength(2) + }) + + test('deleteAccount removes account', async () => { + const acc = makeAccount() + await db.upsertAccount(acc) + await db.deleteAccount('acc-1') + expect(db.getAccounts()).toHaveLength(0) + }) + + test('deleteAccount on non-existent id is a no-op', async () => { + await expect(db.deleteAccount('does-not-exist')).resolves.toBeUndefined() + }) +}) + +// ── reauth lock ─────────────────────────────────────────────────────────────── + +describe('KiroDatabase: reauth lock', () => { + test('acquireReauthLock returns true when no lock held', () => { + expect(db.acquireReauthLock()).toBe(true) + }) + + test('acquireReauthLock returns false when lock already held by this process', () => { + db.acquireReauthLock() + // Same process — process.kill(pid, 0) succeeds, so it's not dead + expect(db.acquireReauthLock()).toBe(false) + }) + + test('isReauthLockHeld returns false when no lock', () => { + expect(db.isReauthLockHeld()).toBe(false) + }) + + test('isReauthLockHeld returns true after acquire', () => { + db.acquireReauthLock() + expect(db.isReauthLockHeld()).toBe(true) + }) + + test('releaseReauthLock clears the lock', () => { + db.acquireReauthLock() + db.releaseReauthLock() + expect(db.isReauthLockHeld()).toBe(false) + }) + + test('after release, lock can be acquired again', () => { + db.acquireReauthLock() + db.releaseReauthLock() + expect(db.acquireReauthLock()).toBe(true) + }) + + test('stale lock (dead pid) is evicted on acquire', () => { + // Insert a lock row with a pid that no process uses (high number) + const { Database } = require('bun:sqlite') + const rawDb = new Database(dbPath) + rawDb.prepare('INSERT INTO reauth_lock (id, pid, acquired_at) VALUES (1, 9999999, ?)').run( + Date.now() - 1000 // recent but dead pid + ) + rawDb.close() + // Re-open our db instance + db.close() + db = new KiroDatabase(dbPath) + // Should evict dead-pid lock and acquire + expect(db.acquireReauthLock()).toBe(true) + }) + + test('expired lock is evicted on acquire', () => { + const { Database } = require('bun:sqlite') + const rawDb = new Database(dbPath) + rawDb.prepare('INSERT INTO reauth_lock (id, pid, acquired_at) VALUES (1, ?, ?)').run( + process.pid, + Date.now() - 200_000 // 200s ago, well past 120s TTL + ) + rawDb.close() + db.close() + db = new KiroDatabase(dbPath) + expect(db.acquireReauthLock()).toBe(true) + }) +}) + +// ── conversations ───────────────────────────────────────────────────────────── + +describe('KiroDatabase: conversations', () => { + test('getConversationId returns undefined when not set', () => { + expect(db.getConversationId('ws', 'fp')).toBeUndefined() + }) + + test('setConversationId and getConversationId round-trip', () => { + db.setConversationId('ws', 'fp', 'conv-123', 'agent-123') + expect(db.getConversationId('ws', 'fp')).toEqual({ + convId: 'conv-123', + agentContinuationId: 'agent-123' + }) + }) + + test('setConversationId updates existing entry', () => { + db.setConversationId('ws', 'fp', 'conv-1', 'agent-1') + db.setConversationId('ws', 'fp', 'conv-2', 'agent-2') + expect(db.getConversationId('ws', 'fp')).toEqual({ + convId: 'conv-2', + agentContinuationId: 'agent-2' + }) + }) + + test('different fingerprints are independent', () => { + db.setConversationId('ws', 'fp1', 'conv-A', 'agent-A') + db.setConversationId('ws', 'fp2', 'conv-B', 'agent-B') + expect(db.getConversationId('ws', 'fp1')).toEqual({ + convId: 'conv-A', + agentContinuationId: 'agent-A' + }) + expect(db.getConversationId('ws', 'fp2')).toEqual({ + convId: 'conv-B', + agentContinuationId: 'agent-B' + }) + }) + + test('TTL cleanup removes old entries', () => { + const { Database } = require('bun:sqlite') + const rawDb = new Database(dbPath) + rawDb + .prepare( + 'INSERT INTO conversations (workspace, fingerprint, conv_id, agent_continuation_id, last_used) VALUES (?, ?, ?, ?, ?)' + ) + .run('ws', 'old', 'conv-old', 'agent-old', Date.now() - 10 * 24 * 3600000) // 10 days old + rawDb.close() + db.close() + db = new KiroDatabase(dbPath) + // Trigger cleanup by setting a new one (ttlDays=7) + db.setConversationId('ws', 'new', 'conv-new', 'agent-new', 7) + expect(db.getConversationId('ws', 'old')).toBeUndefined() + expect(db.getConversationId('ws', 'new')).toEqual({ + convId: 'conv-new', + agentContinuationId: 'agent-new' + }) + }) +}) diff --git a/src/__tests__/stream-transformer.test.ts b/src/__tests__/stream-transformer.test.ts new file mode 100644 index 0000000..9b67073 --- /dev/null +++ b/src/__tests__/stream-transformer.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from 'bun:test' +import { transformKiroStream } from '../plugin/streaming/stream-transformer.js' + +// Helper: create a Response with a ReadableStream from text chunks +function makeStreamResponse(chunks: string[]): Response { + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(new TextEncoder().encode(chunk)) + } + controller.close() + } + }) + return new Response(stream) +} + +async function collect(gen: AsyncGenerator): Promise { + const result: any[] = [] + for await (const item of gen) result.push(item) + return result +} + +const MODEL = 'CLAUDE_SONNET_4_5' +const CONV = 'conv-test' + +// ── basic text content ──────────────────────────────────────────────────────── + +describe('transformKiroStream: text', () => { + test('plain text content produces text delta events', async () => { + const response = makeStreamResponse(['{"content":"Hello world"}']) + const events = await collect(transformKiroStream(response, MODEL, CONV)) + // Should contain at least a content_block_delta and message_stop + const deltas = events.filter( + (e) => e.choices?.[0]?.delta?.content !== undefined || e.choices?.[0]?.delta?.type === 'text' + ) + expect(deltas.length).toBeGreaterThan(0) + const stop = events.find((e) => e.object === 'chat.completion.chunk') + expect(stop).toBeDefined() + }) + + test('multiple content chunks are concatenated', async () => { + const response = makeStreamResponse(['{"content":"Hello "}', '{"content":"world"}']) + const events = await collect(transformKiroStream(response, MODEL, CONV)) + const allText = events + .filter((e) => e.choices?.[0]?.delta?.content) + .map((e) => e.choices[0].delta.content) + .join('') + expect(allText).toContain('Hello') + expect(allText).toContain('world') + }) + + test('empty body throws', async () => { + const response = new Response(null) + await expect(collect(transformKiroStream(response, MODEL, CONV))).rejects.toThrow( + 'Response body is null' + ) + }) +}) + +// ── thinking tags ───────────────────────────────────────────────────────────── + +describe('transformKiroStream: thinking tags', () => { + test('thinking content produces thinking delta events', async () => { + const response = makeStreamResponse(['{"content":"I think...Answer"}']) + const events = await collect(transformKiroStream(response, MODEL, CONV)) + // Thinking events have type 'thinking' in the delta + const thinkingEvents = events.filter( + (e) => e.choices?.[0]?.delta?.reasoning_content !== undefined + ) + expect(thinkingEvents.length).toBeGreaterThan(0) + }) + + test('text after thinking tags is emitted as normal text', async () => { + const response = makeStreamResponse(['{"content":"ThinkFinal answer"}']) + const events = await collect(transformKiroStream(response, MODEL, CONV)) + const textContent = events + .filter((e) => e.choices?.[0]?.delta?.content) + .map((e) => e.choices[0].delta.content) + .join('') + expect(textContent).toContain('Final answer') + }) + + test('thinking tag inside code block is not treated as thinking', async () => { + const response = makeStreamResponse([ + '{"content":"```\\nnot thinking\\n```"}' + ]) + const events = await collect(transformKiroStream(response, MODEL, CONV)) + const thinkingEvents = events.filter((e) => e.choices?.[0]?.delta?.thinking) + expect(thinkingEvents.length).toBe(0) + }) +}) + +// ── tool use ────────────────────────────────────────────────────────────────── + +describe('transformKiroStream: tool use', () => { + test('toolUse events produce tool_call chunks', async () => { + const chunks = [ + '{"name":"bash","toolUseId":"t-1","input":"","stop":false}', + '{"input":"{\\\"command\\\":\\\"ls\\\"}"}', + '{"stop":true}' + ] + const response = makeStreamResponse(chunks) + const events = await collect(transformKiroStream(response, MODEL, CONV)) + // Should have tool_calls in at least one event + const toolEvents = events.filter( + (e) => e.choices?.[0]?.delta?.tool_calls || e.choices?.[0]?.delta?.type === 'tool_use' + ) + expect(toolEvents.length).toBeGreaterThan(0) + }) +}) + +// ── context usage ───────────────────────────────────────────────────────────── + +describe('transformKiroStream: context usage', () => { + test('contextUsagePercentage is reflected in final usage', async () => { + const response = makeStreamResponse(['{"content":"hi"}', '{"contextUsagePercentage":50}']) + const events = await collect(transformKiroStream(response, MODEL, CONV)) + const delta = events.find((e) => e.choices?.[0]?.delta?.stop_reason || e.usage) + expect(delta).toBeDefined() + }) +}) + +// ── TextDecoder flush ───────────────────────────────────────────────────────── + +describe('transformKiroStream: TextDecoder flush', () => { + test('multi-byte UTF-8 split across chunks is decoded correctly', async () => { + // € = E2 82 AC (3 bytes), split: [E2 82] + [AC ...] + const euro = new TextEncoder().encode('{"content":"€"}') + const part1 = euro.slice(0, euro.length - 5) // cut before '€' completes + const part2 = euro.slice(euro.length - 5) + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(part1) + controller.enqueue(part2) + controller.close() + } + }) + const response = new Response(stream) + const events = await collect(transformKiroStream(response, MODEL, CONV)) + const allText = events + .filter((e) => e.choices?.[0]?.delta?.content) + .map((e) => e.choices[0].delta.content) + .join('') + expect(allText).toContain('€') + }) +}) + +// ── SDK stream: tool call chunking ─────────────────────────────────────────── + +import { transformSdkStream } from '../plugin/streaming/sdk-stream-transformer.js' + +function makeSdkResponse(events: any[]) { + return { + generateAssistantResponseResponse: (async function* () { + for (const e of events) yield e + })() + } +} + +async function collectSdk(gen: AsyncGenerator): Promise { + const result: any[] = [] + for await (const item of gen) result.push(item) + return result +} + +describe('transformSdkStream: tool call streaming', () => { + test('tool call input chunks without name are concatenated', async () => { + const events = [ + { toolUseEvent: { toolUseId: 't-1', name: 'write', input: '{"file' } }, + { toolUseEvent: { toolUseId: 't-1', input: 'Path":"/tmp/x.txt","content":"hello"}' } }, + { toolUseEvent: { toolUseId: 't-1', stop: true } } + ] + const sdk = makeSdkResponse(events) + const result = await collectSdk(transformSdkStream(sdk, 'auto', 'conv-1')) + const toolBlock = result.find( + (e) => e.choices?.[0]?.delta?.tool_calls?.[0]?.function?.arguments + ) + expect(toolBlock).toBeDefined() + const args = JSON.parse(toolBlock.choices[0].delta.tool_calls[0].function.arguments) + expect(args.filePath).toBe('/tmp/x.txt') + expect(args.content).toBe('hello') + }) + + test('multiple tool calls in one response are parsed correctly', async () => { + const events = [ + { assistantResponseEvent: { content: 'I will run two tools.' } }, + { toolUseEvent: { toolUseId: 't-1', name: 'read', input: '{"path":"/a"}', stop: true } }, + { toolUseEvent: { toolUseId: 't-2', name: 'read', input: '{"path":"/b"}', stop: true } } + ] + const sdk = makeSdkResponse(events) + const result = await collectSdk(transformSdkStream(sdk, 'auto', 'conv-1')) + const toolBlocks = result.filter((e) => e.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name) + expect(toolBlocks.length).toBe(2) + }) +}) + +// ── isNewThread after merge ────────────────────────────────────────────────── + +import { buildHistory } from '../infrastructure/transformers/history-builder.js' +import { mergeAdjacentMessages } from '../infrastructure/transformers/message-transformer.js' + +describe('isNewThread detection after merge', () => { + test('consecutive user messages merge to one — treated as new thread', () => { + const msgs = [ + { role: 'user', content: 'msg1' }, + { role: 'user', content: 'msg2' }, + { role: 'user', content: 'msg3' } + ] + const merged = mergeAdjacentMessages([...msgs]) + expect(merged.length).toBe(1) + const history = buildHistory(merged, 'auto') + expect(history.length).toBe(0) + }) + + test('user+assistant+user produces history', () => { + const msgs = [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + { role: 'user', content: 'next' } + ] + const merged = mergeAdjacentMessages([...msgs]) + expect(merged.length).toBe(3) + const history = buildHistory(merged, 'auto') + expect(history.length).toBe(2) + }) + + test('single user message produces empty history', () => { + const msgs = [{ role: 'user', content: 'only one' }] + const merged = mergeAdjacentMessages([...msgs]) + expect(merged.length).toBe(1) + const history = buildHistory(merged, 'auto') + expect(history.length).toBe(0) + }) + + test('consecutive assistant messages are merged', () => { + const msgs = [ + { role: 'user', content: 'start' }, + { role: 'assistant', content: 'part1' }, + { role: 'assistant', content: 'part2' }, + { role: 'user', content: 'next' } + ] + const merged = mergeAdjacentMessages([...msgs]) + expect(merged.length).toBe(3) + expect(merged[1].content).toContain('part1') + expect(merged[1].content).toContain('part2') + }) +}) diff --git a/src/__tests__/tool-call-parser.test.ts b/src/__tests__/tool-call-parser.test.ts new file mode 100644 index 0000000..0b17e77 --- /dev/null +++ b/src/__tests__/tool-call-parser.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test' +import { + cleanToolCallsFromText, + deduplicateToolCalls, + parseBracketToolCalls +} from '../infrastructure/transformers/tool-call-parser.js' + +describe('parseBracketToolCalls', () => { + test('returns empty array for plain text', () => { + expect(parseBracketToolCalls('Hello world')).toHaveLength(0) + }) + + test('parses single bracket tool call', () => { + const text = '[Called bash with args: {"command":"ls"}]' + const result = parseBracketToolCalls(text) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('bash') + expect(result[0].input).toEqual({ command: 'ls' }) + }) + + test('parses multiple bracket tool calls', () => { + const text = + '[Called bash with args: {"command":"ls"}] [Called read with args: {"path":"/tmp"}]' + const result = parseBracketToolCalls(text) + expect(result).toHaveLength(2) + expect(result[0].name).toBe('bash') + expect(result[1].name).toBe('read') + }) + + test('skips malformed JSON args', () => { + const text = '[Called bash with args: {not valid json}]' + const result = parseBracketToolCalls(text) + expect(result).toHaveLength(0) + }) + + test('assigns unique toolUseIds', () => { + const text = + '[Called bash with args: {"command":"ls"}][Called bash with args: {"command":"pwd"}]' + const result = parseBracketToolCalls(text) + expect(result[0].toolUseId).not.toBe(result[1].toolUseId) + }) +}) + +describe('deduplicateToolCalls', () => { + test('returns empty array for empty input', () => { + expect(deduplicateToolCalls([])).toHaveLength(0) + }) + + test('keeps all unique tool calls', () => { + const calls = [ + { toolUseId: 'a', name: 'bash', input: {} }, + { toolUseId: 'b', name: 'read', input: {} } + ] + expect(deduplicateToolCalls(calls)).toHaveLength(2) + }) + + test('removes duplicates by toolUseId', () => { + const calls = [ + { toolUseId: 'a', name: 'bash', input: {} }, + { toolUseId: 'a', name: 'bash', input: { x: 1 } } + ] + const result = deduplicateToolCalls(calls) + expect(result).toHaveLength(1) + expect(result[0].input).toEqual({}) // first one kept + }) +}) + +describe('cleanToolCallsFromText', () => { + test('removes bracket tool call from text', () => { + const text = 'Here is the result [Called bash with args: {"command":"ls"}] done.' + const toolCalls = [{ toolUseId: 'x', name: 'bash', input: {} }] + const result = cleanToolCallsFromText(text, toolCalls) + expect(result).not.toContain('[Called bash') + expect(result).toContain('done.') + }) + + test('leaves text unchanged when no tool calls match', () => { + const text = 'Hello world' + const result = cleanToolCallsFromText(text, []) + expect(result).toBe('Hello world') + }) + + test('trims and collapses whitespace', () => { + const text = ' hello world ' + const result = cleanToolCallsFromText(text, []) + expect(result).toBe('hello world') + }) +}) diff --git a/src/__tests__/usage.test.ts b/src/__tests__/usage.test.ts new file mode 100644 index 0000000..a381e90 --- /dev/null +++ b/src/__tests__/usage.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { KiroAuthDetails, ManagedAccount } from '../plugin/types.js' +import { fetchUsageLimits, updateAccountQuota } from '../plugin/usage.js' + +function makeAuth(overrides: Partial = {}): KiroAuthDetails { + return { + access: 'access-token', + region: 'eu-central-1', + profileArn: 'arn:aws:codewhisperer:eu-central-1:000000:profile/ABC', + ...overrides + } +} + +function makeAccount(overrides: Partial = {}): ManagedAccount { + return { + id: 'acc-1', + email: 'test@example.com', + authMethod: 'idc', + region: 'eu-central-1', + refreshToken: 'r', + accessToken: 'a', + expiresAt: Date.now() + 3600000, + isHealthy: true, + failCount: 0, + lastUsed: 0, + usedCount: 0, + limitCount: 0, + ...overrides + } +} + +// ── updateAccountQuota ──────────────────────────────────────────────────────── + +describe('updateAccountQuota', () => { + test('updates usedCount and limitCount on account', () => { + const acc = makeAccount() + updateAccountQuota(acc, { usedCount: 150, limitCount: 2000 }) + expect(acc.usedCount).toBe(150) + expect(acc.limitCount).toBe(2000) + }) + + test('updates email when provided', () => { + const acc = makeAccount({ email: 'old@example.com' }) + updateAccountQuota(acc, { usedCount: 0, limitCount: 0, email: 'new@example.com' }) + expect(acc.email).toBe('new@example.com') + }) + + test('does not update email when not provided', () => { + const acc = makeAccount({ email: 'keep@example.com' }) + updateAccountQuota(acc, { usedCount: 5, limitCount: 100 }) + expect(acc.email).toBe('keep@example.com') + }) + + test('calls accountManager.updateUsage when provided', () => { + const acc = makeAccount() + const calls: any[] = [] + const mgr = { updateUsage: (id: string, meta: any) => calls.push({ id, meta }) } + updateAccountQuota(acc, { usedCount: 10, limitCount: 50 }, mgr) + expect(calls).toHaveLength(1) + expect(calls[0].id).toBe('acc-1') + expect(calls[0].meta.usedCount).toBe(10) + expect(calls[0].meta.limitCount).toBe(50) + }) + + test('handles missing usedCount/limitCount gracefully', () => { + const acc = makeAccount() + updateAccountQuota(acc, {}) + expect(acc.usedCount).toBe(0) + expect(acc.limitCount).toBe(0) + }) +}) + +// ── fetchUsageLimits ────────────────────────────────────────────────────────── + +describe('fetchUsageLimits', () => { + test('returns usedCount and limitCount from usageBreakdownList', async () => { + const mockFetch = mock( + async () => + new Response( + JSON.stringify({ + usageBreakdownList: [ + { + freeTrialInfo: { currentUsage: 100, usageLimit: 1000 }, + currentUsage: 50, + usageLimit: 500 + } + ], + userInfo: { email: 'test@example.com' } + }), + { status: 200 } + ) + ) + const original = globalThis.fetch + globalThis.fetch = mockFetch as any + try { + const result = await fetchUsageLimits(makeAuth()) + expect(result.usedCount).toBe(150) // 100 + 50 + expect(result.limitCount).toBe(1500) // 1000 + 500 + expect(result.email).toBe('test@example.com') + } finally { + globalThis.fetch = original + } + }) + + test('retries on FEATURE_NOT_SUPPORTED and succeeds on later attempt', async () => { + let callCount = 0 + const mockFetch = mock(async () => { + callCount++ + if (callCount < 3) { + return new Response('FEATURE_NOT_SUPPORTED', { status: 400 }) + } + return new Response(JSON.stringify({ usageBreakdownList: [], userInfo: {} }), { status: 200 }) + }) + const original = globalThis.fetch + globalThis.fetch = mockFetch as any + try { + const result = await fetchUsageLimits(makeAuth()) + expect(callCount).toBeGreaterThanOrEqual(3) + expect(result.usedCount).toBe(0) + } finally { + globalThis.fetch = original + } + }) + + test('throws when all attempts fail', async () => { + const mockFetch = mock(async () => new Response('Server Error', { status: 500 })) + const original = globalThis.fetch + globalThis.fetch = mockFetch as any + try { + await expect(fetchUsageLimits(makeAuth())).rejects.toThrow() + } finally { + globalThis.fetch = original + } + }) +}) diff --git a/src/core/auth/token-refresher.ts b/src/core/auth/token-refresher.ts index a1b927c..b1b8a06 100644 --- a/src/core/auth/token-refresher.ts +++ b/src/core/auth/token-refresher.ts @@ -81,7 +81,7 @@ export class TokenRefresher { error.message.includes('Invalid grant provided') || error.message.includes('Client is expired')) ) { - this.accountManager.markUnhealthy(account, error.message) + this.accountManager.markUnhealthy(account, error.code || error.message) await this.repository.batchSave(this.accountManager.getAccounts()) return { account, shouldContinue: true } } diff --git a/src/core/request/error-handler.ts b/src/core/request/error-handler.ts index 803a185..c7c16ae 100644 --- a/src/core/request/error-handler.ts +++ b/src/core/request/error-handler.ts @@ -76,9 +76,11 @@ export class ErrorHandler { await this.sleep(delay) return { shouldRetry: true } } else { + account.failCount = 9 // markUnhealthy will increment to 10 and set isHealthy=false this.accountManager.markUnhealthy( account, - `Server Error (500) after 5 attempts: ${errorMessage}` + `Server error (500) after 5 attempts: ${errorMessage}`, + Date.now() + 1800000 // 30 min recovery ) await this.repository.batchSave(this.accountManager.getAccounts()) showToast(`500: ${errorMessage}. Marking account as unhealthy and switching...`, 'warning') @@ -121,14 +123,10 @@ export class ErrorHandler { errorReason = 'Account Suspended' isPermanent = true } - if ( - errorReason.includes('bearer token included in the request is invalid') || - errorReason.includes('The bearer token included in the request is invalid') - ) { - isPermanent = true - } - if (isPermanent) { - account.failCount = 10 + if (errorReason.includes('bearer token included in the request is invalid')) { + // Force token refresh on next retry + account.expiresAt = 0 + return { shouldRetry: true } } logger.warn(`HTTP ${response.status} on ${account.email}: ${errorReason}`, { @@ -144,6 +142,12 @@ export class ErrorHandler { return { shouldRetry: true, switchAccount: true } } + if (isPermanent) { + this.accountManager.markUnhealthy(account, errorReason) + await this.repository.batchSave(this.accountManager.getAccounts()) + return { shouldRetry: false } + } + if ( response.status === 403 && !isPermanent && diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index 099c23c..819fb4e 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -17,7 +17,7 @@ export function createDeterministicAccountId( clientId?: string, profileArn?: string ): string { - // IDC clientId rotates on every re-auth; use profileArn + email as the stable identity. + // IDC clientId rotates on re-auth; profileArn + email is the stable identity. const key = method === 'idc' ? `${email}:${method}:${profileArn || ''}` @@ -105,8 +105,16 @@ export class AccountManager { if (this.strategy === 'sticky') { selected = available.find((_, i) => i === this.cursor) || available[0] } else if (this.strategy === 'round-robin') { - selected = available[this.cursor % available.length] - this.cursor = (this.cursor + 1) % available.length + // Cursor anchored to this.accounts, not the filtered `available` list + const n = this.accounts.length + for (let i = 0; i < n; i++) { + const candidate = this.accounts[(this.cursor + i) % n] + if (candidate && available.includes(candidate)) { + selected = candidate + this.cursor = (this.accounts.indexOf(candidate) + 1) % n + break + } + } } else if (this.strategy === 'lowest-usage') { selected = [...available].sort( (a, b) => (a.usedCount || 0) - (b.usedCount || 0) || (a.lastUsed || 0) - (b.lastUsed || 0) @@ -114,22 +122,30 @@ export class AccountManager { } } if (!selected) { + // Fallback: unhealthy accounts without a scheduled recoveryTime const fallback = this.accounts - .filter((a) => !a.isHealthy && a.failCount < 10 && !isPermanentError(a.unhealthyReason)) + .filter( + (a) => + !a.isHealthy && + a.failCount < 10 && + !isPermanentError(a.unhealthyReason) && + !a.recoveryTime + ) .sort( (a, b) => (a.usedCount || 0) - (b.usedCount || 0) || (a.lastUsed || 0) - (b.lastUsed || 0) )[0] if (fallback) { fallback.isHealthy = true delete fallback.unhealthyReason - delete fallback.recoveryTime selected = fallback } } if (selected) { selected.lastUsed = now selected.usedCount = (selected.usedCount || 0) + 1 - this.cursor = this.accounts.indexOf(selected) + if (this.strategy !== 'round-robin') { + this.cursor = this.accounts.indexOf(selected) + } return selected } return null diff --git a/src/plugin/health.ts b/src/plugin/health.ts index 10eb464..6916b69 100644 --- a/src/plugin/health.ts +++ b/src/plugin/health.ts @@ -9,6 +9,6 @@ export function isPermanentError(reason?: string): boolean { reason.includes('ExpiredClientException') || reason.includes('Client is expired') || reason.includes('HTTP_401') || - reason.includes('HTTP_403') + reason.includes('Account Suspended') ) } diff --git a/src/plugin/request.ts b/src/plugin/request.ts index 999110e..99e1db4 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -30,13 +30,12 @@ import type { SdkPreparedRequest } from './types' -// Stable conversationId per thread — fingerprint is SHA-256(workspace + firstUserContent). -// New thread always gets a fresh UUID; continuation reuses what's in the DB. -function deriveConversationId( +// Stable conversationId + agentContinuationId per thread, persisted in SQLite. +function deriveConversationIds( workspace: string, firstUserContent: string, isNewThread: boolean -): string { +): { convId: string; agentContinuationId: string } { const fingerprint = crypto .createHash('sha256') .update(workspace + '\0' + (firstUserContent || '_empty_')) @@ -45,18 +44,31 @@ function deriveConversationId( if (!isNewThread) { const existing = kiroDb.getConversationId(workspace, fingerprint) - if (existing) return existing + if (existing) { + if (!existing.agentContinuationId) { + existing.agentContinuationId = crypto.randomUUID() + kiroDb.setConversationId( + workspace, + fingerprint, + existing.convId, + existing.agentContinuationId + ) + } + return existing + } } - const id = crypto.randomUUID() - kiroDb.setConversationId(workspace, fingerprint, id) - return id + const convId = crypto.randomUUID() + const agentContinuationId = crypto.randomUUID() + kiroDb.setConversationId(workspace, fingerprint, convId, agentContinuationId) + return { convId, agentContinuationId } } interface TransformResult { request: CodeWhispererRequest resolved: string convId: string + agentContinuationId: string } type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void @@ -74,18 +86,6 @@ function buildCodeWhispererRequest( const { messages, tools, system } = req if (!messages || messages.length === 0) throw new Error('No messages') - // Keep the same conversationId across all turns in a thread (including tool calls) - // so Kiro treats them as one session. New thread = fresh UUID, continuation = reuse from DB. - const nonSystemMessages = messages.filter((m: any) => m.role !== 'system') - const isNewThread = nonSystemMessages.length === 1 - const firstUserMsg = nonSystemMessages.find((m: any) => m.role === 'user') - const firstUserContent = firstUserMsg - ? typeof firstUserMsg.content === 'string' - ? firstUserMsg.content - : JSON.stringify(firstUserMsg.content) - : '' - const convId = deriveConversationId(workspace, firstUserContent, isNewThread) - const resolved = resolveKiroModel(model) const systemMsgs = messages.filter((m: any) => m.role === 'system') const otherMsgs = messages.filter((m: any) => m.role !== 'system') let sys = system || '' @@ -100,6 +100,21 @@ function buildCodeWhispererRequest( const msgs = mergeAdjacentMessages([...otherMsgs]) const lastMsg = msgs[msgs.length - 1] if (lastMsg && lastMsg.role === 'assistant' && getContentText(lastMsg) === '{') msgs.pop() + + // isNewThread after merge — consecutive same-role messages collapse into one + const isNewThread = msgs.length <= 1 + const firstUserMsg = msgs.find((m: any) => m.role === 'user') + const firstUserContent = firstUserMsg + ? typeof firstUserMsg.content === 'string' + ? firstUserMsg.content + : JSON.stringify(firstUserMsg.content) + : '' + const { convId, agentContinuationId } = deriveConversationIds( + workspace, + firstUserContent, + isNewThread + ) + const resolved = resolveKiroModel(model) const cwTools = tools ? convertToolsToCodeWhisperer(tools) : [] let history = buildHistory(msgs, resolved) @@ -215,6 +230,8 @@ function buildCodeWhispererRequest( } const request: CodeWhispererRequest = { conversationState: { + agentContinuationId, + agentTaskType: 'vibe', chatTriggerType: KIRO_CONSTANTS.CHAT_TRIGGER_TYPE_MANUAL, conversationId: convId, currentMessage: { @@ -306,7 +323,7 @@ function buildCodeWhispererRequest( } } - return { request, resolved, convId } + return { request, resolved, convId, agentContinuationId } } export function transformToCodeWhisperer( diff --git a/src/plugin/sdk-client.ts b/src/plugin/sdk-client.ts index ba29311..08ba707 100644 --- a/src/plugin/sdk-client.ts +++ b/src/plugin/sdk-client.ts @@ -21,12 +21,17 @@ export function createSdkClient( endpoint: `https://q.${region}.amazonaws.com`, token: () => Promise.resolve({ token }), maxAttempts: 1, - customUserAgent: [[KIRO_CONSTANTS.USER_AGENT]] + customUserAgent: [[KIRO_CONSTANTS.USER_AGENT]], + requestHandler: { + connectionTimeout: 10000, + requestTimeout: 120000 + } }) client.middlewareStack.add( (next: any) => async (args: any) => { args.request.headers['x-amzn-kiro-agent-mode'] = 'vibe' + args.request.headers['Connection'] = 'close' return next(args) }, { step: 'build', name: 'addKiroHeaders' } diff --git a/src/plugin/storage/migrations.ts b/src/plugin/storage/migrations.ts index 21ca1b8..d6e96c2 100644 --- a/src/plugin/storage/migrations.ts +++ b/src/plugin/storage/migrations.ts @@ -8,6 +8,18 @@ export function runMigrations(db: Database): void { migrateOidcRegionColumn(db) migrateDropRefreshTokenUniqueIndex(db) migrateConversationsTable(db) + migrateReauthLockTable(db) + migrateConversationsAgentContinuationId(db) +} + +function migrateReauthLockTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS reauth_lock ( + id INTEGER PRIMARY KEY CHECK (id = 1), + pid INTEGER NOT NULL, + acquired_at INTEGER NOT NULL + ) + `) } function migrateConversationsTable(db: Database): void { @@ -200,3 +212,11 @@ function migrateDropRefreshTokenUniqueIndex(db: Database): void { } } } + +function migrateConversationsAgentContinuationId(db: Database): void { + const columns = db.prepare('PRAGMA table_info(conversations)').all() as any[] + const names = new Set(columns.map((c: any) => c.name)) + if (!names.has('agent_continuation_id')) { + db.run('ALTER TABLE conversations ADD COLUMN agent_continuation_id TEXT') + } +} diff --git a/src/plugin/storage/sqlite.ts b/src/plugin/storage/sqlite.ts index b986909..c997b15 100644 --- a/src/plugin/storage/sqlite.ts +++ b/src/plugin/storage/sqlite.ts @@ -191,37 +191,113 @@ export class KiroDatabase { } } + private static readonly REAUTH_LOCK_TTL_MS = 120_000 + + acquireReauthLock(): boolean { + const now = Date.now() + try { + this.db.run('BEGIN IMMEDIATE') + } catch { + // Another write transaction is active — treat as lock held + return false + } + try { + const existing = this.db + .prepare('SELECT pid, acquired_at FROM reauth_lock WHERE id = 1') + .get() as { pid: number; acquired_at: number } | undefined + + if (existing) { + const expired = now - existing.acquired_at >= KiroDatabase.REAUTH_LOCK_TTL_MS + const dead = (() => { + try { + process.kill(existing.pid, 0) + return false + } catch { + return true + } + })() + if (expired || dead) { + this.db.prepare('DELETE FROM reauth_lock WHERE id = 1').run() + } else { + this.db.run('ROLLBACK') + return false + } + } + + this.db + .prepare('INSERT INTO reauth_lock (id, pid, acquired_at) VALUES (1, ?, ?)') + .run(process.pid, now) + this.db.run('COMMIT') + return true + } catch { + this.db.run('ROLLBACK') + return false + } + } + + isReauthLockHeld(): boolean { + const row = this.db.prepare('SELECT pid FROM reauth_lock WHERE id = 1').get() as + | { pid: number } + | undefined + if (!row) return false + try { + process.kill(row.pid, 0) + return true + } catch { + return false + } + } + + releaseReauthLock(): void { + this.db.prepare('DELETE FROM reauth_lock WHERE id = 1 AND pid = ?').run(process.pid) + } + close() { this.db.close() } - getConversationId(workspace: string, fingerprint: string): string | undefined { + getConversationId( + workspace: string, + fingerprint: string + ): { convId: string; agentContinuationId: string } | undefined { const row = this.db - .prepare('SELECT conv_id FROM conversations WHERE workspace = ? AND fingerprint = ?') - .get(workspace, fingerprint) as { conv_id: string } | undefined + .prepare( + 'SELECT conv_id, agent_continuation_id FROM conversations WHERE workspace = ? AND fingerprint = ?' + ) + .get(workspace, fingerprint) as + | { conv_id: string; agent_continuation_id: string | null } + | undefined if (row) { this.db .prepare('UPDATE conversations SET last_used = ? WHERE workspace = ? AND fingerprint = ?') .run(Date.now(), workspace, fingerprint) } - return row?.conv_id + return row + ? { convId: row.conv_id, agentContinuationId: row.agent_continuation_id || '' } + : undefined } /** - * Persist a conversationId and clean up entries older than ttlDays (default 7). + * Persist a conversationId and agentContinuationId, clean up entries older than ttlDays (default 7). */ - setConversationId(workspace: string, fingerprint: string, convId: string, ttlDays = 7): void { + setConversationId( + workspace: string, + fingerprint: string, + convId: string, + agentContinuationId: string, + ttlDays = 7 + ): void { const now = Date.now() const cutoff = now - ttlDays * 24 * 60 * 60 * 1000 this.db.run('BEGIN TRANSACTION') try { this.db .prepare( - `INSERT INTO conversations (workspace, fingerprint, conv_id, last_used) - VALUES (?, ?, ?, ?) - ON CONFLICT(workspace, fingerprint) DO UPDATE SET conv_id = excluded.conv_id, last_used = excluded.last_used` + `INSERT INTO conversations (workspace, fingerprint, conv_id, agent_continuation_id, last_used) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(workspace, fingerprint) DO UPDATE SET conv_id = excluded.conv_id, agent_continuation_id = excluded.agent_continuation_id, last_used = excluded.last_used` ) - .run(workspace, fingerprint, convId, now) + .run(workspace, fingerprint, convId, agentContinuationId, now) this.db.prepare('DELETE FROM conversations WHERE last_used < ?').run(cutoff) this.db.run('COMMIT') } catch (e) { diff --git a/src/plugin/streaming/stream-transformer.ts b/src/plugin/streaming/stream-transformer.ts index 404c94b..e8de8fb 100644 --- a/src/plugin/streaming/stream-transformer.ts +++ b/src/plugin/streaming/stream-transformer.ts @@ -44,8 +44,7 @@ export async function* transformKiroStream( while (true) { const { done, value } = await reader.read() if (done) { - // Flush any bytes buffered inside the TextDecoder for incomplete - // multi-byte UTF-8 sequences at the end of the stream. + // Flush remaining multi-byte UTF-8 from TextDecoder const tail = decoder.decode() if (tail) rawBuffer += tail break diff --git a/src/plugin/sync/kiro-cli.ts b/src/plugin/sync/kiro-cli.ts index c5ab5b9..3883871 100644 --- a/src/plugin/sync/kiro-cli.ts +++ b/src/plugin/sync/kiro-cli.ts @@ -122,8 +122,7 @@ export async function syncFromKiroCli() { } } - // If fetchUsageLimits failed, reuse a known real email for this profileArn/clientId - // to avoid creating a new placeholder row with a different hash. + // Reuse known email for this profileArn to avoid duplicate placeholder rows let resolvedEmail: string = email || makePlaceholderEmail(authMethod, serviceRegion, clientId, profileArn) if (resolvedEmail.startsWith('placeholder-')) { @@ -223,8 +222,6 @@ export async function syncFromKiroCli() { lastSync: Date.now() }) - // Remove stale rows for the same logical account that were created with - // the old hash key (which included the rotating clientId for IDC accounts). if (authMethod === 'idc' && profileArn) { await kiroDb.deleteStaleIdcDuplicates(id, resolvedEmail, profileArn) } diff --git a/src/plugin/types.ts b/src/plugin/types.ts index e8b05d3..ced5db6 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -82,6 +82,8 @@ export interface CodeWhispererMessage { export interface CodeWhispererRequest { conversationState: { + agentContinuationId?: string + agentTaskType?: string chatTriggerType: string conversationId: string history?: CodeWhispererMessage[] From 26eba2ae8eee2cb853d723704eefa8615e948739 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Wed, 20 May 2026 11:39:36 +0200 Subject: [PATCH 06/20] feat: agentContinuationId, isNewThread detection, and tool call streaming - Add agentContinuationId and agentTaskType to conversationState so Kiro groups agentic loop turns for credit optimization - Determine isNewThread after mergeAdjacentMessages (prevents false 400 on subagents/compaction) - Tool call streaming: accept chunks by toolUseId alone (name only on first chunk), fixing incomplete JSON on large tool inputs - SDK client: Connection: close header, explicit timeouts --- .../streaming/sdk-stream-transformer.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index af662c6..23e3150 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -1,4 +1,5 @@ import { parseBracketToolCalls } from '../../infrastructure/transformers/tool-call-parser.js' +import * as logger from '../logger.js' import { getContextWindowSize } from '../models.js' import { estimateTokens } from '../response.js' import { convertToOpenAI } from './openai-converter.js' @@ -125,10 +126,10 @@ export async function* transformSdkStream( if (tc.name) totalContent += tc.name if (tc.input) totalContent += tc.input - if (tc.name && tc.toolUseId) { + if (tc.toolUseId) { if (currentToolCall && currentToolCall.toolUseId === tc.toolUseId) { currentToolCall.input += tc.input || '' - } else { + } else if (tc.name) { if (currentToolCall) toolCalls.push(currentToolCall) currentToolCall = { toolUseId: tc.toolUseId, @@ -150,6 +151,11 @@ export async function* transformSdkStream( if (cue.contextUsagePercentage) { contextUsagePercentage = cue.contextUsagePercentage } + } else if ((event as any).meteringEvent) { + const me = (event as any).meteringEvent + logger.debug( + `[CREDITS] usage=${me.usage} ${me.unit || 'credit'}${me.usage !== 1 ? 's' : ''}` + ) } } @@ -215,6 +221,9 @@ export async function* transformSdkStream( const parsed = JSON.parse(tc.input) inputJson = JSON.stringify(parsed) } catch (e) { + logger.debug( + `[TOOL_CALL] Invalid JSON for tool "${tc.name}" (id=${tc.toolUseId}): ${tc.input.slice(0, 500)}` + ) inputJson = tc.input } @@ -264,6 +273,14 @@ export async function* transformSdkStream( yield convertToOpenAI({ type: 'message_stop' }, conversationId, model) } catch (e) { + logger.debug( + `[STREAM] Error in transformSdkStream: ${e instanceof Error ? e.message : String(e)}` + ) + if (currentToolCall) { + logger.debug( + `[STREAM] Incomplete tool call: name=${currentToolCall.name} id=${currentToolCall.toolUseId} inputLen=${currentToolCall.input.length}` + ) + } throw e } } From f939d154cf1578e0d4b473eb4d7d1ff1f8d91231 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Wed, 20 May 2026 11:40:06 +0200 Subject: [PATCH 07/20] chore: debug logging for request lifecycle and credits All behind DEBUG or OPENCODE_LOG_LEVEL=debug: - [REQ] convId, history length, agentContinuationId per request - [REQ] done/error on completion - [CREDITS] usage from Kiro metering event - [TOOL_CALL] invalid JSON on tool input parse failure - [STREAM] errors in stream transformer --- src/core/request/request-handler.ts | 70 +++++++++++++++++++++++++---- src/plugin/logger.ts | 2 +- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index 915a52b..1a97127 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -6,6 +6,7 @@ import { isPermanentError } from '../../plugin/health' import * as logger from '../../plugin/logger' import { transformToSdkRequest } from '../../plugin/request' import { createSdkClient } from '../../plugin/sdk-client' +import { kiroDb } from '../../plugin/storage/sqlite' import { syncFromKiroCli } from '../../plugin/sync/kiro-cli' import type { KiroAuthDetails, ManagedAccount, SdkPreparedRequest } from '../../plugin/types' import { AccountSelector } from '../account/account-selector' @@ -19,6 +20,7 @@ type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | const KIRO_API_PATTERN = /^(https?:\/\/)?q\.[a-z0-9-]+\.amazonaws\.com/ const REAUTH_FAILURE_COOLDOWN_MS = 60000 +const REAUTH_TIMEOUT_MS = 90_000 export class RequestHandler { private accountSelector: AccountSelector @@ -111,6 +113,12 @@ export class RequestHandler { const sdkPrep = this.prepareSdkRequest(init?.body, model, auth, think, budget, showToast) + const histLen = (sdkPrep.conversationState as any).history?.length || 0 + const agentContId = (sdkPrep.conversationState as any).agentContinuationId || 'none' + logger.debug( + `[REQ] convId=${sdkPrep.conversationId} history=${histLen} agentCont=${agentContId} model=${model}` + ) + const apiTimestamp = this.config.enable_log_api_request ? logger.getTimestamp() : null if (apiTimestamp) { this.logSdkRequest(sdkPrep, acc, apiTimestamp) @@ -132,13 +140,18 @@ export class RequestHandler { this.handleSuccessfulRequest(acc) this.usageTracker.syncUsage(acc, auth) - return await this.responseHandler.handleSdkSuccess( + const result = await this.responseHandler.handleSdkSuccess( sdkResponse, model, sdkPrep.conversationId, sdkPrep.streaming ) + logger.debug(`[REQ] done convId=${sdkPrep.conversationId}`) + return result } catch (e: any) { + logger.warn( + `[REQ] error convId=${sdkPrep.conversationId}: ${e?.name || ''} ${e?.message?.slice(0, 200) || String(e).slice(0, 200)}` + ) const httpStatus = e?.$metadata?.httpStatusCode if (httpStatus) { @@ -308,9 +321,26 @@ export class RequestHandler { return this.reauthInFlight } + if (!kiroDb.acquireReauthLock()) { + logger.warn('Reauth lock held by another instance — polling for completion') + showToast('Another session is re-authenticating. Please wait...', 'info') + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + await this.sleep(1000) + if (kiroDb.isReauthLockHeld()) continue + this.repository.invalidateCache() + const accounts = await this.repository.findAll() + for (const acc of accounts) this.accountManager.addAccount(acc) + return this.hasUsableAccount(accounts) + } + showToast('Re-authentication timed out. Please try again.', 'error') + return false + } + this.reauthInFlight = this.performReauth(showToast) const success = await this.reauthInFlight.finally(() => { this.reauthInFlight = null + kiroDb.releaseReauthLock() }) if (!success) this.lastFailedReauthAt = Date.now() return success @@ -319,15 +349,31 @@ export class RequestHandler { private async performReauth(showToast: ToastFunction): Promise { try { showToast('Session expired. Re-authenticating...', 'warning') - await this.client.provider.oauth.authorize({ - path: { id: 'kiro' }, - body: { method: 0 } - }) + logger.warn('Reauth: starting oauth flow') + + const withTimeout = (promise: Promise, label: string): Promise => { + let timer: ReturnType | undefined + return Promise.race([ + promise.finally(() => clearTimeout(timer)), + new Promise( + (_, reject) => + (timer = setTimeout( + () => reject(new Error(`Reauth timed out waiting for ${label}`)), + REAUTH_TIMEOUT_MS + )) + ) + ]) + } - await this.client.provider.oauth.callback({ - path: { id: 'kiro' }, - body: { method: 0 } - }) + await withTimeout( + this.client.provider.oauth.authorize({ path: { id: 'kiro' }, body: { method: 0 } }), + 'oauth.authorize' + ) + + await withTimeout( + this.client.provider.oauth.callback({ path: { id: 'kiro' }, body: { method: 0 } }), + 'oauth.callback' + ) this.repository.invalidateCache() const accounts = await this.repository.findAll() @@ -345,6 +391,12 @@ export class RequestHandler { return true } catch (e) { logger.error('Re-auth failed', e instanceof Error ? e : new Error(String(e))) + showToast( + e instanceof Error && e.message.includes('timed out') + ? 'Re-authentication timed out. Please try again.' + : 'Re-authentication failed. Please try again.', + 'error' + ) return false } } diff --git a/src/plugin/logger.ts b/src/plugin/logger.ts index b0f85fe..1938668 100644 --- a/src/plugin/logger.ts +++ b/src/plugin/logger.ts @@ -72,7 +72,7 @@ export function warn(message: string, ...args: unknown[]): void { } export function debug(message: string, ...args: unknown[]): void { - if (process.env.DEBUG) { + if (process.env.DEBUG || process.env.OPENCODE_LOG_LEVEL === 'debug') { writeToFile('DEBUG', message, ...args) } } From e8e1de4e9dbdd9db1e193590b8b959ddf33ea2fe Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Wed, 20 May 2026 11:59:34 +0200 Subject: [PATCH 08/20] fix: tool name length limit, input sanitization, and response dedup - Enforce 64-char max on tool names with SHA256 hash suffix for long names - Bidirectional name mapping: shorten on send, restore on response - Sanitize tool inputs: strip empty-string keys that Kiro rejects - Deduplicate tool calls in response (by name+arguments) - User-Agent with version and stable machineId hash - x-amzn-codewhisperer-optout header Reviewed best practices from github.com/justlovemaki/AIClient2API --- src/core/request/request-handler.ts | 3 +- src/core/request/response-handler.ts | 10 ++-- .../transformers/history-builder.ts | 6 +- .../transformers/tool-transformer.ts | 58 ++++++++++++++++++- src/plugin/request.ts | 19 +++--- src/plugin/sdk-client.ts | 13 ++++- .../streaming/sdk-stream-transformer.ts | 16 +++-- src/plugin/types.ts | 1 + 8 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index 1a97127..e499f7c 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -144,7 +144,8 @@ export class RequestHandler { sdkResponse, model, sdkPrep.conversationId, - sdkPrep.streaming + sdkPrep.streaming, + sdkPrep.toolNameMapper ) logger.debug(`[REQ] done convId=${sdkPrep.conversationId}`) return result diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index e95c78f..a1f7439 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -19,10 +19,11 @@ export class ResponseHandler { sdkResponse: any, model: string, conversationId: string, - streaming: boolean + streaming: boolean, + toolNameMapper?: (name: string) => string ): Promise { if (streaming) { - return this.handleSdkStreaming(sdkResponse, model, conversationId) + return this.handleSdkStreaming(sdkResponse, model, conversationId, toolNameMapper) } return this.handleSdkNonStreaming(sdkResponse, model, conversationId) } @@ -53,9 +54,10 @@ export class ResponseHandler { private async handleSdkStreaming( sdkResponse: any, model: string, - conversationId: string + conversationId: string, + toolNameMapper?: (name: string) => string ): Promise { - const s = transformSdkStream(sdkResponse, model, conversationId) + const s = transformSdkStream(sdkResponse, model, conversationId, toolNameMapper) return new Response( new ReadableStream({ async start(c) { diff --git a/src/infrastructure/transformers/history-builder.ts b/src/infrastructure/transformers/history-builder.ts index 951b914..0456898 100644 --- a/src/infrastructure/transformers/history-builder.ts +++ b/src/infrastructure/transformers/history-builder.ts @@ -6,7 +6,7 @@ import { } from '../../plugin/image-handler.js' import type { CodeWhispererMessage } from '../../plugin/types' import { getContentText } from './message-transformer.js' -import { deduplicateToolResults } from './tool-transformer.js' +import { deduplicateToolResults, shortenToolName } from './tool-transformer.js' /** * Collapse agentic loop sequences in the built history. @@ -154,7 +154,7 @@ export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessag if (p.type === 'text') arm.content += p.text || '' else if (p.type === 'thinking') th += p.thinking || p.text || '' else if (p.type === 'tool_use') - tus.push({ input: p.input, name: p.name, toolUseId: p.id }) + tus.push({ input: p.input, name: shortenToolName(p.name), toolUseId: p.id }) } } else arm.content = getContentText(m) if (m.tool_calls && Array.isArray(m.tool_calls)) { @@ -164,7 +164,7 @@ export function buildHistory(msgs: any[], resolved: string): CodeWhispererMessag typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function?.arguments, - name: tc.function?.name, + name: shortenToolName(tc.function?.name), toolUseId: tc.id }) } diff --git a/src/infrastructure/transformers/tool-transformer.ts b/src/infrastructure/transformers/tool-transformer.ts index ef785d9..d68a816 100644 --- a/src/infrastructure/transformers/tool-transformer.ts +++ b/src/infrastructure/transformers/tool-transformer.ts @@ -1,9 +1,50 @@ +import * as crypto from 'crypto' + +const MAX_TOOL_NAME_LENGTH = 64 + +export function shortenToolName(name: string): string { + if (!name || name.length <= MAX_TOOL_NAME_LENGTH) return name + const hash = crypto.createHash('sha256').update(name).digest('hex').slice(0, 12) + return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}` +} + +export function buildToolNameMaps(tools: any[]): { + toKiroName: (name: string) => string + fromKiroName: (name: string) => string +} { + const originalToAlias = new Map() + const aliasToOriginal = new Map() + + for (const t of tools) { + const name = t.name || t.function?.name + if (!name) continue + const alias = shortenToolName(name) + originalToAlias.set(name, alias) + if (alias !== name) aliasToOriginal.set(alias, name) + } + + return { + toKiroName: (name: string) => originalToAlias.get(name) || shortenToolName(name), + fromKiroName: (name: string) => aliasToOriginal.get(name) || name + } +} + +function sanitizeToolInput(input: any): any { + if (!input || typeof input !== 'object' || Array.isArray(input)) return input + const result: Record = {} + for (const [key, value] of Object.entries(input)) { + if (key === '') continue + result[key] = value + } + return result +} + export function convertToolsToCodeWhisperer(tools: any[]): any[] { return tools.map((t) => ({ toolSpecification: { - name: t.name || t.function?.name, + name: shortenToolName(t.name || t.function?.name || ''), description: (t.description || t.function?.description || '').substring(0, 9216), - inputSchema: { json: t.input_schema || t.function?.parameters || {} } + inputSchema: { json: sanitizeToolInput(t.input_schema || t.function?.parameters || {}) } } })) } @@ -19,3 +60,16 @@ export function deduplicateToolResults(trs: any[]): any[] { } return u } + +export function deduplicateToolCallsByContent(toolCalls: any[]): any[] { + const seen = new Set() + const unique: any[] = [] + for (const tc of toolCalls) { + const key = `${tc.name || tc.function?.name}-${tc.input || tc.function?.arguments}` + if (!seen.has(key)) { + seen.add(key) + unique.push(tc) + } + } + return unique +} diff --git a/src/plugin/request.ts b/src/plugin/request.ts index 99e1db4..937872c 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -13,8 +13,10 @@ import { mergeAdjacentMessages } from '../infrastructure/transformers/message-transformer.js' import { + buildToolNameMaps, convertToolsToCodeWhisperer, - deduplicateToolResults + deduplicateToolResults, + shortenToolName } from '../infrastructure/transformers/tool-transformer.js' import { convertImagesToKiroFormat, @@ -69,6 +71,7 @@ interface TransformResult { resolved: string convId: string agentContinuationId: string + toolNameMapper?: (name: string) => string } type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void @@ -115,6 +118,7 @@ function buildCodeWhispererRequest( isNewThread ) const resolved = resolveKiroModel(model) + const toolMaps = tools ? buildToolNameMaps(tools) : undefined const cwTools = tools ? convertToolsToCodeWhisperer(tools) : [] let history = buildHistory(msgs, resolved) @@ -158,7 +162,7 @@ function buildCodeWhispererRequest( else if (p.type === 'thinking') 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 }) + arm.toolUses.push({ input: p.input, name: shortenToolName(p.name), toolUseId: p.id }) } } } else arm.content = getContentText(curMsg) @@ -170,7 +174,7 @@ function buildCodeWhispererRequest( typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function?.arguments, - name: tc.function?.name, + name: shortenToolName(tc.function?.name), toolUseId: tc.id }) } @@ -255,7 +259,7 @@ function buildCodeWhispererRequest( if (originalCall) { orphanedTrs.push({ call: { - name: originalCall.name || originalCall.function?.name || 'tool', + name: shortenToolName(originalCall.name || originalCall.function?.name || 'tool'), toolUseId: tr.toolUseId, input: originalCall.input || @@ -323,7 +327,7 @@ function buildCodeWhispererRequest( } } - return { request, resolved, convId, agentContinuationId } + return { request, resolved, convId, agentContinuationId, toolNameMapper: toolMaps?.fromKiroName } } export function transformToCodeWhisperer( @@ -382,7 +386,7 @@ export function transformToSdkRequest( showToast?: ToastFunction, workspace = '' ): SdkPreparedRequest { - const { request, resolved, convId } = buildCodeWhispererRequest( + const { request, resolved, convId, toolNameMapper } = buildCodeWhispererRequest( body, model, auth, @@ -397,6 +401,7 @@ export function transformToSdkRequest( streaming: true, effectiveModel: resolved, conversationId: convId, - region: extractRegionFromArn(auth.profileArn) ?? auth.region + region: extractRegionFromArn(auth.profileArn) ?? auth.region, + toolNameMapper } } diff --git a/src/plugin/sdk-client.ts b/src/plugin/sdk-client.ts index 08ba707..402b63b 100644 --- a/src/plugin/sdk-client.ts +++ b/src/plugin/sdk-client.ts @@ -1,7 +1,15 @@ import { CodeWhispererStreamingClient } from '@aws/codewhisperer-streaming-client' +import * as crypto from 'crypto' import { KIRO_CONSTANTS } from '../constants.js' import type { KiroAuthDetails } from './types' +const KIRO_VERSION = '0.11.63' + +function getMachineId(auth: KiroAuthDetails): string { + const key = auth.profileArn || auth.email || 'default' + return crypto.createHash('sha256').update(key).digest('hex') +} + const clientCache = new Map() export function createSdkClient( @@ -15,13 +23,14 @@ export function createSdkClient( return cached.client } + const machineId = getMachineId(auth) const token = auth.access const client = new CodeWhispererStreamingClient({ region, endpoint: `https://q.${region}.amazonaws.com`, token: () => Promise.resolve({ token }), maxAttempts: 1, - customUserAgent: [[KIRO_CONSTANTS.USER_AGENT]], + customUserAgent: [[`${KIRO_CONSTANTS.USER_AGENT}-${KIRO_VERSION}-${machineId}`]], requestHandler: { connectionTimeout: 10000, requestTimeout: 120000 @@ -31,7 +40,7 @@ export function createSdkClient( client.middlewareStack.add( (next: any) => async (args: any) => { args.request.headers['x-amzn-kiro-agent-mode'] = 'vibe' - args.request.headers['Connection'] = 'close' + args.request.headers['x-amzn-codewhisperer-optout'] = 'true' return next(args) }, { step: 'build', name: 'addKiroHeaders' } diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index 23e3150..d2e6a4f 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -1,4 +1,5 @@ import { parseBracketToolCalls } from '../../infrastructure/transformers/tool-call-parser.js' +import { deduplicateToolCallsByContent } from '../../infrastructure/transformers/tool-transformer.js' import * as logger from '../logger.js' import { getContextWindowSize } from '../models.js' import { estimateTokens } from '../response.js' @@ -10,7 +11,8 @@ import { StreamState, THINKING_END_TAG, THINKING_START_TAG, ToolCallState } from export async function* transformSdkStream( sdkResponse: any, model: string, - conversationId: string + conversationId: string, + toolNameMapper?: (name: string) => string ): AsyncGenerator { const thinkingRequested = true @@ -133,7 +135,7 @@ export async function* transformSdkStream( if (currentToolCall) toolCalls.push(currentToolCall) currentToolCall = { toolUseId: tc.toolUseId, - name: tc.name, + name: toolNameMapper ? toolNameMapper(tc.name) : tc.name, input: tc.input || '' } } @@ -194,10 +196,12 @@ export async function* transformSdkStream( } } - if (toolCalls.length > 0) { + const dedupedToolCalls = deduplicateToolCallsByContent(toolCalls) + + if (dedupedToolCalls.length > 0) { const baseIndex = streamState.nextBlockIndex - for (let i = 0; i < toolCalls.length; i++) { - const tc = toolCalls[i] + for (let i = 0; i < dedupedToolCalls.length; i++) { + const tc = dedupedToolCalls[i] if (!tc) continue const blockIndex = baseIndex + i @@ -259,7 +263,7 @@ export async function* transformSdkStream( yield convertToOpenAI( { type: 'message_delta', - delta: { stop_reason: toolCalls.length > 0 ? 'tool_use' : 'end_turn' }, + delta: { stop_reason: dedupedToolCalls.length > 0 ? 'tool_use' : 'end_turn' }, usage: { input_tokens: inputTokens, output_tokens: outputTokens, diff --git a/src/plugin/types.ts b/src/plugin/types.ts index ced5db6..1be18b4 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -121,6 +121,7 @@ export interface SdkPreparedRequest { effectiveModel: string conversationId: string region: string + toolNameMapper?: (name: string) => string } export type AccountSelectionStrategy = 'sticky' | 'round-robin' | 'lowest-usage' From b636d837c4717c13c4dc8b84ddc87a9ab46e49f1 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Thu, 21 May 2026 08:50:41 +0200 Subject: [PATCH 09/20] fix: payload size guard, schema sanitization, strip empty toolUses - Trim history from front when payload exceeds Kiro's ~615KB limit - Recursively strip additionalProperties and empty required arrays from tool input schemas (Kiro rejects these) - Remove empty toolUses arrays from history entries (Kiro quirk) Reviewed best practices from github.com/jwadow/kiro-gateway --- .../transformers/tool-transformer.ts | 27 ++++++++++++++++++- src/plugin/request.ts | 15 +++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/transformers/tool-transformer.ts b/src/infrastructure/transformers/tool-transformer.ts index d68a816..12aca12 100644 --- a/src/infrastructure/transformers/tool-transformer.ts +++ b/src/infrastructure/transformers/tool-transformer.ts @@ -39,12 +39,37 @@ function sanitizeToolInput(input: any): any { return result } +function sanitizeSchema(schema: any): any { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema + const result: Record = {} + for (const [key, value] of Object.entries(schema)) { + if (key === 'additionalProperties') continue + if (key === 'required' && Array.isArray(value) && value.length === 0) continue + if (key === 'properties' && typeof value === 'object' && value !== null) { + const props: Record = {} + for (const [pk, pv] of Object.entries(value)) { + props[pk] = sanitizeSchema(pv) + } + result[key] = props + } else if ((key === 'anyOf' || key === 'oneOf' || key === 'allOf') && Array.isArray(value)) { + result[key] = value.map(sanitizeSchema) + } else if (key === 'items' && typeof value === 'object') { + result[key] = sanitizeSchema(value) + } else { + result[key] = value + } + } + return result +} + export function convertToolsToCodeWhisperer(tools: any[]): any[] { return tools.map((t) => ({ toolSpecification: { name: shortenToolName(t.name || t.function?.name || ''), description: (t.description || t.function?.description || '').substring(0, 9216), - inputSchema: { json: sanitizeToolInput(t.input_schema || t.function?.parameters || {}) } + inputSchema: { + json: sanitizeSchema(sanitizeToolInput(t.input_schema || t.function?.parameters || {})) + } } })) } diff --git a/src/plugin/request.ts b/src/plugin/request.ts index 937872c..cb68730 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -327,6 +327,21 @@ function buildCodeWhispererRequest( } } + // Strip empty toolUses arrays from history (Kiro quirk) + for (const h of history) { + if (h.assistantResponseMessage?.toolUses && h.assistantResponseMessage.toolUses.length === 0) { + delete h.assistantResponseMessage.toolUses + } + } + + // Trim history if payload exceeds Kiro's ~615KB limit + const MAX_PAYLOAD_BYTES = 600_000 + while (history.length > 2) { + const size = JSON.stringify(request).length + if (size <= MAX_PAYLOAD_BYTES) break + history.splice(0, 2) + } + return { request, resolved, convId, agentContinuationId, toolNameMapper: toolMaps?.fromKiroName } } From 450789931a388948058f00dafbb56b7e8b4f5f73 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Thu, 21 May 2026 09:45:16 +0200 Subject: [PATCH 10/20] fix: reset stale conversationId on ValidationException 400 --- src/__tests__/sqlite.test.ts | 11 +++++ src/__tests__/stream-transformer.test.ts | 57 ++++++++++++++++++++++++ src/core/request/request-handler.ts | 11 +++++ src/plugin/request.ts | 21 ++++++--- src/plugin/storage/sqlite.ts | 6 +++ src/plugin/types.ts | 1 + 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/__tests__/sqlite.test.ts b/src/__tests__/sqlite.test.ts index bb7b2ca..41456d0 100644 --- a/src/__tests__/sqlite.test.ts +++ b/src/__tests__/sqlite.test.ts @@ -214,4 +214,15 @@ describe('KiroDatabase: conversations', () => { agentContinuationId: 'agent-new' }) }) + + test('deleteConversationId removes entry so next lookup returns undefined', () => { + db.setConversationId('ws', 'fp', 'conv-del', 'agent-del') + expect(db.getConversationId('ws', 'fp')).toBeDefined() + db.deleteConversationId('ws', 'fp') + expect(db.getConversationId('ws', 'fp')).toBeUndefined() + }) + + test('deleteConversationId is a no-op for non-existent entry', () => { + expect(() => db.deleteConversationId('ws', 'missing')).not.toThrow() + }) }) diff --git a/src/__tests__/stream-transformer.test.ts b/src/__tests__/stream-transformer.test.ts index 9b67073..cbe2129 100644 --- a/src/__tests__/stream-transformer.test.ts +++ b/src/__tests__/stream-transformer.test.ts @@ -246,3 +246,60 @@ describe('isNewThread detection after merge', () => { expect(merged[1].content).toContain('part2') }) }) + +// ── Tool name consistency (>64 chars must be shortened everywhere) ─────────── + +import { shortenToolName } from '../infrastructure/transformers/tool-transformer.js' + +describe('tool name >64 chars is shortened consistently in history', () => { + const longName = + 'a_very_long_tool_name_that_definitely_exceeds_the_sixty_four_character_limit_imposed_by_kiro' + + test('buildHistory shortens tool_use names', () => { + const msgs = [ + { role: 'user', content: 'do it' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'calling tool' }, + { type: 'tool_use', id: 'tu-1', name: longName, input: { x: 1 } } + ] + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tu-1', content: 'done' }] + } + ] + const merged = mergeAdjacentMessages([...msgs]) + const history = buildHistory(merged, 'auto') + const toolUses = history.flatMap((h) => h.assistantResponseMessage?.toolUses || []) + expect(toolUses.length).toBeGreaterThan(0) + for (const tu of toolUses) { + expect(tu.name.length).toBeLessThanOrEqual(64) + expect(tu.name).toBe(shortenToolName(longName)) + } + }) + + test('buildHistory shortens tool_calls names', () => { + const msgs = [ + { role: 'user', content: 'do it' }, + { + role: 'assistant', + content: 'ok', + tool_calls: [{ id: 'tc-1', function: { name: longName, arguments: '{"a":1}' } }] + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tc-1', content: 'result' }] + } + ] + const merged = mergeAdjacentMessages([...msgs]) + const history = buildHistory(merged, 'auto') + const toolUses = history.flatMap((h) => h.assistantResponseMessage?.toolUses || []) + expect(toolUses.length).toBeGreaterThan(0) + for (const tu of toolUses) { + expect(tu.name.length).toBeLessThanOrEqual(64) + expect(tu.name).toBe(shortenToolName(longName)) + } + }) +}) diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index e499f7c..482cd1d 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -69,6 +69,7 @@ export class RequestHandler { let retry = 0 let consecutiveNullAccounts = 0 + let forceNewConversation = false const retryContext = this.retryStrategy.createContext() while (true) { @@ -187,6 +188,16 @@ export class RequestHandler { continue } + if (httpStatus === 400 && e?.name === 'ValidationException' && !forceNewConversation) { + const { workspace, fingerprint } = sdkPrep.conversationKey + kiroDb.deleteConversationId(workspace, fingerprint) + logger.warn( + `[REQ] stale conversationId reset, retrying convId=${sdkPrep.conversationId}` + ) + forceNewConversation = true + continue + } + if (this.allAccountsPermanentlyUnhealthy()) { const reauthed = await this.triggerReauth(showToast) if (reauthed) continue diff --git a/src/plugin/request.ts b/src/plugin/request.ts index cb68730..7c0543f 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -37,7 +37,7 @@ function deriveConversationIds( workspace: string, firstUserContent: string, isNewThread: boolean -): { convId: string; agentContinuationId: string } { +): { convId: string; agentContinuationId: string; fingerprint: string } { const fingerprint = crypto .createHash('sha256') .update(workspace + '\0' + (firstUserContent || '_empty_')) @@ -56,14 +56,14 @@ function deriveConversationIds( existing.agentContinuationId ) } - return existing + return { ...existing, fingerprint } } } const convId = crypto.randomUUID() const agentContinuationId = crypto.randomUUID() kiroDb.setConversationId(workspace, fingerprint, convId, agentContinuationId) - return { convId, agentContinuationId } + return { convId, agentContinuationId, fingerprint } } interface TransformResult { @@ -71,6 +71,7 @@ interface TransformResult { resolved: string convId: string agentContinuationId: string + fingerprint: string toolNameMapper?: (name: string) => string } @@ -112,7 +113,7 @@ function buildCodeWhispererRequest( ? firstUserMsg.content : JSON.stringify(firstUserMsg.content) : '' - const { convId, agentContinuationId } = deriveConversationIds( + const { convId, agentContinuationId, fingerprint } = deriveConversationIds( workspace, firstUserContent, isNewThread @@ -342,7 +343,14 @@ function buildCodeWhispererRequest( history.splice(0, 2) } - return { request, resolved, convId, agentContinuationId, toolNameMapper: toolMaps?.fromKiroName } + return { + request, + resolved, + convId, + agentContinuationId, + fingerprint, + toolNameMapper: toolMaps?.fromKiroName + } } export function transformToCodeWhisperer( @@ -401,7 +409,7 @@ export function transformToSdkRequest( showToast?: ToastFunction, workspace = '' ): SdkPreparedRequest { - const { request, resolved, convId, toolNameMapper } = buildCodeWhispererRequest( + const { request, resolved, convId, fingerprint, toolNameMapper } = buildCodeWhispererRequest( body, model, auth, @@ -416,6 +424,7 @@ export function transformToSdkRequest( streaming: true, effectiveModel: resolved, conversationId: convId, + conversationKey: { workspace, fingerprint }, region: extractRegionFromArn(auth.profileArn) ?? auth.region, toolNameMapper } diff --git a/src/plugin/storage/sqlite.ts b/src/plugin/storage/sqlite.ts index c997b15..fcdf362 100644 --- a/src/plugin/storage/sqlite.ts +++ b/src/plugin/storage/sqlite.ts @@ -277,6 +277,12 @@ export class KiroDatabase { : undefined } + deleteConversationId(workspace: string, fingerprint: string): void { + this.db + .prepare('DELETE FROM conversations WHERE workspace = ? AND fingerprint = ?') + .run(workspace, fingerprint) + } + /** * Persist a conversationId and agentContinuationId, clean up entries older than ttlDays (default 7). */ diff --git a/src/plugin/types.ts b/src/plugin/types.ts index 1be18b4..06dfdca 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -120,6 +120,7 @@ export interface SdkPreparedRequest { streaming: boolean effectiveModel: string conversationId: string + conversationKey: { workspace: string; fingerprint: string } region: string toolNameMapper?: (name: string) => string } From dff010fe11b9ace615f8b8c16be46db6e1b1508a Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Thu, 21 May 2026 12:36:53 +0200 Subject: [PATCH 11/20] fix: payload trim, schema sanitization, tool name edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Payload trim: incremental size accounting (O(N) instead of O(N²) on large histories), strip orphaned toolUses/toolResults to keep history valid for Kiro - shortenToolName: don't split surrogate pairs when truncating long names - sanitizeSchema: recurse into not/patternProperties/prefixItems/$defs /definitions/contains, guard against circular schemas - deduplicateToolCallsByContent: use \\x00 separator to avoid name/input boundary collisions Adds edge-case test suite covering all of the above. --- src/__tests__/edge-cases.test.ts | 688 ++++++++++++++++++ .../transformers/tool-transformer.ts | 49 +- src/plugin/request.ts | 41 +- 3 files changed, 764 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/edge-cases.test.ts diff --git a/src/__tests__/edge-cases.test.ts b/src/__tests__/edge-cases.test.ts new file mode 100644 index 0000000..13898f6 --- /dev/null +++ b/src/__tests__/edge-cases.test.ts @@ -0,0 +1,688 @@ +import { describe, expect, mock, test } from 'bun:test' + +mock.module('../plugin/storage/sqlite.js', () => ({ + kiroDb: { + getConversationId: () => undefined, + setConversationId: () => {}, + deleteConversationId: () => {}, + getAccounts: () => [], + upsertAccount: () => Promise.resolve(), + deleteAccount: () => Promise.resolve(), + batchUpsertAccounts: () => Promise.resolve() + } +})) + +mock.module('../plugin/sync/kiro-cli.js', () => ({ writeToKiroCli: () => Promise.resolve() })) +mock.module('../kiro/auth.js', () => ({ + decodeRefreshToken: (t: string) => ({ refreshToken: t }), + encodeRefreshToken: (p: any) => p.refreshToken +})) + +import { + buildHistory, + collapseAgenticLoops +} from '../infrastructure/transformers/history-builder.js' +import { mergeAdjacentMessages } from '../infrastructure/transformers/message-transformer.js' +import { + buildToolNameMaps, + convertToolsToCodeWhisperer, + deduplicateToolCallsByContent, + shortenToolName +} from '../infrastructure/transformers/tool-transformer.js' +import { transformToSdkRequest } from '../plugin/request.js' + +const auth = { + access: 'token', + region: 'us-east-1', + email: 'test@test.com', + profileArn: 'arn:aws:codewhisperer:us-east-1:123:profile/ABC' +} + +describe('shortenToolName edge cases', () => { + test('null input', () => { + expect(shortenToolName(null as any)).toBeFalsy() + }) + + test('undefined input', () => { + expect(shortenToolName(undefined as any)).toBeFalsy() + }) + + test('empty string', () => { + expect(shortenToolName('')).toBe('') + }) + + test('exactly 64 chars', () => { + const name = 'a'.repeat(64) + expect(shortenToolName(name)).toBe(name) + }) + + test('65 chars', () => { + const name = 'a'.repeat(65) + const result = shortenToolName(name) + expect(result.length).toBeLessThanOrEqual(64) + }) + + test('200 chars', () => { + const name = 'a'.repeat(200) + const result = shortenToolName(name) + expect(result.length).toBeLessThanOrEqual(64) + }) + + test('special chars', () => { + const name = '🎉'.repeat(40) + '\n\t' + 'ñ'.repeat(30) + const result = shortenToolName(name) + expect(result.length).toBeLessThanOrEqual(64) + }) + + test('does not split surrogate pair', () => { + // 🎉 is U+1F389 = 2 UTF-16 code units. 40 of them = 80 UTF-16 chars. + // The naive slice would land between high+low surrogate. + const name = '🎉'.repeat(40) + const result = shortenToolName(name) + expect(result.length).toBeLessThanOrEqual(64) + // No unpaired surrogate left in the prefix + expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(result)).toBe(false) + expect(/(? { + const a = 'tool_a_'.repeat(20) + const b = 'tool_b_'.repeat(20) + expect(shortenToolName(a)).not.toBe(shortenToolName(b)) + }) + + test('same long name is deterministic', () => { + const name = 'x'.repeat(100) + expect(shortenToolName(name)).toBe(shortenToolName(name)) + }) +}) + +describe('sanitizeSchema via convertToolsToCodeWhisperer', () => { + test('deeply nested schema strips additionalProperties', () => { + const tools = [ + { + name: 'deep', + description: 'test', + input_schema: { + type: 'object', + additionalProperties: false, + required: [], + properties: { + nested: { + type: 'object', + additionalProperties: true, + required: [], + properties: { + items: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: [], + properties: { + value: { + anyOf: [ + { type: 'string', additionalProperties: false, required: [] }, + { type: 'number', additionalProperties: true } + ] + } + } + } + } + } + } + } + } + } + ] + const result = convertToolsToCodeWhisperer(tools) + const json = JSON.stringify(result) + expect(json).not.toContain('additionalProperties') + expect(json).not.toContain('"required":[]') + }) + + test('schema with empty required at multiple levels', () => { + const tools = [ + { + name: 'req', + description: 'test', + input_schema: { + type: 'object', + required: [], + properties: { + a: { type: 'object', required: [], properties: { b: { type: 'string', required: [] } } } + } + } + } + ] + const result = convertToolsToCodeWhisperer(tools) + expect(JSON.stringify(result)).not.toContain('"required":[]') + }) + + test('schema with ONLY additionalProperties and required:[]', () => { + const tools = [ + { + name: 'minimal', + description: 'test', + input_schema: { additionalProperties: false, required: [] } + } + ] + const result = convertToolsToCodeWhisperer(tools) + expect(result[0].toolSpecification.inputSchema.json).toEqual({}) + }) + + test('null schema', () => { + const tools = [{ name: 'n', description: 'test', input_schema: null }] + expect(() => convertToolsToCodeWhisperer(tools)).not.toThrow() + }) + + test('undefined schema', () => { + const tools = [{ name: 'u', description: 'test' }] + expect(() => convertToolsToCodeWhisperer(tools)).not.toThrow() + }) + + test('strips additionalProperties from "not" subschema', () => { + const tools = [ + { + name: 'n', + description: 'test', + input_schema: { type: 'object', not: { additionalProperties: false, required: [] } } + } + ] + const result = convertToolsToCodeWhisperer(tools) + expect(JSON.stringify(result)).not.toContain('additionalProperties') + expect(JSON.stringify(result)).not.toContain('"required":[]') + }) + + test('strips additionalProperties from patternProperties', () => { + const tools = [ + { + name: 'p', + description: 'test', + input_schema: { + type: 'object', + patternProperties: { '^x': { additionalProperties: false, required: [] } } + } + } + ] + const result = convertToolsToCodeWhisperer(tools) + expect(JSON.stringify(result)).not.toContain('additionalProperties') + }) + + test('strips additionalProperties from $defs', () => { + const tools = [ + { + name: 'd', + description: 'test', + input_schema: { + type: 'object', + $defs: { foo: { additionalProperties: false, required: [] } } + } + } + ] + const result = convertToolsToCodeWhisperer(tools) + expect(JSON.stringify(result)).not.toContain('additionalProperties') + }) + + test('strips additionalProperties from prefixItems', () => { + const tools = [ + { + name: 'pi', + description: 'test', + input_schema: { + type: 'array', + prefixItems: [{ additionalProperties: false }, { additionalProperties: true }] + } + } + ] + const result = convertToolsToCodeWhisperer(tools) + expect(JSON.stringify(result)).not.toContain('additionalProperties') + }) + + test('handles circular schema reference without stack overflow', () => { + const obj: any = { type: 'object', additionalProperties: false, properties: {} } + obj.properties.self = obj + const tools = [{ name: 'c', description: 'test', input_schema: obj }] + expect(() => convertToolsToCodeWhisperer(tools)).not.toThrow() + }) + + test('does not mutate input schema', () => { + const original = { + type: 'object', + additionalProperties: false, + required: [], + properties: { x: { type: 'string', additionalProperties: false } } + } + const before = JSON.stringify(original) + convertToolsToCodeWhisperer([{ name: 't', description: 't', input_schema: original }]) + expect(JSON.stringify(original)).toBe(before) + }) +}) + +describe('payload trim preserves valid structure', () => { + test('large payload trimmed to ≤600KB', () => { + const messages: any[] = [] + for (let i = 0; i < 80; i++) { + messages.push({ role: 'user', content: 'x'.repeat(10000) }) + messages.push({ + role: 'assistant', + content: [ + { type: 'text', text: 'y'.repeat(5000) }, + { type: 'tool_use', id: `tool-${i}`, name: 'myTool', input: { q: 'test' } } + ] + }) + } + messages.push({ + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-79', content: 'result' }] + }) + messages.push({ role: 'user', content: 'final question' }) + + const body = { + messages, + tools: [{ name: 'myTool', description: 'a tool', input_schema: { type: 'object' } }] + } + const result = transformToSdkRequest(body, 'auto', auth) + const payload = JSON.stringify(result.conversationState) + expect(payload.length).toBeLessThanOrEqual(600_000) + }) + + test('history starts with userInputMessage after trim', () => { + const messages: any[] = [] + for (let i = 0; i < 80; i++) { + messages.push({ role: 'user', content: 'x'.repeat(10000) }) + messages.push({ role: 'assistant', content: 'y'.repeat(10000) }) + } + messages.push({ role: 'user', content: 'final' }) + + const body = { messages } + const result = transformToSdkRequest(body, 'auto', auth) + const history = result.conversationState.history + if (history && history.length > 0) { + expect(history[0].userInputMessage).toBeDefined() + } + }) + + test('toolResults have matching toolUseIds in history after trim', () => { + const messages: any[] = [] + for (let i = 0; i < 40; i++) { + messages.push({ role: 'user', content: 'x'.repeat(20000) }) + messages.push({ + role: 'assistant', + content: [ + { type: 'text', text: 'y'.repeat(5000) }, + { type: 'tool_use', id: `t-${i}`, name: 'myTool', input: { q: 'test' } } + ] + }) + messages.push({ + role: 'user', + content: [{ type: 'tool_result', tool_use_id: `t-${i}`, content: 'result '.repeat(1000) }] + }) + } + messages.push({ role: 'user', content: 'final' }) + + const body = { + messages, + tools: [{ name: 'myTool', description: 'd', input_schema: { type: 'object' } }] + } + const result = transformToSdkRequest(body, 'auto', auth) + const history = result.conversationState.history || [] + + const allToolUseIds = new Set() + for (const h of history) { + if (h.assistantResponseMessage?.toolUses) { + for (const tu of h.assistantResponseMessage.toolUses) allToolUseIds.add(tu.toolUseId) + } + } + for (const h of history) { + if (h.userInputMessage?.userInputMessageContext?.toolResults) { + for (const tr of h.userInputMessage.userInputMessageContext.toolResults) { + expect(allToolUseIds.has(tr.toolUseId)).toBe(true) + } + } + } + }) +}) + +describe('mergeAdjacentMessages edge cases', () => { + test('empty array', () => { + expect(mergeAdjacentMessages([])).toEqual([]) + }) + + test('assistant string + assistant array with tool_use', () => { + const msgs = [ + { role: 'assistant', content: 'hello' }, + { role: 'assistant', content: [{ type: 'tool_use', id: '1', name: 'x', input: {} }] } + ] + const result = mergeAdjacentMessages(msgs) + expect(result).toHaveLength(1) + expect(result[0].content).toContainEqual({ type: 'tool_use', id: '1', name: 'x', input: {} }) + }) + + test('assistant with tool_calls + assistant with tool_calls', () => { + const msgs = [ + { + role: 'assistant', + content: 'a', + tool_calls: [{ id: '1', function: { name: 'x', arguments: '{}' } }] + }, + { + role: 'assistant', + content: 'b', + tool_calls: [{ id: '2', function: { name: 'y', arguments: '{}' } }] + } + ] + const result = mergeAdjacentMessages(msgs) + expect(result).toHaveLength(1) + expect(result[0].tool_calls).toHaveLength(2) + }) + + test('user string + user array', () => { + const msgs = [ + { role: 'user', content: 'hello' }, + { role: 'user', content: [{ type: 'text', text: 'world' }] } + ] + const result = mergeAdjacentMessages(msgs) + expect(result).toHaveLength(1) + expect(Array.isArray(result[0].content)).toBe(true) + }) + + test('3 consecutive same-role messages', () => { + const msgs = [ + { role: 'assistant', content: 'a' }, + { role: 'assistant', content: 'b' }, + { role: 'assistant', content: 'c' } + ] + const result = mergeAdjacentMessages(msgs) + expect(result).toHaveLength(1) + expect(result[0].content).toBe('a\nb\nc') + }) +}) + +describe('buildHistory with null/undefined content', () => { + test('assistant with content: null', () => { + const msgs = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: null }, + { role: 'user', content: 'bye' } + ] + expect(() => buildHistory(msgs, 'model')).not.toThrow() + }) + + test('assistant with content: undefined', () => { + const msgs = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: undefined }, + { role: 'user', content: 'bye' } + ] + expect(() => buildHistory(msgs, 'model')).not.toThrow() + }) + + test('user with content: null', () => { + const msgs = [ + { role: 'user', content: null }, + { role: 'user', content: 'bye' } + ] + expect(() => buildHistory(msgs, 'model')).not.toThrow() + }) + + test('assistant with empty tool_calls array', () => { + const msgs = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'text', tool_calls: [] }, + { role: 'user', content: 'bye' } + ] + const history = buildHistory(msgs, 'model') + const asst = history.find((h) => h.assistantResponseMessage) + expect(asst?.assistantResponseMessage?.toolUses).toBeUndefined() + }) + + test('assistant with tool_use where name is undefined', () => { + const msgs = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: [{ type: 'tool_use', id: '1', name: undefined, input: {} }] }, + { role: 'user', content: 'bye' } + ] + expect(() => buildHistory(msgs, 'model')).not.toThrow() + }) +}) + +describe('collapseAgenticLoops edge cases', () => { + test('single assistant+user pair passes through unchanged', () => { + const history = [ + { + assistantResponseMessage: { + content: 'text', + toolUses: [{ name: 'x', toolUseId: '1', input: {} }] + } + }, + { + userInputMessage: { + content: 'result', + userInputMessageContext: { + toolResults: [{ toolUseId: '1', content: [{ text: 'r' }], status: 'success' }] + } + } + } + ] + const result = collapseAgenticLoops(history as any) + expect(result).toHaveLength(2) + expect(result[0].assistantResponseMessage?.content).toBe('text') + }) + + test('assistant with toolUses without following user passes through', () => { + const history = [ + { userInputMessage: { content: 'hi' } }, + { + assistantResponseMessage: { + content: 'text', + toolUses: [{ name: 'x', toolUseId: '1', input: {} }] + } + } + ] + const result = collapseAgenticLoops(history as any) + expect(result).toHaveLength(2) + }) + + test('3 pairs collapse correctly', () => { + const history = [ + { + assistantResponseMessage: { + content: 'first', + toolUses: [{ name: 'a', toolUseId: '1', input: {} }] + } + }, + { + userInputMessage: { + content: 'r1', + userInputMessageContext: { + toolResults: [{ toolUseId: '1', content: [{ text: 'r' }], status: 'success' }] + } + } + }, + { + assistantResponseMessage: { + content: 'second', + toolUses: [{ name: 'b', toolUseId: '2', input: {} }] + } + }, + { + userInputMessage: { + content: 'r2', + userInputMessageContext: { + toolResults: [{ toolUseId: '2', content: [{ text: 'r' }], status: 'success' }] + } + } + }, + { + assistantResponseMessage: { + content: 'third', + toolUses: [{ name: 'c', toolUseId: '3', input: {} }] + } + }, + { + userInputMessage: { + content: 'r3', + userInputMessageContext: { + toolResults: [{ toolUseId: '3', content: [{ text: 'r' }], status: 'success' }] + } + } + } + ] + const result = collapseAgenticLoops(history as any) + expect(result[0].assistantResponseMessage?.content).toBe('first') + expect(result[2].assistantResponseMessage?.content).toBe('[system: tool calling continues]') + expect(result[4].assistantResponseMessage?.content).toBe('[system: tool calling continues]') + }) +}) + +describe('deduplicateToolCallsByContent edge cases', () => { + test('same name different string inputs kept', () => { + const calls = [ + { name: 'tool', input: 'input1' }, + { name: 'tool', input: 'input2' } + ] + expect(deduplicateToolCallsByContent(calls)).toHaveLength(2) + }) + + test('same name same string input deduplicated', () => { + const calls = [ + { name: 'tool', input: 'same' }, + { name: 'tool', input: 'same' } + ] + expect(deduplicateToolCallsByContent(calls)).toHaveLength(1) + }) + + test('empty array', () => { + expect(deduplicateToolCallsByContent([])).toEqual([]) + }) + + test('separator collision: name="a-b" input="c" vs name="a" input="b-c"', () => { + // Old impl used `${name}-${input}` so these would collapse to "a-b-c". + const calls = [ + { name: 'a-b', input: 'c' }, + { name: 'a', input: 'b-c' } + ] + expect(deduplicateToolCallsByContent(calls)).toHaveLength(2) + }) + + test('object input is stringified before compare', () => { + const calls = [ + { name: 'tool', input: { x: 1 } }, + { name: 'tool', input: { x: 1 } }, + { name: 'tool', input: { x: 2 } } + ] + expect(deduplicateToolCallsByContent(calls)).toHaveLength(2) + }) +}) + +describe('convertToolsToCodeWhisperer edge cases', () => { + test('empty tools array', () => { + expect(convertToolsToCodeWhisperer([])).toEqual([]) + }) + + test('tool with undefined name', () => { + const tools = [{ description: 'test', input_schema: { type: 'object' } }] + expect(() => convertToolsToCodeWhisperer(tools)).not.toThrow() + }) + + test('tool with description >9216 chars truncated', () => { + const tools = [ + { name: 'big', description: 'x'.repeat(10000), input_schema: { type: 'object' } } + ] + const result = convertToolsToCodeWhisperer(tools) + expect(result[0].toolSpecification.description.length).toBe(9216) + }) + + test('tool with empty input_schema', () => { + const tools = [{ name: 'empty', description: 'test', input_schema: {} }] + const result = convertToolsToCodeWhisperer(tools) + expect(result[0].toolSpecification.inputSchema.json).toEqual({}) + }) +}) + +describe('buildToolNameMaps edge cases', () => { + test('empty tools array', () => { + const maps = buildToolNameMaps([]) + expect(maps.toKiroName('anything')).toBeDefined() + expect(maps.fromKiroName('anything')).toBe('anything') + }) + + test('two tools with same name', () => { + const tools = [{ name: 'dup' }, { name: 'dup' }] + expect(() => buildToolNameMaps(tools)).not.toThrow() + const maps = buildToolNameMaps(tools) + expect(maps.toKiroName('dup')).toBe('dup') + }) + + test('tool with undefined name skipped', () => { + const tools = [{ name: undefined }, { name: 'valid' }] + const maps = buildToolNameMaps(tools) + expect(maps.toKiroName('valid')).toBe('valid') + }) +}) + +describe('history alternation after trim', () => { + test('after splice first entry is userInputMessage', () => { + const messages: any[] = [] + for (let i = 0; i < 100; i++) { + messages.push({ role: 'user', content: 'u'.repeat(8000) }) + messages.push({ role: 'assistant', content: 'a'.repeat(8000) }) + } + messages.push({ role: 'user', content: 'final' }) + + const body = { messages } + const result = transformToSdkRequest(body, 'auto', auth) + const history = result.conversationState.history + if (history && history.length > 0) { + expect(history[0].userInputMessage).toBeDefined() + expect(history[0].assistantResponseMessage).toBeUndefined() + } + }) + + test('remaining history alternates after trim', () => { + const messages: any[] = [] + for (let i = 0; i < 100; i++) { + messages.push({ role: 'user', content: 'u'.repeat(8000) }) + messages.push({ role: 'assistant', content: 'a'.repeat(8000) }) + } + messages.push({ role: 'user', content: 'final' }) + + const body = { messages } + const result = transformToSdkRequest(body, 'auto', auth) + const history = result.conversationState.history || [] + for (let i = 0; i < history.length - 1; i++) { + const curr = history[i] + const next = history[i + 1] + if (curr.userInputMessage) { + expect(next.assistantResponseMessage).toBeDefined() + } + if (curr.assistantResponseMessage) { + expect(next.userInputMessage).toBeDefined() + } + } + }) +}) + +describe('payload trim performance', () => { + test('trims very large history in <50ms', () => { + // 200 user/asst pairs of 10KB each = ~4MB raw — would be O(N²) without + // the incremental size accounting. + const messages: any[] = [] + for (let i = 0; i < 200; i++) { + messages.push({ role: 'user', content: 'u'.repeat(10000) }) + messages.push({ role: 'assistant', content: 'a'.repeat(10000) }) + } + messages.push({ role: 'user', content: 'final' }) + + const start = performance.now() + const result = transformToSdkRequest({ messages }, 'auto', auth) + const elapsed = performance.now() - start + + // Result must still be under the limit. + const size = JSON.stringify(result.conversationState).length + expect(size).toBeLessThanOrEqual(600_000) + // Allow 100ms on slow CI; fail if we regress to seconds (O(N²)). + expect(elapsed).toBeLessThan(100) + }) +}) diff --git a/src/infrastructure/transformers/tool-transformer.ts b/src/infrastructure/transformers/tool-transformer.ts index 12aca12..aa60b92 100644 --- a/src/infrastructure/transformers/tool-transformer.ts +++ b/src/infrastructure/transformers/tool-transformer.ts @@ -2,10 +2,20 @@ import * as crypto from 'crypto' const MAX_TOOL_NAME_LENGTH = 64 +// Slice without breaking surrogate pairs. +function safeSlice(s: string, end: number): string { + if (end <= 0) return '' + if (end >= s.length) return s + const code = s.charCodeAt(end - 1) + if (code >= 0xd800 && code <= 0xdbff) end -= 1 + return s.slice(0, end) +} + export function shortenToolName(name: string): string { if (!name || name.length <= MAX_TOOL_NAME_LENGTH) return name const hash = crypto.createHash('sha256').update(name).digest('hex').slice(0, 12) - return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}` + const prefix = safeSlice(name, MAX_TOOL_NAME_LENGTH - hash.length - 1) + return `${prefix}_${hash}` } export function buildToolNameMaps(tools: any[]): { @@ -39,22 +49,40 @@ function sanitizeToolInput(input: any): any { return result } -function sanitizeSchema(schema: any): any { +function sanitizeSchema(schema: any, seen: WeakSet = new WeakSet()): any { if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema + if (seen.has(schema)) return {} + seen.add(schema) + const result: Record = {} for (const [key, value] of Object.entries(schema)) { if (key === 'additionalProperties') continue if (key === 'required' && Array.isArray(value) && value.length === 0) continue - if (key === 'properties' && typeof value === 'object' && value !== null) { + + if ( + (key === 'properties' || + key === 'patternProperties' || + key === '$defs' || + key === 'definitions') && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { const props: Record = {} for (const [pk, pv] of Object.entries(value)) { - props[pk] = sanitizeSchema(pv) + props[pk] = sanitizeSchema(pv, seen) } result[key] = props - } else if ((key === 'anyOf' || key === 'oneOf' || key === 'allOf') && Array.isArray(value)) { - result[key] = value.map(sanitizeSchema) - } else if (key === 'items' && typeof value === 'object') { - result[key] = sanitizeSchema(value) + } else if ( + (key === 'anyOf' || key === 'oneOf' || key === 'allOf' || key === 'prefixItems') && + Array.isArray(value) + ) { + result[key] = value.map((v) => sanitizeSchema(v, seen)) + } else if ( + (key === 'items' || key === 'not' || key === 'contains') && + typeof value === 'object' + ) { + result[key] = sanitizeSchema(value, seen) } else { result[key] = value } @@ -90,7 +118,10 @@ export function deduplicateToolCallsByContent(toolCalls: any[]): any[] { const seen = new Set() const unique: any[] = [] for (const tc of toolCalls) { - const key = `${tc.name || tc.function?.name}-${tc.input || tc.function?.arguments}` + // \x00 as separator (can't appear in a tool name) + const name = tc.name || tc.function?.name || '' + const input = tc.input || tc.function?.arguments || '' + const key = `${name}\x00${typeof input === 'string' ? input : JSON.stringify(input)}` if (!seen.has(key)) { seen.add(key) unique.push(tc) diff --git a/src/plugin/request.ts b/src/plugin/request.ts index 7c0543f..ad7b548 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -335,12 +335,43 @@ function buildCodeWhispererRequest( } } - // Trim history if payload exceeds Kiro's ~615KB limit + // Trim history if payload exceeds Kiro's ~615KB limit. + // Compute per-entry sizes once and shrink incrementally to avoid O(N²) + // re-stringifying the full request on every iteration. const MAX_PAYLOAD_BYTES = 600_000 - while (history.length > 2) { - const size = JSON.stringify(request).length - if (size <= MAX_PAYLOAD_BYTES) break - history.splice(0, 2) + if (history.length > 2) { + const sizes = history.map((h) => JSON.stringify(h).length + 1) + const baseRequest: any = { ...request, conversationState: { ...request.conversationState } } + delete baseRequest.conversationState.history + let totalSize = JSON.stringify(baseRequest).length + 2 // for `"history":[]` + for (const s of sizes) totalSize += s + + while (history.length > 2 && totalSize > MAX_PAYLOAD_BYTES) { + // Drop the two oldest entries (typically user + assistant pair). + totalSize -= (sizes.shift() || 0) + (sizes.shift() || 0) + history.splice(0, 2) + + // Strip leading orphans: assistantResponseMessage can't start the history. + while (history.length > 0 && history[0]?.assistantResponseMessage) { + totalSize -= sizes.shift() || 0 + history.shift() + } + + // Strip leading toolResult-only userInputMessages whose toolUseIds are gone. + const toolUseIds = new Set( + history.flatMap( + (h) => h.assistantResponseMessage?.toolUses?.map((tu: any) => tu.toolUseId) ?? [] + ) + ) + while (history.length > 0) { + const trs = history[0]?.userInputMessage?.userInputMessageContext?.toolResults + if (!trs) break + const allMatched = trs.every((tr: any) => toolUseIds.has(tr.toolUseId)) + if (allMatched) break + totalSize -= sizes.shift() || 0 + history.shift() + } + } } return { From 0bdca61520e50462390a4d811e9e5d756974d3f1 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Thu, 21 May 2026 12:37:10 +0200 Subject: [PATCH 12/20] fix: rate-limit amplification and reauth lock race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchUsageLimits: only chain to next param combo on FEATURE_NOT_SUPPORTED; bubble up 429/401/5xx/network errors instead of hitting the API 4x per call - UsageTracker: skip retries on rate-limit errors (don't amplify), don't mark accounts unhealthy on 429 (the request flow handles it) - ErrorHandler 429: track sleep time so the request timeout budget excludes rate-limit waits — a 60s wait shouldn't burn a 120s request timeout - RetryStrategy: subtract excludedMs from elapsed time on shouldContinue - Reauth lock: INSERT OR REPLACE handles the race where two instances both observe the same dead/expired lock and both try to claim it --- src/__tests__/error-handler.test.ts | 26 +++++++++++++++ src/__tests__/sqlite.test.ts | 17 ++++++++++ src/__tests__/usage.test.ts | 52 +++++++++++++++++++++++++++++ src/core/account/usage-tracker.ts | 30 ++++++++++++----- src/core/request/error-handler.ts | 9 ++++- src/core/request/request-handler.ts | 4 ++- src/core/request/retry-strategy.ts | 12 +++++-- src/plugin/storage/sqlite.ts | 10 ++++-- src/plugin/usage.ts | 27 ++++++++------- 9 files changed, 160 insertions(+), 27 deletions(-) diff --git a/src/__tests__/error-handler.test.ts b/src/__tests__/error-handler.test.ts index 7004ec6..48e7e62 100644 --- a/src/__tests__/error-handler.test.ts +++ b/src/__tests__/error-handler.test.ts @@ -173,6 +173,32 @@ describe('ErrorHandler: 429', () => { expect(result.shouldRetry).toBe(true) expect(acc.rateLimitResetTime).toBeGreaterThan(Date.now() - 100) }) + + test('with single account, sleep is excluded from request timeout budget', async () => { + const acc = makeAccount() + const mgr = new AccountManager([acc]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc])) + const res = new Response('', { + status: 429, + headers: { 'retry-after': '1' } // 1s sleep + }) + const result = await handler.handle(null, res, acc, { retry: 0 }, noToast) + expect(result.shouldRetry).toBe(true) + expect(result.newContext?.excludedMs).toBeGreaterThanOrEqual(1000) + }) + + test('with multiple accounts, switches without sleeping', async () => { + const acc1 = makeAccount({ id: 'a' }) + const acc2 = makeAccount({ id: 'b' }) + const mgr = new AccountManager([acc1, acc2]) + const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc1, acc2])) + const res = new Response('', { status: 429, headers: { 'retry-after': '60' } }) + const start = Date.now() + const result = await handler.handle(null, res, acc1, { retry: 0 }, noToast) + const elapsed = Date.now() - start + expect(result.switchAccount).toBe(true) + expect(elapsed).toBeLessThan(500) // didn't sleep + }) }) // ── 500 ─────────────────────────────────────────────────────────────────────── diff --git a/src/__tests__/sqlite.test.ts b/src/__tests__/sqlite.test.ts index 41456d0..b6d15ad 100644 --- a/src/__tests__/sqlite.test.ts +++ b/src/__tests__/sqlite.test.ts @@ -156,6 +156,23 @@ describe('KiroDatabase: reauth lock', () => { db = new KiroDatabase(dbPath) expect(db.acquireReauthLock()).toBe(true) }) + + test('race-safe: row-replacement when prior dead-pid row exists', () => { + // Simulate a row that the SELECT picked up as "expired" but is actually + // the same one INSERT will try to write. INSERT OR REPLACE handles this. + const { Database } = require('bun:sqlite') + const rawDb = new Database(dbPath) + rawDb + .prepare('INSERT INTO reauth_lock (id, pid, acquired_at) VALUES (1, 9999998, ?)') + .run(Date.now() - 200_000) + rawDb.close() + db.close() + db = new KiroDatabase(dbPath) + // Should not throw on PRIMARY KEY conflict + expect(db.acquireReauthLock()).toBe(true) + // The row now belongs to this process + expect(db.isReauthLockHeld()).toBe(true) + }) }) // ── conversations ───────────────────────────────────────────────────────────── diff --git a/src/__tests__/usage.test.ts b/src/__tests__/usage.test.ts index a381e90..6caafc1 100644 --- a/src/__tests__/usage.test.ts +++ b/src/__tests__/usage.test.ts @@ -132,4 +132,56 @@ describe('fetchUsageLimits', () => { globalThis.fetch = original } }) + + test('does NOT chain to next param combo on 429 (rate limit)', async () => { + let callCount = 0 + const mockFetch = mock(async () => { + callCount++ + return new Response(JSON.stringify({ message: 'rate limited' }), { + status: 429, + headers: { 'x-amzn-errortype': 'ThrottlingException' } + }) + }) + const original = globalThis.fetch + globalThis.fetch = mockFetch as any + try { + await expect(fetchUsageLimits(makeAuth())).rejects.toThrow(/429|Throttling/i) + // Old behaviour would call all 4 attempts. New behaviour stops at 1. + expect(callCount).toBe(1) + } finally { + globalThis.fetch = original + } + }) + + test('does NOT chain to next param combo on 401', async () => { + let callCount = 0 + const mockFetch = mock(async () => { + callCount++ + return new Response(JSON.stringify({ message: 'unauthorized' }), { status: 401 }) + }) + const original = globalThis.fetch + globalThis.fetch = mockFetch as any + try { + await expect(fetchUsageLimits(makeAuth())).rejects.toThrow(/401/) + expect(callCount).toBe(1) + } finally { + globalThis.fetch = original + } + }) + + test('does NOT retry on network error across all combos', async () => { + let callCount = 0 + const mockFetch = mock(async () => { + callCount++ + throw new Error('fetch failed: ECONNRESET') + }) + const original = globalThis.fetch + globalThis.fetch = mockFetch as any + try { + await expect(fetchUsageLimits(makeAuth())).rejects.toThrow(/ECONNRESET/) + expect(callCount).toBe(1) + } finally { + globalThis.fetch = original + } + }) }) diff --git a/src/core/account/usage-tracker.ts b/src/core/account/usage-tracker.ts index 2ea1a74..5c504a5 100644 --- a/src/core/account/usage-tracker.ts +++ b/src/core/account/usage-tracker.ts @@ -47,22 +47,34 @@ export class UsageTracker { updateAccountQuota(account, u, this.accountManager) await this.repository.batchSave(this.accountManager.getAccounts()) } catch (e: any) { - if (attempt < this.config.usage_sync_max_retries) { + const msg = e?.message || '' + + // Don't retry rate-limit errors — that just amplifies the problem. + const isRateLimit = + msg.includes('429') || + msg.includes('ThrottlingException') || + msg.includes('TooManyRequests') + + if (!isRateLimit && attempt < this.config.usage_sync_max_retries) { await this.sleep(1000 * Math.pow(2, attempt)) return this.syncWithRetry(account, auth, attempt + 1) } - if (e.message?.includes('FEATURE_NOT_SUPPORTED')) { - // Some IDC profiles don't support getUsageLimits; don't penalize the account. + if (msg.includes('FEATURE_NOT_SUPPORTED')) { + // Some IDC profiles don't expose getUsageLimits — not an error. + return + } + + if (isRateLimit) { + // Don't penalize the account; the request flow has its own 429 handler. + logger.warn('Usage sync rate-limited; skipping until next cooldown', { + accountId: account.id + }) return } - if ( - e.message?.includes('403') || - e.message?.includes('invalid') || - e.message?.includes('bearer token') - ) { - this.accountManager.markUnhealthy(account, e.message) + if (msg.includes('403') || msg.includes('invalid') || msg.includes('bearer token')) { + this.accountManager.markUnhealthy(account, msg) this.repository.save(account).catch(() => {}) } diff --git a/src/core/request/error-handler.ts b/src/core/request/error-handler.ts index c7c16ae..5446f0b 100644 --- a/src/core/request/error-handler.ts +++ b/src/core/request/error-handler.ts @@ -7,6 +7,9 @@ type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | interface RequestContext { retry: number + // Time spent in rate-limit sleeps, propagated up so the retry strategy can + // exclude it from the request timeout budget. + excludedMs?: number } interface ErrorHandlerConfig { @@ -99,7 +102,11 @@ export class ErrorHandler { } showToast(`429: Rate limited. Waiting ${Math.ceil(w / 1000)}s...`, 'warning') await this.sleep(w) - return { shouldRetry: true } + // The wait is not request runtime — exclude it from the timeout budget. + return { + shouldRetry: true, + newContext: { ...context, excludedMs: (context.excludedMs ?? 0) + w } + } } if (response.status === 402 || response.status === 403) { diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index 482cd1d..3b3645f 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -174,13 +174,15 @@ export class RequestHandler { e, mockResponse, acc, - { retry }, + { retry, excludedMs: retryContext.excludedMs }, showToast ) if (errorResult.shouldRetry) { if (errorResult.newContext) { retry = errorResult.newContext.retry + const sleptMs = (errorResult.newContext.excludedMs ?? 0) - retryContext.excludedMs + if (sleptMs > 0) this.retryStrategy.markSleep(retryContext, sleptMs) } if (errorResult.switchAccount) { continue diff --git a/src/core/request/retry-strategy.ts b/src/core/request/retry-strategy.ts index 4acf5db..b72616e 100644 --- a/src/core/request/retry-strategy.ts +++ b/src/core/request/retry-strategy.ts @@ -6,6 +6,8 @@ interface RetryConfig { interface RetryContext { iterations: number startTime: number + // Time spent in rate-limit waits, excluded from the request timeout budget. + excludedMs: number } export class RetryStrategy { @@ -21,7 +23,8 @@ export class RetryStrategy { } } - if (Date.now() - context.startTime > this.config.request_timeout_ms) { + const elapsed = Date.now() - context.startTime - context.excludedMs + if (elapsed > this.config.request_timeout_ms) { return { canContinue: false, error: 'Request timeout' @@ -31,10 +34,15 @@ export class RetryStrategy { return { canContinue: true } } + markSleep(context: RetryContext, ms: number): void { + context.excludedMs += Math.max(0, ms) + } + createContext(): RetryContext { return { iterations: 0, - startTime: Date.now() + startTime: Date.now(), + excludedMs: 0 } } } diff --git a/src/plugin/storage/sqlite.ts b/src/plugin/storage/sqlite.ts index fcdf362..0673371 100644 --- a/src/plugin/storage/sqlite.ts +++ b/src/plugin/storage/sqlite.ts @@ -224,13 +224,19 @@ export class KiroDatabase { } } + // INSERT OR REPLACE handles a race where two instances both saw the + // same dead/expired lock and both reach this branch. this.db - .prepare('INSERT INTO reauth_lock (id, pid, acquired_at) VALUES (1, ?, ?)') + .prepare('INSERT OR REPLACE INTO reauth_lock (id, pid, acquired_at) VALUES (1, ?, ?)') .run(process.pid, now) this.db.run('COMMIT') return true } catch { - this.db.run('ROLLBACK') + try { + this.db.run('ROLLBACK') + } catch { + // already rolled back + } return false } } diff --git a/src/plugin/usage.ts b/src/plugin/usage.ts index 41e1284..16eddbf 100644 --- a/src/plugin/usage.ts +++ b/src/plugin/usage.ts @@ -39,20 +39,23 @@ export async function fetchUsageLimits(auth: KiroAuthDetails): Promise { const errType = res.headers.get('x-amzn-errortype') || res.headers.get('x-amzn-error-type') || '' - if (body.includes('FEATURE_NOT_SUPPORTED') && index < attempts.length - 1) { - continue - } - const msg = body && body.length > 0 ? `${body.slice(0, 2000)}${body.length > 2000 ? '…' : ''}` : `HTTP ${res.status}` - lastError = new Error( - `Status: ${res.status}${errType ? ` (${errType})` : ''}${ - requestId ? ` [${requestId}]` : '' - }: ${msg}` - ) - continue + const errorMessage = `Status: ${res.status}${errType ? ` (${errType})` : ''}${ + requestId ? ` [${requestId}]` : '' + }: ${msg}` + + // Only chain to the next param combo for FEATURE_NOT_SUPPORTED. + // Other failures (429, 401, 5xx, network) bubble up so the caller's + // retry/backoff handles them, instead of hitting the API 4x per call. + if (body.includes('FEATURE_NOT_SUPPORTED') && index < attempts.length - 1) { + lastError = new Error(errorMessage) + continue + } + + throw new Error(errorMessage) } const data: any = await res.json() @@ -70,8 +73,8 @@ export async function fetchUsageLimits(auth: KiroAuthDetails): Promise { } return { usedCount, limitCount, email: data.userInfo?.email } } catch (e) { - lastError = e instanceof Error ? e : new Error(String(e)) - if (index < attempts.length - 1) continue + // Network errors bubble up — don't try the next param combo. + throw e instanceof Error ? e : new Error(String(e)) } } From cff635829ef60175765c3fadcd1705f7cd70c220 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Fri, 22 May 2026 08:45:15 +0200 Subject: [PATCH 13/20] chore: refresh bun.lock --- bun.lock | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index b3aa7f6..bee7753 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,8 @@ "": { "name": "@zhafron/opencode-kiro-auth", "dependencies": { - "@opencode-ai/plugin": "^1.2.6", + "@aws/codewhisperer-streaming-client": "^1.0.34", + "@opencode-ai/plugin": "^1.14.39", "proper-lockfile": "^4.1.2", "zod": "^3.24.0", }, @@ -22,9 +23,131 @@ }, }, "packages": { - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.6", "https://registry.npmmirror.com/@opencode-ai/plugin/-/plugin-1.2.6.tgz", { "dependencies": { "@opencode-ai/sdk": "1.2.6", "zod": "4.1.8" } }, "sha512-CJEp3k17yWsjyfivm3zQof8L42pdze3a7iTqMOyesHgJplSuLiBYAMndbBYMDuJkyAh0dHYjw8v10vVw7Kfl4Q=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.6", "https://registry.npmmirror.com/@opencode-ai/sdk/-/sdk-1.2.6.tgz", {}, "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA=="], + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-EA3+u2LD3kGcfRNmCSjyJuzX4XvG4zYv57i4ZksH+1IEciuSyHQGvzivEz7vZ+jbRPdAAe7WWKy/4M8InCKDcw=="], + + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.12", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-NxB2dS4/mV3380hNkC72TkhMaLLjWGGBeTAEucqlJptVVovTbNmQWZLwaMC74ICo9NZHmFiBVVTHzDfAh/3y6Q=="], + + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.14", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-bqL+upATpOJ/7px4IVfMVxcM6Lyt9uRizmEx3mNg4N6+IQlnOaYXXOJ4TNX6P0mKPPW0lwn9ZW8QEhXwQuCH9A=="], + + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-U7jjlJKQnuUlI2swC2umFLFzLAxMLudSRFv+Bqk2F8ORmr5bG25qsFxGm4GEFwoZeGaFFnAFmTY0xReVRfyl2A=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.10", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg=="], + + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-/YaivCvKUkEeMN9VTKBSvBn5w/4osAM1YboM58DKaLF/vqFGf/FdJCLmppqiPPJWZaXcASqByVjc3evE7KHKdA=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1051.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-VRHgswmx5IVJknXy+mYoESj/coTj0lQ0Bw9WsFmtiLuLiWN1ipzG742/kmEGjKjytuy8vU5OQmpfXQGrmcHcGQ=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.11", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@smithy/core": "^3.24.2", "tslib": "^2.6.2" } }, "sha512-BUMJ6VoL54r6Udj/wKy8uKRIndL04rGbaS/wTIV0dM1ewxSrR8yARBHdvZKQsK55ZSW2JrmAPk3KP15kBDxJMw=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-wfk9ZdVwh187gdGXB1EyAoprwjSMt/bSfVtva+OaZx+LyNdKD7smlZf611yMd42UpfQ9vaS8NOftjSajgpdd+w=="], + + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-A2l/PTRzsOS9L8dmZbXtDyJQgeeX+qjqLJ+fr0UU5Dz0AUQMuxgZCPSLKZgUDlHAmLFuk34owdMEvJxmDTBgRg=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws/codewhisperer-streaming-client": ["@aws/codewhisperer-streaming-client@1.0.45", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.7", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.37", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/token-providers": "^3.1040.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.23", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XvqUjgrA+hS8bK/dU9tUhooxlYWLbIYNYg461gMcD2QQHAd+fXwqrImgcJ/pvU6LedguvTal1Vdd9Pf4I0O0Jg=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.6", "", { "dependencies": { "@opencode-ai/sdk": "1.15.6", "effect": "4.0.0-beta.66", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.15", "@opentui/keymap": ">=0.2.15", "@opentui/solid": ">=0.2.15" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-yucBZCA+Hsru6J+LZ7321OkTyRi7v1bqs5qEyIRJBO+xcFiFHOf+JDMJtZ5xJnjn7E2t5wcdeH4784QgukShgg=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.6", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zeMyjgf7jjp4ge7Y9mp1oza1nHsKZAmOg9RIFixRtrM9S1IPwgNMEybMlJR+wBwPTv2ZDgjvcJasuD0z4XjSDA=="], + + "@smithy/config-resolver": ["@smithy/config-resolver@4.5.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-TpS6Am5zSEtx3ow7VynThEL7UwRM06zZZcmFaP6Ij9hqKPfsFhTYCLcgU7gjFjw9QAI2kzwXrfS7InH8BivJTA=="], + + "@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="], + + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-LXg5yYJPYnVSrpa6LOZ+/wqpI2OlIccy7j5F16EFNYDbXWmnhry/PFRRPyM30H+hJeqfVgckFuvNGnAGCt56cA=="], + + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-MdQxEX5SFNc3QmpiLXtcZXsWk4imCfGVN7Ikz9I/XvavypvHT4mqxwo5JHdr/LBKCfAv89+8193ZWlUwDp8YXQ=="], + + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-54RbRsw9eVaVnqYUXi3F6nMAPgUyKsBvAKBY2lf+81mIgM7N+yS9V5LYk7yUGbrM789b2e1qBuyDSjX1/Axxcw=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="], + + "@smithy/hash-node": ["@smithy/hash-node@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-tSUA38sM7kzMoLhqQ2aCGTwLXovjurz3jjG+a0sxqD4qT/4FhQr/wxMdhCumT70giM+axC1pPjimAHLlEQCfzw=="], + + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-wUWowbCm7DGczl6bfLI6wGGtoxwN5Pon8DhF0Q8AA4NvgLwYfLo3h2DWI7sHr33lLcEsyTLQKeUeTHydqSfQ5Q=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-Up1XAYnj6oxFBypWpkhNpgX+yReQxkKAV/iLaeP0KVLb2oTkmA9X+UJuGBVvEA9uZIN06y0irDi7sBMuTZMVJg=="], + + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.5.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-p60HGFflWsJC6V9GAYeFgbfORn+9ILx8FqgMa/8PzA0rhIUxF57EKoOR4Irs6oe1oy8RLzhjhcGS8CBtPv/t+Q=="], + + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.6.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-MnfYnJs3cBXK3ZBqbPzXRPHIp+QtgpkX5NogcUOWHPU5GbgTAQSIfPLi91lTcEbkFDcH2YbgjLPQjWeyQ689rA=="], + + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-RUVCZgn92izDAARs5OJSM2+KWSfTRvQWwN9t0MmiybT3pquRgDx9vD9t/YZjd/5lwcFbsNuPojJSddYQEZGeWw=="], + + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-+BPabWluqxo3EfMMvOgnAmPtWnCSzj+gf5mJ27wTZUbvS0hpdUIU1g80R01bEGKZx4JCi8P58jAXD9FUGMjhwA=="], + + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-vDtz5OuytrjP4o9GtAOz1JloN003p94utJIQeO0WAjorhpafFFjpbDOrP6btPoCN3UxaU/U84OIEt5dM7ZRRLA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@smithy/protocol-http": ["@smithy/protocol-http@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-P16TBD/d8ZcD9MHQ0ubQ9BbOYSd5HZKbHOLsyFWxKk2oBEoghbRFPfGOoqToZX1yrfLITXRylL16EyPP4IzLPg=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g=="], + + "@smithy/smithy-client": ["@smithy/smithy-client@4.13.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Z8mQ+YryjP5krDadV6unnp5035L4S1brafXpTiRmjPweKSaQ6X9CYDYWvmEggXjDIa1oufX/2a/bdwu8EIz/lw=="], + + "@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + + "@smithy/url-parser": ["@smithy/url-parser@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-TsMTAOnjuMOv1zJBw8cfYGWhopyc3og8tZX/KuyCPjg7V3ji3f4YjFOVu843UjBmrfS/+X6kwFv5ZKg7sSm1bQ=="], + + "@smithy/util-base64": ["@smithy/util-base64@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-91lxjhFpAktA9yPBxniqVR/NSH9zyjMjLmoa+jbQHQFR9WiJA+n61T7HBrfh5APdEoAledJwGq8l4cS+ZJFUnQ=="], + + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-/M6Ya1Fjq8hg3rYjiwwqTen6s1bAa3U3g/2eicBaBQfaoa4ymLUke/x4T8mwb9dSq/L8TQ4YgndS0MaB9ShgmA=="], + + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-M+zdSrevWj0grtZx2RBULPUyjTq1aB+n+13Hrm9owiGpow6DqY/WqiSj6sHVQy/rKp0j7NzV3TNf2LrwDel8JQ=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-Q60hxKkMEkmBsOEzxlMWEymBWov0dtWGgoJhOUs6mE8k2FDPjK8NlsRdMkmO80n2pwzreHtrYcX5jiRP7ZkP3w=="], + + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-RYj+8gr95WiiBqvVghoRvL12NS9ryvLyufp7FOs7EzKwGX0W5gOVlXdCrFkJScSf8gxdjQMRyIZ3Y82/MvXQ3Q=="], + + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.5.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-2JqSmzQtKDKqBckLl/9NXTL1fY+zQBU5fNGMpud7AT65vql0tVFhb2UEZNZmLSHayLeD+X/Qzn84oXw5KS+KSQ=="], + + "@smithy/util-middleware": ["@smithy/util-middleware@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-8NZwlQ+nyAIWn9YZxH14FC8ca0i6ZGW1aJyPjD+zMZz3k9jOhXXKhdCSRvjmcSYLW42uhbrxavXqMkrTKHyY3A=="], + + "@smithy/util-retry": ["@smithy/util-retry@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-8RJXeU5lEhdNfXm4XAuHlf6VtNzd279Z2FJZSR7VaELYCR46ffgjJBSjc+3UAy7V1YqBOLV0G9gWhLB/nA44nA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-c1QpRBn3aMsoqE64dd4Imgjy8Pynfw+eR7GkjElquxUFSnezwYVaOFm8JcYa+Bo/5ssbEyPKcT3+4bmrWYh6eQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/node": ["@types/node@20.19.30", "https://registry.npmmirror.com/@types/node/-/node-20.19.30.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], @@ -38,6 +161,8 @@ "ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.6", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.6.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], @@ -50,24 +175,44 @@ "commander": ["commander@14.0.2", "https://registry.npmmirror.com/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="], + "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "environment": ["environment@1.1.0", "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], + + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-number": ["is-number@7.0.0", "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "lint-staged": ["lint-staged@16.2.7", "https://registry.npmmirror.com/lint-staged/-/lint-staged-16.2.7.tgz", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], "listr2": ["listr2@9.0.5", "https://registry.npmmirror.com/listr2/-/listr2-9.0.5.tgz", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], @@ -78,10 +223,22 @@ "mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "nano-spawn": ["nano-spawn@2.0.0", "https://registry.npmmirror.com/nano-spawn/-/nano-spawn-2.0.0.tgz", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "picomatch": ["picomatch@2.3.1", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "pidtree": ["pidtree@0.6.0", "https://registry.npmmirror.com/pidtree/-/pidtree-0.6.0.tgz", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], @@ -92,12 +249,18 @@ "proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "restore-cursor": ["restore-cursor@5.1.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "slice-ansi": ["slice-ansi@7.1.2", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], @@ -108,20 +271,38 @@ "strip-ansi": ["strip-ansi@7.1.2", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "yaml": ["yaml@2.8.2", "https://registry.npmmirror.com/yaml/-/yaml-2.8.2.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@opencode-ai/plugin/zod": ["zod@4.1.8", "https://registry.npmmirror.com/zod/-/zod-4.1.8.tgz", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "restore-cursor/signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "wrap-ansi/string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], From 39159575867481027bf17faa7bd1c1a431d0d18c Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Mon, 1 Jun 2026 20:08:44 +0200 Subject: [PATCH 14/20] fix: restore green typecheck over the test suite noUncheckedIndexedAccess types indexed access as T | undefined, so several test files failed tsc (TS2532) and one ManagedAccount fixture was missing a required field (rateLimitResetTime, TS2322). release.yml gates publishing on typecheck, so master could not be released until this was green. - Add rateLimitResetTime to account fixtures. - Add non-null assertions on length-checked indexed access in tests. --- src/__tests__/accounts.test.ts | 7 +++--- src/__tests__/edge-cases.test.ts | 24 ++++++++++-------- src/__tests__/error-handler.test.ts | 1 + src/__tests__/event-stream-parser.test.ts | 30 +++++++++++------------ src/__tests__/kiro-cli-profile.test.ts | 10 +++----- src/__tests__/response.test.ts | 2 +- src/__tests__/sqlite.test.ts | 1 + src/__tests__/tool-call-parser.test.ts | 12 ++++----- src/__tests__/usage.test.ts | 4 +++ 9 files changed, 50 insertions(+), 41 deletions(-) diff --git a/src/__tests__/accounts.test.ts b/src/__tests__/accounts.test.ts index c94f9ae..625d90a 100644 --- a/src/__tests__/accounts.test.ts +++ b/src/__tests__/accounts.test.ts @@ -28,6 +28,7 @@ function makeAccount(overrides: Partial = {}): ManagedAccount { refreshToken: 'refresh', accessToken: 'access', expiresAt: Date.now() + 3600000, + rateLimitResetTime: 0, isHealthy: true, failCount: 0, lastUsed: 0, @@ -207,7 +208,7 @@ describe('AccountManager.removeAccount', () => { const mgr = new AccountManager([a, b]) mgr.removeAccount(a) expect(mgr.getAccountCount()).toBe(1) - expect(mgr.getAccounts()[0].id).toBe('b') + expect(mgr.getAccounts()[0]!.id).toBe('b') }) test('cursor resets to 0 when list becomes empty', () => { @@ -239,7 +240,7 @@ describe('AccountManager.addAccount', () => { const mgr = new AccountManager([original]) mgr.addAccount(updated) expect(mgr.getAccountCount()).toBe(1) - expect(mgr.getAccounts()[0].email).toBe('new@x.com') + expect(mgr.getAccounts()[0]!.email).toBe('new@x.com') }) }) @@ -319,7 +320,7 @@ describe('AccountManager.addAccount / removeAccount', () => { const mgr = new AccountManager([acc]) mgr.addAccount({ ...acc, email: 'updated@example.com' }) expect(mgr.getAccountCount()).toBe(1) - expect(mgr.getAccounts()[0].email).toBe('updated@example.com') + expect(mgr.getAccounts()[0]!.email).toBe('updated@example.com') }) test('removeAccount removes the account', () => { diff --git a/src/__tests__/edge-cases.test.ts b/src/__tests__/edge-cases.test.ts index 13898f6..1c52c41 100644 --- a/src/__tests__/edge-cases.test.ts +++ b/src/__tests__/edge-cases.test.ts @@ -30,9 +30,13 @@ import { shortenToolName } from '../infrastructure/transformers/tool-transformer.js' import { transformToSdkRequest } from '../plugin/request.js' +import type { KiroAuthDetails } from '../plugin/types.js' -const auth = { +const auth: KiroAuthDetails = { + refresh: 'refresh', access: 'token', + expires: Date.now() + 3600000, + authMethod: 'idc', region: 'us-east-1', email: 'test@test.com', profileArn: 'arn:aws:codewhisperer:us-east-1:123:profile/ABC' @@ -299,7 +303,7 @@ describe('payload trim preserves valid structure', () => { const result = transformToSdkRequest(body, 'auto', auth) const history = result.conversationState.history if (history && history.length > 0) { - expect(history[0].userInputMessage).toBeDefined() + expect(history[0]!.userInputMessage).toBeDefined() } }) @@ -467,7 +471,7 @@ describe('collapseAgenticLoops edge cases', () => { ] const result = collapseAgenticLoops(history as any) expect(result).toHaveLength(2) - expect(result[0].assistantResponseMessage?.content).toBe('text') + expect(result[0]!.assistantResponseMessage?.content).toBe('text') }) test('assistant with toolUses without following user passes through', () => { @@ -530,9 +534,9 @@ describe('collapseAgenticLoops edge cases', () => { } ] const result = collapseAgenticLoops(history as any) - expect(result[0].assistantResponseMessage?.content).toBe('first') - expect(result[2].assistantResponseMessage?.content).toBe('[system: tool calling continues]') - expect(result[4].assistantResponseMessage?.content).toBe('[system: tool calling continues]') + expect(result[0]!.assistantResponseMessage?.content).toBe('first') + expect(result[2]!.assistantResponseMessage?.content).toBe('[system: tool calling continues]') + expect(result[4]!.assistantResponseMessage?.content).toBe('[system: tool calling continues]') }) }) @@ -635,8 +639,8 @@ describe('history alternation after trim', () => { const result = transformToSdkRequest(body, 'auto', auth) const history = result.conversationState.history if (history && history.length > 0) { - expect(history[0].userInputMessage).toBeDefined() - expect(history[0].assistantResponseMessage).toBeUndefined() + expect(history[0]!.userInputMessage).toBeDefined() + expect(history[0]!.assistantResponseMessage).toBeUndefined() } }) @@ -652,8 +656,8 @@ describe('history alternation after trim', () => { const result = transformToSdkRequest(body, 'auto', auth) const history = result.conversationState.history || [] for (let i = 0; i < history.length - 1; i++) { - const curr = history[i] - const next = history[i + 1] + const curr = history[i]! + const next = history[i + 1]! if (curr.userInputMessage) { expect(next.assistantResponseMessage).toBeDefined() } diff --git a/src/__tests__/error-handler.test.ts b/src/__tests__/error-handler.test.ts index 48e7e62..f6fd133 100644 --- a/src/__tests__/error-handler.test.ts +++ b/src/__tests__/error-handler.test.ts @@ -29,6 +29,7 @@ function makeAccount(overrides: Partial = {}): ManagedAccount { refreshToken: 'r', accessToken: 'a', expiresAt: Date.now() + 3600000, + rateLimitResetTime: 0, isHealthy: true, failCount: 0, lastUsed: 0, diff --git a/src/__tests__/event-stream-parser.test.ts b/src/__tests__/event-stream-parser.test.ts index f0f343a..eabc5ae 100644 --- a/src/__tests__/event-stream-parser.test.ts +++ b/src/__tests__/event-stream-parser.test.ts @@ -27,8 +27,8 @@ describe('parseAwsEventStreamBuffer', () => { test('parses content event', () => { const result = parseAwsEventStreamBuffer('{"content":"Hello"}') expect(result).toHaveLength(1) - expect(result[0].type).toBe('content') - expect(result[0].data).toBe('Hello') + expect(result[0]!.type).toBe('content') + expect(result[0]!.data).toBe('Hello') }) test('skips followupPrompt as not content', () => { @@ -41,39 +41,39 @@ describe('parseAwsEventStreamBuffer', () => { '{"name":"bash","toolUseId":"t-1","input":"ls","stop":false}' ) expect(result).toHaveLength(1) - expect(result[0].type).toBe('toolUse') - expect(result[0].data.name).toBe('bash') - expect(result[0].data.toolUseId).toBe('t-1') + expect(result[0]!.type).toBe('toolUse') + expect(result[0]!.data.name).toBe('bash') + expect(result[0]!.data.toolUseId).toBe('t-1') }) test('parses toolUseInput event', () => { const result = parseAwsEventStreamBuffer('{"input":"-la"}') expect(result).toHaveLength(1) - expect(result[0].type).toBe('toolUseInput') - expect(result[0].data.input).toBe('-la') + expect(result[0]!.type).toBe('toolUseInput') + expect(result[0]!.data.input).toBe('-la') }) test('parses toolUseStop event', () => { const result = parseAwsEventStreamBuffer('{"stop":true}') expect(result).toHaveLength(1) - expect(result[0].type).toBe('toolUseStop') - expect(result[0].data.stop).toBe(true) + expect(result[0]!.type).toBe('toolUseStop') + expect(result[0]!.data.stop).toBe(true) }) test('parses contextUsage event', () => { const result = parseAwsEventStreamBuffer('{"contextUsagePercentage":42}') expect(result).toHaveLength(1) - expect(result[0].type).toBe('contextUsage') - expect(result[0].data.contextUsagePercentage).toBe(42) + expect(result[0]!.type).toBe('contextUsage') + expect(result[0]!.data.contextUsagePercentage).toBe(42) }) test('parses multiple events from a single buffer', () => { const buffer = '{"content":"Hello"}\n{"content":" world"}\n{"contextUsagePercentage":25}' const result = parseAwsEventStreamBuffer(buffer) expect(result).toHaveLength(3) - expect(result[0].data).toBe('Hello') - expect(result[1].data).toBe(' world') - expect(result[2].type).toBe('contextUsage') + expect(result[0]!.data).toBe('Hello') + expect(result[1]!.data).toBe(' world') + expect(result[2]!.type).toBe('contextUsage') }) test('handles incomplete JSON at end (no jsonEnd)', () => { @@ -84,6 +84,6 @@ describe('parseAwsEventStreamBuffer', () => { test('handles escaped strings inside JSON values', () => { const result = parseAwsEventStreamBuffer('{"content":"say \\"hello\\""}') expect(result).toHaveLength(1) - expect(result[0].data).toBe('say "hello"') + expect(result[0]!.data).toBe('say "hello"') }) }) diff --git a/src/__tests__/kiro-cli-profile.test.ts b/src/__tests__/kiro-cli-profile.test.ts index 1a21f91..bbebd72 100644 --- a/src/__tests__/kiro-cli-profile.test.ts +++ b/src/__tests__/kiro-cli-profile.test.ts @@ -28,11 +28,10 @@ describe('readActiveProfileArnFromKiroCli', () => { process.env.KIROCLI_DB_PATH = dbPath const db = new Database(dbPath) db.run('CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT)') - db.run( - 'INSERT INTO state (key, value) VALUES (?, ?)', + db.run('INSERT INTO state (key, value) VALUES (?, ?)', [ 'api.codewhisperer.profile', JSON.stringify({ arn: 'arn:aws:codewhisperer:eu-central-1:123:profile/ABC' }) - ) + ]) db.close() const { readActiveProfileArnFromKiroCli } = await import('../plugin/sync/kiro-cli-profile.js') @@ -56,11 +55,10 @@ describe('readActiveProfileArnFromKiroCli', () => { process.env.KIROCLI_DB_PATH = dbPath const db = new Database(dbPath) db.run('CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT)') - db.run( - 'INSERT INTO state (key, value) VALUES (?, ?)', + db.run('INSERT INTO state (key, value) VALUES (?, ?)', [ 'api.codewhisperer.profile', JSON.stringify({ other: 'field' }) - ) + ]) db.close() const { readActiveProfileArnFromKiroCli } = await import('../plugin/sync/kiro-cli-profile.js') diff --git a/src/__tests__/response.test.ts b/src/__tests__/response.test.ts index 8351d18..baca78f 100644 --- a/src/__tests__/response.test.ts +++ b/src/__tests__/response.test.ts @@ -44,7 +44,7 @@ describe('parseEventStream', () => { ].join('\n') const result = parseEventStream(raw) expect(result.toolCalls).toHaveLength(1) - expect(result.toolCalls[0].name).toBe('bash') + expect(result.toolCalls[0]!.name).toBe('bash') expect(result.stopReason).toBe('tool_use') }) diff --git a/src/__tests__/sqlite.test.ts b/src/__tests__/sqlite.test.ts index b6d15ad..ec06644 100644 --- a/src/__tests__/sqlite.test.ts +++ b/src/__tests__/sqlite.test.ts @@ -14,6 +14,7 @@ function makeAccount(overrides: Partial = {}): ManagedAccount { refreshToken: 'r', accessToken: 'a', expiresAt: Date.now() + 3600000, + rateLimitResetTime: 0, isHealthy: true, failCount: 0, lastUsed: 0, diff --git a/src/__tests__/tool-call-parser.test.ts b/src/__tests__/tool-call-parser.test.ts index 0b17e77..a22a4a6 100644 --- a/src/__tests__/tool-call-parser.test.ts +++ b/src/__tests__/tool-call-parser.test.ts @@ -14,8 +14,8 @@ describe('parseBracketToolCalls', () => { const text = '[Called bash with args: {"command":"ls"}]' const result = parseBracketToolCalls(text) expect(result).toHaveLength(1) - expect(result[0].name).toBe('bash') - expect(result[0].input).toEqual({ command: 'ls' }) + expect(result[0]!.name).toBe('bash') + expect(result[0]!.input).toEqual({ command: 'ls' }) }) test('parses multiple bracket tool calls', () => { @@ -23,8 +23,8 @@ describe('parseBracketToolCalls', () => { '[Called bash with args: {"command":"ls"}] [Called read with args: {"path":"/tmp"}]' const result = parseBracketToolCalls(text) expect(result).toHaveLength(2) - expect(result[0].name).toBe('bash') - expect(result[1].name).toBe('read') + expect(result[0]!.name).toBe('bash') + expect(result[1]!.name).toBe('read') }) test('skips malformed JSON args', () => { @@ -37,7 +37,7 @@ describe('parseBracketToolCalls', () => { const text = '[Called bash with args: {"command":"ls"}][Called bash with args: {"command":"pwd"}]' const result = parseBracketToolCalls(text) - expect(result[0].toolUseId).not.toBe(result[1].toolUseId) + expect(result[0]!.toolUseId).not.toBe(result[1]!.toolUseId) }) }) @@ -61,7 +61,7 @@ describe('deduplicateToolCalls', () => { ] const result = deduplicateToolCalls(calls) expect(result).toHaveLength(1) - expect(result[0].input).toEqual({}) // first one kept + expect(result[0]!.input).toEqual({}) // first one kept }) }) diff --git a/src/__tests__/usage.test.ts b/src/__tests__/usage.test.ts index 6caafc1..c87f2f7 100644 --- a/src/__tests__/usage.test.ts +++ b/src/__tests__/usage.test.ts @@ -4,7 +4,10 @@ import { fetchUsageLimits, updateAccountQuota } from '../plugin/usage.js' function makeAuth(overrides: Partial = {}): KiroAuthDetails { return { + refresh: 'refresh-token', access: 'access-token', + expires: Date.now() + 3600000, + authMethod: 'idc', region: 'eu-central-1', profileArn: 'arn:aws:codewhisperer:eu-central-1:000000:profile/ABC', ...overrides @@ -20,6 +23,7 @@ function makeAccount(overrides: Partial = {}): ManagedAccount { refreshToken: 'r', accessToken: 'a', expiresAt: Date.now() + 3600000, + rateLimitResetTime: 0, isHealthy: true, failCount: 0, lastUsed: 0, From d9a041af884fae7141acf7791b71644dee729dac Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Mon, 1 Jun 2026 20:10:29 +0200 Subject: [PATCH 15/20] fix: destroy stale SDK client on token rotation, pass parsed body to request - createSdkClient tears down the cached client when the token rotated, so its sockets/agent don't leak before the replacement is cached. - RequestHandler passes the parsed body object (not the raw JSON string) to transformToSdkRequest. --- src/core/request/request-handler.ts | 2 +- src/core/request/response-handler.ts | 6 ++++-- src/plugin/sdk-client.ts | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/core/request/request-handler.ts b/src/core/request/request-handler.ts index 3b3645f..7009d30 100644 --- a/src/core/request/request-handler.ts +++ b/src/core/request/request-handler.ts @@ -112,7 +112,7 @@ export class RequestHandler { continue } - const sdkPrep = this.prepareSdkRequest(init?.body, model, auth, think, budget, showToast) + const sdkPrep = this.prepareSdkRequest(body, model, auth, think, budget, showToast) const histLen = (sdkPrep.conversationState as any).history?.length || 0 const agentContId = (sdkPrep.conversationState as any).agentContinuationId || 'none' diff --git a/src/core/request/response-handler.ts b/src/core/request/response-handler.ts index a1f7439..3036e39 100644 --- a/src/core/request/response-handler.ts +++ b/src/core/request/response-handler.ts @@ -34,12 +34,13 @@ export class ResponseHandler { conversationId: string ): Promise { const s = transformKiroStream(response, model, conversationId) + const enc = new TextEncoder() return new Response( new ReadableStream({ async start(c) { try { for await (const e of s) { - c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(e)}\n\n`)) + c.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`)) } c.close() } catch (err) { @@ -58,12 +59,13 @@ export class ResponseHandler { toolNameMapper?: (name: string) => string ): Promise { const s = transformSdkStream(sdkResponse, model, conversationId, toolNameMapper) + const enc = new TextEncoder() return new Response( new ReadableStream({ async start(c) { try { for await (const e of s) { - c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(e)}\n\n`)) + c.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`)) } c.close() } catch (err) { diff --git a/src/plugin/sdk-client.ts b/src/plugin/sdk-client.ts index 402b63b..948e218 100644 --- a/src/plugin/sdk-client.ts +++ b/src/plugin/sdk-client.ts @@ -23,6 +23,14 @@ export function createSdkClient( return cached.client } + // Token rotated (refresh) — tear down the stale client so its sockets/agent + // don't leak before we replace the cache entry. + if (cached) { + try { + cached.client.destroy() + } catch {} + } + const machineId = getMachineId(auth) const token = auth.access const client = new CodeWhispererStreamingClient({ From 0ccda36b60d803d641f20b92ee5bde76119da1ed Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Mon, 1 Jun 2026 20:13:40 +0200 Subject: [PATCH 16/20] feat: report Kiro usage in credits and refresh it at startup - getUsageLimits reads currentUsageWithPrecision/usageLimitWithPrecision, so the usage toast matches the Kiro dashboard credit figure instead of a rounded, stale request count. - AuthHandler refreshes usage from the API at startup (token-refresh aware, non-blocking, falls back to the stored value), so the first toast of a new billing period is not last month's number. - transformSdkStream prefers Kiro's real tokenUsage over the context-% estimate. - Extract summarizeUsage() and UsageTracker.syncNow() to remove duplication. --- src/__tests__/auth-handler.test.ts | 124 ++++++++++++++++++ src/__tests__/stream-transformer.test.ts | 20 +++ src/__tests__/usage.test.ts | 56 ++++++++ src/core/account/account-selector.ts | 8 +- src/core/account/usage-tracker.ts | 12 +- src/core/auth/auth-handler.ts | 56 +++++++- .../streaming/sdk-stream-transformer.ts | 11 ++ src/plugin/usage.ts | 24 +++- 8 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/auth-handler.test.ts diff --git a/src/__tests__/auth-handler.test.ts b/src/__tests__/auth-handler.test.ts new file mode 100644 index 0000000..3d0ea12 --- /dev/null +++ b/src/__tests__/auth-handler.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, mock, test } from 'bun:test' +import { AuthHandler } from '../core/auth/auth-handler.js' +import type { KiroAuthDetails, ManagedAccount } from '../plugin/types.js' + +function makeAccount(overrides: Partial = {}): ManagedAccount { + return { + id: 'acc-1', + email: 'test@example.com', + authMethod: 'idc', + region: 'eu-central-1', + refreshToken: 'r', + accessToken: 'a', + expiresAt: Date.now() + 3600000, + rateLimitResetTime: 0, + isHealthy: true, + failCount: 0, + lastUsed: 0, + usedCount: 0, + limitCount: 0, + ...overrides + } +} + +function makeAuth(): KiroAuthDetails { + return { + refresh: 'refresh-token', + access: 'access-token', + expires: Date.now() + 3600000, // not expired -> no refresh attempted + authMethod: 'idc', + region: 'eu-central-1', + profileArn: 'arn:aws:codewhisperer:eu-central-1:000000:profile/ABC' + } +} + +function makeManager(acc: ManagedAccount) { + return { + getAccounts: () => [acc], + toAuthDetails: () => makeAuth(), + updateUsage: () => {} + } +} + +const fakeRepo: any = { + batchSave: async () => {}, + invalidateCache: () => {}, + findAll: async () => [] +} + +const CREDIT_RESPONSE = JSON.stringify({ + usageBreakdownList: [ + { + freeTrialInfo: null, + currentUsage: 70, + currentUsageWithPrecision: 70.45, + usageLimit: 10000, + usageLimitWithPrecision: 10000, + displayNamePlural: 'Credits', + resourceType: 'CREDIT' + } + ], + userInfo: { email: 'test@example.com' } +}) + +describe('AuthHandler.refreshUsageFromApi', () => { + test('fetches live usage and updates the account with dashboard credits', async () => { + const acc = makeAccount({ usedCount: 4292, limitCount: 10000 }) // stale prior-period value + const handler = new AuthHandler( + { usage_tracking_enabled: true, token_expiry_buffer_ms: 300000, auto_sync_kiro_cli: false }, + fakeRepo + ) + handler.setAccountManager(makeManager(acc)) + + const original = globalThis.fetch + globalThis.fetch = mock(async () => new Response(CREDIT_RESPONSE, { status: 200 })) as any + try { + await handler.refreshUsageFromApi() + expect(acc.usedCount).toBe(70.45) + expect(acc.limitCount).toBe(10000) + } finally { + globalThis.fetch = original + } + }) + + test('keeps stored value when the live fetch fails', async () => { + const acc = makeAccount({ usedCount: 70.45, limitCount: 10000 }) + const handler = new AuthHandler( + { usage_tracking_enabled: true, token_expiry_buffer_ms: 300000, auto_sync_kiro_cli: false }, + fakeRepo + ) + handler.setAccountManager(makeManager(acc)) + + const original = globalThis.fetch + globalThis.fetch = mock(async () => new Response('boom', { status: 500 })) as any + try { + await handler.refreshUsageFromApi() + expect(acc.usedCount).toBe(70.45) // unchanged + } finally { + globalThis.fetch = original + } + }) + + test('is a one-time guard (skips the second call)', async () => { + const acc = makeAccount() + const handler = new AuthHandler( + { usage_tracking_enabled: true, token_expiry_buffer_ms: 300000, auto_sync_kiro_cli: false }, + fakeRepo + ) + handler.setAccountManager(makeManager(acc)) + + let calls = 0 + const original = globalThis.fetch + globalThis.fetch = mock(async () => { + calls++ + return new Response(CREDIT_RESPONSE, { status: 200 }) + }) as any + try { + await handler.refreshUsageFromApi() + await handler.refreshUsageFromApi() + expect(calls).toBe(1) + } finally { + globalThis.fetch = original + } + }) +}) diff --git a/src/__tests__/stream-transformer.test.ts b/src/__tests__/stream-transformer.test.ts index cbe2129..75b0110 100644 --- a/src/__tests__/stream-transformer.test.ts +++ b/src/__tests__/stream-transformer.test.ts @@ -195,6 +195,26 @@ describe('transformSdkStream: tool call streaming', () => { }) }) +// ── SDK stream: real token usage ───────────────────────────────────────────── + +describe('transformSdkStream: token usage', () => { + test('real tokenUsage from metadata wins over the context% estimate', async () => { + const events = [ + { assistantResponseEvent: { content: 'hello' } }, + { + metadataEvent: { + contextUsagePercentage: 80, + tokenUsage: { inputTokens: 1234, outputTokens: 56 } + } + } + ] + const result = await collectSdk(transformSdkStream(makeSdkResponse(events), 'auto', 'conv-1')) + const usageEvent = result.find((e) => e.usage) + expect(usageEvent.usage.prompt_tokens).toBe(1234) + expect(usageEvent.usage.completion_tokens).toBe(56) + }) +}) + // ── isNewThread after merge ────────────────────────────────────────────────── import { buildHistory } from '../infrastructure/transformers/history-builder.js' diff --git a/src/__tests__/usage.test.ts b/src/__tests__/usage.test.ts index c87f2f7..5951794 100644 --- a/src/__tests__/usage.test.ts +++ b/src/__tests__/usage.test.ts @@ -106,6 +106,62 @@ describe('fetchUsageLimits', () => { } }) + test('prefers WithPrecision fields (matches Kiro dashboard credits)', async () => { + // Mirrors the real Kiro Power getUsageLimits response: the integer + // currentUsage is rounded, the dashboard shows currentUsageWithPrecision. + const mockFetch = mock( + async () => + new Response( + JSON.stringify({ + usageBreakdownList: [ + { + freeTrialInfo: null, + currentUsage: 70, + currentUsageWithPrecision: 70.45, + usageLimit: 10000, + usageLimitWithPrecision: 10000, + displayNamePlural: 'Credits', + resourceType: 'CREDIT' + } + ], + userInfo: { email: 'test@example.com' } + }), + { status: 200 } + ) + ) + const original = globalThis.fetch + globalThis.fetch = mockFetch as any + try { + const result = await fetchUsageLimits(makeAuth()) + expect(result.usedCount).toBe(70.45) + expect(result.limitCount).toBe(10000) + } finally { + globalThis.fetch = original + } + }) + + test('falls back to integer fields when precision absent', async () => { + const mockFetch = mock( + async () => + new Response( + JSON.stringify({ + usageBreakdownList: [{ currentUsage: 50, usageLimit: 500 }], + userInfo: { email: 'test@example.com' } + }), + { status: 200 } + ) + ) + const original = globalThis.fetch + globalThis.fetch = mockFetch as any + try { + const result = await fetchUsageLimits(makeAuth()) + expect(result.usedCount).toBe(50) + expect(result.limitCount).toBe(500) + } finally { + globalThis.fetch = original + } + }) + test('retries on FEATURE_NOT_SUPPORTED and succeeds on later attempt', async () => { let callCount = 0 const mockFetch = mock(async () => { diff --git a/src/core/account/account-selector.ts b/src/core/account/account-selector.ts index fa26969..f26e6d0 100644 --- a/src/core/account/account-selector.ts +++ b/src/core/account/account-selector.ts @@ -1,6 +1,7 @@ import type { AccountRepository } from '../../infrastructure/database/account-repository' import type { AccountManager } from '../../plugin/accounts' import type { ManagedAccount } from '../../plugin/types' +import { summarizeUsage } from '../../plugin/usage' type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void @@ -72,11 +73,8 @@ export class AccountSelector { } private formatUsageMessage(usedCount: number, limitCount: number, email: string): string { - if (limitCount > 0) { - const percentage = Math.round((usedCount / limitCount) * 100) - return `Usage (${email}): ${usedCount}/${limitCount} (${percentage}%)` - } - return `Usage (${email}): ${usedCount}` + const { used, limit, pct } = summarizeUsage(usedCount, limitCount) + return limit > 0 ? `Usage (${email}): ${used}/${limit} (${pct}%)` : `Usage (${email}): ${used}` } private checkCircuitBreaker(): void { diff --git a/src/core/account/usage-tracker.ts b/src/core/account/usage-tracker.ts index 5c504a5..cf55498 100644 --- a/src/core/account/usage-tracker.ts +++ b/src/core/account/usage-tracker.ts @@ -37,15 +37,21 @@ export class UsageTracker { }) } + // Fetch usage once and persist it, bypassing the cooldown/retry loop. Used by + // the startup refresh, where the caller handles token refresh and fallback. + async syncNow(account: ManagedAccount, auth: KiroAuthDetails): Promise { + const u = await fetchUsageLimits(auth) + updateAccountQuota(account, u, this.accountManager) + await this.repository.batchSave(this.accountManager.getAccounts()) + } + private async syncWithRetry( account: ManagedAccount, auth: KiroAuthDetails, attempt: number ): Promise { try { - const u = await fetchUsageLimits(auth) - updateAccountQuota(account, u, this.accountManager) - await this.repository.batchSave(this.accountManager.getAccounts()) + await this.syncNow(account, auth) } catch (e: any) { const msg = e?.message || '' diff --git a/src/core/auth/auth-handler.ts b/src/core/auth/auth-handler.ts index ab00448..a3e1147 100644 --- a/src/core/auth/auth-handler.ts +++ b/src/core/auth/auth-handler.ts @@ -2,12 +2,16 @@ import type { AuthHook } from '@opencode-ai/plugin' import type { AccountRepository } from '../../infrastructure/database/account-repository.js' import { RegionSchema } from '../../plugin/config/schema.js' import * as logger from '../../plugin/logger.js' +import { summarizeUsage } from '../../plugin/usage.js' +import { UsageTracker } from '../account/usage-tracker.js' import { IdcAuthMethod } from './idc-auth-method.js' +import { TokenRefresher } from './token-refresher.js' type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void export class AuthHandler { private accountManager?: any + private startupUsageFetched = false constructor( private config: any, @@ -29,7 +33,53 @@ export class AuthHandler { logger.log('Kiro CLI sync: done', { importedAccounts: accounts.length }) } - this.logUsageSummary(showToast) + // Refresh usage before the summary toast: the persisted value is stale after + // the monthly reset until the first request syncs. Backgrounded so it never + // delays the auth loader, and falls back to the stored value on error. + void (async () => { + try { + await this.refreshUsageFromApi(showToast) + } catch (e) { + logger.warn('Startup usage refresh failed', { + error: e instanceof Error ? e.message : String(e) + }) + } + this.logUsageSummary(showToast) + })() + } + + async refreshUsageFromApi(showToast?: ToastFunction): Promise { + if (!this.accountManager || this.config.usage_tracking_enabled === false) return + if (this.startupUsageFetched) return + this.startupUsageFetched = true + + const { syncFromKiroCli } = await import('../../plugin/sync/kiro-cli.js') + const tokenRefresher = new TokenRefresher( + this.config, + this.accountManager, + syncFromKiroCli, + this.repository + ) + const usageTracker = new UsageTracker(this.config, this.accountManager, this.repository) + const toast: ToastFunction = showToast ?? (() => {}) + + for (const acc of this.accountManager.getAccounts()) { + if (!acc.isHealthy) continue + try { + const { account: usable } = await tokenRefresher.refreshIfNeeded( + acc, + this.accountManager.toAuthDetails(acc), + toast + ) + if (!usable.isHealthy) continue + await usageTracker.syncNow(usable, this.accountManager.toAuthDetails(usable)) + } catch (e) { + logger.warn('Startup usage fetch failed; keeping stored value', { + email: acc.email, + error: e instanceof Error ? e.message : String(e) + }) + } + } } private logUsageSummary(showToast?: ToastFunction): void { @@ -38,10 +88,8 @@ export class AuthHandler { if (!accounts.length) return for (const acc of accounts) { - const used = acc.usedCount ?? 0 - const limit = acc.limitCount ?? 0 + const { used, limit, pct } = summarizeUsage(acc.usedCount ?? 0, acc.limitCount ?? 0) if (limit > 0) { - const pct = Math.round((used / limit) * 100) const msg = `Kiro usage (${acc.email}): ${used}/${limit} (${pct}%)` logger.log(msg) if (showToast) { diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index d2e6a4f..71437fc 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -32,6 +32,8 @@ export async function* transformSdkStream( let outputTokens = 0 let inputTokens = 0 let contextUsagePercentage: number | null = null + let realInputTokens: number | undefined + let realOutputTokens: number | undefined const toolCalls: ToolCallState[] = [] let currentToolCall: ToolCallState | null = null @@ -148,6 +150,11 @@ export async function* transformSdkStream( if (event.metadataEvent.contextUsagePercentage) { contextUsagePercentage = event.metadataEvent.contextUsagePercentage } + if (event.metadataEvent.tokenUsage) { + const tu = event.metadataEvent.tokenUsage + if (typeof tu.inputTokens === 'number') realInputTokens = tu.inputTokens + if (typeof tu.outputTokens === 'number') realOutputTokens = tu.outputTokens + } } else if ((event as any).contextUsageEvent) { const cue = (event as any).contextUsageEvent if (cue.contextUsagePercentage) { @@ -260,6 +267,10 @@ export async function* transformSdkStream( inputTokens = Math.max(0, totalTokens - outputTokens) } + // Real token counts from Kiro's metadata win over the context-% estimate. + if (realInputTokens !== undefined) inputTokens = realInputTokens + if (realOutputTokens !== undefined) outputTokens = realOutputTokens + yield convertToOpenAI( { type: 'message_delta', diff --git a/src/plugin/usage.ts b/src/plugin/usage.ts index 16eddbf..6c058fc 100644 --- a/src/plugin/usage.ts +++ b/src/plugin/usage.ts @@ -63,12 +63,16 @@ export async function fetchUsageLimits(auth: KiroAuthDetails): Promise { limitCount = 0 if (Array.isArray(data.usageBreakdownList)) { for (const s of data.usageBreakdownList) { + // Kiro reports a rounded integer (currentUsage) plus the exact value + // (currentUsageWithPrecision) — the latter is what the Kiro dashboard + // shows (e.g. 70.45 credits). Prefer it; fall back to the integer. if (s.freeTrialInfo) { - usedCount += s.freeTrialInfo.currentUsage || 0 - limitCount += s.freeTrialInfo.usageLimit || 0 + usedCount += + s.freeTrialInfo.currentUsageWithPrecision ?? s.freeTrialInfo.currentUsage ?? 0 + limitCount += s.freeTrialInfo.usageLimitWithPrecision ?? s.freeTrialInfo.usageLimit ?? 0 } - usedCount += s.currentUsage || 0 - limitCount += s.usageLimit || 0 + usedCount += s.currentUsageWithPrecision ?? s.currentUsage ?? 0 + limitCount += s.usageLimitWithPrecision ?? s.usageLimit ?? 0 } } return { usedCount, limitCount, email: data.userInfo?.email } @@ -81,6 +85,18 @@ export async function fetchUsageLimits(auth: KiroAuthDetails): Promise { throw lastError || new Error('All getUsageLimits attempts failed') } +// Credits come back fractional (e.g. 70.45); round for display and derive the +// percentage in one place so the startup summary and the high-usage warning agree. +export function summarizeUsage( + usedCount: number, + limitCount: number +): { used: number; limit: number; pct: number } { + const used = Number(usedCount.toFixed(2)) + const limit = Number(limitCount.toFixed(2)) + const pct = limit > 0 ? Math.round((used / limit) * 100) : 0 + return { used, limit, pct } +} + export function updateAccountQuota( account: ManagedAccount, usage: any, From 5703be6e2c4e15fc8baa0105a0a1a716412acf9f Mon Sep 17 00:00:00 2001 From: Ryan Breen Date: Wed, 27 May 2026 19:38:56 -0400 Subject: [PATCH 17/20] fix: restore compatibility with OpenCode >=1.15 and update models (#97) Merge PR #97 with maintainer adjustments:\n\n- Align default plugin provider id with the new kiro provider id.\n- Harden OpenCode auth bootstrap so malformed auth.json files are not overwritten and restrictive file permissions are preserved.\n- Add regression tests for auth bootstrap, provider id, Zod enum compatibility, and empty OpenAI-compatible stream chunks.\n- Bump @opencode-ai/plugin to 1.15.11 and align docs/model context examples. --- README.md | 127 ++++++++------- bun.lock | 104 ++++++------ package-lock.json | 122 +++++++------- package.json | 4 +- src/__tests__/auth-bootstrap.test.ts | 80 ++++++++++ src/__tests__/constants.test.ts | 8 +- src/__tests__/openai-converter.test.ts | 25 +++ src/__tests__/plugin-module.test.ts | 8 + src/constants.ts | 23 ++- src/index.ts | 2 +- src/plugin.ts | 59 +++++-- src/plugin/auth-bootstrap.ts | 86 ++++++++++ src/plugin/streaming/openai-converter.ts | 22 ++- .../streaming/sdk-stream-transformer.ts | 145 ++++++++++------- src/plugin/streaming/stream-transformer.ts | 149 +++++++++++------- 15 files changed, 643 insertions(+), 321 deletions(-) create mode 100644 src/__tests__/auth-bootstrap.test.ts create mode 100644 src/__tests__/openai-converter.test.ts create mode 100644 src/__tests__/plugin-module.test.ts create mode 100644 src/plugin/auth-bootstrap.ts diff --git a/README.md b/README.md index 2e74895..4e646a4 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,12 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: }, "claude-sonnet-4-6": { "name": "Claude Sonnet 4.6", - "limit": { "context": 200000, "output": 64000 }, + "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": 200000, "output": 64000 }, + "limit": { "context": 1000000, "output": 64000 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, @@ -86,12 +86,12 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: }, "claude-opus-4-6": { "name": "Claude Opus 4.6", - "limit": { "context": 200000, "output": 64000 }, + "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": 200000, "output": 64000 }, + "limit": { "context": 1000000, "output": 64000 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, @@ -114,6 +114,21 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: "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 } }, + "max": { "thinkingConfig": { "thinkingBudget": 32768 } } + } + }, "claude-sonnet-4-5-1m": { "name": "Claude Sonnet 4.5 (1M Context)", "limit": { "context": 1000000, "output": 64000 }, @@ -135,11 +150,27 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: } }, "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 } }, - "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 } } + "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 } + } } } } @@ -150,7 +181,9 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: 1. **Authentication via Kiro CLI (Recommended)**: - Perform login directly in your terminal using `kiro-cli login`. - - The plugin will automatically detect and import your session on startup. + - The plugin automatically bootstraps a minimal `kiro` placeholder in + OpenCode's `auth.json` when it detects the Kiro CLI database, then imports + and synchronizes your active session on startup. - For AWS IAM Identity Center (SSO/IDC), the plugin imports both the token and device registration (OIDC client credentials) from the `kiro-cli` database. 2. **Direct Authentication**: @@ -177,30 +210,19 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: ## Local plugin development -OpenCode installs plugins into a cache directory (typically -`~/.cache/opencode/node_modules`). +The simplest way to test local changes is to point OpenCode directly at your local repo +path in `opencode.json` or `opencode.jsonc`: -The simplest way to test local changes (without publishing to npm) is to build this repo -and hot-swap the cached plugin `dist/` folder: - -1. Build this repo: `bun run build` (or `npm run build`) -2. Hot-swap `dist/` (creates a timestamped backup): - -```bash -PLUGIN_DIR="$HOME/.cache/opencode/node_modules/@zhafron/opencode-kiro-auth" -TS=$(date +%Y%m%d-%H%M%S) -cp -a "$PLUGIN_DIR/dist" "$PLUGIN_DIR/dist.bak.$TS" -rm -rf "$PLUGIN_DIR/dist" -cp -a "/absolute/path/to/opencode-kiro-auth/dist" "$PLUGIN_DIR/dist" -echo "Backup at: $PLUGIN_DIR/dist.bak.$TS" +```json +{ + "plugin": ["/path/to/opencode-kiro-auth"] +} ``` -Revert: +Then build and restart OpenCode to pick up changes: ```bash -PLUGIN_DIR="$HOME/.cache/opencode/node_modules/@zhafron/opencode-kiro-auth" -rm -rf "$PLUGIN_DIR/dist" -mv "$PLUGIN_DIR/dist.bak.YYYYMMDD-HHMMSS" "$PLUGIN_DIR/dist" +npm run build ``` ## Troubleshooting @@ -237,48 +259,23 @@ Note for IDC/SSO (ODIC): the plugin may temporarily create an account with a pla email if it cannot fetch the real email during sync (e.g. offline). It will replace it with the real email once usage/email lookup succeeds. -### Kiro CLI (Google/GitHub OAuth) users: plugin sync never runs +### Kiro CLI (Google/GitHub OAuth) users: plugin sync does not start If you authenticated via `kiro-cli login` using Google or GitHub OAuth (not AWS Builder -ID or IAM Identity Center), the plugin's sync may never trigger. -This happens because OpenCode requires a kiro entry in `auth.json` before making API -requests, but the plugin loader only runs when a request is made. +ID or IAM Identity Center), OpenCode still needs a stored `kiro` auth entry before it +will call the plugin loader. -**Workaround:** Add a minimal placeholder entry to `~/.local/share/opencode/auth.json`: - -```json -{ - "kiro": { - "type": "api", - "key": "placeholder" - } -} -``` +The plugin now creates that minimal placeholder automatically when it detects the local +Kiro CLI database. Restart OpenCode after `kiro-cli login`; the loader should then run +and sync your actual tokens into `kiro.db`. The placeholder values are not used for API +calls. -After adding this, OpenCode will treat the provider as connected, trigger the plugin -loader, and the kiro-cli sync will populate `kiro.db` with your actual tokens. -The placeholder values are not used for API calls. +If bootstrap is skipped because `auth.json` is malformed, fix the JSON first. The plugin +will not overwrite malformed auth files because they may contain other provider +credentials. **Important:** Ensure `auto_sync_kiro_cli` is `true` in `~/.config/opencode/kiro.json` -and that `kiro-cli login` succeeds before applying this workaround. - -### Error: ERR_INVALID_URL - -`TypeError [ERR_INVALID_URL]: "undefined/chat/completions" cannot be parsed as a URL` - -If this happens, check your auth.json in .local/share/opencode. -example: - -```json -{ - "kiro": { - "type": "api", - "key": "whatever" - } -} -``` - -## Configuration +and that `kiro-cli login` succeeds. The plugin supports extensive configuration options. Edit `~/.config/opencode/kiro.json`: diff --git a/bun.lock b/bun.lock index bee7753..57e620f 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "@zhafron/opencode-kiro-auth", "dependencies": { "@aws/codewhisperer-streaming-client": "^1.0.34", - "@opencode-ai/plugin": "^1.14.39", + "@opencode-ai/plugin": "^1.15.11", "proper-lockfile": "^4.1.2", "zod": "^3.24.0", }, @@ -33,119 +33,119 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-EA3+u2LD3kGcfRNmCSjyJuzX4XvG4zYv57i4ZksH+1IEciuSyHQGvzivEz7vZ+jbRPdAAe7WWKy/4M8InCKDcw=="], + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "tslib": "^2.6.2" } }, "sha512-BMvPJcSE+e5Up4eRvIVDOp6Aiwh3ce0z9FJ8LsRBGE9d64j67HLdyqlDaUtzCjmhxDvxw+qIiAElu3+AOzhAnA=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.12", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-NxB2dS4/mV3380hNkC72TkhMaLLjWGGBeTAEucqlJptVVovTbNmQWZLwaMC74ICo9NZHmFiBVVTHzDfAh/3y6Q=="], + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.14", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "tslib": "^2.6.2" } }, "sha512-f/9Rln5bNIoHwPVxSXmW5+8SnteHFwwuoR5Ce+SEis36fKw7pu84G2SpaWRpGqj7zbIAECFVJ+pDiGfQq5r4FQ=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.14", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-bqL+upATpOJ/7px4IVfMVxcM6Lyt9uRizmEx3mNg4N6+IQlnOaYXXOJ4TNX6P0mKPPW0lwn9ZW8QEhXwQuCH9A=="], + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "tslib": "^2.6.2" } }, "sha512-AGAe/c6Da/FEjtkR3LViqvaJdxaHQqcVpmf6UMBRQ3e8G2c6EWqjRidMw/FiQzWXUrmfjgXoVcc8QXc4kcuWLQ=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-U7jjlJKQnuUlI2swC2umFLFzLAxMLudSRFv+Bqk2F8ORmr5bG25qsFxGm4GEFwoZeGaFFnAFmTY0xReVRfyl2A=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "tslib": "^2.6.2" } }, "sha512-K9kryZwhKBsAdk1R6TCMSUDGF2XjMcC89Qm/6o8szdRSodN5MIlo3KzAV4vKyKTkOu/UwoB+Yrj78M+omPZzkA=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.10", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.12", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q=="], - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-/YaivCvKUkEeMN9VTKBSvBn5w/4osAM1YboM58DKaLF/vqFGf/FdJCLmppqiPPJWZaXcASqByVjc3evE7KHKdA=="], + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "tslib": "^2.6.2" } }, "sha512-FQLLmG0RHPleDCQAkl10LNH5L+FDOcUKwnRHRhfH0NHhleo46j9u1q0UHlVbKYOBfB0LjQMQgioxrWvLqEZ5Bg=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.29", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1051.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-VRHgswmx5IVJknXy+mYoESj/coTj0lQ0Bw9WsFmtiLuLiWN1ipzG742/kmEGjKjytuy8vU5OQmpfXQGrmcHcGQ=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1055.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-NSCniZ0HmeOWrX1k9aXZtKEST+JlNqOMNe87Vll1y25WlHOErNGgk8oFoMGnbiuWsG8ZS8zLby8fqVmrH8/ufQ=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="], - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.11", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@smithy/core": "^3.24.2", "tslib": "^2.6.2" } }, "sha512-BUMJ6VoL54r6Udj/wKy8uKRIndL04rGbaS/wTIV0dM1ewxSrR8yARBHdvZKQsK55ZSW2JrmAPk3KP15kBDxJMw=="], + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.13", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-uuAuMkEzZHfiOkIRj2xexSSgfvBnhbI+6pd+o/RET7EZXDG1wrC4PINCs0PTkswYhD6kOb+av4NG89CWLC5/4Q=="], "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.13", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-wfk9ZdVwh187gdGXB1EyAoprwjSMt/bSfVtva+OaZx+LyNdKD7smlZf611yMd42UpfQ9vaS8NOftjSajgpdd+w=="], + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "tslib": "^2.6.2" } }, "sha512-8/+GtB/BjIul7uGFXNztIeiQBZbb1eEl/+M8Q+l1qg9uKSGEZf4VtI+9VidrQ3BBE1J1GDkn5W/n+tuHhXSbOg=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "tslib": "^2.6.2" } }, "sha512-A2l/PTRzsOS9L8dmZbXtDyJQgeeX+qjqLJ+fr0UU5Dz0AUQMuxgZCPSLKZgUDlHAmLFuk34owdMEvJxmDTBgRg=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.30", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "tslib": "^2.6.2" } }, "sha512-W5Pr3Kl9tyH7Pcht2sx90YeO2ennDIwzbBVfQ+bt6MQUNMtf+hPMic8O/IsSWRNn1qTgfuCjTYmB/UD4A75xBA=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="], "@aws/codewhisperer-streaming-client": ["@aws/codewhisperer-streaming-client@1.0.45", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.7", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.37", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/token-providers": "^3.1040.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.23", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XvqUjgrA+hS8bK/dU9tUhooxlYWLbIYNYg461gMcD2QQHAd+fXwqrImgcJ/pvU6LedguvTal1Vdd9Pf4I0O0Jg=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.6", "", { "dependencies": { "@opencode-ai/sdk": "1.15.6", "effect": "4.0.0-beta.66", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.15", "@opentui/keymap": ">=0.2.15", "@opentui/solid": ">=0.2.15" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-yucBZCA+Hsru6J+LZ7321OkTyRi7v1bqs5qEyIRJBO+xcFiFHOf+JDMJtZ5xJnjn7E2t5wcdeH4784QgukShgg=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.11", "", { "dependencies": { "@opencode-ai/sdk": "1.15.11", "effect": "4.0.0-beta.66", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.15", "@opentui/keymap": ">=0.2.15", "@opentui/solid": ">=0.2.15" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-RDvYDCHO0+3OAGD590oDQqtryrENrUW04SjtoA4sgRV2efpZeBFjx3TnonBsXKq6nWqYg172nhltUEZsihGnXQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.6", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zeMyjgf7jjp4ge7Y9mp1oza1nHsKZAmOg9RIFixRtrM9S1IPwgNMEybMlJR+wBwPTv2ZDgjvcJasuD0z4XjSDA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-IyYyDVsO8SKbKbkSadHpDuYnYC+2vmEeLU+rW+rH2M54Sigq6l3gDHno16+U6SRut+lowbph7v/ry3WbV67V3w=="], - "@smithy/config-resolver": ["@smithy/config-resolver@4.5.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-TpS6Am5zSEtx3ow7VynThEL7UwRM06zZZcmFaP6Ij9hqKPfsFhTYCLcgU7gjFjw9QAI2kzwXrfS7InH8BivJTA=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.5.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-HehAZr4sq2m+4zHgEqDvtWENy/B5yywMKA8Pl4gBcU3F4ekelpZqDLDxQHdJlguaKNyTq31cZYjLWomzdujQrA=="], - "@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="], + "@smithy/core": ["@smithy/core@3.24.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA=="], - "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-LXg5yYJPYnVSrpa6LOZ+/wqpI2OlIccy7j5F16EFNYDbXWmnhry/PFRRPyM30H+hJeqfVgckFuvNGnAGCt56cA=="], + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-M9rMkTar7JcRrvUHsK1271AuWDmrISIPQpQ4TSHmYZ4KMisGnMH0gfjCWnBwdndR7skvvp/UheHhZGvO3Cr8/g=="], - "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-MdQxEX5SFNc3QmpiLXtcZXsWk4imCfGVN7Ikz9I/XvavypvHT4mqxwo5JHdr/LBKCfAv89+8193ZWlUwDp8YXQ=="], + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-lUwPPu7DNNVJjeS+gV7g2rDHbW9X1wSRQIsIyzOgBtP7KDMefLhz0kz42AWAxZIFPcOO3pUbtq76LSkVcxLKRw=="], - "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-54RbRsw9eVaVnqYUXi3F6nMAPgUyKsBvAKBY2lf+81mIgM7N+yS9V5LYk7yUGbrM789b2e1qBuyDSjX1/Axxcw=="], + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-QydEYKqvdiS6dJb0tOfDiogt12FzzImt2FnL7gMD72hNrkiUAUKqtStRmkTrdzDKFJ46abe3yH94luCuhtnCkQ=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ=="], - "@smithy/hash-node": ["@smithy/hash-node@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-tSUA38sM7kzMoLhqQ2aCGTwLXovjurz3jjG+a0sxqD4qT/4FhQr/wxMdhCumT70giM+axC1pPjimAHLlEQCfzw=="], + "@smithy/hash-node": ["@smithy/hash-node@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-/tUIDaB36qjLq/CIhMRIiFXCT7rVGBGAhFmMA9PbC/iW2u3QPNATZuFSdK0JBO3qeSPoHBeudFMmsbFq2Mf5EQ=="], - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-wUWowbCm7DGczl6bfLI6wGGtoxwN5Pon8DhF0Q8AA4NvgLwYfLo3h2DWI7sHr33lLcEsyTLQKeUeTHydqSfQ5Q=="], + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-c8C1GzrU4PcY1QT/HP0ILCTLutyVONT93kPSisOyHoZaXlKQZtV6+RKqolhBtPolGULf59vq2yseagU6+WY82w=="], "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-Up1XAYnj6oxFBypWpkhNpgX+yReQxkKAV/iLaeP0KVLb2oTkmA9X+UJuGBVvEA9uZIN06y0irDi7sBMuTZMVJg=="], + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-lzOzJ4c0t3vkBut02CjdWNgduN3mUWjc1WK9TPr75KVV6OgVWico9wMDn9ZnQN97VJPYfweBW6Dm5CElvQl8BQ=="], - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.5.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-p60HGFflWsJC6V9GAYeFgbfORn+9ILx8FqgMa/8PzA0rhIUxF57EKoOR4Irs6oe1oy8RLzhjhcGS8CBtPv/t+Q=="], + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.5.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-8DnkSoUMQAcuT/DHdigsFPti8M/Dm6TPCAsrIQ/bUDGxRkrgGuI++3dXRr8CoUyc9r0kGSCcZHjJje407ydgBQ=="], - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.6.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-MnfYnJs3cBXK3ZBqbPzXRPHIp+QtgpkX5NogcUOWHPU5GbgTAQSIfPLi91lTcEbkFDcH2YbgjLPQjWeyQ689rA=="], + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.6.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-fumMIfh5xOFjirylbSzmBX9bgQtrWFtQrosPfkjsJSBzqXVbQMNDGIC8oJBz4V3bokIm2F0CL3bziLtbXR7cbA=="], - "@smithy/middleware-serde": ["@smithy/middleware-serde@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-RUVCZgn92izDAARs5OJSM2+KWSfTRvQWwN9t0MmiybT3pquRgDx9vD9t/YZjd/5lwcFbsNuPojJSddYQEZGeWw=="], + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-+7glfRrb7byruZCPAM53TvmK8cx/ghzAThB4EvPzHynAYobtISl0g+DzzSVEC0NQob5BunP9gC9GP+Fcz6H9yw=="], - "@smithy/middleware-stack": ["@smithy/middleware-stack@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-+BPabWluqxo3EfMMvOgnAmPtWnCSzj+gf5mJ27wTZUbvS0hpdUIU1g80R01bEGKZx4JCi8P58jAXD9FUGMjhwA=="], + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-Yj4wjBQZXHePRIy9cBIKfCOn/kPjRlgDPGlr7DjIhwrnz8kWu7Ux7UwPr51P/wcug5oq4nWdBXSY4TV5afBdew=="], - "@smithy/node-config-provider": ["@smithy/node-config-provider@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-vDtz5OuytrjP4o9GtAOz1JloN003p94utJIQeO0WAjorhpafFFjpbDOrP6btPoCN3UxaU/U84OIEt5dM7ZRRLA=="], + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-c2G9QJ4xVZLwAkAf+WQESSSCkKbtt33ytje1klGvTcBn6cKuqV28E+62wbRPHwuTikkB3LQ7CBnNrayCoJur5A=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw=="], - "@smithy/protocol-http": ["@smithy/protocol-http@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-P16TBD/d8ZcD9MHQ0ubQ9BbOYSd5HZKbHOLsyFWxKk2oBEoghbRFPfGOoqToZX1yrfLITXRylL16EyPP4IzLPg=="], + "@smithy/protocol-http": ["@smithy/protocol-http@5.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-jOD+4WNWQLntiLJn3r82C7BLheEbRCKTbU5U5bskZmT7nwRiGkh0IghuHwHRZ1ZEFXpHltQxxp9/koOPsdluJg=="], - "@smithy/signature-v4": ["@smithy/signature-v4@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA=="], - "@smithy/smithy-client": ["@smithy/smithy-client@4.13.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Z8mQ+YryjP5krDadV6unnp5035L4S1brafXpTiRmjPweKSaQ6X9CYDYWvmEggXjDIa1oufX/2a/bdwu8EIz/lw=="], + "@smithy/smithy-client": ["@smithy/smithy-client@4.13.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-pg9QRQESz3m/5HgAW/z9lA3ln8MSsCWNWc82MX40Djlxpcj/+7DZQ0yIk7tGWYJCVZog/9LBdNl1uEVRAhqm5Q=="], "@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], - "@smithy/url-parser": ["@smithy/url-parser@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-TsMTAOnjuMOv1zJBw8cfYGWhopyc3og8tZX/KuyCPjg7V3ji3f4YjFOVu843UjBmrfS/+X6kwFv5ZKg7sSm1bQ=="], + "@smithy/url-parser": ["@smithy/url-parser@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-f7kUYrRdLiAHz10WXQXiUkuBFaL2c2ZBD2kSwZyQBh73lWFTvXwdpS9l5irQ/uldk8YMJpm66BozmqCg/3uZvA=="], - "@smithy/util-base64": ["@smithy/util-base64@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-91lxjhFpAktA9yPBxniqVR/NSH9zyjMjLmoa+jbQHQFR9WiJA+n61T7HBrfh5APdEoAledJwGq8l4cS+ZJFUnQ=="], + "@smithy/util-base64": ["@smithy/util-base64@4.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-2J8l+DoX3IIiP75X5SYkJ3mIgOkxW29MxOs7oPjbXLuInQ7UL6zLw2IJHbQ44+eKDBBhTjvt+GgwsTTNBGt8zA=="], - "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-/M6Ya1Fjq8hg3rYjiwwqTen6s1bAa3U3g/2eicBaBQfaoa4ymLUke/x4T8mwb9dSq/L8TQ4YgndS0MaB9ShgmA=="], + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-nQtYwXg4spM6uc0Luq3yck+WXZ1VPfrYkC2SqkQ+YOGks0qR2bKKlSCjidSqfpq+VAY/RJe1O5V+CtBmnT63KQ=="], - "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-M+zdSrevWj0grtZx2RBULPUyjTq1aB+n+13Hrm9owiGpow6DqY/WqiSj6sHVQy/rKp0j7NzV3TNf2LrwDel8JQ=="], + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-BAsAed9yWExECwNIi61Le6D8ZTY71MFEFrf3d4L2+uzcbTjFAWxOtymkA1vCV8bNZQN9TGgZo4c68JDsnjNShA=="], "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-Q60hxKkMEkmBsOEzxlMWEymBWov0dtWGgoJhOUs6mE8k2FDPjK8NlsRdMkmO80n2pwzreHtrYcX5jiRP7ZkP3w=="], + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-LbE6AGHhQOunqIN5UyWDMgpPwmUHUzrV2NtUOQ+lt6Stpipzo6S7uDyeGtO0GGgUD1balEPCNu8Xfl1AQNiruQ=="], - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-RYj+8gr95WiiBqvVghoRvL12NS9ryvLyufp7FOs7EzKwGX0W5gOVlXdCrFkJScSf8gxdjQMRyIZ3Y82/MvXQ3Q=="], + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-72gNpNDQ2iIGbaNmeaF9I58shWsEuD5tNI7my5uXlm1CSPH5i8IKI/nzU50qqB8y+kgw/qTLGgsf0We5qeM/aA=="], - "@smithy/util-endpoints": ["@smithy/util-endpoints@3.5.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-2JqSmzQtKDKqBckLl/9NXTL1fY+zQBU5fNGMpud7AT65vql0tVFhb2UEZNZmLSHayLeD+X/Qzn84oXw5KS+KSQ=="], + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.5.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-NJhe8KmNjeZ7V+gJsQR5xw0IN47N8pBKosed40xfhelDuYkg8VQ5CVGDcHTEuJq3e3zQb21vnoOOReQothejhA=="], - "@smithy/util-middleware": ["@smithy/util-middleware@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-8NZwlQ+nyAIWn9YZxH14FC8ca0i6ZGW1aJyPjD+zMZz3k9jOhXXKhdCSRvjmcSYLW42uhbrxavXqMkrTKHyY3A=="], + "@smithy/util-middleware": ["@smithy/util-middleware@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-N1IR4bMHIDbqO3GxkJHgqNGsnrd7MNrj+EVqhFqKeRqSBV5I3KCjNllKfnbF9KV0YteGhfLqcMR5CYsPLJqpqw=="], - "@smithy/util-retry": ["@smithy/util-retry@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-8RJXeU5lEhdNfXm4XAuHlf6VtNzd279Z2FJZSR7VaELYCR46ffgjJBSjc+3UAy7V1YqBOLV0G9gWhLB/nA44nA=="], + "@smithy/util-retry": ["@smithy/util-retry@4.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-W9Ovy9i02yGqtLlpqZNQuXNxXc5OPfXujnembxN/FxyBtGjJd8vKY0PQYEJ8FNybTOcXG+ZxsSsX23HOb3zQzg=="], - "@smithy/util-utf8": ["@smithy/util-utf8@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-c1QpRBn3aMsoqE64dd4Imgjy8Pynfw+eR7GkjElquxUFSnezwYVaOFm8JcYa+Bo/5ssbEyPKcT3+4bmrWYh6eQ=="], + "@smithy/util-utf8": ["@smithy/util-utf8@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "tslib": "^2.6.2" } }, "sha512-l1d7I7YP2LjXjAZDC7eXqkzuEB75KfCANwhNj/knmT6+0a9XG3QasvI8kEn8WAI3tx/q8PdmSuuXcM+MTkk/7Q=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -225,7 +225,7 @@ "msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="], - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], diff --git a/package-lock.json b/package-lock.json index 9b5cfa1..afb6a35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@zhafron/opencode-kiro-auth", - "version": "1.10.1", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@zhafron/opencode-kiro-auth", - "version": "1.10.1", + "version": "1.11.0", "license": "MIT", "dependencies": { "@aws/codewhisperer-streaming-client": "^1.0.34", - "@opencode-ai/plugin": "^1.14.39", + "@opencode-ai/plugin": "^1.15.11", "proper-lockfile": "^4.1.2", "zod": "^3.24.0" }, @@ -547,9 +547,9 @@ } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -560,9 +560,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -573,9 +573,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -586,9 +586,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -599,9 +599,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -612,9 +612,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -637,23 +637,27 @@ "license": "MIT" }, "node_modules/@opencode-ai/plugin": { - "version": "1.14.39", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.39.tgz", - "integrity": "sha512-h3p3qCZLjodiKquCI9/YSDxgUoHTQ0/AK7t71tLWkUpEUicPZWsixdn3lk53/uLU+Wh+qp5FV+GTZWAXkKmAkw==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.11.tgz", + "integrity": "sha512-RDvYDCHO0+3OAGD590oDQqtryrENrUW04SjtoA4sgRV2efpZeBFjx3TnonBsXKq6nWqYg172nhltUEZsihGnXQ==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.14.39", - "effect": "4.0.0-beta.59", + "@opencode-ai/sdk": "1.15.11", + "effect": "4.0.0-beta.66", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.2.2", - "@opentui/solid": ">=0.2.2" + "@opentui/core": ">=0.2.15", + "@opentui/keymap": ">=0.2.15", + "@opentui/solid": ">=0.2.15" }, "peerDependenciesMeta": { "@opentui/core": { "optional": true }, + "@opentui/keymap": { + "optional": true + }, "@opentui/solid": { "optional": true } @@ -669,9 +673,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.14.39", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.39.tgz", - "integrity": "sha512-hguOA5huhys7zwCR3ESbSHyQNuJBNtfrxUxYZF/s/6trRW+imqGmDtC/RsOSNuk7GE06ZnvOTwb4T2WhXYIBxw==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.11.tgz", + "integrity": "sha512-IyYyDVsO8SKbKbkSadHpDuYnYC+2vmEeLU+rW+rH2M54Sigq6l3gDHno16+U6SRut+lowbph7v/ry3WbV67V3w==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -1481,9 +1485,9 @@ } }, "node_modules/effect": { - "version": "4.0.0-beta.59", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz", - "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==", + "version": "4.0.0-beta.66", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.66.tgz", + "integrity": "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -1498,19 +1502,6 @@ "yaml": "^2.8.3" } }, - "node_modules/effect/node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -1539,9 +1530,9 @@ "license": "MIT" }, "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", "funding": [ { "type": "individual", @@ -1776,9 +1767,9 @@ } }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1789,12 +1780,12 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/multipasta": { @@ -2122,6 +2113,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 9715c4a..2a28137 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zhafron/opencode-kiro-auth", - "version": "1.10.1", + "version": "1.11.0", "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models", "type": "module", "main": "dist/index.js", @@ -33,7 +33,7 @@ }, "dependencies": { "@aws/codewhisperer-streaming-client": "^1.0.34", - "@opencode-ai/plugin": "^1.14.39", + "@opencode-ai/plugin": "^1.15.11", "proper-lockfile": "^4.1.2", "zod": "^3.24.0" }, diff --git a/src/__tests__/auth-bootstrap.test.ts b/src/__tests__/auth-bootstrap.test.ts new file mode 100644 index 0000000..286df55 --- /dev/null +++ b/src/__tests__/auth-bootstrap.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { bootstrapAuthIfNeeded } from '../plugin/auth-bootstrap.js' + +const originalHome = process.env.HOME +const originalXdgDataHome = process.env.XDG_DATA_HOME +const originalKiroCliDbPath = process.env.KIROCLI_DB_PATH + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + + if (originalXdgDataHome === undefined) delete process.env.XDG_DATA_HOME + else process.env.XDG_DATA_HOME = originalXdgDataHome + + if (originalKiroCliDbPath === undefined) delete process.env.KIROCLI_DB_PATH + else process.env.KIROCLI_DB_PATH = originalKiroCliDbPath +}) + +function setupBootstrapFixture() { + const home = mkdtempSync(join(tmpdir(), 'kiro-auth-bootstrap-')) + process.env.HOME = home + process.env.XDG_DATA_HOME = join(home, '.local', 'share') + + const cliDbPath = join(home, 'kiro-cli.sqlite3') + writeFileSync(cliDbPath, '') + process.env.KIROCLI_DB_PATH = cliDbPath + + const authDir = join(home, '.local', 'share', 'opencode') + const authPath = join(authDir, 'auth.json') + mkdirSync(authDir, { recursive: true }) + + return { home, authPath } +} + +describe('bootstrapAuthIfNeeded', () => { + test('does not rewrite malformed auth.json', () => { + const { home, authPath } = setupBootstrapFixture() + writeFileSync(authPath, '{"github":') + + bootstrapAuthIfNeeded('kiro') + + expect(readFileSync(authPath, 'utf-8')).toBe('{"github":') + rmSync(home, { recursive: true, force: true }) + }) + + test('adds placeholder while preserving existing auth providers', () => { + const { home, authPath } = setupBootstrapFixture() + writeFileSync(authPath, JSON.stringify({ github: { type: 'api', key: 'existing' } }, null, 2)) + + bootstrapAuthIfNeeded('kiro') + + expect(JSON.parse(readFileSync(authPath, 'utf-8'))).toEqual({ + github: { type: 'api', key: 'existing' }, + kiro: { type: 'api', key: 'kiro-bootstrap-placeholder' } + }) + rmSync(home, { recursive: true, force: true }) + }) + + test('preserves restrictive auth.json permissions when rewriting', () => { + const { home, authPath } = setupBootstrapFixture() + writeFileSync(authPath, JSON.stringify({ github: { type: 'api', key: 'existing' } }, null, 2)) + chmodSync(authPath, 0o600) + + bootstrapAuthIfNeeded('kiro') + + expect(statSync(authPath).mode & 0o777).toBe(0o600) + rmSync(home, { recursive: true, force: true }) + }) +}) diff --git a/src/__tests__/constants.test.ts b/src/__tests__/constants.test.ts index 4629379..3196d2f 100644 --- a/src/__tests__/constants.test.ts +++ b/src/__tests__/constants.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { isLongContextModel, SUPPORTED_MODELS } from '../constants.js' +import { isLongContextModel, normalizeRegion, SUPPORTED_MODELS } from '../constants.js' describe('isLongContextModel', () => { test('returns true for all 1m model variants', () => { @@ -24,3 +24,9 @@ describe('isLongContextModel', () => { expect(isLongContextModel('claude-sonnet-4-6')).toBe(false) }) }) + +describe('normalizeRegion', () => { + test('accepts configured AWS regions with the installed Zod enum shape', () => { + expect(normalizeRegion('us-west-2')).toBe('us-west-2') + }) +}) diff --git a/src/__tests__/openai-converter.test.ts b/src/__tests__/openai-converter.test.ts new file mode 100644 index 0000000..e35f276 --- /dev/null +++ b/src/__tests__/openai-converter.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' +import { convertToOpenAI } from '../plugin/streaming/openai-converter.js' + +describe('convertToOpenAI', () => { + test('skips Anthropic-only events instead of emitting empty choices', () => { + expect( + convertToOpenAI({ type: 'content_block_stop', index: 0 }, 'chatcmpl-test', 'auto') + ).toBeNull() + expect(convertToOpenAI({ type: 'message_stop' }, 'chatcmpl-test', 'auto')).toBeNull() + }) + + test('skips empty reasoning deltas instead of emitting empty choices', () => { + const chunk = convertToOpenAI( + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: '' } + }, + 'chatcmpl-test', + 'auto' + ) + + expect(chunk).toBeNull() + }) +}) diff --git a/src/__tests__/plugin-module.test.ts b/src/__tests__/plugin-module.test.ts new file mode 100644 index 0000000..8ad0f95 --- /dev/null +++ b/src/__tests__/plugin-module.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from 'bun:test' +import pluginModule from '../index.js' + +describe('package plugin module', () => { + test('uses the kiro provider id in the default export', () => { + expect(pluginModule.id).toBe('kiro') + }) +}) diff --git a/src/constants.ts b/src/constants.ts index 698a42d..b816f8d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,7 @@ import { RegionSchema } from './plugin/config/schema' import type { KiroRegion } from './plugin/types' -const VALID_REGIONS: readonly KiroRegion[] = Object.values(RegionSchema.Values) +const VALID_REGIONS: readonly KiroRegion[] = RegionSchema.options export function isValidRegion(region: string): region is KiroRegion { return VALID_REGIONS.includes(region as KiroRegion) @@ -50,8 +50,11 @@ export const KIRO_CONSTANTS = { } export const MODEL_MAPPING: Record = { + // Claude Haiku 'claude-haiku-4-5': 'claude-haiku-4.5', 'claude-haiku-4-5-thinking': 'claude-haiku-4.5', + // Claude Sonnet + 'claude-sonnet-4': 'claude-sonnet-4', 'claude-sonnet-4-5': 'claude-sonnet-4.5', 'claude-sonnet-4-5-thinking': 'claude-sonnet-4.5', 'claude-sonnet-4-5-1m': 'claude-sonnet-4.5-1m', @@ -60,6 +63,7 @@ export const MODEL_MAPPING: Record = { 'claude-sonnet-4-6-thinking': 'claude-sonnet-4.6', 'claude-sonnet-4-6-1m': 'claude-sonnet-4.6-1m', 'claude-sonnet-4-6-1m-thinking': 'claude-sonnet-4.6-1m', + // Claude Opus 'claude-opus-4-5': 'claude-opus-4.5', 'claude-opus-4-5-thinking': 'claude-opus-4.5', 'claude-opus-4-6': 'claude-opus-4.6', @@ -68,17 +72,20 @@ export const MODEL_MAPPING: Record = { 'claude-opus-4-6-1m-thinking': 'claude-opus-4.6-1m', 'claude-opus-4-7': 'claude-opus-4.7', 'claude-opus-4-7-thinking': 'claude-opus-4.7', - 'claude-sonnet-4': 'claude-sonnet-4', - 'claude-3-7-sonnet': 'CLAUDE_3_7_SONNET_20250219_V1_0', - 'nova-swe': 'AGI_NOVA_SWE_V1_5', - 'gpt-oss-120b': 'OPENAI_GPT_OSS_120B_1_0', - 'minimax-m2': 'MINIMAX_MINIMAX_M2', - 'kimi-k2-thinking': 'MOONSHOT_KIMI_K2_THINKING', + // Auto auto: 'auto', + // Open weight models 'deepseek-3.2': 'deepseek-3.2', + 'glm-5': 'glm-5', 'minimax-m2.5': 'minimax-m2.5', 'minimax-m2.1': 'minimax-m2.1', - 'qwen3-coder-next': 'qwen3-coder-next' + 'qwen3-coder-next': 'qwen3-coder-next', + // Legacy / internal mappings kept for backwards compatibility + 'claude-3-7-sonnet': 'CLAUDE_3_7_SONNET_20250219_V1_0', + 'nova-swe': 'AGI_NOVA_SWE_V1_5', + 'gpt-oss-120b': 'OPENAI_GPT_OSS_120B_1_0', + 'minimax-m2': 'MINIMAX_MINIMAX_M2', + 'kimi-k2-thinking': 'MOONSHOT_KIMI_K2_THINKING' } export const SUPPORTED_MODELS = Object.keys(MODEL_MAPPING) diff --git a/src/index.ts b/src/index.ts index ffa4183..9174053 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,4 @@ export { KiroOAuthPlugin } from './plugin.js' export type { KiroConfig } from './plugin/config/index.js' export type { KiroAuthMethod, KiroRegion, ManagedAccount } from './plugin/types.js' -export default { id: 'kiro-auth', server: (await import('./plugin.js')).KiroOAuthPlugin } +export default { id: 'kiro', server: (await import('./plugin.js')).KiroOAuthPlugin } diff --git a/src/plugin.ts b/src/plugin.ts index a1cc96b..c080c51 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,11 +4,12 @@ import { RequestHandler } from './core/request/request-handler.js' import { AccountCache } from './infrastructure/database/account-cache.js' import { AccountRepository } from './infrastructure/database/account-repository.js' import { AccountManager } from './plugin/accounts.js' +import { bootstrapAuthIfNeeded } from './plugin/auth-bootstrap.js' import { loadConfig } from './plugin/config/index.js' type ToastFunction = (message: string, variant: string) => void -const KIRO_PROVIDER_ID = 'kiro-auth' +const KIRO_PROVIDER_ID = 'kiro' export const createKiroPlugin = (id: string) => @@ -28,11 +29,29 @@ export const createKiroPlugin = const requestHandler = new RequestHandler(accountManager, config, repository, client, directory) + // Compute the base URL once so both the config hook and auth loader use the same value + const baseURL = KIRO_CONSTANTS.BASE_URL.replace('/generateAssistantResponse', '').replace( + '{{region}}', + config.default_region || 'us-east-1' + ) + return { config: async (input: any) => { + // Ensure there's an auth entry so OpenCode calls the loader on startup. + // This is a no-op if the entry already exists. + bootstrapAuthIfNeeded(id) + if (!input.provider) input.provider = {} if (!input.provider[id]) input.provider[id] = {} + // Always set npm and api — these must be present regardless of whether + // the user has already defined the provider in their opencode.json. input.provider[id].npm = '@ai-sdk/openai-compatible' + // Set the base URL at the provider level. OpenCode reads provider.api as + // model.api.url, which resolveSDK() uses to construct the endpoint URL. + // Only set if not already overridden by the user. + if (!input.provider[id].api) { + input.provider[id].api = baseURL + } if (!input.provider[id].models) { input.provider[id].models = { auto: { @@ -40,6 +59,12 @@ export const createKiroPlugin = 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 }, @@ -50,16 +75,13 @@ export const createKiroPlugin = limit: { context: 1000000, output: 64000 }, modalities: { input: ['text', 'image', 'pdf'], output: ['text'] } }, - 'claude-sonnet-4': { - name: 'Claude Sonnet 4.0 (1.3x)', - limit: { context: 200000, 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 }, @@ -75,6 +97,17 @@ export const createKiroPlugin = 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 }, @@ -101,10 +134,10 @@ export const createKiroPlugin = return { apiKey: '', - baseURL: KIRO_CONSTANTS.BASE_URL.replace('/generateAssistantResponse', '').replace( - '{{region}}', - config.default_region || 'us-east-1' - ), + // Provide baseURL explicitly so the @ai-sdk/openai-compatible provider + // always has a valid URL. The custom fetch below intercepts all Kiro + // API calls, so this value is only used for URL construction. + baseURL, fetch: (input: any, init?: any) => requestHandler.handle(input, init, showToast) } }, @@ -122,7 +155,11 @@ export const createKiroPlugin = ...modelInfo, api: { ...(modelInfo.api || {}), - npm: '@ai-sdk/openai-compatible' + npm: '@ai-sdk/openai-compatible', + // Ensure url is always set. modelInfo.api.url should already be + // populated from the config hook's provider.api field, but we + // set it explicitly as a fallback for any edge cases. + url: modelInfo.api?.url || baseURL } } } diff --git a/src/plugin/auth-bootstrap.ts b/src/plugin/auth-bootstrap.ts new file mode 100644 index 0000000..d00d550 --- /dev/null +++ b/src/plugin/auth-bootstrap.ts @@ -0,0 +1,86 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + writeFileSync +} from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import * as logger from './logger.js' +import { getCliDbPath } from './sync/kiro-cli-parser.js' + +function getOpenCodeAuthPath(): string { + const dataRoot = + process.platform === 'win32' + ? process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local') + : process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share') + + return join(dataRoot, 'opencode', 'auth.json') +} + +function readAuthFile(authPath: string): Record | null { + if (!existsSync(authPath)) return {} + + try { + const parsed = JSON.parse(readFileSync(authPath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + logger.warn('Bootstrap: auth.json is not an object, skipping placeholder auth setup') + return null + } + return parsed + } catch (e) { + logger.warn( + `Bootstrap: invalid auth.json, skipping placeholder auth setup: ${e instanceof Error ? e.message : String(e)}` + ) + return null + } +} + +function writeAuthFile(authPath: string, auth: Record): void { + mkdirSync(dirname(authPath), { recursive: true }) + const mode = existsSync(authPath) ? statSync(authPath).mode & 0o777 : 0o600 + const tempPath = `${authPath}.${process.pid}.${Date.now()}.tmp` + writeFileSync(tempPath, JSON.stringify(auth, null, 2), { encoding: 'utf-8', mode }) + chmodSync(tempPath, mode) + renameSync(tempPath, authPath) +} + +/** + * OpenCode only calls the auth loader when there is a stored auth entry for the + * provider in auth.json. The plugin syncs credentials from the Kiro IDE's local + * SQLite database, so it doesn't need the user to go through an OAuth flow first. + * + * This writes a minimal placeholder entry into auth.json so OpenCode calls the + * loader on the next startup, where real credentials are synced from Kiro CLI DB. + */ +export function bootstrapAuthIfNeeded(providerId: string): void { + try { + const cliDbPath = getCliDbPath() + if (!existsSync(cliDbPath)) { + logger.log('Bootstrap: Kiro CLI DB not found, skipping') + return + } + + const authPath = getOpenCodeAuthPath() + const auth = readAuthFile(authPath) + if (!auth) return + + if (auth[providerId]) { + return + } + + logger.log(`Bootstrap: writing placeholder auth entry for provider "${providerId}"`) + auth[providerId] = { + type: 'api', + key: 'kiro-bootstrap-placeholder' + } + + writeAuthFile(authPath, auth) + logger.log('Bootstrap: auth.json updated — loader will run on next request') + } catch (e) { + logger.warn(`Bootstrap failed: ${e instanceof Error ? e.message : String(e)}`) + } +} diff --git a/src/plugin/streaming/openai-converter.ts b/src/plugin/streaming/openai-converter.ts index bdca132..97191d2 100644 --- a/src/plugin/streaming/openai-converter.ts +++ b/src/plugin/streaming/openai-converter.ts @@ -17,11 +17,15 @@ export function convertToOpenAI(event: StreamEvent, id: string, model: string): finish_reason: null }) } else if (event.delta.type === 'thinking_delta') { - base.choices.push({ - index: 0, - delta: { reasoning_content: event.delta.thinking }, - finish_reason: null - }) + // reasoning_content is supported by some OpenAI-compatible clients; + // emit it but also skip if empty to avoid noise. + if (event.delta.thinking) { + base.choices.push({ + index: 0, + delta: { reasoning_content: event.delta.thinking }, + finish_reason: null + }) + } } else if (event.delta.type === 'input_json_delta') { base.choices.push({ index: 0, @@ -62,7 +66,15 @@ export function convertToOpenAI(event: StreamEvent, id: string, model: string): completion_tokens: event.usage?.output_tokens || 0, total_tokens: (event.usage?.input_tokens || 0) + (event.usage?.output_tokens || 0) } + } else { + // Skip Anthropic-specific events that @ai-sdk/openai-compatible doesn't understand + // (content_block_start for text/thinking, content_block_stop, message_stop, etc.) + // Returning null signals the caller to skip this event. + return null } + // Don't emit chunks with empty choices — the SDK may mishandle them. + if (base.choices.length === 0) return null + return base } diff --git a/src/plugin/streaming/sdk-stream-transformer.ts b/src/plugin/streaming/sdk-stream-transformer.ts index 71437fc..02ba9d8 100644 --- a/src/plugin/streaming/sdk-stream-transformer.ts +++ b/src/plugin/streaming/sdk-stream-transformer.ts @@ -51,7 +51,10 @@ export async function* transformSdkStream( if (!thinkingRequested) { for (const ev of createTextDeltaEvents(text, streamState)) { - yield convertToOpenAI(ev, conversationId, model) + { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } } continue } @@ -123,7 +126,8 @@ export async function* transformSdkStream( } for (const ev of deltaEvents) { - yield convertToOpenAI(ev, conversationId, model) + const chunk = convertToOpenAI(ev, conversationId, model) + if (chunk !== null) yield chunk } } else if (event.toolUseEvent) { const tc = event.toolUseEvent @@ -175,22 +179,32 @@ export async function* transformSdkStream( if (thinkingRequested && streamState.buffer) { if (streamState.inThinking) { - for (const ev of createThinkingDeltaEvents(streamState.buffer, streamState)) - yield convertToOpenAI(ev, conversationId, model) + for (const ev of createThinkingDeltaEvents(streamState.buffer, streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } streamState.buffer = '' - for (const ev of createThinkingDeltaEvents('', streamState)) - yield convertToOpenAI(ev, conversationId, model) - for (const ev of stopBlock(streamState.thinkingBlockIndex, streamState)) - yield convertToOpenAI(ev, conversationId, model) + for (const ev of createThinkingDeltaEvents('', streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } + for (const ev of stopBlock(streamState.thinkingBlockIndex, streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } } else { - for (const ev of createTextDeltaEvents(streamState.buffer, streamState)) - yield convertToOpenAI(ev, conversationId, model) + for (const ev of createTextDeltaEvents(streamState.buffer, streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } streamState.buffer = '' } } - for (const ev of stopBlock(streamState.textBlockIndex, streamState)) - yield convertToOpenAI(ev, conversationId, model) + for (const ev of stopBlock(streamState.textBlockIndex, streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } const bracketToolCalls = parseBracketToolCalls(totalContent) if (bracketToolCalls.length > 0) { @@ -212,20 +226,23 @@ export async function* transformSdkStream( if (!tc) continue const blockIndex = baseIndex + i - yield convertToOpenAI( - { - type: 'content_block_start', - index: blockIndex, - content_block: { - type: 'tool_use', - id: tc.toolUseId, - name: tc.name, - input: {} - } - }, - conversationId, - model - ) + { + const _c = convertToOpenAI( + { + type: 'content_block_start', + index: blockIndex, + content_block: { + type: 'tool_use', + id: tc.toolUseId, + name: tc.name, + input: {} + } + }, + conversationId, + model + ) + if (_c !== null) yield _c + } let inputJson: string try { @@ -238,24 +255,30 @@ export async function* transformSdkStream( inputJson = tc.input } - yield convertToOpenAI( - { - type: 'content_block_delta', - index: blockIndex, - delta: { - type: 'input_json_delta', - partial_json: inputJson - } - }, - conversationId, - model - ) + { + const _c = convertToOpenAI( + { + type: 'content_block_delta', + index: blockIndex, + delta: { + type: 'input_json_delta', + partial_json: inputJson + } + }, + conversationId, + model + ) + if (_c !== null) yield _c + } - yield convertToOpenAI( - { type: 'content_block_stop', index: blockIndex }, - conversationId, - model - ) + { + const _c = convertToOpenAI( + { type: 'content_block_stop', index: blockIndex }, + conversationId, + model + ) + if (_c !== null) yield _c + } } } @@ -271,22 +294,28 @@ export async function* transformSdkStream( if (realInputTokens !== undefined) inputTokens = realInputTokens if (realOutputTokens !== undefined) outputTokens = realOutputTokens - yield convertToOpenAI( - { - type: 'message_delta', - delta: { stop_reason: dedupedToolCalls.length > 0 ? 'tool_use' : 'end_turn' }, - usage: { - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0 - } - }, - conversationId, - model - ) + { + const _c = convertToOpenAI( + { + type: 'message_delta', + delta: { stop_reason: dedupedToolCalls.length > 0 ? 'tool_use' : 'end_turn' }, + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + }, + conversationId, + model + ) + if (_c !== null) yield _c + } - yield convertToOpenAI({ type: 'message_stop' }, conversationId, model) + { + const _c = convertToOpenAI({ type: 'message_stop' }, conversationId, model) + if (_c !== null) yield _c + } } catch (e) { logger.debug( `[STREAM] Error in transformSdkStream: ${e instanceof Error ? e.message : String(e)}` diff --git a/src/plugin/streaming/stream-transformer.ts b/src/plugin/streaming/stream-transformer.ts index e8de8fb..af9e839 100644 --- a/src/plugin/streaming/stream-transformer.ts +++ b/src/plugin/streaming/stream-transformer.ts @@ -65,7 +65,10 @@ export async function* transformKiroStream( if (!thinkingRequested) { for (const ev of createTextDeltaEvents(event.data, streamState)) { - yield convertToOpenAI(ev, conversationId, model) + { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } } continue } @@ -141,7 +144,10 @@ export async function* transformKiroStream( } for (const ev of deltaEvents) { - yield convertToOpenAI(ev, conversationId, model) + { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } } } else if (event.type === 'toolUse') { const tc = event.data @@ -194,22 +200,32 @@ export async function* transformKiroStream( if (thinkingRequested && streamState.buffer) { if (streamState.inThinking) { - for (const ev of createThinkingDeltaEvents(streamState.buffer, streamState)) - yield convertToOpenAI(ev, conversationId, model) + for (const ev of createThinkingDeltaEvents(streamState.buffer, streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } streamState.buffer = '' - for (const ev of createThinkingDeltaEvents('', streamState)) - yield convertToOpenAI(ev, conversationId, model) - for (const ev of stopBlock(streamState.thinkingBlockIndex, streamState)) - yield convertToOpenAI(ev, conversationId, model) + for (const ev of createThinkingDeltaEvents('', streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } + for (const ev of stopBlock(streamState.thinkingBlockIndex, streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } } else { - for (const ev of createTextDeltaEvents(streamState.buffer, streamState)) - yield convertToOpenAI(ev, conversationId, model) + for (const ev of createTextDeltaEvents(streamState.buffer, streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } streamState.buffer = '' } } - for (const ev of stopBlock(streamState.textBlockIndex, streamState)) - yield convertToOpenAI(ev, conversationId, model) + for (const ev of stopBlock(streamState.textBlockIndex, streamState)) { + const _c = convertToOpenAI(ev, conversationId, model) + if (_c !== null) yield _c + } const bracketToolCalls = parseBracketToolCalls(totalContent) if (bracketToolCalls.length > 0) { @@ -230,20 +246,23 @@ export async function* transformKiroStream( const blockIndex = baseIndex + i - yield convertToOpenAI( - { - type: 'content_block_start', - index: blockIndex, - content_block: { - type: 'tool_use', - id: tc.toolUseId, - name: tc.name, - input: {} - } - }, - conversationId, - model - ) + { + const _c = convertToOpenAI( + { + type: 'content_block_start', + index: blockIndex, + content_block: { + type: 'tool_use', + id: tc.toolUseId, + name: tc.name, + input: {} + } + }, + conversationId, + model + ) + if (_c !== null) yield _c + } let inputJson: string try { @@ -253,24 +272,30 @@ export async function* transformKiroStream( inputJson = tc.input } - yield convertToOpenAI( - { - type: 'content_block_delta', - index: blockIndex, - delta: { - type: 'input_json_delta', - partial_json: inputJson - } - }, - conversationId, - model - ) - - yield convertToOpenAI( - { type: 'content_block_stop', index: blockIndex }, - conversationId, - model - ) + { + const _c = convertToOpenAI( + { + type: 'content_block_delta', + index: blockIndex, + delta: { + type: 'input_json_delta', + partial_json: inputJson + } + }, + conversationId, + model + ) + if (_c !== null) yield _c + } + + { + const _c = convertToOpenAI( + { type: 'content_block_stop', index: blockIndex }, + conversationId, + model + ) + if (_c !== null) yield _c + } } } @@ -282,22 +307,28 @@ export async function* transformKiroStream( inputTokens = Math.max(0, totalTokens - outputTokens) } - yield convertToOpenAI( - { - type: 'message_delta', - delta: { stop_reason: toolCalls.length > 0 ? 'tool_use' : 'end_turn' }, - usage: { - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0 - } - }, - conversationId, - model - ) + { + const _c = convertToOpenAI( + { + type: 'message_delta', + delta: { stop_reason: toolCalls.length > 0 ? 'tool_use' : 'end_turn' }, + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + }, + conversationId, + model + ) + if (_c !== null) yield _c + } - yield convertToOpenAI({ type: 'message_stop' }, conversationId, model) + { + const _c = convertToOpenAI({ type: 'message_stop' }, conversationId, model) + if (_c !== null) yield _c + } } finally { reader.releaseLock() } From 3bc6de1d2939da032c7a24a5db6071dd4ceaa8ae Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Mon, 1 Jun 2026 21:25:26 +0200 Subject: [PATCH 18/20] feat: serve both kiro and kiro-auth provider ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode briefly shipped a built-in `kiro` provider that collided with this plugin's `kiro` id and crashed every request with `Y.languageModel is not a function` (anomalyco/opencode#26221). It was reverted, but is likely to return — so this is a pre-emptive fix. Move the primary provider id to `kiro-auth` so installs never clash with a future built-in `kiro`. Keep `kiro` registered as a back-compat alias so existing installs keep working; both ids share one custom fetch. Mechanics: OpenCode binds auth.loader to a single provider id, so the custom fetch is attached via provider.options in the config hook. resolveSDK reads options.fetch per provider, so both ids route through the plugin, and the fetch self-identifies Kiro requests by URL. --- README.md | 12 +- src/__tests__/plugin-module.test.ts | 6 +- src/index.ts | 2 +- src/plugin.ts | 190 +++++++++++++++------------- 4 files changed, 116 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 4e646a4..7a4620a 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,15 @@ models with substantial trial quotas. Add the plugin to your `opencode.json` or `opencode.jsonc`: +> Use the `kiro-auth` provider id. The plugin also keeps serving the legacy `kiro` +> id for existing installs, but `kiro-auth` is recommended because OpenCode is +> expected to ship a built-in `kiro` provider that would otherwise clash. + ```json { "plugin": ["@zhafron/opencode-kiro-auth"], "provider": { - "kiro": { + "kiro-auth": { "models": { "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5", @@ -181,14 +185,14 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: 1. **Authentication via Kiro CLI (Recommended)**: - Perform login directly in your terminal using `kiro-cli login`. - - The plugin automatically bootstraps a minimal `kiro` placeholder in + - The plugin automatically bootstraps a minimal `kiro-auth` placeholder in OpenCode's `auth.json` when it detects the Kiro CLI database, then imports and synchronizes your active session on startup. - For AWS IAM Identity Center (SSO/IDC), the plugin imports both the token and device registration (OIDC client credentials) from the `kiro-cli` database. 2. **Direct Authentication**: - Run `opencode auth login`. - - Select `Other`, type `kiro`, and press enter. + - Select `Other`, type `kiro-auth`, and press enter. - You'll be prompted for your **IAM Identity Center Start URL** and **IAM Identity Center region** (`sso_region`). - Leave it blank to sign in with **AWS Builder ID**. @@ -262,7 +266,7 @@ It will replace it with the real email once usage/email lookup succeeds. ### Kiro CLI (Google/GitHub OAuth) users: plugin sync does not start If you authenticated via `kiro-cli login` using Google or GitHub OAuth (not AWS Builder -ID or IAM Identity Center), OpenCode still needs a stored `kiro` auth entry before it +ID or IAM Identity Center), OpenCode still needs a stored `kiro-auth` auth entry before it will call the plugin loader. The plugin now creates that minimal placeholder automatically when it detects the local diff --git a/src/__tests__/plugin-module.test.ts b/src/__tests__/plugin-module.test.ts index 8ad0f95..4cab8b6 100644 --- a/src/__tests__/plugin-module.test.ts +++ b/src/__tests__/plugin-module.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from 'bun:test' import pluginModule from '../index.js' describe('package plugin module', () => { - test('uses the kiro provider id in the default export', () => { - expect(pluginModule.id).toBe('kiro') + test('uses the kiro-auth provider id in the default export', () => { + // kiro-auth is the primary id (avoids clashing with a future built-in `kiro`); + // `kiro` remains registered as a back-compat alias by the config hook. + expect(pluginModule.id).toBe('kiro-auth') }) }) diff --git a/src/index.ts b/src/index.ts index 9174053..ffa4183 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,4 @@ export { KiroOAuthPlugin } from './plugin.js' export type { KiroConfig } from './plugin/config/index.js' export type { KiroAuthMethod, KiroRegion, ManagedAccount } from './plugin/types.js' -export default { id: 'kiro', server: (await import('./plugin.js')).KiroOAuthPlugin } +export default { id: 'kiro-auth', server: (await import('./plugin.js')).KiroOAuthPlugin } diff --git a/src/plugin.ts b/src/plugin.ts index c080c51..e5a2581 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -9,7 +9,84 @@ import { loadConfig } from './plugin/config/index.js' type ToastFunction = (message: string, variant: string) => void -const KIRO_PROVIDER_ID = 'kiro' +// `kiro-auth` is the recommended provider id. OpenCode is expected to ship a +// built-in `kiro` provider, which would clash with our default — so new installs +// use `kiro-auth`. We still register `kiro` as a back-compat alias so existing +// installs configured against `kiro` keep working. +const KIRO_PROVIDER_ID = 'kiro-auth' +const KIRO_LEGACY_PROVIDER_ID = 'kiro' + +const DEFAULT_MODELS: Record = { + 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 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'] } + }, + // 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'] } + } +} export const createKiroPlugin = (id: string) => @@ -35,6 +112,24 @@ export const createKiroPlugin = config.default_region || 'us-east-1' ) + // The custom fetch self-identifies Kiro requests by URL, so a single instance + // serves any provider id. OpenCode binds auth.loader to one id only, so we + // attach this fetch via provider.options in the config hook — resolveSDK reads + // options.fetch per provider, which is how both `kiro-auth` and `kiro` route + // through us. + const kiroFetch = (input: any, init?: any) => requestHandler.handle(input, init, showToast) + + const registerProvider = (input: any, providerId: string) => { + if (!input.provider[providerId]) input.provider[providerId] = {} + const p = input.provider[providerId] + p.npm = '@ai-sdk/openai-compatible' + // OpenCode resolves model.api.url / model.api.npm from these provider-level + // fields, so the models don't need per-model api entries. + if (!p.api) p.api = baseURL + p.options = { ...(p.options ?? {}), fetch: kiroFetch } + if (!p.models) p.models = { ...DEFAULT_MODELS } + } + return { config: async (input: any) => { // Ensure there's an auth entry so OpenCode calls the loader on startup. @@ -42,89 +137,10 @@ export const createKiroPlugin = bootstrapAuthIfNeeded(id) if (!input.provider) input.provider = {} - if (!input.provider[id]) input.provider[id] = {} - // Always set npm and api — these must be present regardless of whether - // the user has already defined the provider in their opencode.json. - input.provider[id].npm = '@ai-sdk/openai-compatible' - // Set the base URL at the provider level. OpenCode reads provider.api as - // model.api.url, which resolveSDK() uses to construct the endpoint URL. - // Only set if not already overridden by the user. - if (!input.provider[id].api) { - 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 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'] } - }, - // 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'] } - } - } - } + // Primary id (kiro-auth) plus the back-compat alias (kiro), both sharing + // the same custom fetch. + registerProvider(input, id) + registerProvider(input, KIRO_LEGACY_PROVIDER_ID) }, auth: { provider: id, @@ -135,10 +151,10 @@ export const createKiroPlugin = return { apiKey: '', // Provide baseURL explicitly so the @ai-sdk/openai-compatible provider - // always has a valid URL. The custom fetch below intercepts all Kiro - // API calls, so this value is only used for URL construction. + // always has a valid URL. The custom fetch intercepts all Kiro API + // calls, so this value is only used for URL construction. baseURL, - fetch: (input: any, init?: any) => requestHandler.handle(input, init, showToast) + fetch: kiroFetch } }, methods: authHandler.getMethods() From 7c8fcc79613dc3d1aa19cf5f75ccb9336c9280a0 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Mon, 1 Jun 2026 21:38:52 +0200 Subject: [PATCH 19/20] feat: add Claude Opus 4.8 model Per the Kiro model docs (kiro.dev/docs/models), Opus 4.8 (1M context, 2.2x, experimental) is the latest model and was missing. Add the mapping, default model entry, thinking variant, README example, and a resolution test, mirroring the Opus 4.7 entries. --- README.md | 15 +++++++++++++++ src/__tests__/model-resolution.test.ts | 5 +++++ src/constants.ts | 2 ++ src/plugin.ts | 5 +++++ 4 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 7a4620a..01bfc90 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,21 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`: "max": { "thinkingConfig": { "thinkingBudget": 32768 } } } }, + "claude-opus-4-8": { + "name": "Claude Opus 4.8", + "limit": { "context": 1000000, "output": 64000 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } + }, + "claude-opus-4-8-thinking": { + "name": "Claude Opus 4.8 Thinking", + "limit": { "context": 1000000, "output": 64000 }, + "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, + "variants": { + "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, + "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, + "max": { "thinkingConfig": { "thinkingBudget": 32768 } } + } + }, "claude-sonnet-4-5-1m": { "name": "Claude Sonnet 4.5 (1M Context)", "limit": { "context": 1000000, "output": 64000 }, diff --git a/src/__tests__/model-resolution.test.ts b/src/__tests__/model-resolution.test.ts index 9b54c9d..02f0c5f 100644 --- a/src/__tests__/model-resolution.test.ts +++ b/src/__tests__/model-resolution.test.ts @@ -16,6 +16,11 @@ describe('resolveKiroModel', () => { expect(resolveKiroModel('claude-sonnet-4')).toBe('claude-sonnet-4') }) + test('resolves Claude Opus 4.8 (and its thinking variant)', () => { + expect(resolveKiroModel('claude-opus-4-8')).toBe('claude-opus-4.8') + expect(resolveKiroModel('claude-opus-4-8-thinking')).toBe('claude-opus-4.8') + }) + test('rejects removed qwen3-coder-480b slug', () => { expect(() => resolveKiroModel('qwen3-coder-480b')).toThrow( 'Unsupported model: qwen3-coder-480b' diff --git a/src/constants.ts b/src/constants.ts index b816f8d..43bb22f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -72,6 +72,8 @@ export const MODEL_MAPPING: Record = { 'claude-opus-4-6-1m-thinking': 'claude-opus-4.6-1m', 'claude-opus-4-7': 'claude-opus-4.7', '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', // Auto auto: 'auto', // Open weight models diff --git a/src/plugin.ts b/src/plugin.ts index e5a2581..541ba82 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -60,6 +60,11 @@ const DEFAULT_MODELS: Record = { 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'] } + }, // Open weight models 'deepseek-3.2': { name: 'DeepSeek 3.2 (0.25x)', From ed9859b212c1dfdfe3ad676cc3a77e6e89ca4b54 Mon Sep 17 00:00:00 2001 From: Rene van Veen Date: Mon, 1 Jun 2026 22:20:14 +0200 Subject: [PATCH 20/20] docs: simplify install example and document the kiro fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin registers the kiro-auth provider and its models automatically, so the manual provider/models block is no longer needed — reduce the install example to just the plugin entry. Also call out explicitly that the old `kiro` id still works as a fallback alias, with `kiro-auth` recommended going forward. --- README.md | 177 +++++------------------------------------------------- 1 file changed, 14 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 01bfc90..5ceb710 100644 --- a/README.md +++ b/README.md @@ -28,174 +28,25 @@ models with substantial trial quotas. Add the plugin to your `opencode.json` or `opencode.jsonc`: -> Use the `kiro-auth` provider id. The plugin also keeps serving the legacy `kiro` -> id for existing installs, but `kiro-auth` is recommended because OpenCode is -> expected to ship a built-in `kiro` provider that would otherwise clash. - ```json { - "plugin": ["@zhafron/opencode-kiro-auth"], - "provider": { - "kiro-auth": { - "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 } }, - "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 } }, - "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 } }, - "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 } }, - "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 } }, - "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 } }, - "max": { "thinkingConfig": { "thinkingBudget": 32768 } } - } - }, - "claude-opus-4-8": { - "name": "Claude Opus 4.8", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } - }, - "claude-opus-4-8-thinking": { - "name": "Claude Opus 4.8 Thinking", - "limit": { "context": 1000000, "output": 64000 }, - "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, - "variants": { - "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, - "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, - "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 } }, - "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's all you need — the plugin registers the `kiro-auth` provider and its +models automatically. + +**Provider id — `kiro-auth` (with a `kiro` fallback):** use `kiro-auth` for new +setups. If you previously configured this plugin under the old `kiro` id, it +keeps working — the plugin also registers `kiro` as a fallback alias, routing it +through the same backend. `kiro-auth` is recommended going forward because +OpenCode is expected to ship a built-in `kiro` provider that would otherwise +clash with this plugin. + +You only need a `provider` block if you want to override the defaults — for +example a custom model list or your own thinking budgets. + ## Setup 1. **Authentication via Kiro CLI (Recommended)**: