From 0e2b47143d772e1230dcda9a11c6e91057fe2eca Mon Sep 17 00:00:00 2001 From: Guillaume Gay Date: Tue, 26 May 2026 09:54:21 +0200 Subject: [PATCH] Add Kiro subscription provider --- .changeset/kiro-subscription-provider.md | 5 + .../src/common/constants/providers.spec.ts | 11 + .../common/utils/provider-inference.spec.ts | 5 + .../model-discovery.service.spec.ts | 76 +++ .../model-discovery.service.ts | 17 + .../provider-model-fetcher.service.spec.ts | 94 ++++ .../provider-model-fetcher.service.ts | 58 +++ .../src/routing/kiro-oauth.controller.spec.ts | 56 ++ .../src/routing/oauth/kiro-cli-token.spec.ts | 174 +++++++ .../src/routing/oauth/kiro-cli-token.ts | 150 ++++++ .../routing/oauth/kiro-oauth.controller.ts | 27 + .../routing/oauth/kiro-oauth.service.spec.ts | 141 +++++ .../src/routing/oauth/kiro-oauth.service.ts | 70 +++ .../backend/src/routing/oauth/oauth.module.ts | 5 + .../proxy/__tests__/kiro-adapter.spec.ts | 439 ++++++++++++++++ .../proxy/__tests__/provider-client.spec.ts | 47 ++ .../__tests__/provider-endpoints.spec.ts | 13 + .../__tests__/proxy-fallback.routes.spec.ts | 7 + .../__tests__/proxy-fallback.service.spec.ts | 41 ++ .../proxy/__tests__/proxy.service.spec.ts | 4 + .../backend/src/routing/proxy/kiro-adapter.ts | 487 ++++++++++++++++++ .../src/routing/proxy/provider-client.ts | 20 + .../src/routing/proxy/provider-endpoints.ts | 9 +- .../routing/proxy/proxy-fallback.service.ts | 8 + .../src/routing/proxy/proxy.service.ts | 3 + .../src/components/CliOAuthDetailView.tsx | 117 +++++ .../src/components/ProviderDetailView.tsx | 22 +- .../frontend/src/components/ProviderIcon.tsx | 26 + packages/frontend/src/services/api/oauth.ts | 14 + .../src/services/provider-api-key-urls.ts | 2 + packages/frontend/src/services/providers.ts | 16 +- .../components/ProviderDetailView.test.tsx | 276 +++++++++- .../tests/components/ProviderIcon.test.tsx | 1 + .../frontend/tests/services/api/oauth.test.ts | 21 + .../frontend/tests/services/providers.test.ts | 19 + packages/shared/src/provider-inference.ts | 1 + packages/shared/src/providers.ts | 12 + packages/shared/src/subscription/configs.ts | 21 + packages/shared/src/subscription/types.ts | 2 +- 39 files changed, 2498 insertions(+), 19 deletions(-) create mode 100644 .changeset/kiro-subscription-provider.md create mode 100644 packages/backend/src/routing/kiro-oauth.controller.spec.ts create mode 100644 packages/backend/src/routing/oauth/kiro-cli-token.spec.ts create mode 100644 packages/backend/src/routing/oauth/kiro-cli-token.ts create mode 100644 packages/backend/src/routing/oauth/kiro-oauth.controller.ts create mode 100644 packages/backend/src/routing/oauth/kiro-oauth.service.spec.ts create mode 100644 packages/backend/src/routing/oauth/kiro-oauth.service.ts create mode 100644 packages/backend/src/routing/proxy/__tests__/kiro-adapter.spec.ts create mode 100644 packages/backend/src/routing/proxy/kiro-adapter.ts create mode 100644 packages/frontend/src/components/CliOAuthDetailView.tsx diff --git a/.changeset/kiro-subscription-provider.md b/.changeset/kiro-subscription-provider.md new file mode 100644 index 0000000000..e20e6867b7 --- /dev/null +++ b/.changeset/kiro-subscription-provider.md @@ -0,0 +1,5 @@ +--- +'manifest': patch +--- + +Add Kiro subscription provider support with dynamic model discovery and proxy forwarding. diff --git a/packages/backend/src/common/constants/providers.spec.ts b/packages/backend/src/common/constants/providers.spec.ts index 474fa5c660..36bb652144 100644 --- a/packages/backend/src/common/constants/providers.spec.ts +++ b/packages/backend/src/common/constants/providers.spec.ts @@ -96,6 +96,17 @@ describe('PROVIDER_REGISTRY', () => { expect(kilo!.localOnly).toBe(false); expect(kilo!.color).toBe('#f0e68c'); }); + + it('kiro is registered as a CLI OAuth subscription provider', () => { + const kiro = PROVIDER_REGISTRY.find((p) => p.id === 'kiro'); + expect(kiro).toBeDefined(); + expect(kiro!.displayName).toBe('Kiro'); + expect(kiro!.aliases).toEqual([]); + expect(kiro!.openRouterPrefixes).toEqual([]); + expect(kiro!.requiresApiKey).toBe(false); + expect(kiro!.localOnly).toBe(false); + expect(kiro!.keyPrefix).toBe(''); + }); }); describe('PROVIDER_BY_ID', () => { diff --git a/packages/backend/src/common/utils/provider-inference.spec.ts b/packages/backend/src/common/utils/provider-inference.spec.ts index a933784d86..37b64778da 100644 --- a/packages/backend/src/common/utils/provider-inference.spec.ts +++ b/packages/backend/src/common/utils/provider-inference.spec.ts @@ -67,6 +67,11 @@ describe('inferProviderFromModel', () => { expect(inferProviderFromModel('qwq-32b')).toBe('qwen'); }); + it('returns "kiro" for Kiro subscription model IDs before generic vendor fallback', () => { + expect(inferProviderFromModel('kiro/auto')).toBe('kiro'); + expect(inferProviderFromModel('kiro/claude-sonnet-4.5')).toBe('kiro'); + }); + it('returns "openrouter" for vendor/model format', () => { expect(inferProviderFromModel('stepfun/step-3.5-flash:free')).toBe('openrouter'); }); diff --git a/packages/backend/src/model-discovery/model-discovery.service.spec.ts b/packages/backend/src/model-discovery/model-discovery.service.spec.ts index 3f42d82564..3f7dd7e9f5 100644 --- a/packages/backend/src/model-discovery/model-discovery.service.spec.ts +++ b/packages/backend/src/model-discovery/model-discovery.service.spec.ts @@ -19,9 +19,24 @@ jest.mock('./anthropic-subscription-probe', () => ({ filterBySubscriptionAccess: jest.fn().mockImplementation((models: unknown[]) => models), })); +jest.mock('../routing/oauth/kiro-cli-token', () => ({ + getFreshKiroCliToken: jest.fn(), + parseKiroCliTokenBlob: jest.fn((rawValue: string) => { + try { + const parsed = JSON.parse(rawValue) as { source?: string; t?: string; e?: number }; + return parsed.source === 'kiro-cli' && parsed.t && typeof parsed.e === 'number' + ? parsed + : null; + } catch { + return null; + } + }), +})); + import { decrypt, getEncryptionSecret } from '../common/utils/crypto.util'; import { filterBySubscriptionAccess } from './anthropic-subscription-probe'; import { computeQualityScore } from '../database/quality-score.util'; +import { getFreshKiroCliToken } from '../routing/oauth/kiro-cli-token'; const mockDecrypt = decrypt as jest.MockedFunction; const mockGetSecret = getEncryptionSecret as jest.MockedFunction; @@ -179,6 +194,67 @@ describe('ModelDiscoveryService', () => { expect(fetcher.fetch).toHaveBeenCalledWith('openai', '', 'api_key', undefined); }); + it('should unwrap Kiro CLI OAuth blobs before fetching models', async () => { + mockDecrypt.mockReturnValue( + JSON.stringify({ + source: 'kiro-cli', + t: 'kiro-access', + e: Date.now() + 10 * 60_000, + }), + ); + const provider = makeProvider({ provider: 'kiro', auth_type: 'subscription' }); + + await service.discoverModels(provider); + + expect(fetcher.fetch).toHaveBeenCalledWith('kiro', 'kiro-access', 'subscription', undefined); + }); + + it('should refresh expired Kiro CLI OAuth blobs before fetching models', async () => { + mockDecrypt.mockReturnValue( + JSON.stringify({ + source: 'kiro-cli', + t: 'old-kiro-access', + e: Date.now() - 1, + }), + ); + jest.mocked(getFreshKiroCliToken).mockResolvedValue({ + source: 'kiro-cli', + t: 'fresh-kiro-access', + e: Date.now() + 10 * 60_000, + }); + const provider = makeProvider({ provider: 'kiro', auth_type: 'subscription' }); + + await service.discoverModels(provider); + + expect(fetcher.fetch).toHaveBeenCalledWith( + 'kiro', + 'fresh-kiro-access', + 'subscription', + undefined, + ); + }); + + it('should use stored Kiro CLI OAuth blobs when refresh fails during model discovery', async () => { + mockDecrypt.mockReturnValue( + JSON.stringify({ + source: 'kiro-cli', + t: 'old-kiro-access', + e: Date.now() - 1, + }), + ); + jest.mocked(getFreshKiroCliToken).mockRejectedValue(new Error('refresh failed')); + const provider = makeProvider({ provider: 'kiro', auth_type: 'subscription' }); + + await service.discoverModels(provider); + + expect(fetcher.fetch).toHaveBeenCalledWith( + 'kiro', + 'old-kiro-access', + 'subscription', + undefined, + ); + }); + it('should enrich models with openRouter pricing when available', async () => { mockPricingSync.lookupPricing.mockImplementation((key: string) => { if (key === 'openai/gpt-4') { diff --git a/packages/backend/src/model-discovery/model-discovery.service.ts b/packages/backend/src/model-discovery/model-discovery.service.ts index bddbd3da46..36fa4fde34 100644 --- a/packages/backend/src/model-discovery/model-discovery.service.ts +++ b/packages/backend/src/model-discovery/model-discovery.service.ts @@ -12,6 +12,7 @@ import { computeQualityScore } from '../database/quality-score.util'; import { PricingSyncService } from '../database/pricing-sync.service'; import { ModelsDevSyncService } from '../database/models-dev-sync.service'; import { parseOAuthTokenBlob } from '../routing/oauth/openai-oauth.types'; +import { getFreshKiroCliToken, parseKiroCliTokenBlob } from '../routing/oauth/kiro-cli-token'; import { getQwenCompatibleBaseUrl, isQwenResolvedRegion } from '../routing/qwen-region'; import { MINIMAX_BASE_URLS } from '../routing/oauth/minimax-oauth-helpers'; import { CopilotTokenService } from '../routing/proxy/copilot-token.service'; @@ -90,6 +91,22 @@ export class ModelDiscoveryService { if (lowerProvider === 'minimax' && !endpointOverride && provider.region === 'cn') { endpointOverride = `${MINIMAX_BASE_URLS.cn}/anthropic`; } + } else if (lowerProvider === 'kiro') { + const blob = parseKiroCliTokenBlob(apiKey); + if (blob?.t) { + if (Date.now() < blob.e - 60_000) { + apiKey = blob.t; + } else { + try { + apiKey = (await getFreshKiroCliToken()).t; + } catch { + this.logger.warn( + 'Kiro CLI token refresh failed for model discovery — using stored token', + ); + apiKey = blob.t; + } + } + } } else if (lowerProvider === 'copilot' && this.copilotTokenService) { try { apiKey = await this.copilotTokenService.getCopilotToken(apiKey); diff --git a/packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts b/packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts index bb3c4e3a31..9e8979eec3 100644 --- a/packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts +++ b/packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts @@ -461,6 +461,100 @@ describe('ProviderModelFetcherService', () => { ); }); + /* ── Kiro subscription provider ── */ + + it('should fetch Kiro models dynamically through the Kiro model-list operation', async () => { + fetchSpy + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [ + { + model_id: 'auto', + model_name: 'auto', + context_window_tokens: 1000000, + }, + ], + nextToken: 'next-page', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [ + { + modelId: 'claude-sonnet-4.5', + modelName: 'Claude Sonnet 4.5', + tokenLimits: { maxInputTokens: 200000 }, + }, + ], + }), + }); + + const result = await service.fetch('kiro', 'ksk_test', 'subscription'); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + 'https://q.us-east-1.amazonaws.com', + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: 'Bearer ksk_test', + 'Content-Type': 'application/x-amz-json-1.0', + 'x-amz-target': 'AmazonCodeWhispererService.ListAvailableModels', + }, + }), + ); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ + origin: 'KIRO_CLI', + maxResults: 100, + }); + expect(JSON.parse(fetchSpy.mock.calls[1][1].body)).toEqual({ + origin: 'KIRO_CLI', + maxResults: 100, + nextToken: 'next-page', + }); + expect(result).toEqual([ + expect.objectContaining({ + id: 'kiro/auto', + displayName: 'auto', + provider: 'kiro', + contextWindow: 1000000, + inputPricePerToken: 0, + outputPricePerToken: 0, + capabilityCode: true, + }), + expect.objectContaining({ + id: 'kiro/claude-sonnet-4.5', + displayName: 'Claude Sonnet 4.5', + contextWindow: 200000, + }), + ]); + }); + + it('should return [] when Kiro model discovery rejects the API key', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 403, + }); + + const result = await service.fetch('kiro', 'ksk_bad', 'subscription'); + + expect(result).toEqual([]); + }); + + it('should clear the Kiro model discovery timeout when fetch rejects', async () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + fetchSpy.mockRejectedValue(new Error('network failure')); + + const result = await service.fetch('kiro', 'ksk_test', 'subscription'); + + expect(result).toEqual([]); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); + clearTimeoutSpy.mockRestore(); + }); + /* ── Groq provider ── */ describe('groq provider', () => { diff --git a/packages/backend/src/model-discovery/provider-model-fetcher.service.ts b/packages/backend/src/model-discovery/provider-model-fetcher.service.ts index e4f4c7d812..ac40ef7015 100644 --- a/packages/backend/src/model-discovery/provider-model-fetcher.service.ts +++ b/packages/backend/src/model-discovery/provider-model-fetcher.service.ts @@ -11,6 +11,12 @@ import { import { normalizeMinimaxSubscriptionBaseUrl } from '../routing/provider-base-url'; import { getQwenCompatibleBaseUrl, normalizeQwenCompatibleBaseUrl } from '../routing/qwen-region'; import { OpencodeGoCatalogService } from './opencode-go-catalog.service'; +import { + buildKiroHeaders, + KIRO_BASE_URL, + KIRO_MODELS_TARGET, + parseKiroModels, +} from '../routing/proxy/kiro-adapter'; const FETCH_TIMEOUT_MS = 5000; const DEFAULT_CONTEXT_WINDOW = 128000; @@ -540,6 +546,8 @@ export class ProviderModelFetcherService { // `/models` endpoint; the discovery fallback chain pulls Gemini // models from the OpenRouter cache instead. return []; + } else if (configKey === 'kiro') { + return this.fetchKiroModels(apiKey); } const config = PROVIDER_CONFIGS[configKey]; if (!config) { @@ -607,4 +615,54 @@ export class ProviderModelFetcherService { qualityScore: 3, })); } + + private async fetchKiroModels(apiKey: string): Promise { + const models: DiscoveredModel[] = []; + let nextToken: string | undefined; + + try { + for (let page = 0; page < 10; page += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(KIRO_BASE_URL, { + method: 'POST', + headers: buildKiroHeaders(apiKey, KIRO_MODELS_TARGET), + body: JSON.stringify({ + origin: 'KIRO_CLI', + maxResults: 100, + ...(nextToken ? { nextToken } : {}), + }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (!res.ok) { + this.logger.warn(`Provider kiro returned ${res.status} from ${KIRO_BASE_URL}`); + return []; + } + + const body = await res.json(); + models.push(...parseKiroModels(body, 'kiro')); + const maybeNextToken = (body as { nextToken?: unknown; next_token?: unknown }).nextToken; + const snakeNextToken = (body as { next_token?: unknown }).next_token; + nextToken = + typeof maybeNextToken === 'string' + ? maybeNextToken + : typeof snakeNextToken === 'string' + ? snakeNextToken + : undefined; + if (!nextToken) break; + } + + return filterNonChatModels(models, 'kiro'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`Failed to fetch models from kiro: ${message}`); + return []; + } + } } diff --git a/packages/backend/src/routing/kiro-oauth.controller.spec.ts b/packages/backend/src/routing/kiro-oauth.controller.spec.ts new file mode 100644 index 0000000000..7bede7acc0 --- /dev/null +++ b/packages/backend/src/routing/kiro-oauth.controller.spec.ts @@ -0,0 +1,56 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { KiroOauthController } from './oauth/kiro-oauth.controller'; +import { KiroOauthService } from './oauth/kiro-oauth.service'; +import { ResolveAgentService } from './routing-core/resolve-agent.service'; + +describe('KiroOauthController', () => { + let controller: KiroOauthController; + let oauthService: jest.Mocked; + let resolveAgent: jest.Mocked; + + beforeEach(() => { + oauthService = { + connectFromCli: jest.fn(), + } as unknown as jest.Mocked; + + resolveAgent = { + resolve: jest.fn(), + } as unknown as jest.Mocked; + + controller = new KiroOauthController(oauthService, resolveAgent); + }); + + it('connects Kiro from the resolved agent CLI session', async () => { + resolveAgent.resolve.mockResolvedValue({ id: 'agent-id-1' } as never); + oauthService.connectFromCli.mockResolvedValue({ + ok: true, + expiresAt: '2026-05-26T08:33:56.000Z', + authMethod: 'social', + provider: 'github', + }); + + const result = await controller.connectFromCli('my-agent', { id: 'user-1' } as never); + + expect(resolveAgent.resolve).toHaveBeenCalledWith('user-1', 'my-agent'); + expect(oauthService.connectFromCli).toHaveBeenCalledWith('agent-id-1', 'user-1'); + expect(result.ok).toBe(true); + }); + + it('throws 400 when agentName is missing', async () => { + await expect(controller.connectFromCli('', { id: 'user-1' } as never)).rejects.toThrow( + HttpException, + ); + }); + + it('maps CLI connection failures to 503', async () => { + resolveAgent.resolve.mockResolvedValue({ id: 'agent-id-1' } as never); + oauthService.connectFromCli.mockRejectedValue(new Error('Kiro CLI is not logged in')); + + await expect( + controller.connectFromCli('my-agent', { id: 'user-1' } as never), + ).rejects.toMatchObject({ + message: 'Kiro CLI is not logged in', + status: HttpStatus.SERVICE_UNAVAILABLE, + }); + }); +}); diff --git a/packages/backend/src/routing/oauth/kiro-cli-token.spec.ts b/packages/backend/src/routing/oauth/kiro-cli-token.spec.ts new file mode 100644 index 0000000000..699e6d2a98 --- /dev/null +++ b/packages/backend/src/routing/oauth/kiro-cli-token.spec.ts @@ -0,0 +1,174 @@ +import { execFile } from 'node:child_process'; +import { promises as fs, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + getFreshKiroCliToken, + getKiroCliTokenCachePath, + isKiroCliTokenBlob, + KIRO_CLI_BIN_ENV, + KIRO_CLI_CACHE_ENV, + KIRO_CLI_LOGIN_COMMAND, + parseKiroCliTokenBlob, + readKiroCliTokenCache, + serializeKiroCliTokenBlob, +} from './kiro-cli-token'; + +jest.mock('node:child_process', () => ({ + execFile: jest.fn(), +})); + +describe('kiro-cli-token', () => { + const mockExecFile = execFile as unknown as jest.Mock; + + beforeEach(() => { + mockExecFile.mockReset(); + delete process.env[KIRO_CLI_BIN_ENV]; + delete process.env[KIRO_CLI_CACHE_ENV]; + }); + + afterEach(() => { + delete process.env[KIRO_CLI_BIN_ENV]; + delete process.env[KIRO_CLI_CACHE_ENV]; + }); + + async function writeCache(body: unknown): Promise { + const dir = await fs.mkdtemp(path.join(tmpdir(), 'kiro-cli-token-')); + const file = path.join(dir, 'cache.json'); + await fs.writeFile(file, JSON.stringify(body), 'utf8'); + return file; + } + + it('reads the Kiro CLI token cache as a compact OAuth blob', async () => { + const file = await writeCache({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: '2026-05-26T08:33:56.027005Z', + authMethod: 'social', + provider: 'github', + profileArn: 'arn:aws:codewhisperer:us-east-1:123456789012:profile/ABC', + }); + + const blob = await readKiroCliTokenCache({ cachePath: file }); + + expect(blob).toEqual({ + source: 'kiro-cli', + t: 'access-token', + r: 'refresh-token', + e: Date.parse('2026-05-26T08:33:56.027005Z'), + authMethod: 'social', + provider: 'github', + profileArn: 'arn:aws:codewhisperer:us-east-1:123456789012:profile/ABC', + }); + }); + + it('resolves the default and overridden Kiro CLI cache paths', () => { + expect(getKiroCliTokenCachePath()).toContain( + path.join('.aws', 'sso', 'cache', 'kiro-auth-token-cli.json'), + ); + + process.env[KIRO_CLI_CACHE_ENV] = '/tmp/custom-kiro-cache.json'; + + expect(getKiroCliTokenCachePath()).toBe('/tmp/custom-kiro-cache.json'); + }); + + it('rejects malformed cache files', async () => { + const file = await writeCache({ refreshToken: 'refresh-token' }); + + await expect(readKiroCliTokenCache({ cachePath: file })).rejects.toThrow( + 'Kiro CLI token cache is missing accessToken', + ); + }); + + it('rejects missing cache files with the CLI login command', async () => { + await expect( + readKiroCliTokenCache({ cachePath: '/tmp/missing-kiro-cache.json' }), + ).rejects.toThrow(`Kiro CLI token cache not found. Run \`${KIRO_CLI_LOGIN_COMMAND}\`.`); + }); + + it('parses only Kiro CLI token blobs', () => { + const blob = { + source: 'kiro-cli' as const, + t: 'access-token', + r: 'refresh-token', + e: Date.parse('2026-05-26T08:33:56Z'), + }; + + expect(isKiroCliTokenBlob(blob)).toBe(true); + expect(parseKiroCliTokenBlob(serializeKiroCliTokenBlob(blob))).toEqual(blob); + expect(parseKiroCliTokenBlob('{"t":"access-token","e":1}')).toBeNull(); + expect(parseKiroCliTokenBlob('not-json')).toBeNull(); + }); + + it('returns cached tokens while they are fresh', async () => { + const file = await writeCache({ + accessToken: 'fresh-access', + expiresAt: '2026-05-26T08:30:00Z', + }); + + const blob = await getFreshKiroCliToken({ + cachePath: file, + now: () => Date.parse('2026-05-26T08:00:00Z'), + }); + + expect(blob.t).toBe('fresh-access'); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('refreshes expired tokens through the Kiro CLI before reading the cache again', async () => { + const file = await writeCache({ + accessToken: 'expired-access', + expiresAt: '2026-05-26T07:59:00Z', + }); + mockExecFile.mockImplementation( + (_bin: string, _args: string[], _options: unknown, callback: (err: Error | null) => void) => { + writeFileSync( + file, + JSON.stringify({ + accessToken: 'refreshed-access', + expiresAt: '2026-05-26T08:30:00Z', + }), + 'utf8', + ); + callback(null); + }, + ); + + const blob = await getFreshKiroCliToken({ + cachePath: file, + cliBin: 'kiro-test', + now: () => Date.parse('2026-05-26T08:00:00Z'), + }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'kiro-test', + ['chat', '--list-models', '--format', 'json'], + expect.objectContaining({ + env: expect.objectContaining({ NO_COLOR: '1', TERM: 'dumb' }), + }), + expect.any(Function), + ); + expect(blob.t).toBe('refreshed-access'); + }); + + it('surfaces CLI login guidance when refresh fails', async () => { + const file = await writeCache({ + accessToken: 'expired-access', + expiresAt: '2026-05-26T07:59:00Z', + }); + process.env[KIRO_CLI_BIN_ENV] = 'kiro-from-env'; + mockExecFile.mockImplementation( + (_bin: string, _args: string[], _options: unknown, callback: (err: Error) => void) => { + callback(new Error('not logged in')); + }, + ); + + await expect( + getFreshKiroCliToken({ + cachePath: file, + now: () => Date.parse('2026-05-26T08:00:00Z'), + }), + ).rejects.toThrow(`Kiro CLI is not logged in. Run \`${KIRO_CLI_LOGIN_COMMAND}\`.`); + expect(mockExecFile.mock.calls[0][0]).toBe('kiro-from-env'); + }); +}); diff --git a/packages/backend/src/routing/oauth/kiro-cli-token.ts b/packages/backend/src/routing/oauth/kiro-cli-token.ts new file mode 100644 index 0000000000..92a62160cf --- /dev/null +++ b/packages/backend/src/routing/oauth/kiro-cli-token.ts @@ -0,0 +1,150 @@ +import { execFile as execFileCb } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFile = promisify(execFileCb); + +export const KIRO_CLI_LOGIN_COMMAND = 'kiro-cli login --use-device-flow'; +export const KIRO_CLI_CACHE_ENV = 'KIRO_CLI_TOKEN_CACHE'; +export const KIRO_CLI_BIN_ENV = 'KIRO_CLI_BIN'; + +const DEFAULT_REFRESH_BUFFER_MS = 60_000; +const KIRO_CLI_TIMEOUT_MS = 30_000; +const KIRO_CLI_MAX_BUFFER = 1024 * 1024; + +export interface KiroCliTokenBlob { + source: 'kiro-cli'; + t: string; + r?: string; + e: number; + authMethod?: string; + provider?: string; + profileArn?: string; +} + +interface KiroCliCacheFile { + accessToken?: unknown; + refreshToken?: unknown; + expiresAt?: unknown; + authMethod?: unknown; + provider?: unknown; + profileArn?: unknown; +} + +export interface KiroCliTokenOptions { + cachePath?: string; + cliBin?: string; + refreshBufferMs?: number; + now?: () => number; +} + +export function getKiroCliTokenCachePath(): string { + return ( + process.env[KIRO_CLI_CACHE_ENV] ?? + path.join(homedir(), '.aws', 'sso', 'cache', 'kiro-auth-token-cli.json') + ); +} + +export function isKiroCliTokenBlob(value: unknown): value is KiroCliTokenBlob { + if (!value || typeof value !== 'object') return false; + const blob = value as Partial; + return ( + blob.source === 'kiro-cli' && + typeof blob.t === 'string' && + blob.t.length > 0 && + typeof blob.e === 'number' && + Number.isFinite(blob.e) + ); +} + +export function parseKiroCliTokenBlob(rawValue: string): KiroCliTokenBlob | null { + try { + const parsed = JSON.parse(rawValue) as unknown; + return isKiroCliTokenBlob(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function serializeKiroCliTokenBlob(blob: KiroCliTokenBlob): string { + return JSON.stringify(blob); +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function parseExpiry(value: unknown): number { + if (typeof value !== 'string') throw new Error('Kiro CLI token cache is missing expiresAt'); + const expiresAt = Date.parse(value); + if (!Number.isFinite(expiresAt)) throw new Error('Kiro CLI token cache has invalid expiresAt'); + return expiresAt; +} + +function toBlob(cache: KiroCliCacheFile): KiroCliTokenBlob { + const accessToken = readOptionalString(cache.accessToken); + const refreshToken = readOptionalString(cache.refreshToken); + const authMethod = readOptionalString(cache.authMethod); + const provider = readOptionalString(cache.provider); + const profileArn = readOptionalString(cache.profileArn); + if (!accessToken) throw new Error('Kiro CLI token cache is missing accessToken'); + return { + source: 'kiro-cli', + t: accessToken, + ...(refreshToken ? { r: refreshToken } : {}), + e: parseExpiry(cache.expiresAt), + ...(authMethod ? { authMethod } : {}), + ...(provider ? { provider } : {}), + ...(profileArn ? { profileArn } : {}), + }; +} + +export async function readKiroCliTokenCache( + options: KiroCliTokenOptions = {}, +): Promise { + const cachePath = options.cachePath ?? getKiroCliTokenCachePath(); + let raw: string; + try { + raw = await fs.readFile(cachePath, 'utf8'); + } catch { + throw new Error(`Kiro CLI token cache not found. Run \`${KIRO_CLI_LOGIN_COMMAND}\`.`); + } + try { + return toBlob(JSON.parse(raw) as KiroCliCacheFile); + } catch (err) { + const message = err instanceof Error ? err.message : 'Kiro CLI token cache is invalid'; + throw new Error(message); + } +} + +async function refreshKiroCliTokenCache( + options: KiroCliTokenOptions = {}, +): Promise { + const cliBin = options.cliBin ?? process.env[KIRO_CLI_BIN_ENV] ?? 'kiro-cli'; + try { + await execFile(cliBin, ['chat', '--list-models', '--format', 'json'], { + timeout: KIRO_CLI_TIMEOUT_MS, + maxBuffer: KIRO_CLI_MAX_BUFFER, + env: { + ...process.env, + NO_COLOR: '1', + TERM: 'dumb', + }, + }); + } catch { + throw new Error(`Kiro CLI is not logged in. Run \`${KIRO_CLI_LOGIN_COMMAND}\`.`); + } + return readKiroCliTokenCache(options); +} + +export async function getFreshKiroCliToken( + options: KiroCliTokenOptions = {}, +): Promise { + const now = options.now?.() ?? Date.now(); + const refreshBufferMs = options.refreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS; + const cached = await readKiroCliTokenCache(options); + if (cached.e > now + refreshBufferMs) return cached; + return refreshKiroCliTokenCache(options); +} diff --git a/packages/backend/src/routing/oauth/kiro-oauth.controller.ts b/packages/backend/src/routing/oauth/kiro-oauth.controller.ts new file mode 100644 index 0000000000..8071eb8fd8 --- /dev/null +++ b/packages/backend/src/routing/oauth/kiro-oauth.controller.ts @@ -0,0 +1,27 @@ +import { Controller, HttpException, HttpStatus, Post, Query } from '@nestjs/common'; +import { CurrentUser } from '../../auth/current-user.decorator'; +import { AuthUser } from '../../auth/auth.instance'; +import { KiroOauthService } from './kiro-oauth.service'; +import { ResolveAgentService } from '../routing-core/resolve-agent.service'; + +@Controller('api/v1/oauth/kiro') +export class KiroOauthController { + constructor( + private readonly oauthService: KiroOauthService, + private readonly resolveAgent: ResolveAgentService, + ) {} + + @Post('cli-connect') + async connectFromCli(@Query('agentName') agentName: string, @CurrentUser() user: AuthUser) { + if (!agentName) { + throw new HttpException('agentName query parameter is required', HttpStatus.BAD_REQUEST); + } + const agent = await this.resolveAgent.resolve(user.id, agentName); + try { + return await this.oauthService.connectFromCli(agent.id, user.id); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to connect Kiro CLI OAuth'; + throw new HttpException(message, HttpStatus.SERVICE_UNAVAILABLE); + } + } +} diff --git a/packages/backend/src/routing/oauth/kiro-oauth.service.spec.ts b/packages/backend/src/routing/oauth/kiro-oauth.service.spec.ts new file mode 100644 index 0000000000..60390d4c85 --- /dev/null +++ b/packages/backend/src/routing/oauth/kiro-oauth.service.spec.ts @@ -0,0 +1,141 @@ +import { KiroOauthService } from './kiro-oauth.service'; +import { ProviderService } from '../routing-core/provider.service'; +import { ModelDiscoveryService } from '../../model-discovery/model-discovery.service'; +import { + getFreshKiroCliToken, + parseKiroCliTokenBlob, + serializeKiroCliTokenBlob, + type KiroCliTokenBlob, +} from './kiro-cli-token'; + +jest.mock('./kiro-cli-token', () => ({ + getFreshKiroCliToken: jest.fn(), + parseKiroCliTokenBlob: jest.fn(), + serializeKiroCliTokenBlob: jest.fn((blob: KiroCliTokenBlob) => JSON.stringify(blob)), +})); + +describe('KiroOauthService', () => { + let providerService: jest.Mocked; + let discoveryService: jest.Mocked; + let service: KiroOauthService; + const token: KiroCliTokenBlob = { + source: 'kiro-cli', + t: 'access-token', + r: 'refresh-token', + e: Date.parse('2026-05-26T08:33:56Z'), + authMethod: 'social', + provider: 'github', + profileArn: 'profile-arn', + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-26T08:00:00Z')); + jest.clearAllMocks(); + jest.mocked(getFreshKiroCliToken).mockResolvedValue(token); + jest.mocked(parseKiroCliTokenBlob).mockReturnValue(null); + providerService = { + upsertProvider: jest.fn().mockResolvedValue({ provider: { id: 'provider-1' } }), + recalculateTiers: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + discoveryService = { + discoverModels: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + service = new KiroOauthService(providerService, discoveryService); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('connects from the local Kiro CLI token cache and triggers discovery', async () => { + const result = await service.connectFromCli('agent-1', 'user-1'); + + expect(providerService.upsertProvider).toHaveBeenCalledWith( + 'agent-1', + 'user-1', + 'kiro', + JSON.stringify(token), + 'subscription', + ); + expect(discoveryService.discoverModels).toHaveBeenCalledWith({ id: 'provider-1' }); + expect(providerService.recalculateTiers).toHaveBeenCalledWith('agent-1'); + expect(result).toEqual({ + ok: true, + expiresAt: '2026-05-26T08:33:56.000Z', + authMethod: 'social', + provider: 'github', + }); + }); + + it('keeps the CLI connection when post-connect discovery fails', async () => { + discoveryService.discoverModels.mockRejectedValue(new Error('discovery failed')); + + const result = await service.connectFromCli('agent-1', 'user-1'); + + expect(result.ok).toBe(true); + expect(providerService.recalculateTiers).not.toHaveBeenCalled(); + }); + + it('returns a fresh parsed access token without refreshing', async () => { + jest.mocked(parseKiroCliTokenBlob).mockReturnValue({ + source: 'kiro-cli', + t: 'stored-access', + e: Date.parse('2026-05-26T08:10:00Z'), + }); + + const result = await service.unwrapToken('blob', 'agent-1', 'user-1'); + + expect(result).toBe('stored-access'); + expect(getFreshKiroCliToken).not.toHaveBeenCalled(); + }); + + it('refreshes expired Kiro CLI blobs and persists the replacement', async () => { + jest.mocked(parseKiroCliTokenBlob).mockReturnValue({ + source: 'kiro-cli', + t: 'expired-access', + e: Date.parse('2026-05-26T07:59:00Z'), + }); + + const result = await service.unwrapToken('blob', 'agent-1', 'user-1'); + + expect(result).toBe('access-token'); + expect(providerService.upsertProvider).toHaveBeenCalledWith( + 'agent-1', + 'user-1', + 'kiro', + JSON.stringify(token), + 'subscription', + ); + }); + + it('falls back to the stored Kiro CLI token when refresh fails before hard expiry', async () => { + jest.mocked(parseKiroCliTokenBlob).mockReturnValue({ + source: 'kiro-cli', + t: 'stored-access', + e: Date.parse('2026-05-26T08:00:30Z'), + }); + jest.mocked(getFreshKiroCliToken).mockRejectedValue(new Error('refresh failed')); + + const result = await service.unwrapToken('blob', 'agent-1', 'user-1'); + + expect(result).toBe('stored-access'); + }); + + it('returns null when an expired Kiro CLI token cannot refresh', async () => { + jest.mocked(parseKiroCliTokenBlob).mockReturnValue({ + source: 'kiro-cli', + t: 'expired-access', + e: Date.parse('2026-05-26T07:59:00Z'), + }); + jest.mocked(getFreshKiroCliToken).mockRejectedValue(new Error('refresh failed')); + + const result = await service.unwrapToken('blob', 'agent-1', 'user-1'); + + expect(result).toBeNull(); + }); + + it('returns null for non-Kiro blobs', async () => { + expect(await service.unwrapToken('plain-key', 'agent-1', 'user-1')).toBeNull(); + }); +}); diff --git a/packages/backend/src/routing/oauth/kiro-oauth.service.ts b/packages/backend/src/routing/oauth/kiro-oauth.service.ts new file mode 100644 index 0000000000..077196f4a4 --- /dev/null +++ b/packages/backend/src/routing/oauth/kiro-oauth.service.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ProviderService } from '../routing-core/provider.service'; +import { ModelDiscoveryService } from '../../model-discovery/model-discovery.service'; +import { + getFreshKiroCliToken, + parseKiroCliTokenBlob, + serializeKiroCliTokenBlob, +} from './kiro-cli-token'; + +export interface KiroCliConnectResult { + ok: true; + expiresAt: string; + authMethod?: string; + provider?: string; +} + +@Injectable() +export class KiroOauthService { + private readonly logger = new Logger(KiroOauthService.name); + + constructor( + private readonly providerService: ProviderService, + private readonly discoveryService: ModelDiscoveryService, + ) {} + + async connectFromCli(agentId: string, userId: string): Promise { + const token = await getFreshKiroCliToken(); + const { provider: savedProvider } = await this.providerService.upsertProvider( + agentId, + userId, + 'kiro', + serializeKiroCliTokenBlob(token), + 'subscription', + ); + try { + await this.discoveryService.discoverModels(savedProvider); + await this.providerService.recalculateTiers(agentId); + } catch (err) { + this.logger.warn(`Model discovery after Kiro CLI OAuth failed: ${err}`); + } + return { + ok: true, + expiresAt: new Date(token.e).toISOString(), + ...(token.authMethod ? { authMethod: token.authMethod } : {}), + ...(token.provider ? { provider: token.provider } : {}), + }; + } + + async unwrapToken(rawValue: string, agentId: string, userId: string): Promise { + const blob = parseKiroCliTokenBlob(rawValue); + if (!blob) return null; + if (Date.now() < blob.e - 60_000) return blob.t; + + try { + const refreshed = await getFreshKiroCliToken(); + await this.providerService.upsertProvider( + agentId, + userId, + 'kiro', + serializeKiroCliTokenBlob(refreshed), + 'subscription', + ); + this.logger.log(`Kiro CLI OAuth token refreshed for agent=${agentId}`); + return refreshed.t; + } catch (err) { + this.logger.error(`Failed to refresh Kiro CLI OAuth token for agent=${agentId}: ${err}`); + return Date.now() < blob.e ? blob.t : null; + } + } +} diff --git a/packages/backend/src/routing/oauth/oauth.module.ts b/packages/backend/src/routing/oauth/oauth.module.ts index 97d639154d..2288c3ed52 100644 --- a/packages/backend/src/routing/oauth/oauth.module.ts +++ b/packages/backend/src/routing/oauth/oauth.module.ts @@ -8,6 +8,8 @@ import { MinimaxOauthController } from './minimax-oauth.controller'; import { CopilotDeviceAuthService } from './copilot-device-auth.service'; import { AnthropicOauthService } from './anthropic/anthropic-oauth.service'; import { AnthropicOauthController } from './anthropic/anthropic-oauth.controller'; +import { KiroOauthService } from './kiro-oauth.service'; +import { KiroOauthController } from './kiro-oauth.controller'; import { OAuthPendingFlowStore } from './core'; import { GeminiOauthService } from './gemini-oauth.service'; import { GeminiOauthController } from './gemini-oauth.controller'; @@ -20,12 +22,14 @@ import { CodeAssistClientService } from './codeassist-client.service'; MinimaxOauthController, AnthropicOauthController, GeminiOauthController, + KiroOauthController, ], providers: [ OpenaiOauthService, MinimaxOauthService, CopilotDeviceAuthService, AnthropicOauthService, + KiroOauthService, OAuthPendingFlowStore, GeminiOauthService, CodeAssistClientService, @@ -36,6 +40,7 @@ import { CodeAssistClientService } from './codeassist-client.service'; CopilotDeviceAuthService, AnthropicOauthService, GeminiOauthService, + KiroOauthService, ], }) export class OAuthModule {} diff --git a/packages/backend/src/routing/proxy/__tests__/kiro-adapter.spec.ts b/packages/backend/src/routing/proxy/__tests__/kiro-adapter.spec.ts new file mode 100644 index 0000000000..930d58c488 --- /dev/null +++ b/packages/backend/src/routing/proxy/__tests__/kiro-adapter.spec.ts @@ -0,0 +1,439 @@ +import { Buffer } from 'node:buffer'; +import { + buildKiroChatRequest, + buildKiroHeaders, + createKiroOpenAiStream, + forwardKiroChat, + KIRO_BASE_URL, + KIRO_CHAT_TARGET, + KIRO_MODELS_TARGET, + KiroEventStreamParser, + parseKiroModels, + toKiroModelId, +} from '../kiro-adapter'; + +const mockFetch = jest.fn(); +(globalThis as unknown as { fetch: typeof fetch }).fetch = mockFetch; + +function stringHeader(name: string, value: string): Buffer { + const nameBytes = Buffer.from(name); + const valueBytes = Buffer.from(value); + const valueLength = Buffer.alloc(2); + valueLength.writeUInt16BE(valueBytes.length, 0); + return Buffer.concat([ + Buffer.from([nameBytes.length]), + nameBytes, + Buffer.from([7]), + valueLength, + valueBytes, + ]); +} + +function typedHeader(name: string, type: number, value: number | bigint | Buffer = 0): Buffer { + const nameBytes = Buffer.from(name); + if (type === 0 || type === 1) { + return Buffer.concat([Buffer.from([nameBytes.length]), nameBytes, Buffer.from([type])]); + } + if (type === 2) { + const valueBytes = Buffer.alloc(1); + valueBytes.writeInt8(Number(value)); + return Buffer.concat([ + Buffer.from([nameBytes.length]), + nameBytes, + Buffer.from([type]), + valueBytes, + ]); + } + if (type === 3) { + const valueBytes = Buffer.alloc(2); + valueBytes.writeInt16BE(Number(value)); + return Buffer.concat([ + Buffer.from([nameBytes.length]), + nameBytes, + Buffer.from([type]), + valueBytes, + ]); + } + if (type === 4) { + const valueBytes = Buffer.alloc(4); + valueBytes.writeInt32BE(Number(value)); + return Buffer.concat([ + Buffer.from([nameBytes.length]), + nameBytes, + Buffer.from([type]), + valueBytes, + ]); + } + if (type === 5 || type === 8) { + const valueBytes = Buffer.alloc(8); + valueBytes.writeBigInt64BE(BigInt(value as number | bigint)); + return Buffer.concat([ + Buffer.from([nameBytes.length]), + nameBytes, + Buffer.from([type]), + valueBytes, + ]); + } + if (type === 6) { + const valueBytes = Buffer.isBuffer(value) ? value : Buffer.from([Number(value)]); + const valueLength = Buffer.alloc(2); + valueLength.writeUInt16BE(valueBytes.length, 0); + return Buffer.concat([ + Buffer.from([nameBytes.length]), + nameBytes, + Buffer.from([type]), + valueLength, + valueBytes, + ]); + } + if (type === 9) { + const valueBytes = Buffer.isBuffer(value) + ? value + : Buffer.from('00112233445566778899aabbccddeeff', 'hex'); + return Buffer.concat([ + Buffer.from([nameBytes.length]), + nameBytes, + Buffer.from([type]), + valueBytes, + ]); + } + return Buffer.concat([Buffer.from([nameBytes.length]), nameBytes, Buffer.from([type])]); +} + +function eventFrame( + eventType: string, + payload: unknown, + messageType = 'event', + extraHeaders: Buffer[] = [], +): Uint8Array { + const headers = Buffer.concat([ + stringHeader(':message-type', messageType), + stringHeader(':event-type', eventType), + ...extraHeaders, + ]); + const payloadBytes = Buffer.from(JSON.stringify(payload)); + const totalLength = 12 + headers.length + payloadBytes.length + 4; + const frame = Buffer.alloc(totalLength); + frame.writeUInt32BE(totalLength, 0); + frame.writeUInt32BE(headers.length, 4); + headers.copy(frame, 12); + payloadBytes.copy(frame, 12 + headers.length); + return frame; +} + +function streamFrom(chunks: Uint8Array[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); +} + +describe('kiro-adapter', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('builds Kiro AWS JSON headers', () => { + expect(buildKiroHeaders('ksk_test', KIRO_MODELS_TARGET)).toEqual({ + Authorization: 'Bearer ksk_test', + 'Content-Type': 'application/x-amz-json-1.0', + 'x-amz-target': KIRO_MODELS_TARGET, + }); + }); + + it('parses Kiro model list responses into subscription model IDs', () => { + const result = parseKiroModels({ + models: [ + { + model_id: 'auto', + model_name: 'auto', + context_window_tokens: 1000000, + }, + { + modelId: 'claude-sonnet-4.5', + modelName: 'Claude Sonnet 4.5', + tokenLimits: { maxInputTokens: 200000 }, + }, + ], + }); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'kiro/auto', + displayName: 'auto', + provider: 'kiro', + contextWindow: 1000000, + inputPricePerToken: 0, + outputPricePerToken: 0, + capabilityCode: true, + }), + expect.objectContaining({ + id: 'kiro/claude-sonnet-4.5', + displayName: 'Claude Sonnet 4.5', + contextWindow: 200000, + }), + ]); + }); + + it('strips only the kiro vendor prefix before forwarding', () => { + expect(toKiroModelId('kiro/auto')).toBe('auto'); + expect(toKiroModelId('claude-sonnet-4.5')).toBe('claude-sonnet-4.5'); + }); + + it('converts OpenAI messages into a Kiro conversation request', () => { + const request = buildKiroChatRequest( + { + messages: [ + { role: 'system', content: 'Use concise answers.' }, + { role: 'user', content: 'First question' }, + { role: 'assistant', content: 'First answer' }, + { role: 'tool', tool_call_id: 'tool-1', content: { ok: true } }, + { role: 'user', content: [{ type: 'text', text: 'Second question' }] }, + ], + }, + 'auto', + ); + + expect(request).toMatchObject({ + conversationState: { + history: [ + { userInputMessage: { content: 'First question', origin: 'KIRO_CLI' } }, + { assistantResponseMessage: { content: 'First answer' } }, + { + userInputMessage: { + content: 'Tool result tool-1:\n{"ok":true}', + origin: 'KIRO_CLI', + }, + }, + ], + currentMessage: { + userInputMessage: { + content: 'System instructions:\nUse concise answers.\n\nUser:\nSecond question', + origin: 'KIRO_CLI', + modelId: 'auto', + }, + }, + chatTriggerType: 'MANUAL', + }, + agentMode: 'SUPERVISED', + }); + }); + + it('handles image parts, empty parts, and circular object content defensively', () => { + const circular: Record = {}; + circular.self = circular; + + const request = buildKiroChatRequest( + { + messages: [ + { role: 'user', content: [{ type: 'image_url' }, { type: 'unknown' }] }, + { role: 'assistant', content: null }, + { role: 'user', content: circular }, + ], + }, + 'auto', + ) as { + conversationState: { + history: Array<{ userInputMessage?: { content: string } }>; + currentMessage: { userInputMessage: { content: string } }; + }; + }; + + expect(request.conversationState.history).toEqual([ + { userInputMessage: { content: '[image omitted]', origin: 'KIRO_CLI' } }, + ]); + expect(request.conversationState.currentMessage.userInputMessage.content).toBe( + '[object Object]', + ); + }); + + it('parses split AWS event-stream frames', () => { + const frame = eventFrame('assistantResponseEvent', { content: 'hello' }); + const parser = new KiroEventStreamParser(); + + expect(parser.push(frame.subarray(0, 8))).toEqual([]); + expect(parser.push(frame.subarray(8))).toEqual([ + { + eventType: 'assistantResponseEvent', + messageType: 'event', + payload: { content: 'hello' }, + }, + ]); + expect(() => parser.finish()).not.toThrow(); + }); + + it('parses supported AWS event-stream headers and rejects malformed frames', () => { + const parser = new KiroEventStreamParser(); + expect( + parser.push( + eventFrame('assistantResponseEvent', { content: 'hello' }, 'event', [ + typedHeader('bool', 0), + typedHeader('byte', 2, -1), + typedHeader('short', 3, 7), + typedHeader('int', 4, 42), + typedHeader('long', 5, 42n), + typedHeader('bytes', 6, Buffer.from([1, 2, 3])), + typedHeader('timestamp', 8, 1700000000000n), + typedHeader('uuid', 9), + ]), + )[0], + ).toEqual({ + eventType: 'assistantResponseEvent', + messageType: 'event', + payload: { content: 'hello' }, + }); + + const invalidLength = Buffer.alloc(12); + invalidLength.writeUInt32BE(15, 0); + expect(() => new KiroEventStreamParser().push(invalidLength)).toThrow( + 'Invalid Kiro event stream frame', + ); + }); + + it('keeps parsing the payload when Kiro sends an unknown event-stream header type', () => { + const parser = new KiroEventStreamParser(); + + expect( + parser.push( + eventFrame('assistantResponseEvent', { content: 'hello' }, 'event', [ + typedHeader('unsupported', 10), + ]), + )[0], + ).toEqual({ + eventType: 'assistantResponseEvent', + messageType: 'event', + payload: { content: 'hello' }, + }); + }); + + it('unwraps nested Kiro event payloads', async () => { + const response = new Response( + createKiroOpenAiStream( + streamFrom([ + eventFrame('assistantResponseEvent', { + assistantResponseEvent: { content: 'wrapped' }, + }), + ]), + 'auto', + ), + ); + + await expect(response.text()).resolves.toContain('"content":"wrapped"'); + }); + + it('surfaces Kiro exception events from streaming responses', async () => { + const response = new Response( + createKiroOpenAiStream( + streamFrom([eventFrame('accessDeniedException', { message: 'denied' }, 'exception')]), + 'auto', + ), + ); + + await expect(response.text()).rejects.toThrow('denied'); + }); + + it('converts Kiro event-stream output to OpenAI SSE chunks', async () => { + const source = streamFrom([ + eventFrame('reasoningContentEvent', { text: 'thinking' }), + eventFrame('assistantResponseEvent', { content: 'hello' }), + eventFrame('metadataEvent', { + tokenUsage: { + uncachedInputTokens: 4, + cacheReadInputTokens: 1, + cacheWriteInputTokens: 2, + outputTokens: 3, + totalTokens: 10, + }, + }), + ]); + + const response = new Response(createKiroOpenAiStream(source, 'auto')); + const text = await response.text(); + + expect(text).toContain('"reasoning_content":"thinking"'); + expect(text).toContain('"content":"hello"'); + expect(text).toContain('"finish_reason":"stop"'); + expect(text).toContain('"prompt_tokens":7'); + expect(text).toContain('data: [DONE]'); + }); + + it('forwards streaming Kiro chat as OpenAI-compatible SSE', async () => { + mockFetch.mockResolvedValue( + new Response(streamFrom([eventFrame('assistantResponseEvent', { content: 'hello' })]), { + status: 200, + }), + ); + + const response = await forwardKiroChat({ + apiKey: 'ksk_test', + model: 'auto', + body: { messages: [{ role: 'user', content: 'Hello' }] }, + stream: true, + timeoutMs: 1000, + extraHeaders: { 'x-extra': '1' }, + }); + + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + expect(mockFetch.mock.calls[0][1].headers['x-extra']).toBe('1'); + expect(await response.text()).toContain('"content":"hello"'); + }); + + it('forwards non-streaming Kiro chat and returns OpenAI-compatible JSON', async () => { + mockFetch.mockResolvedValue( + new Response( + streamFrom([ + eventFrame('assistantResponseEvent', { content: 'hel' }), + eventFrame('assistantResponseEvent', { content: 'lo' }), + eventFrame('metadataEvent', { + tokenUsage: { inputTokens: 2, outputTokens: 1, totalTokens: 3 }, + }), + ]), + { status: 200 }, + ), + ); + + const response = await forwardKiroChat({ + apiKey: 'ksk_test', + model: 'auto', + body: { messages: [{ role: 'user', content: 'Hello' }] }, + stream: false, + timeoutMs: 1000, + }); + const json = await response.json(); + + expect(mockFetch).toHaveBeenCalledWith( + KIRO_BASE_URL, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer ksk_test', + 'x-amz-target': KIRO_CHAT_TARGET, + 'x-amzn-kiro-agent-mode': 'SUPERVISED', + }), + }), + ); + expect(json).toMatchObject({ + object: 'chat.completion', + model: 'auto', + choices: [{ message: { role: 'assistant', content: 'hello' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + }); + }); + + it('passes upstream Kiro errors through unchanged', async () => { + mockFetch.mockResolvedValue(new Response('forbidden', { status: 403 })); + + const response = await forwardKiroChat({ + apiKey: 'ksk_bad', + model: 'auto', + body: { messages: [{ role: 'user', content: 'Hello' }] }, + stream: false, + timeoutMs: 1000, + }); + + expect(response.status).toBe(403); + expect(await response.text()).toBe('forbidden'); + }); +}); diff --git a/packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts b/packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts index f24f128afc..c58ab89411 100644 --- a/packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts @@ -1220,6 +1220,53 @@ describe('ProviderClient', () => { }); }); + describe('Kiro subscription provider', () => { + it('routes to the Kiro AWS JSON event-stream endpoint', async () => { + mockFetch.mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + { status: 200 }, + ), + ); + + const result = await client.forward({ + provider: 'kiro', + apiKey: 'ksk_test', + model: 'kiro/auto', + body, + stream: true, + authType: 'subscription', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://q.us-east-1.amazonaws.com', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer ksk_test', + 'Content-Type': 'application/x-amz-json-1.0', + 'x-amz-target': 'AmazonCodeWhispererStreamingService.GenerateAssistantResponse', + 'x-amzn-kiro-agent-mode': 'SUPERVISED', + }), + }), + ); + const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(sentBody.conversationState.currentMessage.userInputMessage).toMatchObject({ + content: 'Hello', + origin: 'KIRO_CLI', + modelId: 'auto', + }); + expect(result.isGoogle).toBe(false); + expect(result.isAnthropic).toBe(false); + expect(result.isChatGpt).toBe(false); + expect(result.response.headers.get('Content-Type')).toBe('text/event-stream'); + }); + }); + describe('OpenCode Go provider', () => { it('routes non-minimax models to OpenAI /v1/chat/completions', async () => { mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); diff --git a/packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts b/packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts index f6a6b8dabd..7f7b7498aa 100644 --- a/packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts @@ -106,6 +106,7 @@ describe('resolveEndpointKey', () => { expect(known).toContain('openrouter'); expect(known).toContain('ollama'); expect(known).toContain('ollama-cloud'); + expect(known).toContain('kiro'); expect(known).toContain('opencode-go'); expect(known).toContain('opencode-go-anthropic'); }); @@ -324,6 +325,18 @@ describe('PROVIDER_ENDPOINTS', () => { }); }); + it('kiro uses the Kiro AWS JSON endpoint and target header', () => { + const ep = PROVIDER_ENDPOINTS['kiro']; + expect(ep.baseUrl).toBe('https://q.us-east-1.amazonaws.com'); + expect(ep.format).toBe('kiro'); + expect(ep.buildPath('kiro/auto')).toBe('/'); + expect(ep.buildHeaders('ksk_test')).toEqual({ + Authorization: 'Bearer ksk_test', + 'Content-Type': 'application/x-amz-json-1.0', + 'x-amz-target': 'AmazonCodeWhispererStreamingService.GenerateAssistantResponse', + }); + }); + it('zai-subscription uses Coding Plan base URL', () => { const ep = PROVIDER_ENDPOINTS['zai-subscription']; expect(ep.baseUrl).toBe('https://open.bigmodel.cn/api/coding/paas/v4'); diff --git a/packages/backend/src/routing/proxy/__tests__/proxy-fallback.routes.spec.ts b/packages/backend/src/routing/proxy/__tests__/proxy-fallback.routes.spec.ts index 45aba4ef88..1eb667af70 100644 --- a/packages/backend/src/routing/proxy/__tests__/proxy-fallback.routes.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/proxy-fallback.routes.spec.ts @@ -7,6 +7,7 @@ import { OpenaiOauthService } from '../../oauth/openai-oauth.service'; import { MinimaxOauthService } from '../../oauth/minimax-oauth.service'; import { AnthropicOauthService } from '../../oauth/anthropic/anthropic-oauth.service'; import { GeminiOauthService } from '../../oauth/gemini-oauth.service'; +import { KiroOauthService } from '../../oauth/kiro-oauth.service'; import { ProviderClient } from '../provider-client'; import { CopilotTokenService } from '../copilot-token.service'; import { ModelPricingCacheService } from '../../../model-prices/model-pricing-cache.service'; @@ -36,6 +37,7 @@ describe('ProxyFallbackService.tryFallbacks — route-aware path', () => { let minimaxOauth: jest.Mocked; let anthropicOauth: jest.Mocked; let geminiOauth: jest.Mocked; + let kiroOauth: jest.Mocked; let providerClient: jest.Mocked; let copilotToken: jest.Mocked; let pricingCache: jest.Mocked; @@ -69,6 +71,10 @@ describe('ProxyFallbackService.tryFallbacks — route-aware path', () => { unwrapToken: jest.fn().mockResolvedValue(null), } as unknown as jest.Mocked; + kiroOauth = { + unwrapToken: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked; + providerClient = { forward: jest.fn(), } as unknown as jest.Mocked; @@ -100,6 +106,7 @@ describe('ProxyFallbackService.tryFallbacks — route-aware path', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, providerClient, copilotToken, pricingCache, diff --git a/packages/backend/src/routing/proxy/__tests__/proxy-fallback.service.spec.ts b/packages/backend/src/routing/proxy/__tests__/proxy-fallback.service.spec.ts index afe8b34b8b..db8d996a0c 100644 --- a/packages/backend/src/routing/proxy/__tests__/proxy-fallback.service.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/proxy-fallback.service.spec.ts @@ -10,6 +10,7 @@ import { OpenaiOauthService } from '../../oauth/openai-oauth.service'; import { MinimaxOauthService } from '../../oauth/minimax-oauth.service'; import { AnthropicOauthService } from '../../oauth/anthropic/anthropic-oauth.service'; import { GeminiOauthService } from '../../oauth/gemini-oauth.service'; +import { KiroOauthService } from '../../oauth/kiro-oauth.service'; import { ProviderClient } from '../provider-client'; import { CopilotTokenService } from '../copilot-token.service'; import { ReasoningContentCache } from '../reasoning-content-cache'; @@ -45,6 +46,7 @@ describe('ProxyFallbackService', () => { let minimaxOauth: jest.Mocked; let anthropicOauth: jest.Mocked; let geminiOauth: jest.Mocked; + let kiroOauth: jest.Mocked; let providerClient: jest.Mocked; let copilotToken: jest.Mocked; let pricingCache: jest.Mocked; @@ -80,6 +82,9 @@ describe('ProxyFallbackService', () => { geminiOauth = { unwrapToken: jest.fn().mockResolvedValue(null), } as unknown as jest.Mocked; + kiroOauth = { + unwrapToken: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked; providerClient = { forward: jest.fn(), @@ -125,6 +130,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, providerClient, copilotToken, pricingCache, @@ -1005,6 +1011,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('access-token'); @@ -1029,6 +1036,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('mm-token'); @@ -1046,6 +1054,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('sk-key'); @@ -1065,6 +1074,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('blob'); @@ -1083,6 +1093,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('access-claude'); @@ -1102,9 +1113,31 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('sk-ant-legacy'); + expect(kiroOauth.unwrapToken).not.toHaveBeenCalled(); + }); + + it('unwraps Kiro CLI OAuth subscription tokens', async () => { + kiroOauth.unwrapToken.mockResolvedValue('kiro-access'); + + const result = await resolveApiKey( + 'kiro', + 'blob', + 'subscription', + 'agent-1', + 'user-1', + openaiOauth, + minimaxOauth, + anthropicOauth, + geminiOauth, + kiroOauth, + ); + + expect(result.apiKey).toBe('kiro-access'); + expect(kiroOauth.unwrapToken).toHaveBeenCalledWith('blob', 'agent-1', 'user-1'); }); it('does not unwrap for non-OAuth subscription providers (e.g. Qwen)', async () => { @@ -1118,12 +1151,14 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('qwen-key'); expect(openaiOauth.unwrapToken).not.toHaveBeenCalled(); expect(minimaxOauth.unwrapToken).not.toHaveBeenCalled(); expect(anthropicOauth.unwrapToken).not.toHaveBeenCalled(); + expect(kiroOauth.unwrapToken).not.toHaveBeenCalled(); }); it('returns original key when MiniMax unwrap returns null', async () => { @@ -1139,6 +1174,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('blob'); @@ -1155,6 +1191,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('zai-sub-key'); @@ -1162,6 +1199,7 @@ describe('ProxyFallbackService', () => { expect(openaiOauth.unwrapToken).not.toHaveBeenCalled(); expect(minimaxOauth.unwrapToken).not.toHaveBeenCalled(); expect(anthropicOauth.unwrapToken).not.toHaveBeenCalled(); + expect(kiroOauth.unwrapToken).not.toHaveBeenCalled(); }); it('unwraps Gemini subscription token and reads project id from blob.u', async () => { @@ -1183,6 +1221,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('fresh-access-token'); @@ -1204,6 +1243,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe(blob); @@ -1222,6 +1262,7 @@ describe('ProxyFallbackService', () => { minimaxOauth, anthropicOauth, geminiOauth, + kiroOauth, ); expect(result.apiKey).toBe('fresh-token'); diff --git a/packages/backend/src/routing/proxy/__tests__/proxy.service.spec.ts b/packages/backend/src/routing/proxy/__tests__/proxy.service.spec.ts index e0690dc990..5a39553583 100644 --- a/packages/backend/src/routing/proxy/__tests__/proxy.service.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/proxy.service.spec.ts @@ -14,6 +14,7 @@ import type { OpenaiOauthService } from '../../oauth/openai-oauth.service'; import type { MinimaxOauthService } from '../../oauth/minimax-oauth.service'; import type { AnthropicOauthService } from '../../oauth/anthropic/anthropic-oauth.service'; import type { GeminiOauthService } from '../../oauth/gemini-oauth.service'; +import type { KiroOauthService } from '../../oauth/kiro-oauth.service'; import type { SessionMomentumService } from '../session-momentum.service'; import type { LimitCheckService } from '../../../notifications/services/limit-check.service'; import type { ProxyFallbackService } from '../proxy-fallback.service'; @@ -72,6 +73,7 @@ describe('ProxyService — orchestration', () => { let minimaxOauth: jest.Mocked>; let anthropicOauth: jest.Mocked>; let geminiOauth: jest.Mocked>; + let kiroOauth: jest.Mocked>; let momentum: jest.Mocked< Pick< SessionMomentumService, @@ -106,6 +108,7 @@ describe('ProxyService — orchestration', () => { minimaxOauth = { unwrapToken: jest.fn().mockResolvedValue(null) }; anthropicOauth = { unwrapToken: jest.fn().mockResolvedValue(null) }; geminiOauth = { unwrapToken: jest.fn().mockResolvedValue(null) }; + kiroOauth = { unwrapToken: jest.fn().mockResolvedValue(null) }; momentum = { recordTier: jest.fn(), recordCategory: jest.fn(), @@ -147,6 +150,7 @@ describe('ProxyService — orchestration', () => { minimaxOauth as unknown as MinimaxOauthService, anthropicOauth as unknown as AnthropicOauthService, geminiOauth as unknown as GeminiOauthService, + kiroOauth as unknown as KiroOauthService, momentum as unknown as SessionMomentumService, limitCheck as unknown as LimitCheckService, fallbackService as unknown as ProxyFallbackService, diff --git a/packages/backend/src/routing/proxy/kiro-adapter.ts b/packages/backend/src/routing/proxy/kiro-adapter.ts new file mode 100644 index 0000000000..e601aa8238 --- /dev/null +++ b/packages/backend/src/routing/proxy/kiro-adapter.ts @@ -0,0 +1,487 @@ +import { Buffer } from 'node:buffer'; +import { randomUUID } from 'node:crypto'; +import type { DiscoveredModel } from '../../model-discovery/model-fetcher'; + +export const KIRO_BASE_URL = 'https://q.us-east-1.amazonaws.com'; +export const KIRO_MODELS_TARGET = 'AmazonCodeWhispererService.ListAvailableModels'; +export const KIRO_CHAT_TARGET = 'AmazonCodeWhispererStreamingService.GenerateAssistantResponse'; + +const KIRO_ORIGIN = 'KIRO_CLI'; +const KIRO_AGENT_MODE = 'SUPERVISED'; +const DEFAULT_KIRO_CONTEXT_WINDOW = 200000; +const AUTO_KIRO_CONTEXT_WINDOW = 1000000; + +export function buildKiroHeaders(apiKey: string, target: string): Record { + return { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/x-amz-json-1.0', + 'x-amz-target': target, + }; +} + +export function toKiroModelId(model: string): string { + return model.replace(/^kiro\//i, ''); +} + +function formatKiroModelId(modelId: string): string { + return modelId.toLowerCase().startsWith('kiro/') ? modelId : `kiro/${modelId}`; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function readKiroContextWindow(entry: Record, modelId: string): number { + const tokenLimits = (entry.tokenLimits ?? entry.token_limits) as + | Record + | undefined; + return ( + readNumber(entry.contextWindowTokens) ?? + readNumber(entry.context_window_tokens) ?? + readNumber(tokenLimits?.contextWindowTokens) ?? + readNumber(tokenLimits?.context_window_tokens) ?? + readNumber(tokenLimits?.maxInputTokens) ?? + readNumber(tokenLimits?.max_input_tokens) ?? + (modelId === 'auto' ? AUTO_KIRO_CONTEXT_WINDOW : DEFAULT_KIRO_CONTEXT_WINDOW) + ); +} + +export function parseKiroModels(body: unknown, provider = 'kiro'): DiscoveredModel[] { + const models = (body as { models?: unknown[] })?.models; + if (!Array.isArray(models)) return []; + + return models + .map((raw) => raw as Record) + .filter((entry) => typeof (entry.modelId ?? entry.model_id) === 'string') + .map((entry) => { + const rawId = String(entry.modelId ?? entry.model_id); + const id = formatKiroModelId(rawId); + const displayName = String(entry.modelName ?? entry.model_name ?? rawId); + return { + id, + displayName, + provider, + contextWindow: readKiroContextWindow(entry, rawId), + inputPricePerToken: 0, + outputPricePerToken: 0, + capabilityReasoning: false, + capabilityCode: true, + qualityScore: 3, + }; + }); +} + +type OpenAiMessage = { + role?: string; + content?: unknown; + tool_call_id?: string; +}; + +type KiroMessage = + | { + userInputMessage: { + content: string; + origin: string; + modelId?: string; + }; + } + | { + assistantResponseMessage: { + content: string; + }; + }; + +function stringifyContent(content: unknown): string { + if (typeof content === 'string') return content; + if (content == null) return ''; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === 'string') return part; + if (!part || typeof part !== 'object') return ''; + const record = part as Record; + if (typeof record.text === 'string') return record.text; + if (record.type === 'image_url' || record.type === 'input_image') return '[image omitted]'; + return ''; + }) + .filter(Boolean) + .join('\n'); + } + try { + return JSON.stringify(content); + } catch { + return String(content); + } +} + +function toUserMessage(content: string, modelId?: string): KiroMessage { + return { + userInputMessage: { + content, + origin: KIRO_ORIGIN, + ...(modelId ? { modelId } : {}), + }, + }; +} + +function toAssistantMessage(content: string): KiroMessage { + return { + assistantResponseMessage: { + content, + }, + }; +} + +export function buildKiroChatRequest( + body: Record, + model: string, +): Record { + const messages = Array.isArray(body.messages) ? (body.messages as OpenAiMessage[]) : []; + const systemText = messages + .filter((message) => message.role === 'system' || message.role === 'developer') + .map((message) => stringifyContent(message.content)) + .filter(Boolean) + .join('\n\n'); + + const lastUserIndex = messages.reduce( + (last, message, index) => (message.role === 'user' ? index : last), + -1, + ); + const currentIndex = lastUserIndex >= 0 ? lastUserIndex : messages.length - 1; + const history: KiroMessage[] = []; + + for (let index = 0; index < currentIndex; index += 1) { + const message = messages[index]; + if (message.role === 'system' || message.role === 'developer') continue; + const content = stringifyContent(message.content); + if (!content) continue; + if (message.role === 'assistant') { + history.push(toAssistantMessage(content)); + } else if (message.role === 'tool') { + history.push( + toUserMessage( + `Tool result${message.tool_call_id ? ` ${message.tool_call_id}` : ''}:\n${content}`, + ), + ); + } else { + history.push(toUserMessage(content)); + } + } + + const currentMessage = currentIndex >= 0 ? messages[currentIndex] : undefined; + const currentText = currentMessage ? stringifyContent(currentMessage.content) : ''; + const content = systemText + ? `System instructions:\n${systemText}\n\nUser:\n${currentText}` + : currentText; + + return { + conversationState: { + conversationId: randomUUID(), + history, + currentMessage: toUserMessage(content || 'Hello', model), + chatTriggerType: 'MANUAL', + }, + agentMode: KIRO_AGENT_MODE, + }; +} + +export interface KiroEvent { + eventType?: string; + messageType?: string; + payload: unknown; +} + +function parseEventHeaders(buffer: Buffer): Record { + const headers: Record = {}; + let offset = 0; + + while (offset < buffer.length) { + const nameLength = buffer.readUInt8(offset); + offset += 1; + const name = buffer.toString('utf8', offset, offset + nameLength); + offset += nameLength; + const type = buffer.readUInt8(offset); + offset += 1; + + if (type === 0 || type === 1) { + headers[name] = type === 0; + } else if (type === 2) { + headers[name] = buffer.readInt8(offset); + offset += 1; + } else if (type === 3) { + headers[name] = buffer.readInt16BE(offset); + offset += 2; + } else if (type === 4) { + headers[name] = buffer.readInt32BE(offset); + offset += 4; + } else if (type === 5) { + headers[name] = buffer.readBigInt64BE(offset); + offset += 8; + } else if (type === 6) { + const valueLength = buffer.readUInt16BE(offset); + offset += 2; + headers[name] = Buffer.from(buffer.subarray(offset, offset + valueLength)); + offset += valueLength; + } else if (type === 7) { + const valueLength = buffer.readUInt16BE(offset); + offset += 2; + headers[name] = buffer.toString('utf8', offset, offset + valueLength); + offset += valueLength; + } else if (type === 8) { + headers[name] = new Date(Number(buffer.readBigInt64BE(offset))); + offset += 8; + } else if (type === 9) { + const value = buffer.subarray(offset, offset + 16).toString('hex'); + headers[name] = `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice( + 12, + 16, + )}-${value.slice(16, 20)}-${value.slice(20)}`; + offset += 16; + } else { + // Future header value types have type-specific lengths. Keep the payload + // usable instead of guessing how many bytes to skip and corrupting the + // remaining header parse. + break; + } + } + + return headers; +} + +export class KiroEventStreamParser { + private pending = Buffer.alloc(0); + + push(chunk: Uint8Array): KiroEvent[] { + this.pending = Buffer.concat([this.pending, Buffer.from(chunk)]); + const events: KiroEvent[] = []; + + while (this.pending.length >= 12) { + const totalLength = this.pending.readUInt32BE(0); + const headersLength = this.pending.readUInt32BE(4); + if (totalLength < 16 || headersLength > totalLength - 16) { + throw new Error('Invalid Kiro event stream frame'); + } + if (this.pending.length < totalLength) break; + + const frame = this.pending.subarray(0, totalLength); + this.pending = this.pending.subarray(totalLength); + const headersStart = 12; + const payloadStart = headersStart + headersLength; + const payloadEnd = totalLength - 4; + const headers = parseEventHeaders(frame.subarray(headersStart, payloadStart)); + const payloadBytes = frame.subarray(payloadStart, payloadEnd); + const payloadText = payloadBytes.toString('utf8'); + const payload = payloadText ? JSON.parse(payloadText) : null; + + events.push({ + eventType: typeof headers[':event-type'] === 'string' ? headers[':event-type'] : undefined, + messageType: + typeof headers[':message-type'] === 'string' ? headers[':message-type'] : undefined, + payload, + }); + } + + return events; + } + + finish(): void { + if (this.pending.length > 0) throw new Error('Truncated Kiro event stream'); + } +} + +interface OpenAiUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +interface KiroCollectState { + content: string; + reasoning: string; + usage?: OpenAiUsage; +} + +function eventPayload(event: KiroEvent): Record { + const payload = event.payload; + if (!payload || typeof payload !== 'object') return {}; + const record = payload as Record; + if (event.eventType && record[event.eventType] && typeof record[event.eventType] === 'object') { + return record[event.eventType] as Record; + } + return record; +} + +function numberField(record: Record, ...keys: string[]): number { + for (const key of keys) { + const value = readNumber(record[key]); + if (value !== undefined) return value; + } + return 0; +} + +function normalizeUsage(value: unknown): OpenAiUsage | undefined { + if (!value || typeof value !== 'object') return undefined; + const usage = value as Record; + const prompt = + numberField(usage, 'prompt_tokens', 'inputTokens', 'input_tokens') || + numberField(usage, 'uncachedInputTokens', 'uncached_input_tokens') + + numberField(usage, 'cacheReadInputTokens', 'cache_read_input_tokens') + + numberField(usage, 'cacheWriteInputTokens', 'cache_write_input_tokens'); + const completion = numberField(usage, 'completion_tokens', 'outputTokens', 'output_tokens'); + const total = + numberField(usage, 'total_tokens', 'totalTokens', 'total_tokens') || prompt + completion; + + return { + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: total, + }; +} + +function extractErrorMessage(event: KiroEvent): string | null { + const payload = eventPayload(event); + const message = payload.message ?? payload.errorMessage ?? payload.error; + return typeof message === 'string' ? message : null; +} + +function applyKiroEvent(state: KiroCollectState, event: KiroEvent): Record | null { + const eventType = event.eventType?.toLowerCase() ?? ''; + const payload = eventPayload(event); + + if (event.messageType === 'exception') { + throw new Error(extractErrorMessage(event) ?? 'Kiro returned an exception event'); + } + if (eventType.includes('assistantresponse')) { + const content = typeof payload.content === 'string' ? payload.content : ''; + state.content += content; + return content ? { content } : null; + } + if (eventType.includes('reasoningcontent')) { + const text = typeof payload.text === 'string' ? payload.text : ''; + state.reasoning += text; + return text ? { reasoning_content: text } : null; + } + if (eventType.includes('metadata')) { + state.usage = normalizeUsage(payload.tokenUsage ?? payload.token_usage); + } + return null; +} + +function openAiChunk( + model: string, + delta: Record, + finishReason: string | null = null, + usage?: OpenAiUsage, +): string { + return `data: ${JSON.stringify({ + id: `chatcmpl-${randomUUID()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}), + })}\n\n`; +} + +export function createKiroOpenAiStream( + source: ReadableStream, + model: string, +): ReadableStream { + const encoder = new TextEncoder(); + const parser = new KiroEventStreamParser(); + const state: KiroCollectState = { content: '', reasoning: '' }; + + return new ReadableStream({ + async start(controller) { + const reader = source.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + for (const event of parser.push(value)) { + const delta = applyKiroEvent(state, event); + if (delta) controller.enqueue(encoder.encode(openAiChunk(model, delta))); + } + } + parser.finish(); + controller.enqueue(encoder.encode(openAiChunk(model, {}, 'stop', state.usage))); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); +} + +async function collectKiroCompletion( + source: ReadableStream, + model: string, +): Promise> { + const parser = new KiroEventStreamParser(); + const state: KiroCollectState = { content: '', reasoning: '' }; + const reader = source.getReader(); + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + for (const event of parser.push(value)) { + applyKiroEvent(state, event); + } + } + parser.finish(); + + const message: Record = { + role: 'assistant', + content: state.content, + }; + if (state.reasoning) message.reasoning_content = state.reasoning; + + return { + id: `chatcmpl-${randomUUID()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message, finish_reason: 'stop' }], + ...(state.usage ? { usage: state.usage } : {}), + }; +} + +export async function forwardKiroChat(opts: { + apiKey: string; + model: string; + body: Record; + stream: boolean; + signal?: AbortSignal; + timeoutMs: number; + extraHeaders?: Record; +}): Promise { + const timeoutSignal = AbortSignal.timeout(opts.timeoutMs); + const fetchSignal = opts.signal ? AbortSignal.any([timeoutSignal, opts.signal]) : timeoutSignal; + const headers = { + ...buildKiroHeaders(opts.apiKey, KIRO_CHAT_TARGET), + 'x-amzn-kiro-agent-mode': KIRO_AGENT_MODE, + ...opts.extraHeaders, + }; + const upstream = await fetch(KIRO_BASE_URL, { + method: 'POST', + headers, + body: JSON.stringify(buildKiroChatRequest(opts.body, opts.model)), + signal: fetchSignal, + redirect: 'error', + }); + + if (!upstream.ok || !upstream.body) return upstream; + if (opts.stream) { + return new Response(createKiroOpenAiStream(upstream.body, opts.model), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + const completion = await collectKiroCompletion(upstream.body, opts.model); + return new Response(JSON.stringify(completion), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/packages/backend/src/routing/proxy/provider-client.ts b/packages/backend/src/routing/proxy/provider-client.ts index ff2fe1c3f4..a6082c6fe4 100644 --- a/packages/backend/src/routing/proxy/provider-client.ts +++ b/packages/backend/src/routing/proxy/provider-client.ts @@ -24,6 +24,7 @@ import { } from './provider-client-converters'; import { ForwardOptions } from './proxy-types'; import { toNativeResponsesRequest } from './responses-adapter'; +import { forwardKiroChat } from './kiro-adapter'; export interface ForwardResult { response: Response; @@ -119,6 +120,25 @@ export class ProviderClient { const isCodeAssist = !!endpoint.codeAssistEnvelope; const bareModel = stripModelPrefix(model, endpointKey); + if (endpoint.format === 'kiro') { + const requestSource = + opts.apiMode && opts.apiMode !== 'chat_completions' ? (opts.chatBody ?? body) : body; + const response = await forwardKiroChat({ + apiKey, + model: bareModel, + body: requestSource, + stream, + signal, + timeoutMs: PROVIDER_TIMEOUT_MS, + extraHeaders, + }); + return { + response, + isGoogle: false, + isAnthropic: false, + isChatGpt: false, + }; + } const { url, headers, requestBody } = this.buildRequest({ endpoint, endpointKey, diff --git a/packages/backend/src/routing/proxy/provider-endpoints.ts b/packages/backend/src/routing/proxy/provider-endpoints.ts index 737084a13f..5a9df15ccf 100644 --- a/packages/backend/src/routing/proxy/provider-endpoints.ts +++ b/packages/backend/src/routing/proxy/provider-endpoints.ts @@ -8,6 +8,7 @@ import { } from '../../common/constants/subscription-clients'; import { normalizeProviderBaseUrl } from '../provider-base-url'; import { getQwenCompatibleBaseUrl } from '../qwen-region'; +import { buildKiroHeaders, KIRO_BASE_URL, KIRO_CHAT_TARGET } from './kiro-adapter'; export interface ProviderEndpoint { baseUrl: string; @@ -21,7 +22,7 @@ export interface ProviderEndpoint { * `format: 'google'` streams. */ buildStreamPath?: (model: string) => string; - format: 'openai' | 'google' | 'anthropic' | 'chatgpt'; + format: 'openai' | 'google' | 'anthropic' | 'chatgpt' | 'kiro'; /** * Set to `true` for endpoints whose `baseUrl` is user-supplied (custom * providers, subscription resource URLs). The proxy re-runs SSRF @@ -271,6 +272,12 @@ export const PROVIDER_ENDPOINTS: Record = { buildPath: openaiPath, format: 'openai', }, + kiro: { + baseUrl: KIRO_BASE_URL, + buildHeaders: (apiKey: string) => buildKiroHeaders(apiKey, KIRO_CHAT_TARGET), + buildPath: () => '/', + format: 'kiro', + }, 'opencode-go': { baseUrl: OPENCODE_GO_BASE, buildHeaders: openaiHeaders, diff --git a/packages/backend/src/routing/proxy/proxy-fallback.service.ts b/packages/backend/src/routing/proxy/proxy-fallback.service.ts index 883bfba4f5..d16331473e 100644 --- a/packages/backend/src/routing/proxy/proxy-fallback.service.ts +++ b/packages/backend/src/routing/proxy/proxy-fallback.service.ts @@ -29,6 +29,7 @@ import { MinimaxOauthService } from '../oauth/minimax-oauth.service'; import { AnthropicOauthService } from '../oauth/anthropic/anthropic-oauth.service'; import { GeminiOauthService } from '../oauth/gemini-oauth.service'; import { parseOAuthTokenBlob } from '../oauth/core'; +import { KiroOauthService } from '../oauth/kiro-oauth.service'; import { ModelPricingCacheService } from '../../model-prices/model-pricing-cache.service'; import { ProviderClient, ForwardResult } from './provider-client'; import { @@ -79,6 +80,7 @@ export class ProxyFallbackService { private readonly minimaxOauth: MinimaxOauthService, private readonly anthropicOauth: AnthropicOauthService, private readonly geminiOauth: GeminiOauthService, + private readonly kiroOauth: KiroOauthService, private readonly providerClient: ProviderClient, private readonly copilotToken: CopilotTokenService, private readonly pricingCache: ModelPricingCacheService, @@ -223,6 +225,7 @@ export class ProxyFallbackService { this.minimaxOauth, this.anthropicOauth, this.geminiOauth, + this.kiroOauth, ); const providerRegion = await this.providerKeyService.getProviderRegion( agentId, @@ -500,6 +503,7 @@ export async function resolveApiKey( minimaxOauth: MinimaxOauthService, anthropicOauth: AnthropicOauthService, geminiOauth: GeminiOauthService, + kiroOauth: KiroOauthService, ): Promise<{ apiKey: string; resourceUrl?: string }> { if (authType === 'subscription') { const lower = provider.toLowerCase(); @@ -524,6 +528,10 @@ export async function resolveApiKey( return { apiKey: unwrapped, resourceUrl: projectId }; } } + if (lower === 'kiro') { + const unwrapped = await kiroOauth.unwrapToken(apiKey, agentId, userId); + if (unwrapped) return { apiKey: unwrapped }; + } } return { apiKey }; } diff --git a/packages/backend/src/routing/proxy/proxy.service.ts b/packages/backend/src/routing/proxy/proxy.service.ts index e06da47ebc..aeb3875c04 100644 --- a/packages/backend/src/routing/proxy/proxy.service.ts +++ b/packages/backend/src/routing/proxy/proxy.service.ts @@ -7,6 +7,7 @@ import { OpenaiOauthService } from '../oauth/openai-oauth.service'; import { MinimaxOauthService } from '../oauth/minimax-oauth.service'; import { AnthropicOauthService } from '../oauth/anthropic/anthropic-oauth.service'; import { GeminiOauthService } from '../oauth/gemini-oauth.service'; +import { KiroOauthService } from '../oauth/kiro-oauth.service'; import { ForwardResult } from './provider-client'; import { SessionMomentumService } from './session-momentum.service'; import { LimitCheckService } from '../../notifications/services/limit-check.service'; @@ -129,6 +130,7 @@ export class ProxyService { private readonly minimaxOauth: MinimaxOauthService, private readonly anthropicOauth: AnthropicOauthService, private readonly geminiOauth: GeminiOauthService, + private readonly kiroOauth: KiroOauthService, private readonly momentum: SessionMomentumService, private readonly limitCheck: LimitCheckService, private readonly fallbackService: ProxyFallbackService, @@ -437,6 +439,7 @@ export class ProxyService { this.minimaxOauth, this.anthropicOauth, this.geminiOauth, + this.kiroOauth, ); const providerRegion = await this.providerKeyService.getProviderRegion( agentId, diff --git a/packages/frontend/src/components/CliOAuthDetailView.tsx b/packages/frontend/src/components/CliOAuthDetailView.tsx new file mode 100644 index 0000000000..a20e3a9066 --- /dev/null +++ b/packages/frontend/src/components/CliOAuthDetailView.tsx @@ -0,0 +1,117 @@ +import { Show, type Accessor, type Component, type Setter } from 'solid-js'; +import type { ProviderDef } from '../services/providers.js'; +import { + connectKiroCliOAuth, + disconnectProvider, + type AuthType, + type RoutingProvider, +} from '../services/api.js'; +import { toast } from '../services/toast-store.js'; +import CopyButton from './CopyButton.js'; + +interface Props { + provDef: ProviderDef; + provId: string; + agentName: string; + connected: Accessor; + selectedAuthType: Accessor; + busy: Accessor; + setBusy: Setter; + onBack: () => void; + onUpdate: () => void; + activeKeys?: Accessor; +} + +const KIRO_LOGIN_COMMAND = 'kiro-cli login --use-device-flow'; + +const CliOAuthDetailView: Component = (props) => { + const handleConnect = async () => { + props.setBusy(true); + try { + await connectKiroCliOAuth(props.agentName); + toast.success(`${props.provDef.name} subscription connected`); + props.onUpdate(); + } catch { + // error toast from fetchMutate + } finally { + props.setBusy(false); + } + }; + + const handleDisconnect = async () => { + props.setBusy(true); + try { + const result = await disconnectProvider( + props.agentName, + props.provId, + props.selectedAuthType(), + ); + if (result?.notifications?.length) { + for (const msg of result.notifications) toast.error(msg); + } + props.onBack(); + props.onUpdate(); + } catch { + // error toast from fetchMutate + } finally { + props.setBusy(false); + } + }; + + return ( + <> + +

+ Uses your local Kiro CLI login. Run this once if Kiro CLI is not signed in, then connect. +

+ + +
+ + +
+ Connected via local Kiro CLI login +
+ +
+ + ); +}; + +export default CliOAuthDetailView; diff --git a/packages/frontend/src/components/ProviderDetailView.tsx b/packages/frontend/src/components/ProviderDetailView.tsx index de5cae0d44..66ff1fb033 100644 --- a/packages/frontend/src/components/ProviderDetailView.tsx +++ b/packages/frontend/src/components/ProviderDetailView.tsx @@ -23,6 +23,7 @@ import ProviderKeyForm, { MAX_KEYS_PER_PROVIDER } from './ProviderKeyForm.js'; import OAuthDetailView from './OAuthDetailView.js'; import AnthropicOAuthDetailView from './AnthropicOAuthDetailView.js'; import DeviceCodeDetailView from './DeviceCodeDetailView.js'; +import CliOAuthDetailView from './CliOAuthDetailView.js'; import { getRoutingProviderApiKeyUrl } from '../services/provider-api-key-urls.js'; export interface ProviderDetailViewProps { @@ -82,6 +83,7 @@ const ProviderDetailView: Component = (props) => { const isPopupOAuthFlow = () => isSubMode() && subscriptionAuthMode() === 'popup_oauth'; const isPopupPasteFlow = () => isSubMode() && subscriptionAuthMode() === 'popup_paste'; const isDeviceCodeFlow = () => isSubMode() && subscriptionAuthMode() === 'device_code'; + const isCliOAuthFlow = () => isSubMode() && subscriptionAuthMode() === 'cli_oauth'; const isCommandOnly = () => isSubMode() && !!provDef.subscriptionCommand && @@ -118,6 +120,7 @@ const ProviderDetailView: Component = (props) => { const showAddKeyButton = () => connected() && supportsMultiKey() && + !isCliOAuthFlow() && activeKeys().length < MAX_KEYS_PER_PROVIDER && !addKeyOpen(); @@ -437,6 +440,22 @@ const ProviderDetailView: Component = (props) => { /> + {/* Local CLI OAuth subscription */} + + + + {/* Ollama (no key) */}
@@ -484,7 +503,8 @@ const ProviderDetailView: Component = (props) => { !isCommandOnly() && !isPopupOAuthFlow() && !isPopupPasteFlow() && - !isDeviceCodeFlow() + !isDeviceCodeFlow() && + !isCliOAuthFlow() } > ); + /* ── Kiro ─────────────────────────────────────── */ + case 'kiro': + return ( + + ); + /* ── GitHub Copilot ─────────────────────────────── */ case 'copilot': return ( diff --git a/packages/frontend/src/services/api/oauth.ts b/packages/frontend/src/services/api/oauth.ts index a753a8c218..e9d54612d5 100644 --- a/packages/frontend/src/services/api/oauth.ts +++ b/packages/frontend/src/services/api/oauth.ts @@ -61,6 +61,20 @@ export function revokeMinimaxOAuth(agentName: string, label?: string) { ); } +export interface KiroCliConnectResponse { + ok: true; + expiresAt: string; + authMethod?: string; + provider?: string; +} + +export function connectKiroCliOAuth(agentName: string) { + return fetchMutate( + `/oauth/kiro/cli-connect?agentName=${encodeURIComponent(agentName)}`, + { method: 'POST' }, + ); +} + export interface AnthropicOAuthAuthorizeResponse { url: string; state: string; diff --git a/packages/frontend/src/services/provider-api-key-urls.ts b/packages/frontend/src/services/provider-api-key-urls.ts index 073db00e62..90843b8d50 100644 --- a/packages/frontend/src/services/provider-api-key-urls.ts +++ b/packages/frontend/src/services/provider-api-key-urls.ts @@ -2,6 +2,7 @@ export const ROUTING_PROVIDER_API_KEY_URLS: Record = { anthropic: 'https://console.anthropic.com/settings/keys', deepseek: 'https://platform.deepseek.com/api_keys', gemini: 'https://aistudio.google.com/apikey', + kiro: 'https://app.kiro.dev', groq: 'https://console.groq.com/keys', kilo: 'https://app.kilo.ai', minimax: 'https://platform.minimax.io/user-center/basic-information/interface-key', @@ -27,6 +28,7 @@ export const getRoutingProviderApiKeyUrl = (providerId: string): string | undefi */ export const SUBSCRIPTION_PROVIDER_KEY_URLS: Record = { 'ollama-cloud': 'https://ollama.com/settings/keys', + kiro: 'https://app.kiro.dev', zai: 'https://z.ai/manage-apikey/apikey-list', }; diff --git a/packages/frontend/src/services/providers.ts b/packages/frontend/src/services/providers.ts index 8678dcc81a..7966e00f97 100644 --- a/packages/frontend/src/services/providers.ts +++ b/packages/frontend/src/services/providers.ts @@ -31,7 +31,7 @@ export interface ProviderDef { /** Provider uses GitHub device login instead of token paste. */ deviceLogin?: boolean; /** UI auth mode for subscription flows. */ - subscriptionAuthMode?: 'popup_oauth' | 'popup_paste' | 'device_code' | 'token'; + subscriptionAuthMode?: 'popup_oauth' | 'popup_paste' | 'device_code' | 'token' | 'cli_oauth'; /** * Optional secondary subscription path. Lets a provider expose a pasted-token * shortcut alongside its primary OAuth/device-code flow — currently used so @@ -75,7 +75,7 @@ interface ProviderUIOverlay { subscriptionCredentialKind?: 'setup-token' | 'api-key'; subscriptionCommand?: string; deviceLogin?: boolean; - subscriptionAuthMode?: 'popup_oauth' | 'popup_paste' | 'device_code' | 'token'; + subscriptionAuthMode?: 'popup_oauth' | 'popup_paste' | 'device_code' | 'token' | 'cli_oauth'; subscriptionTokenAlternative?: { prefix: string; placeholder: string; @@ -139,6 +139,16 @@ const PROVIDER_UI: Record = { subscriptionAuthMode: 'popup_oauth', models: [], }, + kiro: { + initial: 'K', + subtitle: 'Claude, DeepSeek, MiniMax, GLM, Qwen via Kiro', + supportsSubscription: true, + subscriptionLabel: 'Kiro subscription', + subscriptionAuthMode: 'cli_oauth', + subscriptionOnly: true, + beta: true, + models: [], + }, groq: { initial: 'Gq', subtitle: 'Llama, Gemma, Mixtral — fast inference', @@ -291,6 +301,7 @@ const PROVIDER_ORDER = [ 'gemini', 'groq', 'kilo', + 'kiro', 'llamacpp', 'lmstudio', 'minimax', @@ -307,6 +318,7 @@ const PROVIDER_ORDER = [ export const PROVIDERS: ProviderDef[] = PROVIDER_ORDER.map((id) => { const shared = SHARED_PROVIDER_BY_ID.get(id); + /* v8 ignore next 3 -- PROVIDER_ORDER is static and must match shared provider metadata. */ if (!shared) { throw new Error(`Unknown provider id in PROVIDER_ORDER: "${id}"`); } diff --git a/packages/frontend/tests/components/ProviderDetailView.test.tsx b/packages/frontend/tests/components/ProviderDetailView.test.tsx index b5dd2bbfbd..6d2151f68a 100644 --- a/packages/frontend/tests/components/ProviderDetailView.test.tsx +++ b/packages/frontend/tests/components/ProviderDetailView.test.tsx @@ -5,11 +5,13 @@ import { createSignal, type Accessor, type Setter } from 'solid-js'; const mockConnectProvider = vi.fn(); const mockDisconnectProvider = vi.fn(); const mockRefreshProviderModels = vi.fn(); +const mockConnectKiroCliOAuth = vi.fn(); vi.mock('../../src/services/api.js', () => ({ connectProvider: (...args: unknown[]) => mockConnectProvider(...args), disconnectProvider: (...args: unknown[]) => mockDisconnectProvider(...args), refreshProviderModels: (...args: unknown[]) => mockRefreshProviderModels(...args), + connectKiroCliOAuth: (...args: unknown[]) => mockConnectKiroCliOAuth(...args), })); vi.mock('../../src/services/formatters.js', () => ({ @@ -30,28 +32,109 @@ vi.mock('../../src/components/CopyButton.js', () => ({ })); vi.mock('../../src/components/ProviderKeyForm.js', () => ({ - default: () =>
, + default: (props: { + provId: string; + agentName: string; + isSubMode: Accessor; + connected: Accessor; + selectedAuthType: Accessor; + busy: Accessor; + keyInput: Accessor; + editing: Accessor; + validationError: Accessor; + providers: RoutingProvider[]; + onBack: () => void; + onUpdate: () => void; + }) => ( +
+ ), MAX_KEYS_PER_PROVIDER: 5, })); vi.mock('../../src/components/OAuthDetailView.js', () => ({ - default: () =>
, + default: (props: { + provId: string; + connected: Accessor; + selectedAuthType: Accessor; + busy: Accessor; + onClose: () => void; + }) => ( +
+ ), +})); + +vi.mock('../../src/components/AnthropicOAuthDetailView.js', () => ({ + default: (props: { + provId: string; + connected: Accessor; + selectedAuthType: Accessor; + busy: Accessor; + onClose: () => void; + }) => ( +
+ ), })); vi.mock('../../src/components/DeviceCodeDetailView.js', () => ({ - default: () =>
, + default: (props: { + provId: string; + connected: Accessor; + selectedAuthType: Accessor; + busy: Accessor; + onClose: () => void; + }) => ( +
+ ), })); import ProviderDetailView from '../../src/components/ProviderDetailView'; import { toast } from '../../src/services/toast-store.js'; import type { AuthType, RoutingProvider } from '../../src/services/api.js'; -function createTestProps(overrides: Partial<{ - provId: string; - providers: RoutingProvider[]; - selectedAuthType: AuthType; -}> = {}) { - const [busy, setBusy] = createSignal(false); +function createTestProps( + overrides: Partial<{ + provId: string; + providers: RoutingProvider[]; + selectedAuthType: AuthType; + busy: boolean; + }> = {}, +) { + const [busy, setBusy] = createSignal(overrides.busy ?? false); const [keyInput, setKeyInput] = createSignal(''); const [editing, setEditing] = createSignal(false); const [validationError, setValidationError] = createSignal(null); @@ -89,6 +172,7 @@ describe('ProviderDetailView', () => { last_fetched_at: '2026-04-12T10:00:00Z', error: null, }); + mockConnectKiroCliOAuth.mockResolvedValue({ ok: true }); }); describe('Ollama connect flow', () => { @@ -160,11 +244,7 @@ describe('ProviderDetailView', () => { fireEvent.click(screen.getByText('Disconnect')); await waitFor(() => { - expect(mockDisconnectProvider).toHaveBeenCalledWith( - 'test-agent', - 'ollama', - 'api_key', - ); + expect(mockDisconnectProvider).toHaveBeenCalledWith('test-agent', 'ollama', 'api_key'); expect(props.onBack).toHaveBeenCalled(); expect(props.onUpdate).toHaveBeenCalled(); }); @@ -249,6 +329,174 @@ describe('ProviderDetailView', () => { }); }); + describe('Anthropic subscription renders paste-code OAuth flow', () => { + it('renders AnthropicOAuthDetailView for popup_paste subscription flow', () => { + const connectedAnthropicSub: RoutingProvider[] = [ + { + id: 'p1', + provider: 'anthropic', + auth_type: 'subscription', + is_active: true, + has_api_key: true, + connected_at: '2025-01-01', + }, + ]; + const props = createTestProps({ + provId: 'anthropic', + providers: connectedAnthropicSub, + selectedAuthType: 'subscription', + }); + render(() => ); + expect(screen.getByTestId('anthropic-oauth-detail-view')).toBeDefined(); + }); + }); + + describe('Copilot subscription renders device-code flow', () => { + it('renders DeviceCodeDetailView for device_code subscription flow', () => { + const connectedCopilotSub: RoutingProvider[] = [ + { + id: 'p1', + provider: 'copilot', + auth_type: 'subscription', + is_active: true, + has_api_key: true, + connected_at: '2025-01-01', + }, + ]; + const props = createTestProps({ + provId: 'copilot', + providers: connectedCopilotSub, + selectedAuthType: 'subscription', + }); + render(() => ); + expect(screen.getByTestId('device-code-detail-view')).toBeDefined(); + }); + }); + + describe('Kiro subscription renders CLI OAuth flow', () => { + const connectedKiroSub: RoutingProvider[] = [ + { + id: 'p1', + provider: 'kiro', + auth_type: 'subscription', + is_active: true, + has_api_key: true, + connected_at: '2025-01-01', + }, + ]; + + it('renders the Kiro CLI login instructions when disconnected', () => { + const props = createTestProps({ + provId: 'kiro', + selectedAuthType: 'subscription', + }); + + render(() => ); + + expect(screen.getByText('Connect with Kiro CLI')).toBeDefined(); + expect(screen.getAllByText('kiro-cli login --use-device-flow').length).toBeGreaterThan(0); + }); + + it('connects Kiro through the local CLI OAuth endpoint', async () => { + const props = createTestProps({ + provId: 'kiro', + selectedAuthType: 'subscription', + }); + render(() => ); + + fireEvent.click(screen.getByText('Connect with Kiro CLI')); + + await waitFor(() => { + expect(mockConnectKiroCliOAuth).toHaveBeenCalledWith('test-agent'); + expect(toast.success).toHaveBeenCalledWith('Kiro subscription connected'); + expect(props.onUpdate).toHaveBeenCalled(); + }); + }); + + it('keeps the Kiro CLI view open when connect fails', async () => { + mockConnectKiroCliOAuth.mockRejectedValueOnce(new Error('not logged in')); + const props = createTestProps({ + provId: 'kiro', + selectedAuthType: 'subscription', + }); + render(() => ); + + fireEvent.click(screen.getByText('Connect with Kiro CLI')); + + await waitFor(() => { + expect(mockConnectKiroCliOAuth).toHaveBeenCalled(); + }); + expect(props.onUpdate).not.toHaveBeenCalled(); + }); + + it('disables the Kiro connect button while busy', () => { + const props = createTestProps({ + provId: 'kiro', + selectedAuthType: 'subscription', + busy: true, + }); + + render(() => ); + + expect( + (screen.getByRole('button', { name: 'Connect with Kiro CLI' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it('disconnects connected Kiro CLI OAuth and shows backend notifications', async () => { + mockDisconnectProvider.mockResolvedValueOnce({ notifications: ['Kiro was used by a tier'] }); + const props = createTestProps({ + provId: 'kiro', + providers: connectedKiroSub, + selectedAuthType: 'subscription', + }); + render(() => ); + + expect(screen.getByText('Connected via local Kiro CLI login')).toBeDefined(); + fireEvent.click(screen.getByText('Disconnect')); + + await waitFor(() => { + expect(mockDisconnectProvider).toHaveBeenCalledWith('test-agent', 'kiro', 'subscription'); + expect(toast.error).toHaveBeenCalledWith('Kiro was used by a tier'); + expect(props.onBack).toHaveBeenCalled(); + expect(props.onUpdate).toHaveBeenCalled(); + }); + }); + + it('keeps connected Kiro CLI OAuth in place when disconnect fails', async () => { + mockDisconnectProvider.mockRejectedValueOnce(new Error('disconnect failed')); + const props = createTestProps({ + provId: 'kiro', + providers: connectedKiroSub, + selectedAuthType: 'subscription', + }); + render(() => ); + + fireEvent.click(screen.getByText('Disconnect')); + + await waitFor(() => { + expect(mockDisconnectProvider).toHaveBeenCalled(); + }); + expect(props.onBack).not.toHaveBeenCalled(); + }); + + it('disables the Kiro disconnect button while busy', () => { + const props = createTestProps({ + provId: 'kiro', + providers: connectedKiroSub, + selectedAuthType: 'subscription', + busy: true, + }); + + render(() => ); + + expect( + (screen.getByRole('button', { name: 'Disconnect Kiro CLI' }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + }); + it('renders back button', () => { const props = createTestProps(); render(() => ); diff --git a/packages/frontend/tests/components/ProviderIcon.test.tsx b/packages/frontend/tests/components/ProviderIcon.test.tsx index d4c758d623..968d09b277 100644 --- a/packages/frontend/tests/components/ProviderIcon.test.tsx +++ b/packages/frontend/tests/components/ProviderIcon.test.tsx @@ -8,6 +8,7 @@ const KNOWN_PROVIDERS = [ "copilot", "gemini", "groq", + "kiro", "deepseek", "mistral", "xai", diff --git a/packages/frontend/tests/services/api/oauth.test.ts b/packages/frontend/tests/services/api/oauth.test.ts index 91ab8a5047..18f01cafd1 100644 --- a/packages/frontend/tests/services/api/oauth.test.ts +++ b/packages/frontend/tests/services/api/oauth.test.ts @@ -105,6 +105,27 @@ describe('oauth API client', () => { expect((init as RequestInit).method).toBe('POST'); }); + it('connectKiroCliOAuth POSTs to the CLI connect endpoint with the encoded agent name', async () => { + const fetchMock = setupFetch({ + ok: true, + expiresAt: '2026-05-26T08:33:56.000Z', + authMethod: 'social', + provider: 'github', + }); + + const out = await oauth.connectKiroCliOAuth('my agent'); + + expect(out).toEqual({ + ok: true, + expiresAt: '2026-05-26T08:33:56.000Z', + authMethod: 'social', + provider: 'github', + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/api/v1/oauth/kiro/cli-connect?agentName=my%20agent'); + expect((init as RequestInit).method).toBe('POST'); + }); + it('startAnthropicOAuth POSTs the encoded agent name and returns the auth URL + state', async () => { const fetchMock = setupFetch({ url: 'https://claude.ai/oauth/authorize?state=s', state: 's' }); const out = await oauth.startAnthropicOAuth('my agent'); diff --git a/packages/frontend/tests/services/providers.test.ts b/packages/frontend/tests/services/providers.test.ts index d351ee206a..c18d8ff940 100644 --- a/packages/frontend/tests/services/providers.test.ts +++ b/packages/frontend/tests/services/providers.test.ts @@ -424,6 +424,25 @@ describe("PROVIDERS", () => { expect(og.models).toEqual([]); }); + it("Kiro is subscription-only with CLI OAuth flow and dynamic models", () => { + const kiro = PROVIDERS.find((p) => p.id === "kiro")!; + expect(kiro).toBeDefined(); + expect(kiro.name).toBe("Kiro"); + expect(kiro.supportsSubscription).toBe(true); + expect(kiro.subscriptionOnly).toBe(true); + expect(kiro.subscriptionAuthMode).toBe("cli_oauth"); + expect(kiro.subscriptionLabel).toBe("Kiro subscription"); + expect(kiro.subscriptionKeyPlaceholder).toBeUndefined(); + expect(kiro.subscriptionSignInUrl).toBeUndefined(); + expect(kiro.beta).toBe(true); + expect(kiro.models).toEqual([]); + }); + + it("provides the Kiro account URL in both provider maps", () => { + expect(getRoutingProviderApiKeyUrl("kiro")).toBe("https://app.kiro.dev"); + expect(getSubscriptionProviderKeyUrl("kiro")).toBe("https://app.kiro.dev"); + }); + it("OpenCode Go subscription key is validated with generic min-length check", () => { const og = PROVIDERS.find((p) => p.id === "opencode-go")!; expect(validateSubscriptionKey(og, "")).toEqual({ diff --git a/packages/shared/src/provider-inference.ts b/packages/shared/src/provider-inference.ts index b0aa4b7498..9623357d0b 100644 --- a/packages/shared/src/provider-inference.ts +++ b/packages/shared/src/provider-inference.ts @@ -16,6 +16,7 @@ const MODEL_PREFIX_MAP: [RegExp, string][] = [ [/^qwen[23]|^qwq-/, 'qwen'], [/^copilot\//, 'copilot'], [/^opencode-go\//, 'opencode-go'], + [/^kiro\//, 'kiro'], [/^llamacpp\//, 'llamacpp'], [/^[a-z][\w-]*\//, 'openrouter'], ]; diff --git a/packages/shared/src/providers.ts b/packages/shared/src/providers.ts index dd0c4e1408..aa14f8df1e 100644 --- a/packages/shared/src/providers.ts +++ b/packages/shared/src/providers.ts @@ -135,6 +135,18 @@ export const SHARED_PROVIDERS: readonly SharedProviderEntry[] = [ minKeyLength: 30, keyPlaceholder: 'API key', }, + { + id: 'kiro', + displayName: 'Kiro', + aliases: [], + openRouterPrefixes: [], + requiresApiKey: false, + localOnly: false, + color: '#6D5EF9', + keyPrefix: '', + minKeyLength: 0, + keyPlaceholder: '', + }, { id: 'minimax', displayName: 'MiniMax', diff --git a/packages/shared/src/subscription/configs.ts b/packages/shared/src/subscription/configs.ts index 4ceced2429..f326c1a3bd 100644 --- a/packages/shared/src/subscription/configs.ts +++ b/packages/shared/src/subscription/configs.ts @@ -69,6 +69,27 @@ export const SUBSCRIPTION_PROVIDER_CONFIGS: Readonly< supportsBatching: false, }), }), + kiro: Object.freeze({ + supportsSubscription: true as const, + subscriptionLabel: 'Kiro subscription', + subscriptionAuthMode: 'cli_oauth' as const, + knownModels: Object.freeze([ + 'kiro/auto', + 'kiro/claude-sonnet-4.5', + 'kiro/claude-sonnet-4', + 'kiro/claude-haiku-4.5', + 'kiro/deepseek-3.2', + 'kiro/minimax-m2.5', + 'kiro/minimax-m2.1', + 'kiro/glm-5', + 'kiro/qwen3-coder-next', + ]), + subscriptionCapabilities: Object.freeze({ + maxContextWindow: 1000000, + supportsPromptCaching: false, + supportsBatching: false, + }), + }), zai: Object.freeze({ supportsSubscription: true as const, subscriptionLabel: 'GLM Coding Plan', diff --git a/packages/shared/src/subscription/types.ts b/packages/shared/src/subscription/types.ts index 652c2a026a..b20bf38683 100644 --- a/packages/shared/src/subscription/types.ts +++ b/packages/shared/src/subscription/types.ts @@ -9,7 +9,7 @@ export interface SubscriptionProviderConfig { subscriptionLabel: string; subscriptionKeyPlaceholder?: string; subscriptionCommand?: string; - subscriptionAuthMode?: 'popup_oauth' | 'device_code' | 'token'; + subscriptionAuthMode?: 'popup_oauth' | 'device_code' | 'token' | 'cli_oauth'; subscriptionTokenPrefix?: string; knownModels?: readonly string[]; /**