kimi-code/packages/server/test/model-catalog.e2e.test.ts
Haozhe 14d9e98903
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
2026-06-30 12:29:10 +08:00

380 lines
11 KiB
TypeScript

import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IModelCatalogService, type IModelCatalogService as ModelCatalogServiceShape } from '@moonshot-ai/agent-core';
import { IRestGateway, startServer, type RunningServer, type ServerStartOptions } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
let bridgeHome: string;
let server: RunningServer | undefined;
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 () => {
try {
await server?.close();
} catch {
// 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 });
});
async function bootDaemon(
serviceOverrides?: ServerStartOptions['serviceOverrides'],
): Promise<RunningServer> {
server = await startServer({
host: '127.0.0.1',
port: 0,
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])],
});
return server;
}
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {
code: number;
msg: string;
data: T | null;
request_id: string;
} {
return body as {
code: number;
msg: string;
data: T | null;
request_id: string;
};
}
function seedConfig(toml: string): void {
writeFileSync(join(bridgeHome, 'config.toml'), toml, 'utf-8');
}
function seedCatalogConfig(): void {
seedConfig(
[
'default_model = "k2"',
'',
'[providers.kimi]',
'type = "kimi"',
'api_key = "sk-test"',
'base_url = "https://api.example.test/v1"',
'',
'[providers.openai]',
'type = "openai"',
'',
'[models.k2]',
'provider = "kimi"',
'model = "kimi-k2"',
'max_context_size = 131072',
'display_name = "Kimi K2"',
'capabilities = ["thinking"]',
'',
'[models.turbo]',
'provider = "kimi"',
'model = "kimi-turbo"',
'max_context_size = 32768',
'display_name = "Kimi Turbo"',
'',
'[models.gpt4o]',
'provider = "openai"',
'model = "gpt-4o"',
'max_context_size = 128000',
'',
].join('\n'),
);
}
describe('model/provider catalog routes', () => {
it('lists configured models as selectable aliases', async () => {
seedCatalogConfig();
const r = await bootDaemon();
const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/models' });
expect(res.statusCode).toBe(200);
const env = envelopeOf<{ items: unknown[] }>(res.json());
expect(env.code).toBe(0);
expect(env.data?.items).toEqual([
{
provider: 'kimi',
model: 'k2',
display_name: 'Kimi K2',
max_context_size: 131072,
capabilities: ['thinking'],
},
{
provider: 'kimi',
model: 'turbo',
display_name: 'Kimi Turbo',
max_context_size: 32768,
},
{
provider: 'openai',
model: 'gpt4o',
display_name: 'gpt-4o',
max_context_size: 128000,
},
]);
});
it('lists providers and returns a single provider by id', async () => {
seedCatalogConfig();
const r = await bootDaemon();
const list = await appOf(r).inject({ method: 'GET', url: '/api/v1/providers' });
const listEnv = envelopeOf<{ items: unknown[] }>(list.json());
expect(listEnv.code).toBe(0);
expect(listEnv.data?.items).toEqual([
{
id: 'kimi',
type: 'kimi',
base_url: 'https://api.example.test/v1',
default_model: 'k2',
has_api_key: true,
status: 'connected',
models: ['k2', 'turbo'],
},
{
id: 'openai',
type: 'openai',
has_api_key: false,
status: 'unconfigured',
models: ['gpt4o'],
},
]);
const single = await appOf(r).inject({
method: 'GET',
url: '/api/v1/providers/kimi',
});
const singleEnv = envelopeOf<unknown>(single.json());
expect(singleEnv.code).toBe(0);
expect(singleEnv.data).toEqual({
id: 'kimi',
type: 'kimi',
base_url: 'https://api.example.test/v1',
default_model: 'k2',
has_api_key: true,
status: 'connected',
models: ['k2', 'turbo'],
});
});
it('sets the global default model and updates /auth', async () => {
seedCatalogConfig();
const r = await bootDaemon();
const setDefault = await appOf(r).inject({
method: 'POST',
url: '/api/v1/models/turbo:set_default',
payload: {},
});
const setEnv = envelopeOf<unknown>(setDefault.json());
expect(setEnv.code).toBe(0);
expect(setEnv.data).toEqual({
default_model: 'turbo',
model: {
provider: 'kimi',
model: 'turbo',
display_name: 'Kimi Turbo',
max_context_size: 32768,
},
});
const auth = await appOf(r).inject({ method: 'GET', url: '/api/v1/auth' });
const authEnv = envelopeOf<{ default_model: string | null }>(auth.json());
expect(authEnv.code).toBe(0);
expect(authEnv.data?.default_model).toBe('turbo');
});
it('maps unknown provider and model ids to catalog not-found error codes', async () => {
seedCatalogConfig();
const r = await bootDaemon();
const provider = await appOf(r).inject({
method: 'GET',
url: '/api/v1/providers/missing',
});
expect(envelopeOf<unknown>(provider.json()).code).toBe(40412);
const model = await appOf(r).inject({
method: 'POST',
url: '/api/v1/models/missing:set_default',
payload: {},
});
expect(envelopeOf<unknown>(model.json()).code).toBe(40413);
});
it('refreshes OAuth provider models through the catalog route', async () => {
const stub: ModelCatalogServiceShape = {
_serviceBrand: undefined,
listModels: async () => [],
listProviders: async () => [],
getProvider: async () => {
throw new Error('unused');
},
setDefaultModel: async () => {
throw new Error('unused');
},
refreshOAuthProviderModels: async () => ({
changed: [
{
provider_id: 'managed:kimi-code',
provider_name: 'Kimi Code',
added: 1,
removed: 0,
},
],
unchanged: [],
failed: [],
}),
refreshProviderModels: async () => ({
changed: [],
unchanged: [],
failed: [],
}),
};
const r = await bootDaemon([[IModelCatalogService, stub]]);
const res = await appOf(r).inject({
method: 'POST',
url: '/api/v1/providers:refresh_oauth',
payload: {},
});
expect(res.statusCode).toBe(200);
const env = envelopeOf<unknown>(res.json());
expect(env.code).toBe(0);
expect(env.data).toEqual({
changed: [
{
provider_id: 'managed:kimi-code',
provider_name: 'Kimi Code',
added: 1,
removed: 0,
},
],
unchanged: [],
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);
});
});