From 14d9e98903f30f83199e30b5fa20b3c61ab28781 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 30 Jun 2026 12:29:10 +0800 Subject: [PATCH] feat(server): auto-refresh provider model catalog and push change events (#1207) * feat(server): auto-refresh provider models and push change events - add scheduled provider-model refresh in the daemon (configurable interval + refresh-on-start) plus manual endpoints: POST /providers:refresh and POST /providers/{id}:refresh - publish global event.model_catalog.changed when a refresh changes the catalog so connected clients can resync - extract the refresh orchestrator into @moonshot-ai/kimi-code-oauth so the CLI and server share managed/open-platform/custom-registry logic - wire the web daemon client to the new refresh endpoints * chore: add changeset for provider model auto-refresh * fix(web): reload model and provider caches on catalog change events When the daemon's scheduled refresh changes the catalog, the pushed event.model_catalog.changed only advanced the websocket sequence, leaving the web composer's model/provider refs stale until an unrelated reload. Reload both caches when the event arrives. * test(sdk): cover event.model_catalog.changed in event exhaustiveness --- .changeset/auto-refresh-provider-models.md | 5 + .../src/tui/utils/refresh-providers.ts | 574 +--------------- apps/kimi-web/src/api/daemon/client.ts | 36 +- apps/kimi-web/src/api/daemon/eventReducer.ts | 7 + apps/kimi-web/src/api/daemon/mappers.ts | 15 + apps/kimi-web/src/api/daemon/wire.ts | 12 + apps/kimi-web/src/api/types.ts | 9 +- .../client/useModelProviderState.ts | 28 +- .../src/composables/useKimiWebClient.ts | 6 + packages/agent-core/src/config/schema.ts | 12 + .../src/services/modelCatalog/modelCatalog.ts | 12 + .../modelCatalog/modelCatalogService.ts | 299 +++----- .../services/model-catalog-service.test.ts | 119 +++- .../node-sdk/test/session-event-types.test.ts | 1 + packages/oauth/src/index.ts | 9 + packages/oauth/src/refreshProviderModels.ts | 649 ++++++++++++++++++ packages/protocol/src/events.ts | 28 + packages/protocol/src/rest/modelCatalog.ts | 9 + packages/server/src/routes/modelCatalog.ts | 100 ++- .../services/gateway/wsBroadcastService.ts | 3 + .../modelCatalogRefreshScheduler.ts | 121 ++++ .../server/src/services/serviceCollection.ts | 5 + packages/server/src/start.ts | 12 + .../api-surface.snapshot.test.ts.snap | 6 +- .../model-catalog-refresh-scheduler.test.ts | 116 ++++ .../server/test/model-catalog.e2e.test.ts | 90 ++- 26 files changed, 1493 insertions(+), 790 deletions(-) create mode 100644 .changeset/auto-refresh-provider-models.md create mode 100644 packages/oauth/src/refreshProviderModels.ts create mode 100644 packages/server/src/services/modelCatalog/modelCatalogRefreshScheduler.ts create mode 100644 packages/server/test/model-catalog-refresh-scheduler.test.ts diff --git a/.changeset/auto-refresh-provider-models.md b/.changeset/auto-refresh-provider-models.md new file mode 100644 index 000000000..340f57864 --- /dev/null +++ b/.changeset/auto-refresh-provider-models.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Refresh provider model lists automatically in the background instead of only at startup, so newly available models appear without restarting. diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index a25c4b7cf..926b458e1 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -1,22 +1,17 @@ import { - KIMI_CODE_PLATFORM_ID, - KIMI_CODE_PROVIDER_NAME, - applyManagedKimiCodeConfig, - applyOpenPlatformConfig, - applyCustomRegistryProvider, - fetchCustomRegistry, - fetchManagedKimiCodeModels, - fetchOpenPlatformModels, - filterModelsByPrefix, - getOpenPlatformById, - isOpenPlatformId, - removeCustomRegistryProvider, - resolveKimiCodeRuntimeAuth, - type CustomRegistrySource, - type ManagedKimiConfigShape, + refreshProviderModels, + type ProviderChange, + type RefreshProviderOptions, + type RefreshProviderScope, + type RefreshResult, } from '@moonshot-ai/kimi-code-oauth'; -import type { KimiConfig, KimiConfigPatch, ModelAlias, OAuthRef, ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiConfig, KimiConfigPatch, OAuthRef } from '@moonshot-ai/kimi-code-sdk'; +/** + * CLI-side host for provider-model refresh. Kept on the SDK's full config types + * so existing TUI callers (and tests) don't change; the daemon uses the oauth + * package's `ManagedKimiConfigShape`-typed host directly. + */ export interface RefreshProviderHost { getConfig(): Promise; removeProvider(providerId: string): Promise; @@ -24,542 +19,25 @@ export interface RefreshProviderHost { resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise; } -export interface ProviderChange { - readonly providerId: string; - /** User-facing name when available. */ - readonly providerName: string; - readonly added: number; - readonly removed: number; -} - -export interface RefreshResult { - /** Providers whose model list actually changed. */ - readonly changed: readonly ProviderChange[]; - /** Providers whose model list stayed identical after refresh. */ - readonly unchanged: readonly string[]; - readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>; -} - -export type RefreshProviderScope = 'all' | 'oauth'; - -export interface RefreshProviderOptions { - readonly scope?: RefreshProviderScope; -} - -function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined { - const source = provider.source; - if (typeof source !== 'object' || source === null) return undefined; - const candidate = source; - if (candidate['kind'] !== 'apiJson') return undefined; - const url = candidate['url']; - const apiKey = candidate['apiKey']; - if (typeof url !== 'string' || url.length === 0) return undefined; - if (typeof apiKey !== 'string') return undefined; - return { kind: 'apiJson', url, apiKey }; -} - -function customRegistrySourceKey(source: CustomRegistrySource): string { - return JSON.stringify([source.url]); -} - -function customRegistrySourceCredentialKey(source: CustomRegistrySource): string { - return JSON.stringify([source.url, source.apiKey]); -} - -async function fetchCustomRegistryFromSources( - sources: readonly CustomRegistrySource[], -): Promise<{ - readonly entries: Awaited>; - readonly source: CustomRegistrySource; -}> { - let lastError: unknown; - for (const source of sources) { - try { - return { - entries: await fetchCustomRegistry(source), - source, - }; - } catch (error) { - lastError = error; - } - } - if (lastError instanceof Error) throw lastError; - if (typeof lastError === 'string') throw new Error(lastError); - throw new Error('No custom registry sources configured.'); -} - -function asManaged(config: KimiConfig): ManagedKimiConfigShape { - return config as unknown as ManagedKimiConfigShape; -} - -function collectModelIdsForAliases(config: KimiConfig, aliasKeys: ReadonlySet): Set { - const ids = new Set(); - for (const aliasKey of aliasKeys) { - const alias = config.models?.[aliasKey]; - if (alias !== undefined && alias.model.length > 0) { - ids.add(alias.model); - } - } - return ids; -} - -function providerAliasKeys(config: KimiConfig, providerId: string): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId) keys.add(alias); - } - return keys; -} - -function generatedProviderAliasKeys( - config: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId && alias.startsWith(aliasPrefix)) { - keys.add(alias); - } - } - return keys; -} - -function computeChanges(oldIds: Set, newIds: Set): { added: number; removed: number } { - let added = 0; - for (const id of newIds) { - if (!oldIds.has(id)) added++; - } - let removed = 0; - for (const id of oldIds) { - if (!newIds.has(id)) removed++; - } - return { added, removed }; -} - -interface ProviderModelSnapshot { - readonly alias: string; - readonly model: ModelAlias; -} - -// Compare the full model metadata for the relevant aliases, not just model IDs: -// a registry can change capabilities (e.g. enabling reasoning) without changing -// any model ID. Spreading the whole `ModelAlias` keeps this in sync with the -// schema automatically; only `capabilities` needs normalizing because its order -// is not meaningful. -function providerModelSnapshot( - config: KimiConfig, - providerId: string, - aliasKeys: ReadonlySet, -): string { - const snapshots: ProviderModelSnapshot[] = []; - for (const alias of aliasKeys) { - const model = config.models?.[alias]; - if (model === undefined || model.provider !== providerId) continue; - snapshots.push({ - alias, - model: { - ...model, - capabilities: model.capabilities === undefined ? undefined : model.capabilities.toSorted(), - }, - }); - } - snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); - return JSON.stringify(snapshots); -} - -function providerModelsEqual( - config: KimiConfig, - nextConfig: KimiConfig, - providerId: string, - aliasKeys: ReadonlySet, -): boolean { - return ( - providerModelSnapshot(config, providerId, aliasKeys) === - providerModelSnapshot(nextConfig, providerId, aliasKeys) - ); -} - -function providerConfigSnapshot(config: KimiConfig, providerId: string): string { - return JSON.stringify(config.providers[providerId] ?? null); -} - -function providerConfigEqual(config: KimiConfig, nextConfig: KimiConfig, providerId: string): boolean { - return providerConfigSnapshot(config, providerId) === providerConfigSnapshot(nextConfig, providerId); -} - -function providerRefreshAliasKeys( - config: KimiConfig, - nextConfig: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); - for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); - return keys; -} - -function preserveUserProviderAliases( - config: KimiConfig, - providerId: string, - refreshedAliasKeys: ReadonlySet, -): Record { - const preserved: Record = {}; - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue; - preserved[alias] = structuredClone(model); - } - return preserved; -} - -function restoreProviderAliases(config: KimiConfig, aliases: Record): void { - if (Object.keys(aliases).length === 0) return; - config.models = { - ...config.models, - ...aliases, - }; -} - -function restoreDefaultSelection( - config: KimiConfig, - defaultModel: string | undefined, - defaultThinking: boolean | undefined, -): void { - if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; - config.defaultModel = defaultModel; - // A refresh may have just learned that the default model cannot disable - // thinking — never restore a stale thinking-off selection onto it. - const capabilities = config.models[defaultModel]?.capabilities ?? []; - config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; -} - -// `apply*` may leave `defaultModel` pointing at an alias that no longer exists -// (e.g. the previously-selected model was dropped from the registry). The host's -// `setConfig` deep-merge cannot clear a key, so the matching `removeProvider` -// call handles disk cleanup while this drops the dangling reference in memory. -function clampDanglingDefault(config: KimiConfig): void { - if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { - config.defaultModel = undefined; - config.defaultThinking = undefined; - } -} - -function clearDefaultThinkingWhenDefaultRemoved( - config: KimiConfig, - previousDefaultModel: string | undefined, -): void { - if (previousDefaultModel !== undefined && config.defaultModel === undefined) { - config.defaultThinking = undefined; - } -} - -function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<{ id: string }>): string { - const firstModel = models[0]; - if (firstModel === undefined) return ''; - - const existingDefault = config.defaultModel; - if (existingDefault !== undefined) { - const alias = config.models?.[existingDefault]; - if (alias !== undefined && alias.provider === providerId) { - const stillAvailable = models.find((m) => m.id === alias.model); - if (stillAvailable !== undefined) { - return stillAvailable.id; - } - } - } - return firstModel.id; -} +export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult }; +/** + * Refresh remote model metadata for the configured providers. Thin adapter over + * the shared `refreshProviderModels` orchestrator in `@moonshot-ai/kimi-code-oauth` + * (which is also what the daemon's scheduled/manual refresh uses). + */ export async function refreshAllProviderModels( host: RefreshProviderHost, options: RefreshProviderOptions = {}, ): Promise { - const changed: ProviderChange[] = []; - const unchanged: string[] = []; - const failed: Array<{ provider: string; reason: string }> = []; - const scope = options.scope ?? 'all'; - - let config = await host.getConfig(); - - // ------------------------------------------------------------------------- - // 1. Managed Kimi Code (OAuth) - // ------------------------------------------------------------------------- - const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME]; - if ( - managedProvider !== undefined && - managedProvider.type === 'kimi' && - managedProvider.oauth !== undefined - ) { - try { - const auth = resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: managedProvider.baseUrl, - configuredOAuthRef: managedProvider.oauth, - }); - const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); - const models = await fetchManagedKimiCodeModels({ - accessToken, - baseUrl: auth.baseUrl, - }); - if (models.length > 0) { - const next = structuredClone(config); - applyManagedKimiCodeConfig(asManaged(next), { - models, - baseUrl: auth.baseUrl, - oauthKey: auth.oauthRef.key, - oauthHost: auth.oauthRef.oauthHost, - preserveDefaultModel: true, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - KIMI_CODE_PROVIDER_NAME, - `${KIMI_CODE_PLATFORM_ID}/`, - ); - restoreProviderAliases( - next, - preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), - ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(KIMI_CODE_PROVIDER_NAME); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(KIMI_CODE_PROVIDER_NAME); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId: KIMI_CODE_PROVIDER_NAME, - providerName: 'Kimi Code', - added, - removed, - }); - } - } - } catch (error) { - failed.push({ - provider: KIMI_CODE_PROVIDER_NAME, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - if (scope === 'oauth') { - return { changed, unchanged, failed }; - } - - // ------------------------------------------------------------------------- - // 2. Open Platforms (moonshot-cn, moonshot-ai, …) - // ------------------------------------------------------------------------- - const openPlatformIds = Object.keys(config.providers).filter((id) => isOpenPlatformId(id)); - for (const providerId of openPlatformIds) { - const platform = getOpenPlatformById(providerId); - if (platform === undefined) continue; - - const providerConfig = config.providers[providerId]; - if (providerConfig === undefined) continue; - const apiKey = providerConfig.apiKey; - if (typeof apiKey !== 'string' || apiKey.length === 0) continue; - - try { - let models = await fetchOpenPlatformModels(platform, apiKey); - models = filterModelsByPrefix(models, platform); - if (models.length === 0) continue; - - const selectedModelId = pickDefaultModel(config, providerId, models); - const selectedModel = models.find((m) => m.id === selectedModelId); - if (selectedModel === undefined) continue; - const next = structuredClone(config); - applyOpenPlatformConfig(asManaged(next), { - platform, - models, - selectedModel, - thinking: false, - apiKey, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - providerId, - `${providerId}/`, - ); - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - 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, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId, - providerName: platform.name, - 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) - // ------------------------------------------------------------------------- - const customSources = new Map< - string, + return refreshProviderModels( { - readonly sources: CustomRegistrySource[]; - readonly sourceKeys: Set; - readonly providerIds: string[]; - } - >(); - for (const [providerId, providerConfig] of Object.entries(config.providers)) { - if (providerId === KIMI_CODE_PROVIDER_NAME) continue; - if (isOpenPlatformId(providerId)) continue; - const source = readCustomRegistrySource(providerConfig); - if (source === undefined) continue; - const key = customRegistrySourceKey(source); - const sourceKey = customRegistrySourceCredentialKey(source); - const entry = customSources.get(key); - if (entry !== undefined) { - if (!entry.sourceKeys.has(sourceKey)) { - entry.sources.push(source); - entry.sourceKeys.add(sourceKey); - } - entry.providerIds.push(providerId); - } else { - customSources.set(key, { - sources: [source], - sourceKeys: new Set([sourceKey]), - providerIds: [providerId], - }); - } - } - - for (const { sources, providerIds } of customSources.values()) { - try { - const { entries, source } = await fetchCustomRegistryFromSources(sources); - // Build the whole batch on one clone so that several changed providers - // from the same source do not overwrite each other's aliases, and so the - // config we compare is exactly the config we persist. - const next = structuredClone(config); - const changedProviders: Array<{ - readonly providerId: string; - readonly providerName: string; - readonly added: number; - readonly removed: number; - }> = []; - const providersToRemoveBeforeSet = new Set(); - let hasUnreportedConfigChange = false; - const remoteEntries = Object.values(entries); - const remoteEntriesByProviderId = new Map( - remoteEntries.map((entry) => [entry.id, entry]), - ); - const providerIdsToSync = new Set(providerIds); - for (const entry of remoteEntries) providerIdsToSync.add(entry.id); - - for (const providerId of providerIdsToSync) { - const entry = remoteEntriesByProviderId.get(providerId); - if (entry === undefined) { - const oldIds = collectModelIdsForAliases(config, providerAliasKeys(config, providerId)); - removeCustomRegistryProvider(asManaged(next), providerId); - changedProviders.push({ - providerId, - providerName: providerId, - added: 0, - removed: oldIds.size, - }); - providersToRemoveBeforeSet.add(providerId); - continue; - } - - const existed = config.providers[providerId] !== undefined; - applyCustomRegistryProvider(asManaged(next), entry, source); - const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`); - if (existed) { - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - } - - if ( - existed && - providerModelsEqual(config, next, providerId, refreshedAliasKeys) && - providerConfigEqual(config, next, providerId) - ) { - unchanged.push(providerId); - } else if (existed && providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - providersToRemoveBeforeSet.add(providerId); - hasUnreportedConfigChange = true; - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - changedProviders.push({ - providerId, - providerName: entry.name || providerId, - added, - removed, - }); - if (existed) providersToRemoveBeforeSet.add(providerId); - } - } - - if (changedProviders.length > 0 || hasUnreportedConfigChange) { - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - for (const providerId of providersToRemoveBeforeSet) { - await host.removeProvider(providerId); - } - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - for (const change of changedProviders) { - changed.push({ - providerId: change.providerId, - providerName: change.providerName, - added: change.added, - removed: change.removed, - }); - } - } - } catch (error) { - for (const providerId of providerIds) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - } - - return { changed, unchanged, failed }; + getConfig: () => host.getConfig(), + removeProvider: (providerId) => host.removeProvider(providerId), + setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch), + resolveOAuthToken: (providerName, oauthRef) => + host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef), + }, + options, + ); } diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 83e44541a..8904ef228 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -1065,26 +1065,21 @@ export class DaemonKimiWebApi implements KimiWebApi { return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`); } - async refreshProvider(id: string): Promise { - // PRESUMED endpoint: POST /v1/providers/{id}:refresh → WireProvider - const data = await this.http.post( + async refreshProvider(id: string): Promise { + const data = await this.http.post( `/providers/${encodeURIComponent(id)}:refresh`, ); - return toAppProvider(data); + return toProviderRefreshResult(data); + } + + async refreshAllProviders(): Promise { + const data = await this.http.post('/providers:refresh'); + return toProviderRefreshResult(data); } async refreshOAuthProviderModels(): Promise { const data = await this.http.post('/providers:refresh_oauth'); - return { - changed: data.changed.map((item) => ({ - providerId: item.provider_id, - providerName: item.provider_name, - added: item.added, - removed: item.removed, - })), - unchanged: data.unchanged, - failed: data.failed, - }; + return toProviderRefreshResult(data); } // ------------------------------------------------------------------------- @@ -1363,3 +1358,16 @@ export class DaemonKimiWebApi implements KimiWebApi { }; } } + +function toProviderRefreshResult(data: WireProviderRefreshResult): ProviderRefreshResult { + return { + changed: data.changed.map((item) => ({ + providerId: item.provider_id, + providerName: item.provider_name, + added: item.added, + removed: item.removed, + })), + unchanged: data.unchanged, + failed: data.failed, + }; +} diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 47d9c4b47..4af57365b 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -590,6 +590,13 @@ export function reduceAppEvent( break; } + // ------------------------------------------------------------------------- + // Provider-model catalog refresh result. The daemon already persisted the + // new catalog; the web picks it up on the next explicit model/provider load + // (model picker, session switch). Advance seq silently. + case 'modelCatalogChanged': + break; + // ------------------------------------------------------------------------- // Agent-scoped side-channel events (e.g. BTW side chat) are consumed by the // web layer, not the session reducer. Advance seq silently. diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index a32697922..4ae0d7596 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -678,6 +678,21 @@ export function toAppEvent(wire: WireEvent): AppEvent { config: toAppConfig(w.payload.config), }; + case 'event.model_catalog.changed': + return { + type: 'modelCatalogChanged', + changed: w.payload.changed.map( + (item: { provider_id: string; provider_name: string; added: number; removed: number }) => ({ + providerId: item.provider_id, + providerName: item.provider_name, + added: item.added, + removed: item.removed, + }), + ), + unchanged: w.payload.unchanged, + failed: w.payload.failed, + }; + default: { // Truly unknown event — record warning return { type: 'unknown', raw: wire }; diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 3433018d7..dd8cf1718 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -757,6 +757,17 @@ type WireEventConfigChanged = WireEventBase<'event.config.changed', { config: WireConfig; }>; +type WireEventModelCatalogChanged = WireEventBase<'event.model_catalog.changed', { + changed: Array<{ + provider_id: string; + provider_name: string; + added: number; + removed: number; + }>; + unchanged: string[]; + failed: Array<{ provider: string; reason: string }>; +}>; + /** Catch-all for unrecognised event frames — keeps lastSeq advancing without warnings */ type WireEventUnknown = { type: string; seq: number; session_id: string; timestamp: string; payload: unknown }; @@ -805,5 +816,6 @@ export type WireEvent = | WireEventTaskCompleted // Config | WireEventConfigChanged + | WireEventModelCatalogChanged // Unknown / future events | WireEventUnknown; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index fe12d91b1..486ddc8ba 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -419,6 +419,12 @@ export type AppEvent = | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } | { type: 'goalUpdated'; sessionId: string; goal: AppGoal | null } | { type: 'configChanged'; changedFields: string[]; config: AppConfig } + | { + type: 'modelCatalogChanged'; + changed: { providerId: string; providerName: string; added: number; removed: number }[]; + unchanged: string[]; + failed: { provider: string; reason: string }[]; + } | { type: 'unknown'; raw: unknown }; // --------------------------------------------------------------------------- @@ -669,7 +675,8 @@ export interface KimiWebApi { listProviders(): Promise; addProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise; deleteProvider(id: string): Promise<{ deleted: true }>; - refreshProvider(id: string): Promise; + refreshProvider(id: string): Promise; + refreshAllProviders(): Promise; refreshOAuthProviderModels(): Promise; // File upload / download diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index f87184877..465a4ee9b 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -295,17 +295,36 @@ export function useModelProviderState( } } - /** Refresh a provider status */ + /** Refresh a single provider's remote model metadata, then reload caches. */ async function refreshProvider(id: string): Promise { try { - const api = getKimiWebApi(); - const updated = await api.refreshProvider(id); - providers.value = providers.value.map((p) => (p.id === id ? updated : p)); + const result = await getKimiWebApi().refreshProvider(id); + for (const failure of result.failed) { + pushOperationFailure('refreshProvider', new Error(failure.reason), { + message: failure.provider, + }); + } + await Promise.all([loadProviders(), loadModels()]); } catch (err) { pushOperationFailure('refreshProvider', err); } } + /** Refresh every refreshable provider's remote model metadata, then reload caches. */ + async function refreshAllProviders(): Promise { + try { + const result = await getKimiWebApi().refreshAllProviders(); + for (const failure of result.failed) { + pushOperationFailure('refreshAllProviders', new Error(failure.reason), { + message: failure.provider, + }); + } + await Promise.all([loadProviders(), loadModels()]); + } catch (err) { + pushOperationFailure('refreshAllProviders', err); + } + } + /** Start managed Kimi OAuth device flow. Returns flow data or null on error. */ async function startOAuthLogin(): Promise<{ flowId: string; @@ -375,6 +394,7 @@ export function useModelProviderState( addProvider, deleteProvider, refreshProvider, + refreshAllProviders, startOAuthLogin, pollOAuthLogin, cancelOAuthLogin, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index e3833cd32..143ba57ea 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -676,6 +676,11 @@ function applyEvent(event: ReturnType, sessionId: string, seq rawState.defaultModel = event.config.defaultModel ?? null; } + if (event.type === 'modelCatalogChanged') { + void modelProvider.loadModels(); + void modelProvider.loadProviders(); + } + if (event.type === 'sessionUsageUpdated' && event.sessionId === rawState.activeSessionId && event.swarmMode !== undefined) { rawState.swarmMode = event.swarmMode; } @@ -2335,6 +2340,7 @@ export function useKimiWebClient() { addProvider: modelProvider.addProvider, deleteProvider: modelProvider.deleteProvider, refreshProvider: modelProvider.refreshProvider, + refreshAllProviders: modelProvider.refreshAllProviders, // Auth state authReady, diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 96552eb48..071d881f6 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -109,6 +109,15 @@ export const BackgroundConfigSchema = z.object({ export type BackgroundConfig = z.infer; +export const ModelCatalogConfigSchema = z.object({ + /** Interval (ms) between automatic provider-model refreshes. `0` disables. */ + refreshIntervalMs: z.number().int().min(0).optional(), + /** Refresh once shortly after the daemon starts. */ + refreshOnStart: z.boolean().optional(), +}); + +export type ModelCatalogConfig = z.infer; + export const ExperimentalConfigSchema = z.record(z.string(), z.boolean()); export type ExperimentalConfig = z.infer; @@ -223,6 +232,7 @@ export const KimiConfigSchema = z.object({ extraSkillDirs: z.array(z.string()).optional(), loopControl: LoopControlSchema.optional(), background: BackgroundConfigSchema.optional(), + modelCatalog: ModelCatalogConfigSchema.optional(), experimental: ExperimentalConfigSchema.optional(), telemetry: z.boolean().optional(), raw: z.record(z.string(), z.unknown()).optional(), @@ -236,6 +246,7 @@ const ThinkingConfigPatchSchema = ThinkingConfigSchema.partial(); const PermissionConfigPatchSchema = PermissionConfigSchema.partial(); const LoopControlPatchSchema = LoopControlSchema.partial(); const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial(); +const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial(); const ExperimentalConfigPatchSchema = ExperimentalConfigSchema; const MoonshotServiceConfigPatchSchema = MoonshotServiceConfigSchema.partial(); const ServicesConfigPatchSchema = z.object({ @@ -262,6 +273,7 @@ export const KimiConfigPatchSchema = z extraSkillDirs: z.array(z.string()).optional(), loopControl: LoopControlPatchSchema.optional(), background: BackgroundConfigPatchSchema.optional(), + modelCatalog: ModelCatalogConfigPatchSchema.optional(), experimental: ExperimentalConfigPatchSchema.optional(), telemetry: z.boolean().optional(), }) diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts index b17387297..dce51512b 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts @@ -4,9 +4,18 @@ import type { ModelCatalogItem, ProviderCatalogItem, RefreshOAuthProviderModelsResponse, + RefreshProviderModelsResponse, SetDefaultModelResponse, } from '@moonshot-ai/protocol'; +export type RefreshProviderModelsScope = 'all' | 'oauth'; + +export interface RefreshProviderModelsOptions { + readonly scope?: RefreshProviderModelsScope; + /** Refresh only this provider id. When set, `scope` is ignored. */ + readonly providerId?: string; +} + export interface IModelCatalogService { readonly _serviceBrand: undefined; @@ -15,6 +24,9 @@ export interface IModelCatalogService { getProvider(providerId: string): Promise; setDefaultModel(modelId: string): Promise; refreshOAuthProviderModels(): Promise; + refreshProviderModels( + options?: RefreshProviderModelsOptions, + ): Promise; } // eslint-disable-next-line @typescript-eslint/no-redeclare diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index b6d443fbc..6c5c4b434 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -1,29 +1,30 @@ import { Disposable, InstantiationType, registerSingleton } from '../../di'; -import type { KimiConfig, ModelAlias, ProviderConfig } from '../../config'; +import type { KimiConfig, ProviderConfig } from '../../config'; import type { ModelCatalogItem, ProviderCatalogItem, RefreshOAuthProviderModelsResponse, + RefreshProviderModelsResponse, SetDefaultModelResponse, } from '@moonshot-ai/protocol'; import { - KIMI_CODE_PLATFORM_ID, - KIMI_CODE_PROVIDER_NAME, - applyManagedKimiCodeConfig, - fetchManagedKimiCodeModels, - resolveKimiCodeRuntimeAuth, - type ManagedKimiConfigShape, + refreshProviderModels, + type ManagedKimiOAuthRef, + type RefreshProviderHost, + type RefreshResult, } from '@moonshot-ai/kimi-code-oauth'; import { createManagedAuthFacade, type ServicesAuthFacade } from '../auth/managedAuth'; import { ICoreProcessService } from '../coreProcess/coreProcess'; import { IEnvironmentService } from '../environment/environment'; +import { IEventService } from '../event/event'; import { IModelCatalogService, ModelNotFoundError, ProviderNotFoundError, toProtocolModel, toProtocolProvider, + type RefreshProviderModelsOptions, } from './modelCatalog'; export class ModelCatalogService @@ -33,9 +34,14 @@ export class ModelCatalogService private _authFacade: ServicesAuthFacade; + /** Serializes refresh runs so a scheduled refresh and a manual one (or two + * manual ones with different options) never race on writing config.toml. */ + private _refreshChain: Promise = Promise.resolve(); + constructor( @IEnvironmentService env: IEnvironmentService, @ICoreProcessService private readonly core: ICoreProcessService, + @IEventService private readonly eventService: IEventService, ) { super(); this._authFacade = createManagedAuthFacade(env); @@ -45,8 +51,9 @@ export class ModelCatalogService env: IEnvironmentService, core: ICoreProcessService, authFacade: ServicesAuthFacade, + eventService: IEventService = noopEventService, ): ModelCatalogService { - const service = new ModelCatalogService(env, core); + const service = new ModelCatalogService(env, core, eventService); service._authFacade = authFacade; return service; } @@ -92,85 +99,69 @@ export class ModelCatalogService } async refreshOAuthProviderModels(): Promise { - let config = await this._readConfig(); - const changed: RefreshOAuthProviderModelsResponse['changed'] = []; - const unchanged: string[] = []; - const failed: RefreshOAuthProviderModelsResponse['failed'] = []; - const provider = config.providers?.[KIMI_CODE_PROVIDER_NAME]; - if (provider?.type !== 'kimi' || provider.oauth === undefined) { - return { changed, unchanged, failed }; + return this.refreshProviderModels({ scope: 'oauth' }); + } + + refreshProviderModels( + options: RefreshProviderModelsOptions = {}, + ): Promise { + const run = this._refreshChain.then(() => this._doRefreshProviderModels(options)); + this._refreshChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async _doRefreshProviderModels( + options: RefreshProviderModelsOptions, + ): Promise { + if (options.providerId !== undefined) { + const config = await this._readConfig(); + if (config.providers?.[options.providerId] === undefined) { + throw new ProviderNotFoundError(options.providerId); + } } - try { - const auth = resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: provider.baseUrl, - configuredOAuthRef: provider.oauth, - }); - const tokenProvider = this._authFacade.resolveOAuthTokenProvider( - KIMI_CODE_PROVIDER_NAME, - auth.oauthRef, - ); - if (tokenProvider === undefined) { - throw new Error('OAuth token provider is not configured.'); - } - const token = await tokenProvider.getAccessToken(); - const models = await fetchManagedKimiCodeModels({ - accessToken: token, - baseUrl: auth.baseUrl, - headers: this.core.kimiRequestHeaders, - }); - if (models.length === 0) return { changed, unchanged, failed }; + const result = await refreshProviderModels(this._buildRefreshHost(), { + scope: options.scope, + providerId: options.providerId, + }); + const response = mapRefreshResult(result); - const next = structuredClone(config); - applyManagedKimiCodeConfig(next as unknown as ManagedKimiConfigShape, { - models, - baseUrl: auth.baseUrl, - oauthKey: auth.oauthRef.key, - oauthHost: auth.oauthRef.oauthHost, - preserveDefaultModel: true, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - KIMI_CODE_PROVIDER_NAME, - `${KIMI_CODE_PLATFORM_ID}/`, - ); - restoreProviderAliases( - next, - preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), - ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - - if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(KIMI_CODE_PROVIDER_NAME); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await this.core.rpc.removeKimiProvider({ providerId: KIMI_CODE_PROVIDER_NAME }); - await this.core.rpc.setKimiConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - provider_id: KIMI_CODE_PROVIDER_NAME, - provider_name: 'Kimi Code', - added, - removed, - }); - } - } catch (err) { - failed.push({ - provider: KIMI_CODE_PROVIDER_NAME, - reason: err instanceof Error ? err.message : String(err), + if (response.changed.length > 0) { + this.eventService.publish({ + type: 'event.model_catalog.changed', + agentId: 'main', + sessionId: '__global__', + changed: response.changed, + unchanged: response.unchanged, + failed: response.failed, }); } - return { changed, unchanged, failed }; + return response; + } + + private _buildRefreshHost(): RefreshProviderHost { + return { + getConfig: () => this._readConfig(), + removeProvider: (providerId) => this.core.rpc.removeKimiProvider({ providerId }), + setConfig: (patch) => this.core.rpc.setKimiConfig(patch as Record), + resolveOAuthToken: (providerName, oauthRef) => + this._resolveOAuthToken(providerName, oauthRef), + }; + } + + private async _resolveOAuthToken( + providerName: string, + oauthRef?: ManagedKimiOAuthRef, + ): Promise { + const tokenProvider = this._authFacade.resolveOAuthTokenProvider(providerName, oauthRef); + if (tokenProvider === undefined) { + throw new Error('OAuth token provider is not configured.'); + } + return tokenProvider.getAccessToken(); } private async _readConfig(): Promise { @@ -207,6 +198,22 @@ export class ModelCatalogService } } +function mapRefreshResult(result: RefreshResult): RefreshProviderModelsResponse { + return { + changed: result.changed.map((change) => ({ + provider_id: change.providerId, + provider_name: change.providerName, + added: change.added, + removed: change.removed, + })), + unchanged: [...result.unchanged], + failed: result.failed.map((failure) => ({ + provider: failure.provider, + reason: failure.reason, + })), + }; +} + function hasConfiguredApiKey(provider: ProviderConfig): boolean { if (nonEmpty(provider.apiKey) !== undefined) return true; switch (provider.type) { @@ -228,134 +235,16 @@ function hasConfiguredApiKey(provider: ProviderConfig): boolean { return false; } -function collectModelIdsForAliases(config: KimiConfig, aliasKeys: ReadonlySet): Set { - const ids = new Set(); - for (const aliasKey of aliasKeys) { - const alias = config.models?.[aliasKey]; - if (alias !== undefined && alias.model.length > 0) ids.add(alias.model); - } - return ids; -} - -function providerAliasKeys(config: KimiConfig, providerId: string): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId) keys.add(alias); - } - return keys; -} - -function generatedProviderAliasKeys( - config: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId && alias.startsWith(aliasPrefix)) keys.add(alias); - } - return keys; -} - -function computeChanges(oldIds: Set, newIds: Set): { added: number; removed: number } { - let added = 0; - for (const id of newIds) { - if (!oldIds.has(id)) added++; - } - let removed = 0; - for (const id of oldIds) { - if (!newIds.has(id)) removed++; - } - return { added, removed }; -} - -function providerModelsEqual( - config: KimiConfig, - nextConfig: KimiConfig, - providerId: string, - aliasKeys: ReadonlySet, -): boolean { - return ( - providerModelSnapshot(config, providerId, aliasKeys) === - providerModelSnapshot(nextConfig, providerId, aliasKeys) - ); -} - -function providerModelSnapshot( - config: KimiConfig, - providerId: string, - aliasKeys: ReadonlySet, -): string { - const snapshots: Array<{ alias: string; model: ModelAlias }> = []; - for (const alias of aliasKeys) { - const model = config.models?.[alias]; - if (model === undefined || model.provider !== providerId) continue; - snapshots.push({ - alias, - model: { - ...model, - capabilities: model.capabilities === undefined ? undefined : model.capabilities.toSorted(), - }, - }); - } - snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); - return JSON.stringify(snapshots); -} - -function providerRefreshAliasKeys( - config: KimiConfig, - nextConfig: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); - for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); - return keys; -} - -function preserveUserProviderAliases( - config: KimiConfig, - providerId: string, - refreshedAliasKeys: ReadonlySet, -): Record { - const preserved: Record = {}; - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue; - preserved[alias] = structuredClone(model); - } - return preserved; -} - -function restoreProviderAliases(config: KimiConfig, aliases: Record): void { - if (Object.keys(aliases).length === 0) return; - config.models = { - ...config.models, - ...aliases, - }; -} - -function restoreDefaultSelection( - config: KimiConfig, - defaultModel: string | undefined, - defaultThinking: boolean | undefined, -): void { - if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; - config.defaultModel = defaultModel; - const capabilities = config.models[defaultModel]?.capabilities ?? []; - config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; -} - -function clampDanglingDefault(config: KimiConfig): void { - if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { - config.defaultModel = undefined; - config.defaultThinking = undefined; - } -} - function nonEmpty(value: string | undefined): string | undefined { if (value === undefined) return undefined; const trimmed = value.trim(); return trimmed.length === 0 ? undefined : trimmed; } +const noopEventService: IEventService = { + _serviceBrand: undefined, + onDidPublish: () => ({ dispose: () => undefined }), + publish: () => undefined, +}; + registerSingleton(IModelCatalogService, ModelCatalogService, InstantiationType.Delayed); diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index 3abd27c2c..da227b4ec 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -19,6 +19,8 @@ import { toProtocolProvider, } from '../../src/services'; import type { ServicesAuthFacade } from '../../src/services/auth/managedAuth'; +import type { IEventService } from '../../src/services/event/event'; +import type { Event as ProtocolEvent } from '@moonshot-ai/protocol'; afterEach(() => { vi.unstubAllGlobals(); @@ -101,6 +103,18 @@ function authFacade(accessToken = 'token-test'): ServicesAuthFacade { }; } +function makeEventService(): { svc: IEventService; published: ProtocolEvent[] } { + const published: ProtocolEvent[] = []; + const svc: IEventService = { + _serviceBrand: undefined, + onDidPublish: () => ({ dispose: () => undefined }), + publish: (event) => { + published.push(event); + }, + }; + return { svc, published }; +} + function catalogConfig(): KimiConfig { return { providers: { @@ -174,7 +188,7 @@ describe('ModelCatalogService', () => { it('lists models and providers from live config', async () => { const configRef = { current: catalogConfig() }; const { core, getCalls } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(makeEnv(), core, makeEventService().svc); expect(await svc.listModels()).toHaveLength(3); expect(await svc.listProviders()).toHaveLength(2); @@ -184,7 +198,7 @@ describe('ModelCatalogService', () => { it('gets one provider or throws ProviderNotFoundError', async () => { const configRef = { current: catalogConfig() }; const { core } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(makeEnv(), core, makeEventService().svc); await expect(svc.getProvider('kimi')).resolves.toMatchObject({ id: 'kimi' }); await expect(svc.getProvider('missing')).rejects.toBeInstanceOf( @@ -195,7 +209,7 @@ describe('ModelCatalogService', () => { it('sets defaultModel through core config patch', async () => { const configRef = { current: catalogConfig() }; const { core, setCalls } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(makeEnv(), core, makeEventService().svc); await expect(svc.setDefaultModel('turbo')).resolves.toEqual({ default_model: 'turbo', @@ -212,7 +226,7 @@ describe('ModelCatalogService', () => { it('rejects unknown model ids', async () => { const configRef = { current: catalogConfig() }; const { core } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(makeEnv(), core, makeEventService().svc); await expect(svc.setDefaultModel('missing')).rejects.toBeInstanceOf( ModelNotFoundError, @@ -328,4 +342,101 @@ describe('ModelCatalogService', () => { expect(models['kimi-code/kimi-for-coding']?.provider).toBe(KIMI_CODE_PROVIDER_NAME); expect(models['kimi-code/kimi-for-coding']?.protocol).toBe('anthropic'); }); + + it('publishes event.model_catalog.changed when a broad refresh changes the catalog', async () => { + const configRef: { current: KimiConfig } = { + current: { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + apiKey: '', + baseUrl: 'https://api.example.test/coding/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 131_072, + capabilities: ['thinking'], + }, + }, + }, + }; + const { core } = makeCore(configRef); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + data: [{ id: 'kimi-for-coding', context_length: 262_144, supports_reasoning: true }], + }), + ), + ), + ); + const { svc: eventService, published } = makeEventService(); + const svc = ModelCatalogService._createForTest(makeEnv(), core, authFacade(), eventService); + + const result = await svc.refreshProviderModels(); + + expect(result.changed).toEqual([ + { provider_id: KIMI_CODE_PROVIDER_NAME, provider_name: 'Kimi Code', added: 0, removed: 0 }, + ]); + expect(published).toEqual([ + { + type: 'event.model_catalog.changed', + agentId: 'main', + sessionId: '__global__', + changed: result.changed, + unchanged: result.unchanged, + failed: [], + }, + ]); + }); + + it('does not publish an event when the refresh is a no-op', async () => { + const configRef: { current: KimiConfig } = { + current: { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + apiKey: '', + baseUrl: 'https://api.example.test/coding/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262_144, + capabilities: ['thinking', 'tool_use'], + }, + }, + }, + }; + const { core } = makeCore(configRef); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + data: [{ id: 'kimi-for-coding', context_length: 262_144, supports_reasoning: true }], + }), + ), + ), + ); + const { svc: eventService, published } = makeEventService(); + const svc = ModelCatalogService._createForTest(makeEnv(), core, authFacade(), eventService); + + const result = await svc.refreshProviderModels(); + + expect(result.changed).toEqual([]); + expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]); + expect(published).toEqual([]); + }); }); diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index cedd15c28..e57bda388 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -62,6 +62,7 @@ describe('Event public types', () => { case 'event.workspace.updated': case 'event.workspace.deleted': case 'event.config.changed': + case 'event.model_catalog.changed': case 'goal.updated': case 'skill.activated': case 'error': diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 79ffbeecb..3c45ba960 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -159,3 +159,12 @@ export type { KimiOAuthTokenRef, KimiOAuthToolkitOptions, } from './toolkit'; + +export { refreshProviderModels } from './refreshProviderModels'; +export type { + ProviderChange, + RefreshProviderHost, + RefreshProviderOptions, + RefreshProviderScope, + RefreshResult, +} from './refreshProviderModels'; diff --git a/packages/oauth/src/refreshProviderModels.ts b/packages/oauth/src/refreshProviderModels.ts new file mode 100644 index 000000000..2a1043925 --- /dev/null +++ b/packages/oauth/src/refreshProviderModels.ts @@ -0,0 +1,649 @@ +import { + applyCustomRegistryProvider, + fetchCustomRegistry, + removeCustomRegistryProvider, + type CustomRegistrySource, +} from './custom-registry'; +import { + applyManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, + resolveKimiCodeRuntimeAuth, + type ManagedKimiConfigShape, + type ManagedKimiModelAlias, + type ManagedKimiOAuthRef, +} from './managed-kimi-code'; +import { + applyOpenPlatformConfig, + fetchOpenPlatformModels, + filterModelsByPrefix, + getOpenPlatformById, + isOpenPlatformId, +} from './open-platform'; + +/** + * Host capabilities the refresh orchestrator needs. Intentionally typed against + * {@link ManagedKimiConfigShape} (the oauth package's own minimal config shape) + * rather than the SDK's full `KimiConfig`, so this module has no dependency on + * `agent-core` / the SDK and can be reused by both the CLI and the daemon. + */ +export interface RefreshProviderHost { + getConfig(): Promise; + removeProvider(providerId: string): Promise; + setConfig(patch: ManagedKimiConfigShape): Promise; + resolveOAuthToken(providerName: string, oauthRef?: ManagedKimiOAuthRef): Promise; +} + +export interface ProviderChange { + readonly providerId: string; + /** User-facing name when available. */ + readonly providerName: string; + readonly added: number; + readonly removed: number; +} + +export interface RefreshResult { + /** Providers whose model list actually changed. */ + readonly changed: readonly ProviderChange[]; + /** Providers whose model list stayed identical after refresh. */ + readonly unchanged: readonly string[]; + readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>; +} + +export type RefreshProviderScope = 'all' | 'oauth'; + +export interface RefreshProviderOptions { + readonly scope?: RefreshProviderScope; + /** + * Refresh only this provider. When set, managed / open-platform branches + * skip every other provider; for a custom-registry provider the registry + * group it belongs to is fetched but only the target entry is applied. + */ + readonly providerId?: string; +} + +interface ProviderView { + readonly type?: string; + readonly baseUrl?: string; + readonly apiKey?: string; + readonly oauth?: ManagedKimiOAuthRef; + readonly source?: unknown; +} + +function readProvider( + config: ManagedKimiConfigShape, + providerId: string, +): ProviderView | undefined { + const provider = config.providers[providerId]; + if (provider === undefined) return undefined; + return provider as ProviderView; +} + +function readModel( + config: ManagedKimiConfigShape, + alias: string, +): ManagedKimiModelAlias | undefined { + const model = config.models?.[alias]; + if (model === undefined) return undefined; + return model as ManagedKimiModelAlias; +} + +function readCustomRegistrySource(provider: ProviderView): CustomRegistrySource | undefined { + const source = provider.source; + if (typeof source !== 'object' || source === null) return undefined; + const candidate = source as Record; + if (candidate['kind'] !== 'apiJson') return undefined; + const url = candidate['url']; + const apiKey = candidate['apiKey']; + if (typeof url !== 'string' || url.length === 0) return undefined; + if (typeof apiKey !== 'string') return undefined; + return { kind: 'apiJson', url, apiKey }; +} + +function customRegistrySourceKey(source: CustomRegistrySource): string { + return JSON.stringify([source.url]); +} + +function customRegistrySourceCredentialKey(source: CustomRegistrySource): string { + return JSON.stringify([source.url, source.apiKey]); +} + +async function fetchCustomRegistryFromSources( + sources: readonly CustomRegistrySource[], +): Promise<{ + readonly entries: Awaited>; + readonly source: CustomRegistrySource; +}> { + let lastError: unknown; + for (const source of sources) { + try { + return { + entries: await fetchCustomRegistry(source), + source, + }; + } catch (error) { + lastError = error; + } + } + if (lastError instanceof Error) throw lastError; + if (typeof lastError === 'string') throw new Error(lastError); + throw new Error('No custom registry sources configured.'); +} + +function collectModelIdsForAliases( + config: ManagedKimiConfigShape, + aliasKeys: ReadonlySet, +): Set { + const ids = new Set(); + for (const aliasKey of aliasKeys) { + const alias = readModel(config, aliasKey); + if (alias !== undefined && alias.model.length > 0) { + ids.add(alias.model); + } + } + return ids; +} + +function providerAliasKeys(config: ManagedKimiConfigShape, providerId: string): Set { + const keys = new Set(); + for (const [alias, raw] of Object.entries(config.models ?? {})) { + if ((raw as ManagedKimiModelAlias).provider === providerId) keys.add(alias); + } + return keys; +} + +function generatedProviderAliasKeys( + config: ManagedKimiConfigShape, + providerId: string, + aliasPrefix: string, +): Set { + const keys = new Set(); + for (const [alias, raw] of Object.entries(config.models ?? {})) { + const model = raw as ManagedKimiModelAlias; + if (model.provider === providerId && alias.startsWith(aliasPrefix)) { + keys.add(alias); + } + } + return keys; +} + +function computeChanges(oldIds: Set, newIds: Set): { added: number; removed: number } { + let added = 0; + for (const id of newIds) { + if (!oldIds.has(id)) added++; + } + let removed = 0; + for (const id of oldIds) { + if (!newIds.has(id)) removed++; + } + return { added, removed }; +} + +interface ProviderModelSnapshot { + readonly alias: string; + readonly model: ManagedKimiModelAlias; +} + +// Compare the full model metadata for the relevant aliases, not just model IDs: +// a registry can change capabilities (e.g. enabling reasoning) without changing +// any model ID. Spreading the whole alias keeps this in sync with the schema +// automatically; only `capabilities` needs normalizing because its order is not +// meaningful. +function providerModelSnapshot( + config: ManagedKimiConfigShape, + providerId: string, + aliasKeys: ReadonlySet, +): string { + const snapshots: ProviderModelSnapshot[] = []; + for (const alias of aliasKeys) { + const model = readModel(config, alias); + if (model === undefined || model.provider !== providerId) continue; + snapshots.push({ + alias, + model: { + ...model, + capabilities: model.capabilities === undefined ? undefined : model.capabilities.toSorted(), + }, + }); + } + snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); + return JSON.stringify(snapshots); +} + +function providerModelsEqual( + config: ManagedKimiConfigShape, + nextConfig: ManagedKimiConfigShape, + providerId: string, + aliasKeys: ReadonlySet, +): boolean { + return ( + providerModelSnapshot(config, providerId, aliasKeys) === + providerModelSnapshot(nextConfig, providerId, aliasKeys) + ); +} + +function providerConfigSnapshot(config: ManagedKimiConfigShape, providerId: string): string { + return JSON.stringify(config.providers[providerId] ?? null); +} + +function providerConfigEqual( + config: ManagedKimiConfigShape, + nextConfig: ManagedKimiConfigShape, + providerId: string, +): boolean { + return providerConfigSnapshot(config, providerId) === providerConfigSnapshot(nextConfig, providerId); +} + +function providerRefreshAliasKeys( + config: ManagedKimiConfigShape, + nextConfig: ManagedKimiConfigShape, + providerId: string, + aliasPrefix: string, +): Set { + const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); + for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); + return keys; +} + +function preserveUserProviderAliases( + config: ManagedKimiConfigShape, + providerId: string, + refreshedAliasKeys: ReadonlySet, +): Record { + const preserved: Record = {}; + for (const [alias, raw] of Object.entries(config.models ?? {})) { + const model = raw as ManagedKimiModelAlias; + if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue; + preserved[alias] = structuredClone(model); + } + return preserved; +} + +function restoreProviderAliases( + config: ManagedKimiConfigShape, + aliases: Record, +): void { + if (Object.keys(aliases).length === 0) return; + config.models = { + ...config.models, + ...aliases, + }; +} + +function restoreDefaultSelection( + config: ManagedKimiConfigShape, + defaultModel: string | undefined, + defaultThinking: boolean | undefined, +): void { + if (defaultModel === undefined || readModel(config, defaultModel) === undefined) return; + config.defaultModel = defaultModel; + // A refresh may have just learned that the default model cannot disable + // thinking — never restore a stale thinking-off selection onto it. + const capabilities = readModel(config, defaultModel)?.capabilities ?? []; + config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; +} + +// `apply*` may leave `defaultModel` pointing at an alias that no longer exists +// (e.g. the previously-selected model was dropped from the registry). The host's +// `setConfig` deep-merge cannot clear a key, so the matching `removeProvider` +// call handles disk cleanup while this drops the dangling reference in memory. +function clampDanglingDefault(config: ManagedKimiConfigShape): void { + if (config.defaultModel !== undefined && readModel(config, config.defaultModel) === undefined) { + config.defaultModel = undefined; + config.defaultThinking = undefined; + } +} + +function clearDefaultThinkingWhenDefaultRemoved( + config: ManagedKimiConfigShape, + previousDefaultModel: string | undefined, +): void { + if (previousDefaultModel !== undefined && config.defaultModel === undefined) { + config.defaultThinking = undefined; + } +} + +function pickDefaultModel( + config: ManagedKimiConfigShape, + providerId: string, + models: Array<{ id: string }>, +): string { + const firstModel = models[0]; + if (firstModel === undefined) return ''; + + const existingDefault = config.defaultModel; + if (existingDefault !== undefined) { + const alias = readModel(config, existingDefault); + if (alias !== undefined && alias.provider === providerId) { + const stillAvailable = models.find((m) => m.id === alias.model); + if (stillAvailable !== undefined) { + return stillAvailable.id; + } + } + } + return firstModel.id; +} + +/** + * Refresh remote model metadata for the configured providers and persist any + * changes through the host. Handles three 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. + * 3. Custom registries (models.dev-style, keyed by `provider.source`). + * + * Each branch diffs old vs new and only writes when something actually changed + * (`removeProvider` then `setConfig`). Failures are collected per-provider and + * never abort the whole refresh. Pass `providerId` to scope the refresh to a + * single provider; pass `scope: 'oauth'` to refresh only the managed provider. + */ +export async function refreshProviderModels( + host: RefreshProviderHost, + options: RefreshProviderOptions = {}, +): Promise { + const changed: ProviderChange[] = []; + const unchanged: string[] = []; + const failed: Array<{ provider: string; reason: string }> = []; + const scope = options.scope ?? 'all'; + const targetId = options.providerId; + + let config = await host.getConfig(); + + // --------------------------------------------------------------------------- + // 1. Managed Kimi Code (OAuth) + // --------------------------------------------------------------------------- + const managedProvider = readProvider(config, KIMI_CODE_PROVIDER_NAME); + const managedWanted = targetId === undefined || targetId === KIMI_CODE_PROVIDER_NAME; + if ( + managedWanted && + managedProvider !== undefined && + managedProvider.type === 'kimi' && + managedProvider.oauth !== undefined + ) { + try { + const auth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: managedProvider.baseUrl, + configuredOAuthRef: managedProvider.oauth, + }); + const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); + const models = await fetchManagedKimiCodeModels({ + accessToken, + baseUrl: auth.baseUrl, + }); + if (models.length > 0) { + const next = structuredClone(config); + applyManagedKimiCodeConfig(next, { + models, + baseUrl: auth.baseUrl, + oauthKey: auth.oauthRef.key, + oauthHost: auth.oauthRef.oauthHost, + preserveDefaultModel: true, + }); + const refreshedAliasKeys = providerRefreshAliasKeys( + config, + next, + KIMI_CODE_PROVIDER_NAME, + `${KIMI_CODE_PLATFORM_ID}/`, + ); + restoreProviderAliases( + next, + preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), + ); + restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); + clampDanglingDefault(next); + clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); + + if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + unchanged.push(KIMI_CODE_PROVIDER_NAME); + } else { + const { added, removed } = computeChanges( + collectModelIdsForAliases(config, refreshedAliasKeys), + collectModelIdsForAliases(next, refreshedAliasKeys), + ); + await host.removeProvider(KIMI_CODE_PROVIDER_NAME); + config = await host.setConfig({ + providers: next.providers, + models: next.models, + defaultModel: next.defaultModel, + defaultThinking: next.defaultThinking, + }); + changed.push({ + providerId: KIMI_CODE_PROVIDER_NAME, + providerName: 'Kimi Code', + added, + removed, + }); + } + } + } catch (error) { + failed.push({ + provider: KIMI_CODE_PROVIDER_NAME, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + if (scope === 'oauth' || targetId === KIMI_CODE_PROVIDER_NAME) { + return { changed, unchanged, failed }; + } + + // --------------------------------------------------------------------------- + // 2. Open Platforms (moonshot-cn, moonshot-ai, …) + // --------------------------------------------------------------------------- + const openPlatformIds = Object.keys(config.providers).filter((id) => isOpenPlatformId(id)); + for (const providerId of openPlatformIds) { + if (targetId !== undefined && targetId !== providerId) continue; + const platform = getOpenPlatformById(providerId); + if (platform === undefined) continue; + + const providerConfig = readProvider(config, providerId); + if (providerConfig === undefined) continue; + const apiKey = providerConfig.apiKey; + if (typeof apiKey !== 'string' || apiKey.length === 0) continue; + + try { + let models = await fetchOpenPlatformModels(platform, apiKey); + models = filterModelsByPrefix(models, platform); + if (models.length === 0) continue; + + const selectedModelId = pickDefaultModel(config, providerId, models); + const selectedModel = models.find((m) => m.id === selectedModelId); + if (selectedModel === undefined) continue; + const next = structuredClone(config); + applyOpenPlatformConfig(next, { + platform, + models, + selectedModel, + thinking: false, + apiKey, + }); + const refreshedAliasKeys = providerRefreshAliasKeys( + config, + next, + providerId, + `${providerId}/`, + ); + restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); + restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); + 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, + defaultThinking: next.defaultThinking, + }); + changed.push({ + providerId, + providerName: platform.name, + 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) + // --------------------------------------------------------------------------- + const customSources = new Map< + string, + { + readonly sources: CustomRegistrySource[]; + readonly sourceKeys: Set; + readonly providerIds: string[]; + } + >(); + for (const providerId of Object.keys(config.providers)) { + if (providerId === KIMI_CODE_PROVIDER_NAME) continue; + if (isOpenPlatformId(providerId)) continue; + const provider = readProvider(config, providerId); + if (provider === undefined) continue; + const source = readCustomRegistrySource(provider); + if (source === undefined) continue; + const key = customRegistrySourceKey(source); + const sourceKey = customRegistrySourceCredentialKey(source); + const entry = customSources.get(key); + if (entry !== undefined) { + if (!entry.sourceKeys.has(sourceKey)) { + entry.sources.push(source); + entry.sourceKeys.add(sourceKey); + } + entry.providerIds.push(providerId); + } else { + customSources.set(key, { + sources: [source], + sourceKeys: new Set([sourceKey]), + providerIds: [providerId], + }); + } + } + + for (const { sources, providerIds } of customSources.values()) { + // When scoped to a single provider, only refresh the registry group it + // belongs to and only apply the target entry (siblings under the same URL + // are left untouched). + if (targetId !== undefined && !providerIds.includes(targetId)) continue; + try { + const { entries, source } = await fetchCustomRegistryFromSources(sources); + // Build the whole batch on one clone so that several changed providers + // from the same source do not overwrite each other's aliases, and so the + // config we compare is exactly the config we persist. + const next = structuredClone(config); + const changedProviders: Array<{ + readonly providerId: string; + readonly providerName: string; + readonly added: number; + readonly removed: number; + }> = []; + const providersToRemoveBeforeSet = new Set(); + let hasUnreportedConfigChange = false; + const remoteEntries = Object.values(entries); + const remoteEntriesByProviderId = new Map( + remoteEntries.map((entry) => [entry.id, entry]), + ); + const providerIdsToSync = new Set(providerIds); + // Only pull in newly-appeared providers from the registry when running an + // unscoped refresh; a scoped refresh must not add siblings. + if (targetId === undefined) { + for (const entry of remoteEntries) providerIdsToSync.add(entry.id); + } + + for (const providerId of providerIdsToSync) { + if (targetId !== undefined && providerId !== targetId) continue; + const entry = remoteEntriesByProviderId.get(providerId); + if (entry === undefined) { + const oldIds = collectModelIdsForAliases(config, providerAliasKeys(config, providerId)); + removeCustomRegistryProvider(next, providerId); + changedProviders.push({ + providerId, + providerName: providerId, + added: 0, + removed: oldIds.size, + }); + providersToRemoveBeforeSet.add(providerId); + continue; + } + + const existed = config.providers[providerId] !== undefined; + applyCustomRegistryProvider(next, entry, source); + const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`); + if (existed) { + restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); + } + + if ( + existed && + providerModelsEqual(config, next, providerId, refreshedAliasKeys) && + providerConfigEqual(config, next, providerId) + ) { + unchanged.push(providerId); + } else if (existed && providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { + unchanged.push(providerId); + providersToRemoveBeforeSet.add(providerId); + hasUnreportedConfigChange = true; + } else { + const { added, removed } = computeChanges( + collectModelIdsForAliases(config, refreshedAliasKeys), + collectModelIdsForAliases(next, refreshedAliasKeys), + ); + changedProviders.push({ + providerId, + providerName: entry.name || providerId, + added, + removed, + }); + if (existed) providersToRemoveBeforeSet.add(providerId); + } + } + + if (changedProviders.length > 0 || hasUnreportedConfigChange) { + restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); + clampDanglingDefault(next); + clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); + for (const providerId of providersToRemoveBeforeSet) { + await host.removeProvider(providerId); + } + config = await host.setConfig({ + providers: next.providers, + models: next.models, + defaultModel: next.defaultModel, + defaultThinking: next.defaultThinking, + }); + for (const change of changedProviders) { + changed.push({ + providerId: change.providerId, + providerName: change.providerName, + added: change.added, + removed: change.removed, + }); + } + } + } catch (error) { + const reportedIds = targetId !== undefined ? [targetId] : providerIds; + for (const providerId of reportedIds) { + failed.push({ + provider: providerId, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + } + + return { changed, unchanged, failed }; +} diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index d0e22e0d6..7bd9be73d 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -5,6 +5,12 @@ import { messageContentSchema, type MessageContent } from './message'; import { sessionSchema, sessionStatusSchema, type Session, type SessionStatus } from './session'; import { isoDateTimeSchema } from './time'; import { configResponseSchema, type ConfigResponse } from './rest/config'; +import { + providerRefreshChangeSchema, + providerRefreshFailureSchema, + type ProviderRefreshChange, + type ProviderRefreshFailure, +} from './modelCatalog'; import { workspaceSchema, type Workspace } from './workspace'; export interface TokenUsage { @@ -355,6 +361,19 @@ export interface ConfigChangedEvent { readonly config: ConfigResponse; } +/** + * Pushed when the daemon refreshes provider model metadata (manual or + * scheduled) and the effective catalog changed. Carries the per-provider + * diff so clients can both refresh their model/provider caches and surface a + * summary ("3 models added") without re-diffing the whole config. + */ +export interface ModelCatalogChangedEvent { + readonly type: 'event.model_catalog.changed'; + readonly changed: readonly ProviderRefreshChange[]; + readonly unchanged: readonly string[]; + readonly failed: readonly ProviderRefreshFailure[]; +} + export interface GoalUpdatedEvent { readonly type: 'goal.updated'; readonly snapshot: GoalSnapshot | null; @@ -628,6 +647,7 @@ export type AgentEvent = | WorkspaceDeletedEvent | SessionStatusChangedEvent | ConfigChangedEvent + | ModelCatalogChangedEvent | GoalUpdatedEvent | SkillActivatedEvent | TurnStartedEvent @@ -1013,6 +1033,13 @@ export const configChangedEventSchema = z.object({ config: configResponseSchema, }) satisfies z.ZodType; +export const modelCatalogChangedEventSchema = z.object({ + type: z.literal('event.model_catalog.changed'), + changed: z.array(providerRefreshChangeSchema), + unchanged: z.array(z.string().min(1)), + failed: z.array(providerRefreshFailureSchema), +}) satisfies z.ZodType; + export const goalUpdatedEventSchema = z.object({ type: z.literal('goal.updated'), snapshot: goalSnapshotSchema.nullable(), @@ -1279,6 +1306,7 @@ export const agentEventSchema = z.discriminatedUnion('type', [ workspaceUpdatedEventSchema, workspaceDeletedEventSchema, sessionStatusChangedEventSchema, + modelCatalogChangedEventSchema, goalUpdatedEventSchema, skillActivatedEventSchema, turnStartedEventSchema, diff --git a/packages/protocol/src/rest/modelCatalog.ts b/packages/protocol/src/rest/modelCatalog.ts index dd0262c53..278328b75 100644 --- a/packages/protocol/src/rest/modelCatalog.ts +++ b/packages/protocol/src/rest/modelCatalog.ts @@ -34,3 +34,12 @@ export const refreshOAuthProviderModelsResponseSchema = z.object({ export type RefreshOAuthProviderModelsResponse = z.infer< typeof refreshOAuthProviderModelsResponseSchema >; + +export const refreshProviderModelsResponseSchema = z.object({ + changed: z.array(providerRefreshChangeSchema), + unchanged: z.array(z.string().min(1)), + failed: z.array(providerRefreshFailureSchema), +}); +export type RefreshProviderModelsResponse = z.infer< + typeof refreshProviderModelsResponseSchema +>; diff --git a/packages/server/src/routes/modelCatalog.ts b/packages/server/src/routes/modelCatalog.ts index 6d1f84144..74bd07796 100644 --- a/packages/server/src/routes/modelCatalog.ts +++ b/packages/server/src/routes/modelCatalog.ts @@ -5,7 +5,7 @@ import { getProviderResponseSchema, listModelsResponseSchema, listProvidersResponseSchema, - refreshOAuthProviderModelsResponseSchema, + refreshProviderModelsResponseSchema, setDefaultModelResponseSchema, } from '@moonshot-ai/protocol'; import { IModelCatalogService, ModelNotFoundError, ProviderNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core'; @@ -41,6 +41,14 @@ const modelActionTailParamSchema = z.object({ tail: z.string().min(1), }); +const providerActionTailParamSchema = z.object({ + tail: z.string().min(1), +}); + +const providerCollectionActionParamSchema = z.object({ + action: z.string().min(1), +}); + export function registerModelCatalogRoutes( app: ModelCatalogRouteHost, ix: IInstantiationService, @@ -131,26 +139,94 @@ export function registerModelCatalogRoutes( listProvidersRoute.handler as Parameters[2], ); - const refreshOAuthProvidersRoute = defineRoute( + // Collection-level refresh actions share a single `/providers:` route + // because Fastify's router collapses every one-segment `/providers:xxx` path + // onto one parameter slot (so `/providers:refresh` and `/providers:refresh_oauth` + // cannot be registered separately). We dispatch on the captured action instead. + const refreshProvidersRoute = defineRoute( { method: 'POST', - path: '/providers:refresh_oauth', - success: { data: refreshOAuthProviderModelsResponseSchema }, - description: 'Refresh OAuth-backed provider model metadata', + path: '/providers:action', + params: providerCollectionActionParamSchema, + success: { data: refreshProviderModelsResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + }, + description: + 'Refresh provider model metadata. Use `:refresh` for all providers or `:refresh_oauth` for OAuth-backed providers only.', tags: ['providers'], - operationId: 'refreshOAuthProviderModels', + operationId: 'refreshProviders', }, async (req, reply) => { - const result = await ix.invokeFunction((a) => - a.get(IModelCatalogService).refreshOAuthProviderModels(), + const raw = req.params.action; + const action = raw.startsWith(':') ? raw.slice(1) : raw; + if (action === 'refresh_oauth') { + const result = await ix.invokeFunction((a) => + a.get(IModelCatalogService).refreshOAuthProviderModels(), + ); + reply.send(okEnvelope(result, req.id)); + return; + } + if (action === 'refresh') { + const result = await ix.invokeFunction((a) => + a.get(IModelCatalogService).refreshProviderModels({ scope: 'all' }), + ); + reply.send(okEnvelope(result, req.id)); + return; + } + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${raw}`, req.id), ); - reply.send(okEnvelope(result, req.id)); }, ); app.post( - refreshOAuthProvidersRoute.path, - refreshOAuthProvidersRoute.options, - refreshOAuthProvidersRoute.handler as Parameters[2], + refreshProvidersRoute.path, + refreshProvidersRoute.options, + refreshProvidersRoute.handler as Parameters[2], + ); + + const refreshProviderRoute = defineRoute( + { + method: 'POST', + path: '/providers/{tail}', + params: providerActionTailParamSchema, + success: { data: refreshProviderModelsResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PROVIDER_NOT_FOUND]: {}, + }, + description: 'Refresh model metadata for a single provider', + tags: ['providers'], + operationId: 'refreshProvider', + }, + async (req, reply) => { + try { + const { tail } = req.params; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['refresh'] as const, + resourceLabel: 'provider', + }); + if (parsed.kind !== 'action') { + const message = parsed.kind === 'invalid' + ? parsed.reason + : `unsupported action: ${tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + const result = await ix.invokeFunction((a) => + a.get(IModelCatalogService).refreshProviderModels({ providerId: parsed.id }), + ); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + refreshProviderRoute.path, + refreshProviderRoute.options, + refreshProviderRoute.handler as Parameters[2], ); const getProviderRoute = defineRoute( diff --git a/packages/server/src/services/gateway/wsBroadcastService.ts b/packages/server/src/services/gateway/wsBroadcastService.ts index 053fd1f89..d488a35a6 100644 --- a/packages/server/src/services/gateway/wsBroadcastService.ts +++ b/packages/server/src/services/gateway/wsBroadcastService.ts @@ -245,6 +245,9 @@ function isGlobalSessionEvent(type: string): boolean { // when another client creates or renames a session. type === 'session.meta.updated' || type === 'event.config.changed' || + // Provider-model catalog is global (not session-scoped): every connected + // client must learn when a manual or scheduled refresh changes it. + type === 'event.model_catalog.changed' || // Workspace registry is not session-scoped: workspace lifecycle events ride // the '__global__' watermark and fan out to every connection. type === 'event.workspace.created' || diff --git a/packages/server/src/services/modelCatalog/modelCatalogRefreshScheduler.ts b/packages/server/src/services/modelCatalog/modelCatalogRefreshScheduler.ts new file mode 100644 index 000000000..d1b9fab7e --- /dev/null +++ b/packages/server/src/services/modelCatalog/modelCatalogRefreshScheduler.ts @@ -0,0 +1,121 @@ +/** + * Periodic background refresh of provider model metadata for a long-lived + * daemon. The original CLI refreshed `/models` at startup; once the server + * became a daemon that started once and ran for days, the on-disk catalog went + * stale. This scheduler restores "stays fresh" behavior: it refreshes once on + * start and then on a configurable interval, delegating the actual work to + * `IModelCatalogService.refreshProviderModels` (which serializes concurrent + * runs, persists changes, and publishes `event.model_catalog.changed`). + * + * This is a server-local service (only the daemon is long-lived); the CLI uses + * the same underlying refresh orchestrator but triggers it on demand. + */ + +import { + createDecorator, + Disposable, + ICoreProcessService, + ILogService, + IModelCatalogService, +} from '@moonshot-ai/agent-core'; + +const DEFAULT_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours + +const INTERVAL_ENV = 'KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'; +const REFRESH_ON_START_ENV = 'KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'; + +export interface IModelCatalogRefreshScheduler { + readonly _serviceBrand: undefined; + + /** Read config and start the schedule. Idempotent. */ + start(): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IModelCatalogRefreshScheduler = createDecorator( + 'modelCatalogRefreshScheduler', +); + +export class ModelCatalogRefreshScheduler + extends Disposable + implements IModelCatalogRefreshScheduler { + readonly _serviceBrand: undefined; + + private _timer: ReturnType | undefined; + private _started = false; + + constructor( + @IModelCatalogService private readonly modelCatalog: IModelCatalogService, + @ICoreProcessService private readonly core: ICoreProcessService, + @ILogService private readonly logger: ILogService, + ) { + super(); + } + + async start(): Promise { + if (this._started) return; + this._started = true; + + const config = await this.core.rpc.getKimiConfig({ reload: true }); + const intervalMs = resolveIntervalMs(config.modelCatalog?.refreshIntervalMs); + const refreshOnStart = resolveRefreshOnStart(config.modelCatalog?.refreshOnStart); + + if (refreshOnStart) { + // Fire-and-forget so boot is not blocked on a network round-trip. + void this._refresh('startup'); + } + + if (intervalMs > 0) { + this._timer = setInterval(() => void this._refresh('interval'), intervalMs); + // Do not keep the process alive solely because of this timer. + this._timer.unref?.(); + this.logger.info( + { intervalMs }, + 'provider-model catalog auto-refresh enabled', + ); + } + } + + private async _refresh(trigger: 'startup' | 'interval'): Promise { + try { + const result = await this.modelCatalog.refreshProviderModels({ scope: 'all' }); + if (result.failed.length > 0) { + this.logger.warn( + { trigger, failed: result.failed }, + 'provider-model catalog refresh completed with failures', + ); + } + } catch (error) { + this.logger.warn( + { trigger, err: error instanceof Error ? error.message : String(error) }, + 'provider-model catalog refresh failed', + ); + } + } + + override dispose(): void { + if (this._timer !== undefined) { + clearInterval(this._timer); + this._timer = undefined; + } + super.dispose(); + } +} + +function resolveIntervalMs(configValue: number | undefined): number { + const raw = process.env[INTERVAL_ENV]; + if (raw !== undefined && raw.trim().length > 0) { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed >= 0) return parsed; + } + return configValue ?? DEFAULT_REFRESH_INTERVAL_MS; +} + +function resolveRefreshOnStart(configValue: boolean | undefined): boolean { + const raw = process.env[REFRESH_ON_START_ENV]; + if (raw !== undefined && raw.trim().length > 0) { + const normalized = raw.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; + } + return configValue ?? true; +} diff --git a/packages/server/src/services/serviceCollection.ts b/packages/server/src/services/serviceCollection.ts index 7eaf3f2ff..0ffdc8ed9 100644 --- a/packages/server/src/services/serviceCollection.ts +++ b/packages/server/src/services/serviceCollection.ts @@ -22,6 +22,10 @@ import { IWSGateway } from '#/services/gateway/wsGateway'; import { WSGateway } from '#/services/gateway/wsGatewayService'; import { IWSBroadcastService } from '#/services/gateway/wsBroadcast'; import { WSBroadcastService } from '#/services/gateway/wsBroadcastService'; +import { + IModelCatalogRefreshScheduler, + ModelCatalogRefreshScheduler, +} from '#/services/modelCatalog/modelCatalogRefreshScheduler'; import { ISnapshotService, SnapshotService, loadSnapshotConfig } from '#/services/snapshot'; export interface ServerServiceCollectionOptions { @@ -43,6 +47,7 @@ export function createServerServiceCollection( [IConnectionRegistry, new SyncDescriptor(ConnectionRegistry, [], false)], [ISessionClientsService, new SyncDescriptor(SessionClientsService, [], false)], [IWSBroadcastService, new SyncDescriptor(WSBroadcastService, [], false)], + [IModelCatalogRefreshScheduler, new SyncDescriptor(ModelCatalogRefreshScheduler, [], false)], [Services.IApprovalService, new SyncDescriptor(ApprovalService, [], false)], [Services.IQuestionService, new SyncDescriptor(QuestionService, [], false)], ); diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 9d25a6792..dda05f458 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -36,6 +36,7 @@ import { } from '#/services/gateway'; import { createServerServiceCollection } from '#/services/serviceCollection'; import { ISnapshotService, loadSnapshotConfig } from '#/services/snapshot'; +import { IModelCatalogRefreshScheduler } from '#/services/modelCatalog/modelCatalogRefreshScheduler'; import { createAuthTokenService, IAuthTokenService, @@ -411,6 +412,17 @@ export async function startServer(opts: ServerStartOptions): Promise + log.error({ err }, 'failed to start provider-model refresh scheduler'), + ); + const promptService = a.get(IPromptService); const terminalService = a.get(ITerminalService); diff --git a/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap index 397595930..2ad3980b2 100644 --- a/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap +++ b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap @@ -207,7 +207,11 @@ exports[`API surface snapshot > matches the documented v1 route table and meta e ], [ "POST", - "/api/v1/providers{refresh_oauth}", + "/api/v1/providers{action}", + ], + [ + "POST", + "/api/v1/providers/{tail}", ], [ "POST", diff --git a/packages/server/test/model-catalog-refresh-scheduler.test.ts b/packages/server/test/model-catalog-refresh-scheduler.test.ts new file mode 100644 index 000000000..14933cf86 --- /dev/null +++ b/packages/server/test/model-catalog-refresh-scheduler.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ICoreProcessService, ILogService, IModelCatalogService } from '@moonshot-ai/agent-core'; + +import { ModelCatalogRefreshScheduler } from '../src/services/modelCatalog/modelCatalogRefreshScheduler'; + +interface MockCatalog { + refreshProviderModels: ReturnType; +} + +function makeCore(modelCatalog?: { refreshIntervalMs?: number; refreshOnStart?: boolean }): ICoreProcessService { + return { + _serviceBrand: undefined, + rpc: { + getKimiConfig: vi.fn(async () => ({ providers: {}, modelCatalog })), + }, + ready: async () => undefined, + dispose: () => undefined, + } as unknown as ICoreProcessService; +} + +function makeLogger(): ILogService { + const logger = { + _serviceBrand: undefined, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: () => logger, + }; + return logger as unknown as ILogService; +} + +describe('ModelCatalogRefreshScheduler', () => { + let catalog: MockCatalog; + + beforeEach(() => { + vi.useFakeTimers(); + catalog = { + refreshProviderModels: vi.fn(async () => ({ changed: [], unchanged: [], failed: [] })), + }; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it('refreshes on start and then on the configured interval', async () => { + const scheduler = new ModelCatalogRefreshScheduler( + catalog as unknown as IModelCatalogService, + makeCore({ refreshIntervalMs: 60_000, refreshOnStart: true }), + makeLogger(), + ); + + await scheduler.start(); + // The startup refresh is fire-and-forget; wait for its microtask to land. + await vi.waitFor(() => { + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(1); + }); + expect(catalog.refreshProviderModels).toHaveBeenCalledWith({ scope: 'all' }); + + await vi.advanceTimersByTimeAsync(60_000); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(2); + + scheduler.dispose(); + await vi.advanceTimersByTimeAsync(120_000); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(2); + }); + + it('does nothing when interval is 0 and refreshOnStart is false', async () => { + const scheduler = new ModelCatalogRefreshScheduler( + catalog as unknown as IModelCatalogService, + makeCore({ refreshIntervalMs: 0, refreshOnStart: false }), + makeLogger(), + ); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(600_000); + expect(catalog.refreshProviderModels).not.toHaveBeenCalled(); + }); + + it('honors env overrides for interval and refresh-on-start', async () => { + vi.stubEnv('KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS', '1000'); + vi.stubEnv('KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START', '0'); + const scheduler = new ModelCatalogRefreshScheduler( + catalog as unknown as IModelCatalogService, + // Config says 6h + refresh on start, but env should win. + makeCore({ refreshIntervalMs: 6 * 60 * 60 * 1000, refreshOnStart: true }), + makeLogger(), + ); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(999); + expect(catalog.refreshProviderModels).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(1); + }); + + it('swallows refresh errors so a failing tick does not break the schedule', async () => { + catalog.refreshProviderModels.mockRejectedValue(new Error('network down')); + const scheduler = new ModelCatalogRefreshScheduler( + catalog as unknown as IModelCatalogService, + makeCore({ refreshIntervalMs: 1000, refreshOnStart: false }), + makeLogger(), + ); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/server/test/model-catalog.e2e.test.ts b/packages/server/test/model-catalog.e2e.test.ts index 95c89ab3f..38dd7a124 100644 --- a/packages/server/test/model-catalog.e2e.test.ts +++ b/packages/server/test/model-catalog.e2e.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pino } from 'pino'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IModelCatalogService, type IModelCatalogService as ModelCatalogServiceShape } from '@moonshot-ai/agent-core'; @@ -19,6 +19,10 @@ beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-model-catalog-test-')); lockPath = join(tmpDir, 'lock'); bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-model-catalog-home-')); + // Disable the background refresh scheduler so its startup refresh does not + // race the route-level assertions in these tests. + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'] = '0'; + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'] = '0'; }); afterEach(async () => { @@ -28,6 +32,8 @@ afterEach(async () => { // ignore } server = undefined; + delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START']; + delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS']; rmSync(tmpDir, { recursive: true, force: true }); rmSync(bridgeHome, { recursive: true, force: true }); }); @@ -264,6 +270,11 @@ describe('model/provider catalog routes', () => { unchanged: [], failed: [], }), + refreshProviderModels: async () => ({ + changed: [], + unchanged: [], + failed: [], + }), }; const r = await bootDaemon([[IModelCatalogService, stub]]); @@ -289,4 +300,81 @@ describe('model/provider catalog routes', () => { failed: [], }); }); + + function refreshStub(): { + stub: ModelCatalogServiceShape; + refreshProviderModels: ReturnType; + } { + const refreshProviderModels = vi.fn(async () => ({ + changed: [ + { + provider_id: 'managed:kimi-code', + provider_name: 'Kimi Code', + added: 2, + removed: 1, + }, + ], + unchanged: ['moonshot-cn'], + failed: [], + })); + const stub: ModelCatalogServiceShape = { + _serviceBrand: undefined, + listModels: async () => [], + listProviders: async () => [], + getProvider: async () => { + throw new Error('unused'); + }, + setDefaultModel: async () => { + throw new Error('unused'); + }, + refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }), + refreshProviderModels, + }; + return { stub, refreshProviderModels }; + } + + it('refreshes all provider models through POST /providers:refresh', async () => { + const { stub, refreshProviderModels } = refreshStub(); + const r = await bootDaemon([[IModelCatalogService, stub]]); + + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/providers:refresh', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + expect(envelopeOf(res.json()).code).toBe(0); + expect(refreshProviderModels).toHaveBeenCalledWith({ scope: 'all' }); + }); + + it('refreshes a single provider through POST /providers/{id}:refresh', async () => { + const { stub, refreshProviderModels } = refreshStub(); + const r = await bootDaemon([[IModelCatalogService, stub]]); + + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/providers/managed%3Akimi-code:refresh', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + expect(envelopeOf(res.json()).code).toBe(0); + expect(refreshProviderModels).toHaveBeenCalledWith({ + providerId: 'managed:kimi-code', + }); + }); + + it('rejects unsupported provider actions', async () => { + const { stub } = refreshStub(); + const r = await bootDaemon([[IModelCatalogService, stub]]); + + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/providers/foo:bogus', + payload: {}, + }); + + expect(envelopeOf(res.json()).code).toBe(40001); + }); });