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
This commit is contained in:
Haozhe 2026-06-30 12:29:10 +08:00 committed by GitHub
parent 636ccc40f1
commit 14d9e98903
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1493 additions and 790 deletions

View file

@ -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.

View file

@ -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<KimiConfig>;
removeProvider(providerId: string): Promise<KimiConfig>;
@ -24,542 +19,25 @@ export interface RefreshProviderHost {
resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>;
}
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<ReturnType<typeof fetchCustomRegistry>>;
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<string>): Set<string> {
const ids = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string>, newIds: Set<string>): { 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>,
): 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<string>,
): 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<string> {
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<string>,
): Record<string, ModelAlias> {
const preserved: Record<string, ModelAlias> = {};
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<string, ModelAlias>): 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<RefreshResult> {
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<string>;
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<string>();
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,
);
}

View file

@ -1065,26 +1065,21 @@ export class DaemonKimiWebApi implements KimiWebApi {
return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`);
}
async refreshProvider(id: string): Promise<AppProvider> {
// PRESUMED endpoint: POST /v1/providers/{id}:refresh → WireProvider
const data = await this.http.post<WireProvider>(
async refreshProvider(id: string): Promise<ProviderRefreshResult> {
const data = await this.http.post<WireProviderRefreshResult>(
`/providers/${encodeURIComponent(id)}:refresh`,
);
return toAppProvider(data);
return toProviderRefreshResult(data);
}
async refreshAllProviders(): Promise<ProviderRefreshResult> {
const data = await this.http.post<WireProviderRefreshResult>('/providers:refresh');
return toProviderRefreshResult(data);
}
async refreshOAuthProviderModels(): Promise<ProviderRefreshResult> {
const data = await this.http.post<WireProviderRefreshResult>('/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,
};
}

View file

@ -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.

View file

@ -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 };

View file

@ -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;

View file

@ -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<AppProvider[]>;
addProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise<AppProvider>;
deleteProvider(id: string): Promise<{ deleted: true }>;
refreshProvider(id: string): Promise<AppProvider>;
refreshProvider(id: string): Promise<ProviderRefreshResult>;
refreshAllProviders(): Promise<ProviderRefreshResult>;
refreshOAuthProviderModels(): Promise<ProviderRefreshResult>;
// File upload / download

View file

@ -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<void> {
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<void> {
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,

View file

@ -676,6 +676,11 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, 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,

View file

@ -109,6 +109,15 @@ export const BackgroundConfigSchema = z.object({
export type BackgroundConfig = z.infer<typeof BackgroundConfigSchema>;
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<typeof ModelCatalogConfigSchema>;
export const ExperimentalConfigSchema = z.record(z.string(), z.boolean());
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>;
@ -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(),
})

View file

@ -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<ProviderCatalogItem>;
setDefaultModel(modelId: string): Promise<SetDefaultModelResponse>;
refreshOAuthProviderModels(): Promise<RefreshOAuthProviderModelsResponse>;
refreshProviderModels(
options?: RefreshProviderModelsOptions,
): Promise<RefreshProviderModelsResponse>;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare

View file

@ -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<unknown> = 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<RefreshOAuthProviderModelsResponse> {
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<RefreshProviderModelsResponse> {
const run = this._refreshChain.then(() => this._doRefreshProviderModels(options));
this._refreshChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
private async _doRefreshProviderModels(
options: RefreshProviderModelsOptions,
): Promise<RefreshProviderModelsResponse> {
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<string, unknown>),
resolveOAuthToken: (providerName, oauthRef) =>
this._resolveOAuthToken(providerName, oauthRef),
};
}
private async _resolveOAuthToken(
providerName: string,
oauthRef?: ManagedKimiOAuthRef,
): Promise<string> {
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<KimiConfig> {
@ -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<string>): Set<string> {
const ids = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string>, newIds: Set<string>): { 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<string>,
): boolean {
return (
providerModelSnapshot(config, providerId, aliasKeys) ===
providerModelSnapshot(nextConfig, providerId, aliasKeys)
);
}
function providerModelSnapshot(
config: KimiConfig,
providerId: string,
aliasKeys: ReadonlySet<string>,
): 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<string> {
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<string>,
): Record<string, ModelAlias> {
const preserved: Record<string, ModelAlias> = {};
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<string, ModelAlias>): 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);

View file

@ -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([]);
});
});

View file

@ -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':

View file

@ -159,3 +159,12 @@ export type {
KimiOAuthTokenRef,
KimiOAuthToolkitOptions,
} from './toolkit';
export { refreshProviderModels } from './refreshProviderModels';
export type {
ProviderChange,
RefreshProviderHost,
RefreshProviderOptions,
RefreshProviderScope,
RefreshResult,
} from './refreshProviderModels';

View file

@ -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<ManagedKimiConfigShape>;
removeProvider(providerId: string): Promise<ManagedKimiConfigShape>;
setConfig(patch: ManagedKimiConfigShape): Promise<ManagedKimiConfigShape>;
resolveOAuthToken(providerName: string, oauthRef?: ManagedKimiOAuthRef): Promise<string>;
}
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<string, unknown>;
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<ReturnType<typeof fetchCustomRegistry>>;
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<string>,
): Set<string> {
const ids = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string>, newIds: Set<string>): { 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>,
): 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<string>,
): 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<string> {
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<string>,
): Record<string, ManagedKimiModelAlias> {
const preserved: Record<string, ManagedKimiModelAlias> = {};
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<string, ManagedKimiModelAlias>,
): 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<RefreshResult> {
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<string>;
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<string>();
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 };
}

View file

@ -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<ConfigChangedEvent>;
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<ModelCatalogChangedEvent>;
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,

View file

@ -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
>;

View file

@ -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<ModelCatalogRouteHost['get']>[2],
);
const refreshOAuthProvidersRoute = defineRoute(
// Collection-level refresh actions share a single `/providers:<action>` 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<ModelCatalogRouteHost['post']>[2],
refreshProvidersRoute.path,
refreshProvidersRoute.options,
refreshProvidersRoute.handler as Parameters<ModelCatalogRouteHost['post']>[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<ModelCatalogRouteHost['post']>[2],
);
const getProviderRoute = defineRoute(

View file

@ -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' ||

View file

@ -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<void>;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const IModelCatalogRefreshScheduler = createDecorator<IModelCatalogRefreshScheduler>(
'modelCatalogRefreshScheduler',
);
export class ModelCatalogRefreshScheduler
extends Disposable
implements IModelCatalogRefreshScheduler {
readonly _serviceBrand: undefined;
private _timer: ReturnType<typeof setInterval> | undefined;
private _started = false;
constructor(
@IModelCatalogService private readonly modelCatalog: IModelCatalogService,
@ICoreProcessService private readonly core: ICoreProcessService,
@ILogService private readonly logger: ILogService,
) {
super();
}
async start(): Promise<void> {
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<void> {
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;
}

View file

@ -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)],
);

View file

@ -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<RunningServ
a.get(IModelCatalogService);
// Start the background provider-model refresh scheduler (reads config to
// decide interval / refresh-on-start). Must run after IModelCatalogService
// and ICoreProcessService are constructed. Fire-and-forget: the initial
// refresh is async and must not block boot.
const catalogScheduler = a.get(IModelCatalogRefreshScheduler);
catalogScheduler
.start()
.catch((err) =>
log.error({ err }, 'failed to start provider-model refresh scheduler'),
);
const promptService = a.get(IPromptService);
const terminalService = a.get(ITerminalService);

View file

@ -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",

View file

@ -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<typeof vi.fn>;
}
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);
});
});

View file

@ -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<typeof vi.fn>;
} {
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<unknown>(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<unknown>(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<unknown>(res.json()).code).toBe(40001);
});
});