mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
636ccc40f1
commit
14d9e98903
26 changed files with 1493 additions and 790 deletions
5
.changeset/auto-refresh-provider-models.md
Normal file
5
.changeset/auto-refresh-provider-models.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -159,3 +159,12 @@ export type {
|
|||
KimiOAuthTokenRef,
|
||||
KimiOAuthToolkitOptions,
|
||||
} from './toolkit';
|
||||
|
||||
export { refreshProviderModels } from './refreshProviderModels';
|
||||
export type {
|
||||
ProviderChange,
|
||||
RefreshProviderHost,
|
||||
RefreshProviderOptions,
|
||||
RefreshProviderScope,
|
||||
RefreshResult,
|
||||
} from './refreshProviderModels';
|
||||
|
|
|
|||
649
packages/oauth/src/refreshProviderModels.ts
Normal file
649
packages/oauth/src/refreshProviderModels.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
116
packages/server/test/model-catalog-refresh-scheduler.test.ts
Normal file
116
packages/server/test/model-catalog-refresh-scheduler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue