diff --git a/.changeset/apikey-managed-provider-model-refresh.md b/.changeset/apikey-managed-provider-model-refresh.md new file mode 100644 index 000000000..b5439ea64 --- /dev/null +++ b/.changeset/apikey-managed-provider-model-refresh.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Using an API key for Kimi coding models now also fetches the latest model list automatically. diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index ef78085c7..18d43fec7 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -768,4 +768,486 @@ describe('refreshAllProviderModels', () => { expect(host.current().defaultModel).toBe('kimi-code/kimi-deep-coder'); expect(host.current().thinking?.enabled).toBe(true); }); + + it('refreshes a hand-configured API-key provider pointing at the managed endpoint', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const userAliasModel = { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'My Coding', + }; + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Kimi', + }, + 'my-kimi/kimi-old': { + provider: 'my-kimi', + model: 'kimi-old', + maxContextSize: 131072, + capabilities: ['tool_use'], + }, + 'my-fav': userAliasModel, + }, + defaultModel: 'my-kimi/kimi-for-coding', + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-distributed-key'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Kimi', + }, + { id: 'kimi-k2', context_length: 131072 }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { providerId: 'my-kimi', providerName: 'my-kimi', added: 1, removed: 1 }, + ]); + // The provider record is user-owned and must survive untouched. + expect(host.current().providers['my-kimi']).toEqual({ + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }); + // Upstream-owned fields merge; the dropped model disappears; the new one appears. + expect(host.current().models?.['my-kimi/kimi-for-coding']?.displayName).toBe('Fresh Kimi'); + expect(host.current().models?.['my-kimi/kimi-for-coding']?.capabilities).toEqual([ + 'thinking', + 'tool_use', + ]); + expect(host.current().models?.['my-kimi/kimi-k2']).toBeDefined(); + expect(host.current().models?.['my-kimi/kimi-old']).toBeUndefined(); + // Non-prefix user aliases and the default selection are preserved. + expect(host.current().models?.['my-fav']).toEqual(userAliasModel); + expect(host.current().defaultModel).toBe('my-kimi/kimi-for-coding'); + }); + + it('resolves the API key from the provider env sub-table when api_key is empty', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: '', + env: { KIMI_API_KEY: 'sk-env-key' }, + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-env-key'); + return new Response( + JSON.stringify({ + data: [{ id: 'kimi-for-coding', context_length: 262144, supports_reasoning: true }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['my-kimi']); + expect(host.setConfig).not.toHaveBeenCalled(); + }); + + it('matches the managed endpoint even with a trailing slash on the configured baseUrl', async () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.managed.example.test/coding/v1'); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl: 'https://api.managed.example.test/coding/v1/', + apiKey: 'sk-distributed-key', + }, + }, + models: {}, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn(async (input) => { + expect(fetchInputUrl(input)).toBe('https://api.managed.example.test/coding/v1/models'); + return new Response( + JSON.stringify({ data: [{ id: 'kimi-for-coding', context_length: 262144 }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { providerId: 'my-kimi', providerName: 'my-kimi', added: 1, removed: 0 }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not refresh API-key providers pointing at non-managed endpoints', async () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.managed.example.test/coding/v1'); + const host = makeRefreshHost({ + providers: { + gateway: { + type: 'kimi', + baseUrl: 'https://gateway.example.test/v1', + apiKey: 'sk-gateway-key', + }, + 'moonshot-lookalike': { + type: 'kimi', + baseUrl: 'https://api.moonshot.cn/v1', + apiKey: 'sk-platform-key', + }, + }, + models: {}, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result).toEqual({ changed: [], unchanged: [], failed: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes a hand-written managed:kimi-code provider that uses an API key instead of OAuth', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Kimi', + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + telemetry: true, + } as unknown as KimiConfig); + + const resolveOAuthToken = vi.fn(async () => 'oauth-access-token'); + const fetchMock = vi.fn(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-distributed-key'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Kimi', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken, + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { + providerId: KIMI_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, + added: 0, + removed: 0, + }, + ]); + // The OAuth branch must not run: no token resolution, and the provider + // record keeps the user's API-key shape (no oauth ref, no apiKey reset). + expect(resolveOAuthToken).not.toHaveBeenCalled(); + expect(host.current().providers[KIMI_CODE_PROVIDER_NAME]).toEqual({ + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }); + expect(host.current().services).toBeUndefined(); + expect(host.current().models?.['kimi-code/kimi-for-coding']?.displayName).toBe('Fresh Kimi'); + expect(host.current().defaultModel).toBe('kimi-code/kimi-for-coding'); + }); + + it('reports a failed refresh and keeps config when the managed endpoint rejects the API key', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: 'sk-revoked-key', + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ error: { message: 'invalid key' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.changed).toEqual([]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]?.provider).toBe('my-kimi'); + expect(result.failed[0]?.reason).toContain('the API key'); + expect(host.setConfig).not.toHaveBeenCalled(); + expect(host.current().models?.['my-kimi/kimi-for-coding']).toBeDefined(); + }); + + it('skips the API-key refresh when the managed endpoint returns no models', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result).toEqual({ changed: [], unchanged: [], failed: [] }); + expect(host.setConfig).not.toHaveBeenCalled(); + expect(host.current().models?.['my-kimi/kimi-for-coding']).toBeDefined(); + }); + + it('writes defaultProvider back when refreshing the provider it points at', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-kimi/kimi-for-coding': { + provider: 'my-kimi', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Kimi', + }, + }, + defaultProvider: 'my-kimi', + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Kimi', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toHaveLength(1); + // The v1 removeProvider RPC clears defaultProvider when it points at the + // refreshed provider; the setConfig patch must carry the original value + // back. + expect(host.setConfig).toHaveBeenCalledWith( + expect.objectContaining({ defaultProvider: 'my-kimi' }), + ); + expect(host.current().defaultProvider).toBe('my-kimi'); + }); + + it('leaves registry-sourced providers at the managed base URL to the registry branch', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const host = makeRefreshHost({ + providers: { + custom: { + type: 'kimi', + baseUrl, + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + }, + }, + models: { + 'custom/m1': { + provider: 'custom', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn(async (input) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + return new Response( + JSON.stringify({ + custom: { + id: 'custom', + name: 'Custom', + api: baseUrl, + type: 'kimi', + models: { m1: { id: 'm1' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['custom']); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.setConfig).not.toHaveBeenCalled(); + }); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 933f282cc..59504ec0b 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -23,7 +23,7 @@ TOML field names always use snake_case, for example `default_model` and `max_con The following example covers the most commonly used configuration fields. You can copy it and adjust as needed: ```toml -default_model = "kimi-code/kimi-for-coding" +default_model = "kimi-code/k3" default_permission_mode = "manual" default_plan_mode = false merge_all_available_skills = true @@ -34,6 +34,15 @@ type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "" +[models."kimi-code/k3"] +provider = "managed:kimi-code" +model = "k3" +max_context_size = 1048576 +capabilities = [ "thinking", "always_thinking", "image_in", "video_in", "tool_use" ] +display_name = "K3" +support_efforts = [ "max" ] +default_effort = "max" + [models."kimi-code/kimi-for-coding"] provider = "managed:kimi-code" model = "kimi-for-coding" diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 529b16676..07ccfcf1b 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -23,7 +23,7 @@ TOML 字段名一律用下划线(snake_case),如 `default_model`、`max_co 以下示例覆盖最常用的配置项,可直接复制后按需修改: ```toml -default_model = "kimi-code/kimi-for-coding" +default_model = "kimi-code/k3" default_permission_mode = "manual" default_plan_mode = false merge_all_available_skills = true @@ -34,6 +34,15 @@ type = "kimi" base_url = "https://api.kimi.com/coding/v1" api_key = "" +[models."kimi-code/k3"] +provider = "managed:kimi-code" +model = "k3" +max_context_size = 1048576 +capabilities = [ "thinking", "always_thinking", "image_in", "video_in", "tool_use" ] +display_name = "K3" +support_efforts = [ "max" ] +default_effort = "max" + [models."kimi-code/kimi-for-coding"] provider = "managed:kimi-code" model = "kimi-for-coding" diff --git a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts index 37a8062ed..5ca41315e 100644 --- a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts @@ -201,11 +201,18 @@ export class ModelCatalogService implements IModelCatalogService { if (patch.models !== undefined) { await this.config.replace(MODELS_SECTION, patch.models); } - if (patch.defaultModel !== undefined) { - await this.config.set(DEFAULT_MODEL_SECTION, patch.defaultModel); + // The refresh orchestrator always sends all four keys, so key presence is + // the write intent and an explicit `undefined` means CLEAR, not "leave + // alone". `set()` cannot express that — its deepMerge resolves an + // undefined patch back to the base value — so these go through `replace`, + // which deletes the section on undefined. Otherwise a default model (and + // its thinking setting) whose alias the upstream dropped would dangle in + // the user config forever. + if ('defaultModel' in patch) { + await this.config.replace(DEFAULT_MODEL_SECTION, patch.defaultModel); } - if (patch.thinking !== undefined) { - await this.config.set(THINKING_SECTION, patch.thinking); + if ('thinking' in patch) { + await this.config.replace(THINKING_SECTION, patch.thinking); } return this.readUserConfigShape(); } diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 08b9b38fc..75ce0b91d 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -423,6 +423,33 @@ describe('ConfigService env overlay (live)', () => { disposables.dispose(); }); + + it('deletes a scalar section on replace(undefined) — set(undefined) cannot', async () => { + // Contract the refresh host relies on: an explicit undefined in a refresh + // patch must DELETE the section. `set()` deep-merges, so an undefined + // scalar patch resolves back to the base value; only `replace()` deletes. + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg')); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + + await config.replace('defaultModel', 'kimi-code/kimi-k2'); + expect(config.get('defaultModel')).toBe('kimi-code/kimi-k2'); + + await config.set('defaultModel', undefined); + expect(config.get('defaultModel')).toBe('kimi-code/kimi-k2'); + + await config.replace('defaultModel', undefined); + expect(config.get('defaultModel')).toBeUndefined(); + + disposables.dispose(); + }); }); describe('skill config sections', () => { diff --git a/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts b/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts index 3b95dc2a2..3fe0d9c9a 100644 --- a/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts +++ b/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts @@ -129,6 +129,7 @@ describe('ModelCatalogService', () => { afterEach(() => { disposables.dispose(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); function catalog(): IModelCatalogService { @@ -451,4 +452,111 @@ describe('ModelCatalogService', () => { }), ); }); + + it('refreshProviderModels refreshes a hand-configured API-key provider at the managed endpoint', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + backing.providers = { + 'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' }, + }; + backing.models = { + 'my-kimi/kimi-k2': { + provider: 'my-kimi', + model: 'kimi-k2', + maxContextSize: 262144, + displayName: 'Old K2', + }, + }; + backing.defaultModel = 'my-kimi/kimi-k2'; + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-k2', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh K2', + }, + { id: 'kimi-k2.5', context_length: 131072 }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await catalog().refreshProviderModels({ scope: 'all' }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { provider_id: 'my-kimi', provider_name: 'my-kimi', added: 1, removed: 0 }, + ]); + expect(publishEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'event.model_catalog.changed' }), + ); + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/models`, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sk-distributed-key' }), + }), + ); + // The user-owned provider record survives; only model aliases are merged. + expect(backing.providers['my-kimi']).toEqual({ + type: 'kimi', + baseUrl, + apiKey: 'sk-distributed-key', + }); + expect(backing.models['my-kimi/kimi-k2']?.displayName).toBe('Fresh K2'); + expect(backing.models['my-kimi/kimi-k2.5']).toBeDefined(); + // The surviving default selection is written back, not cleared. + expect(backing.defaultModel).toBe('my-kimi/kimi-k2'); + }); + + it('refreshProviderModels clears a stale defaultModel whose alias upstream dropped', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + backing.providers = { + 'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' }, + }; + backing.models = { + 'my-kimi/kimi-k2': { + provider: 'my-kimi', + model: 'kimi-k2', + maxContextSize: 262144, + displayName: 'Old K2', + }, + }; + backing.defaultModel = 'my-kimi/kimi-k2'; + backing.thinking = { enabled: true }; + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [{ id: 'kimi-k3', context_length: 1048576, supports_reasoning: true }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await catalog().refreshProviderModels({ scope: 'all' }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { provider_id: 'my-kimi', provider_name: 'my-kimi', added: 1, removed: 1 }, + ]); + // The dropped alias was the default: an explicit undefined in the patch + // must clear the section instead of leaving the default dangling. It has + // to go through `replace` — `set()`'s deepMerge would resolve undefined + // back to the stale base value. + expect(configReplace).toHaveBeenCalledWith('defaultModel', undefined); + expect(configReplace).toHaveBeenCalledWith('thinking', undefined); + expect(configSet).not.toHaveBeenCalled(); + expect(backing.defaultModel).toBeUndefined(); + expect(backing.thinking).toBeUndefined(); + expect(backing.models['my-kimi/kimi-k3']).toBeDefined(); + expect(backing.models['my-kimi/kimi-k2']).toBeUndefined(); + }); }); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index e6037c295..943d122e8 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -42,6 +42,7 @@ export type { KimiHostIdentity, KimiIdentityOptions } from './identity'; export { KIMI_CODE_FLOW_CONFIG } from './constants'; export { + applyManagedApiKeyProviderModels, applyManagedKimiCodeLogoutConfig, applyManagedKimiCodeConfig, clearManagedKimiCodeConfig, @@ -57,6 +58,7 @@ export { resolveKimiCodeOAuthKey, resolveKimiCodeOAuthRef, resolveKimiCodeRuntimeAuth, + toManagedModelAlias, } from './managed-kimi-code'; export type { FetchManagedKimiCodeModelsOptions, @@ -80,6 +82,7 @@ export { formatDuration, formatResetTime, isManagedKimiCode, + isManagedKimiCodeBaseUrl, kimiCodeBaseUrl, kimiCodeUsageUrl, parseManagedUsagePayload, diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index 8e07edc8e..6b0ce7561 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -55,6 +55,13 @@ export interface FetchManagedKimiCodeModelsOptions { readonly baseUrl?: string | undefined; readonly fetchImpl?: typeof fetch | undefined; readonly headers?: Record | undefined; + /** + * What `accessToken` actually is; only affects auth-error wording. Defaults + * to 'oauth' (the managed login flow); api-key providers refreshed against + * the managed endpoint pass 'apiKey' so a 401 doesn't tell the user their + * "OAuth credentials" were rejected. + */ + readonly credentialKind?: 'oauth' | 'apiKey' | undefined; } export interface ManagedKimiCodeApplyResult { @@ -107,9 +114,12 @@ export class ManagedKimiCodeModelsAuthError extends OAuthUnauthorizedError { readonly status: number; readonly baseUrl: string; readonly message: string; + readonly credentialKind?: 'oauth' | 'apiKey' | undefined; }) { super( - `Kimi Code models endpoint ${options.baseUrl} rejected OAuth credentials: ${options.message}`, + `Kimi Code models endpoint ${options.baseUrl} rejected ${ + options.credentialKind === 'apiKey' ? 'the API key' : 'OAuth credentials' + }: ${options.message}`, ); this.name = 'ManagedKimiCodeModelsAuthError'; this.status = options.status; @@ -497,6 +507,7 @@ export async function fetchManagedKimiCodeModels( status: response.status, baseUrl, message, + credentialKind: options.credentialKind, }); } throw new Error(message); @@ -510,6 +521,45 @@ export async function fetchManagedKimiCodeModels( .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); } +/** + * Builds the upstream-owned alias record for one `/models` entry. Shared by + * `applyManagedKimiCodeConfig` (OAuth provisioning/refresh) and + * `applyManagedApiKeyProviderModels` (api-key provider refresh) so both write + * identical upstream-owned fields for the same model payload. + */ +export function toManagedModelAlias( + providerId: string, + model: ManagedKimiCodeModelInfo, +): ManagedKimiModelAlias { + const capabilities = capabilitiesForModel(model); + // Kimi's Anthropic-compatible endpoint only accepts adaptive thinking + // (`thinking: { type: 'adaptive' }`); the kosong adapter otherwise infers + // budget-based thinking from the model name, which fails for Kimi model ids. + // Restrict the override to thinking-capable models: the UI treats + // `adaptiveThinking === true` as "supports a thinking toggle", so marking a + // non-thinking model would misrepresent it. + const supportsAdaptiveThinking = + model.protocol === 'anthropic' && + (capabilities?.includes('thinking') === true || + capabilities?.includes('always_thinking') === true); + return { + provider: providerId, + model: model.id, + maxContextSize: model.contextLength, + capabilities, + ...(model.displayName !== undefined ? { displayName: model.displayName } : {}), + ...(model.supportEfforts !== undefined ? { supportEfforts: model.supportEfforts } : {}), + ...(model.defaultEffort !== undefined ? { defaultEffort: model.defaultEffort } : {}), + protocol: model.protocol, + // Kimi's anthropic-compatible endpoint is served behind the beta Messages + // API (`/v1/messages?beta=true`), so route anthropic-protocol models + // through `client.beta.messages.create`. Cleared on refresh when the + // server stops declaring anthropic so stale routing never lingers. + betaApi: model.protocol === 'anthropic' ? true : undefined, + adaptiveThinking: supportsAdaptiveThinking ? true : undefined, + }; +} + export function applyManagedKimiCodeConfig( config: ManagedKimiConfigShape, options: { @@ -560,36 +610,13 @@ export function applyManagedKimiCodeConfig( } } for (const model of options.models) { - const capabilities = capabilitiesForModel(model); const key = managedModelKey(model.id); const existing = isRecord(existingModels[key]) ? existingModels[key] : {}; - // Kimi's Anthropic-compatible endpoint only accepts adaptive thinking - // (`thinking: { type: 'adaptive' }`); the kosong adapter otherwise infers - // budget-based thinking from the model name, which fails for Kimi model ids. - // Restrict the override to thinking-capable models: the UI treats - // `adaptiveThinking === true` as "supports a thinking toggle", so marking a - // non-thinking model would misrepresent it. - const supportsAdaptiveThinking = - model.protocol === 'anthropic' && - (capabilities?.includes('thinking') === true || - capabilities?.includes('always_thinking') === true); - const remoteAlias: ManagedKimiModelAlias = { - provider: KIMI_CODE_PROVIDER_NAME, - model: model.id, - maxContextSize: model.contextLength, - capabilities, - ...(model.displayName !== undefined ? { displayName: model.displayName } : {}), - ...(model.supportEfforts !== undefined ? { supportEfforts: model.supportEfforts } : {}), - ...(model.defaultEffort !== undefined ? { defaultEffort: model.defaultEffort } : {}), - protocol: model.protocol, - // Kimi's anthropic-compatible endpoint is served behind the beta Messages - // API (`/v1/messages?beta=true`), so route anthropic-protocol models - // through `client.beta.messages.create`. Cleared on refresh when the - // server stops declaring anthropic so stale routing never lingers. - betaApi: model.protocol === 'anthropic' ? true : undefined, - adaptiveThinking: supportsAdaptiveThinking ? true : undefined, - }; - existingModels[key] = mergeRefreshedModelAlias(existing, remoteAlias, MANAGED_KIMI_MODEL_FIELDS); + existingModels[key] = mergeRefreshedModelAlias( + existing, + toManagedModelAlias(KIMI_CODE_PROVIDER_NAME, model), + MANAGED_KIMI_MODEL_FIELDS, + ); } config.models = existingModels; @@ -614,6 +641,54 @@ export function applyManagedKimiCodeConfig( }; } +/** + * Merge refreshed `/models` entries into the aliases of an api-key provider + * pointing at the managed Kimi Code endpoint (a hand-configured provider using + * a distributed API key instead of OAuth). Unlike `applyManagedKimiCodeConfig` + * this touches ONLY `config.models`: the provider record (type / baseUrl / + * apiKey and any hand-written extras), `services`, `defaultModel`, and + * `thinking` are all left untouched — the provider is user-owned, only its + * model catalog is upstream-owned. + * + * `aliasPrefix` scopes which aliases count as refresh-generated: + * `${providerId}/` for ordinary providers, `kimi-code/` for a hand-written + * `managed:kimi-code` entry so its aliases line up with the OAuth provisioned + * shape. Aliases outside the prefix are the caller's responsibility (the + * refresh orchestrator preserves them via `preserveUserProviderAliases`). + */ +export function applyManagedApiKeyProviderModels( + config: ManagedKimiConfigShape, + providerId: string, + models: readonly ManagedKimiCodeModelInfo[], + aliasPrefix: string, +): void { + for (const model of models) { + assertPositiveContextLength(model); + } + + const existingModels = config.models ?? {}; + // Same merge contract as `applyManagedKimiCodeConfig`: upstream-owned fields + // are overwritten, hand-written extras survive, and aliases upstream no + // longer lists are removed (the orchestrator restores non-prefix ones). + const upstreamKeys = new Set(models.map((m) => `${aliasPrefix}${m.id}`)); + for (const [key, model] of Object.entries(existingModels)) { + if (isRecord(model) && model['provider'] === providerId && !upstreamKeys.has(key)) { + delete existingModels[key]; + } + } + for (const model of models) { + const key = `${aliasPrefix}${model.id}`; + const existing = isRecord(existingModels[key]) ? existingModels[key] : {}; + existingModels[key] = mergeRefreshedModelAlias( + existing, + toManagedModelAlias(providerId, model), + MANAGED_KIMI_MODEL_FIELDS, + ); + } + + config.models = existingModels; +} + export function applyManagedKimiCodeLogoutConfig(config: ManagedKimiConfigShape): void { delete config.providers[KIMI_CODE_PROVIDER_NAME]; diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 907d7ae68..3869e2b11 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -42,6 +42,29 @@ export function kimiCodeUsageUrl(): string { return `${kimiCodeBaseUrl()}/usages`; } +/** + * Strict match against the managed Kimi Code endpoint: both URLs are parsed + * and compared by lowercase origin + pathname without trailing slashes. + * Anything that fails to parse — or differs in host or path, e.g. a proxy, + * gateway, or self-hosted mirror — is NOT the managed endpoint and must not + * be auto-refreshed, because its `/models` schema cannot be trusted. + */ +export function isManagedKimiCodeBaseUrl(baseUrl: string | undefined): boolean { + if (baseUrl === undefined) return false; + const managed = parseNormalizedUrl(kimiCodeBaseUrl()); + const candidate = parseNormalizedUrl(baseUrl); + return managed !== undefined && candidate !== undefined && managed === candidate; +} + +function parseNormalizedUrl(value: string): string | undefined { + try { + const url = new URL(value); + return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, '')}`; + } catch { + return undefined; + } +} + export interface UsageRow { readonly label: string; readonly used: number; diff --git a/packages/oauth/src/refreshProviderModels.ts b/packages/oauth/src/refreshProviderModels.ts index cebfafffc..49c521fd6 100644 --- a/packages/oauth/src/refreshProviderModels.ts +++ b/packages/oauth/src/refreshProviderModels.ts @@ -5,6 +5,7 @@ import { type CustomRegistrySource, } from './custom-registry'; import { + applyManagedApiKeyProviderModels, applyManagedKimiCodeConfig, fetchManagedKimiCodeModels, KIMI_CODE_PLATFORM_ID, @@ -14,6 +15,7 @@ import { type ManagedKimiModelAlias, type ManagedKimiOAuthRef, } from './managed-kimi-code'; +import { isManagedKimiCodeBaseUrl } from './managed-usage'; import { applyOpenPlatformConfig, fetchOpenPlatformModels, @@ -21,6 +23,7 @@ import { getOpenPlatformById, isOpenPlatformId, } from './open-platform'; +import { isRecord } from './utils'; /** * Host capabilities the refresh orchestrator needs. Intentionally typed against @@ -75,6 +78,23 @@ interface ProviderView { readonly apiKey?: string; readonly oauth?: ManagedKimiOAuthRef; readonly source?: unknown; + readonly env?: unknown; +} + +/** + * Mirrors the runtime credential resolution for `type: 'kimi'` providers + * (`providerApiKey` in agent-core's provider-manager): the inline `apiKey` + * wins, with `env.KIMI_API_KEY` as the documented config-file fallback. + */ +function resolveProviderApiKey(provider: ProviderView): string | undefined { + if (typeof provider.apiKey === 'string' && provider.apiKey.length > 0) { + return provider.apiKey; + } + if (isRecord(provider.env)) { + const fromEnv = provider.env['KIMI_API_KEY']; + if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv; + } + return undefined; } function readProvider( @@ -337,10 +357,16 @@ function pickDefaultModel( /** * Refresh remote model metadata for the configured providers and persist any - * changes through the host. Handles three provider kinds, in order: + * changes through the host. Handles four provider kinds, in order: * * 1. Managed Kimi Code (OAuth) — `GET /models` against the runtime endpoint. * 2. Open platforms (moonshot-cn, moonshot-ai, …) — platform catalog fetch. + * 2.5. Managed-endpoint API-key providers — hand-written `type: 'kimi'` + * providers (including a hand-written `managed:kimi-code` without an oauth + * ref) whose baseUrl is exactly the managed Kimi Code endpoint; refreshed + * via `GET /models` with the configured API key as Bearer. Only model + * aliases are merged; the provider record is user-owned and never + * rewritten. * 3. Custom registries (models.dev-style, keyed by `provider.source`). * * Each branch diffs old vs new and only writes when something actually changed @@ -434,7 +460,11 @@ export async function refreshProviderModels( } } - if (scope === 'oauth' || targetId === KIMI_CODE_PROVIDER_NAME) { + // The oauth scope stops here, but a targeted refresh of the managed provider + // must fall through: branch 2 no-ops on a non-open-platform id, branch 2.5 + // handles a hand-written `managed:kimi-code` that carries an API key instead + // of an oauth ref, and branch 3 no-ops when no registry group contains it. + if (scope === 'oauth') { return { changed, unchanged, failed }; } @@ -508,6 +538,83 @@ export async function refreshProviderModels( } } + // --------------------------------------------------------------------------- + // 2.5. Managed-endpoint API-key providers (hand-configured distributed keys) + // --------------------------------------------------------------------------- + // A hand-written `type: 'kimi'` provider whose baseUrl is exactly the managed + // Kimi Code endpoint, carrying an API key (inline or via `env.KIMI_API_KEY`) + // instead of an oauth ref, gets its model list refreshed from + // `{baseUrl}/models` just like the OAuth branch. Strict baseUrl matching + // keeps proxies / gateways with an untrusted `/models` schema out. + for (const providerId of Object.keys(config.providers)) { + if (isOpenPlatformId(providerId)) continue; + if (targetId !== undefined && targetId !== providerId) continue; + const provider = readProvider(config, providerId); + if (provider === undefined) continue; + if (provider.type !== 'kimi') continue; + if (provider.oauth !== undefined) continue; + if (readCustomRegistrySource(provider) !== undefined) continue; + if (!isManagedKimiCodeBaseUrl(provider.baseUrl)) continue; + const apiKey = resolveProviderApiKey(provider); + if (apiKey === undefined) continue; + + try { + const models = await fetchManagedKimiCodeModels({ + accessToken: apiKey, + baseUrl: provider.baseUrl, + credentialKind: 'apiKey', + }); + if (models.length === 0) continue; + + // A hand-written `managed:kimi-code` shares the OAuth branch's + // `kimi-code/` alias prefix so the two shapes merge cleanly if the user + // later logs in via OAuth; ordinary providers use their own id. + const aliasPrefix = + providerId === KIMI_CODE_PROVIDER_NAME ? `${KIMI_CODE_PLATFORM_ID}/` : `${providerId}/`; + const next = structuredClone(config); + applyManagedApiKeyProviderModels(next, providerId, models, aliasPrefix); + const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, aliasPrefix); + restoreProviderAliases( + next, + preserveUserProviderAliases(config, providerId, refreshedAliasKeys), + ); + restoreDefaultSelection(next, config.defaultModel, config.thinking?.enabled); + clampDanglingDefault(next); + clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); + + if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { + unchanged.push(providerId); + } else { + const { added, removed } = computeChanges( + collectModelIdsForAliases(config, refreshedAliasKeys), + collectModelIdsForAliases(next, refreshedAliasKeys), + ); + await host.removeProvider(providerId); + config = await host.setConfig({ + providers: next.providers, + models: next.models, + defaultModel: next.defaultModel, + thinking: next.thinking, + // The v1 `removeProvider` RPC clears `defaultProvider` when it points + // at this provider; the clone still holds the original value, so + // write it back — a refresh must not silently drop the fallback. + defaultProvider: next['defaultProvider'], + }); + changed.push({ + providerId, + providerName: providerId, + added, + removed, + }); + } + } catch (error) { + failed.push({ + provider: providerId, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + // --------------------------------------------------------------------------- // 3. Custom Registry providers (grouped by URL, with API-key candidates) // --------------------------------------------------------------------------- diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts index c84eb6703..92ffd2f5e 100644 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { + applyManagedApiKeyProviderModels, applyManagedKimiCodeLogoutConfig, applyManagedKimiCodeConfig, clearManagedKimiCodeConfig, @@ -1297,6 +1298,98 @@ describe('selective merge', () => { }); }); +describe('applyManagedApiKeyProviderModels', () => { + it('merges upstream models without touching provider, services, or defaults', () => { + const config: ManagedKimiConfigShape = { + providers: { + 'my-kimi': { + type: 'kimi', + baseUrl: 'https://api.example.test/coding/v1', + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-kimi/kimi-k2': { + provider: 'my-kimi', + model: 'kimi-k2', + maxContextSize: 262144, + displayName: 'Old K2', + maxOutputSize: 4096, + } as Record, + 'my-kimi/kimi-old': { + provider: 'my-kimi', + model: 'kimi-old', + maxContextSize: 128000, + }, + 'other/m1': { + provider: 'other', + model: 'm1', + maxContextSize: 128000, + }, + }, + defaultModel: 'my-kimi/kimi-k2', + thinking: { enabled: false }, + }; + + applyManagedApiKeyProviderModels( + config, + 'my-kimi', + [makeModelInfo('kimi-k2', { displayName: 'Fresh K2' }), makeModelInfo('kimi-k2.5')], + 'my-kimi/', + ); + + // The provider record is user-owned: no rewrite, no oauth, no apiKey reset. + expect(config.providers['my-kimi']).toEqual({ + type: 'kimi', + baseUrl: 'https://api.example.test/coding/v1', + apiKey: 'sk-distributed-key', + }); + // Defaults and services are the orchestrator's / OAuth branch's business. + expect(config.defaultModel).toBe('my-kimi/kimi-k2'); + expect(config.thinking).toEqual({ enabled: false }); + expect(config.services).toBeUndefined(); + // Upstream-owned fields merge; hand-written extras survive. + const alias = config.models?.['my-kimi/kimi-k2']; + expect(alias?.['displayName']).toBe('Fresh K2'); + expect(alias?.['maxOutputSize']).toBe(4096); + // New upstream model added; dropped one removed; other providers untouched. + expect(config.models?.['my-kimi/kimi-k2.5']).toBeDefined(); + expect(config.models?.['my-kimi/kimi-old']).toBeUndefined(); + expect(config.models?.['other/m1']).toBeDefined(); + }); + + it('writes protocol routing fields for anthropic-protocol models', () => { + const config: ManagedKimiConfigShape = { providers: {}, models: {} }; + + applyManagedApiKeyProviderModels( + config, + 'my-kimi', + [makeModelInfo('kimi-for-coding', { protocol: 'anthropic', supportsReasoning: true })], + 'my-kimi/', + ); + + const alias = config.models?.['my-kimi/kimi-for-coding']; + expect(alias?.['provider']).toBe('my-kimi'); + expect(alias?.['protocol']).toBe('anthropic'); + expect(alias?.['betaApi']).toBe(true); + expect(alias?.['adaptiveThinking']).toBe(true); + expect(alias?.['capabilities']).toEqual(['thinking', 'tool_use']); + }); + + it('rejects models without a positive context length', () => { + const config: ManagedKimiConfigShape = { providers: {}, models: {} }; + + expect(() => { + applyManagedApiKeyProviderModels( + config, + 'my-kimi', + [makeModelInfo('bad', { contextLength: 0 })], + 'my-kimi/', + ); + }).toThrow('context_length'); + }); +}); + function makeModelInfo( id: string, overrides: Partial = {}, diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 8580b4751..f3c493040 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -5,6 +5,7 @@ import { formatDuration, formatResetTime, isManagedKimiCode, + isManagedKimiCodeBaseUrl, kimiCodeBaseUrl, kimiCodeUsageUrl, parseManagedUsagePayload, @@ -27,6 +28,37 @@ describe('kimiCodeBaseUrl', () => { }); }); +describe('isManagedKimiCodeBaseUrl', () => { + it('matches the default managed endpoint, with or without a trailing slash', () => { + expect(isManagedKimiCodeBaseUrl('https://api.kimi.com/coding/v1')).toBe(true); + expect(isManagedKimiCodeBaseUrl('https://api.kimi.com/coding/v1/')).toBe(true); + }); + + it('matches against the KIMI_CODE_BASE_URL override', () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://gw.example.com/coding/v1/'); + expect(isManagedKimiCodeBaseUrl('https://gw.example.com/coding/v1')).toBe(true); + expect(isManagedKimiCodeBaseUrl('https://api.kimi.com/coding/v1')).toBe(false); + }); + + it('is case-insensitive on the origin but strict on the path', () => { + expect(isManagedKimiCodeBaseUrl('https://API.KIMI.COM/coding/v1')).toBe(true); + expect(isManagedKimiCodeBaseUrl('https://api.kimi.com/CODING/v1')).toBe(false); + }); + + it('rejects other paths on the managed host and other hosts entirely', () => { + expect(isManagedKimiCodeBaseUrl('https://api.kimi.com/coding/v2')).toBe(false); + expect(isManagedKimiCodeBaseUrl('https://api.kimi.com/v1')).toBe(false); + expect(isManagedKimiCodeBaseUrl('https://gateway.example.com/coding/v1')).toBe(false); + expect(isManagedKimiCodeBaseUrl('https://api.moonshot.cn/v1')).toBe(false); + }); + + it('rejects undefined and unparseable values', () => { + expect(isManagedKimiCodeBaseUrl(undefined)).toBe(false); + expect(isManagedKimiCodeBaseUrl('')).toBe(false); + expect(isManagedKimiCodeBaseUrl('not a url')).toBe(false); + }); +}); + describe('isManagedKimiCode', () => { it('matches only the kimi-code managed provider', () => { expect(isManagedKimiCode('managed:kimi-code')).toBe(true);