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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kiro-subscription-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'manifest': patch
---

Add Kiro subscription provider support with dynamic model discovery and proxy forwarding.
11 changes: 11 additions & 0 deletions packages/backend/src/common/constants/providers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/common/utils/provider-inference.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof decrypt>;
const mockGetSecret = getEncryptionSecret as jest.MockedFunction<typeof getEncryptionSecret>;
Expand Down Expand Up @@ -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') {
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/model-discovery/model-discovery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -607,4 +615,54 @@ export class ProviderModelFetcherService {
qualityScore: 3,
}));
}

private async fetchKiroModels(apiKey: string): Promise<DiscoveredModel[]> {
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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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 [];
}
}
}
56 changes: 56 additions & 0 deletions packages/backend/src/routing/kiro-oauth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<KiroOauthService>;
let resolveAgent: jest.Mocked<ResolveAgentService>;

beforeEach(() => {
oauthService = {
connectFromCli: jest.fn(),
} as unknown as jest.Mocked<KiroOauthService>;

resolveAgent = {
resolve: jest.fn(),
} as unknown as jest.Mocked<ResolveAgentService>;

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,
});
});
});
Loading
Loading