diff --git a/.changeset/websearch-services-on-refresh.md b/.changeset/websearch-services-on-refresh.md new file mode 100644 index 000000000..183109496 --- /dev/null +++ b/.changeset/websearch-services-on-refresh.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Provision the managed Kimi Code search/fetch services during provider refresh so the WebSearch tool works after login without a manual `config.toml` edit. Previously the services block was only written on an explicit login, leaving upgraded clients with WebSearch silently disabled. diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index a25c4b7cf..d345c4341 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -92,6 +92,19 @@ function asManaged(config: KimiConfig): ManagedKimiConfigShape { return config as unknown as ManagedKimiConfigShape; } +// The managed Kimi Code login provisions `services.moonshot_search` / +// `services.moonshot_fetch` — the endpoints behind the WebSearch and URL-fetch +// tools. A client is "missing" them when either lacks a baseUrl, e.g. it logged +// in on a build that predates service provisioning. Without them WebSearch is +// silently disabled until the user hand-edits config.toml, so a refresh +// backfills them. +function managedServicesMissing(config: KimiConfig): boolean { + return ( + config.services?.moonshotSearch?.baseUrl === undefined || + config.services?.moonshotFetch?.baseUrl === undefined + ); +} + function collectModelIdsForAliases(config: KimiConfig, aliasKeys: ReadonlySet): Set { const ids = new Set(); for (const aliasKey of aliasKeys) { @@ -324,6 +337,13 @@ export async function refreshAllProviderModels( clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + // Models are identical, but a client provisioned before the managed + // search/fetch services existed has no `services` block — which leaves + // the WebSearch tool disabled. Backfill the services so the refresh + // repairs it, without emitting a spurious model-change report. + if (managedServicesMissing(config)) { + config = await host.setConfig({ services: next.services }); + } unchanged.push(KIMI_CODE_PROVIDER_NAME); } else { const { added, removed } = computeChanges( @@ -336,6 +356,10 @@ export async function refreshAllProviderModels( models: next.models, defaultModel: next.defaultModel, defaultThinking: next.defaultThinking, + // Persist the managed search/fetch services alongside the model + // refresh; omitting them here is what left upgraded clients with a + // non-functional WebSearch tool. + services: next.services, }); changed.push({ providerId: KIMI_CODE_PROVIDER_NAME, 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 aadb8e764..4efeb6e7c 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -113,10 +113,11 @@ describe('refreshAllProviderModels', () => { }); vi.stubGlobal('fetch', fetchMock); + const host = makeRefreshHost(config); const result = await refreshAllProviderModels({ - getConfig: async () => config, - removeProvider: vi.fn(), - setConfig: vi.fn(), + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, resolveOAuthToken, }); @@ -124,6 +125,144 @@ describe('refreshAllProviderModels', () => { expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]); expect(fetchMock).toHaveBeenCalledTimes(1); expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef); + // An unchanged-models refresh still backfills the managed search/fetch + // services so the WebSearch tool works without a manual config edit. + expect(host.removeProvider).not.toHaveBeenCalled(); + expect(host.current().services?.moonshotSearch).toEqual({ + baseUrl: `${envBaseUrl}/search`, + apiKey: '', + oauth: envOauthRef, + }); + expect(host.current().services?.moonshotFetch).toEqual({ + baseUrl: `${envBaseUrl}/fetch`, + apiKey: '', + oauth: envOauthRef, + }); + }); + + it('provisions managed search/fetch services when a model-changing refresh persists', async () => { + const baseUrl = 'https://api.example.test/coding/v1'; + const oauthRef = resolveKimiCodeOAuthRef({ baseUrl }); + const config: KimiConfig = { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + baseUrl, + apiKey: '', + oauth: oauthRef, + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + telemetry: true, + }; + const host = makeRefreshHost(config); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-deep-coder', + 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(async () => 'oauth-access-token'), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { providerId: KIMI_CODE_PROVIDER_NAME, providerName: 'Kimi Code', added: 1, removed: 1 }, + ]); + expect(host.removeProvider).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME); + expect(host.current().services?.moonshotSearch).toEqual({ + baseUrl: `${baseUrl}/search`, + apiKey: '', + oauth: oauthRef, + }); + expect(host.current().services?.moonshotFetch).toEqual({ + baseUrl: `${baseUrl}/fetch`, + apiKey: '', + oauth: oauthRef, + }); + }); + + it('leaves already-provisioned managed services untouched on an unchanged refresh', async () => { + const baseUrl = 'https://api.example.test/coding/v1'; + const oauthRef = resolveKimiCodeOAuthRef({ baseUrl }); + const config: KimiConfig = { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + baseUrl, + apiKey: '', + oauth: oauthRef, + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + services: { + moonshotSearch: { baseUrl: `${baseUrl}/search`, apiKey: 'sk-user-key', oauth: oauthRef }, + moonshotFetch: { baseUrl: `${baseUrl}/fetch`, apiKey: 'sk-user-key', oauth: oauthRef }, + }, + telemetry: true, + }; + const host = makeRefreshHost(config); + const fetchMock = vi.fn( + async () => + 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(async () => 'oauth-access-token'), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]); + // Services already present (and carrying a user-set api_key) must not be + // rewritten — no setConfig churn, no clobbering the manual key. + expect(host.setConfig).not.toHaveBeenCalled(); + expect(host.current().services?.moonshotSearch?.apiKey).toBe('sk-user-key'); }); it('can refresh only the managed OAuth provider without fetching third-party registries', async () => {