From 420cefbc02761b81d7d4d085abbee71a86e2f57d Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 29 Jun 2026 20:10:12 +0800 Subject: [PATCH] feat(server-v2): add model and provider catalog endpoints - add Core-scoped IModelCatalogService in agent-core-v2 covering listModels, listProviders, getProvider, setDefaultModel, and refreshOAuthProviderModels - expose the v1-compatible /api/v1 models and providers catalog routes in server-v2, mapping provider.not_found / model.not_found to the numeric protocol error codes - add IOAuthService.getCachedAccessToken for provider status detection - register provider.not_found and model.not_found protocol error codes --- .../scripts/check-domain-layers.mjs | 19 +- packages/agent-core-v2/src/auth/auth.ts | 1 + .../agent-core-v2/src/auth/authService.ts | 4 + packages/agent-core-v2/src/errors.ts | 9 +- packages/agent-core-v2/src/index.ts | 5 +- .../agent-core-v2/src/modelCatalog/errors.ts | 28 ++ .../agent-core-v2/src/modelCatalog/index.ts | 9 + .../src/modelCatalog/modelCatalog.ts | 92 ++++ .../src/modelCatalog/modelCatalogService.ts | 417 ++++++++++++++++++ .../test/modelCatalog/modelCatalog.test.ts | 285 ++++++++++++ packages/protocol/src/events.ts | 4 + packages/server-v2/src/routes/modelCatalog.ts | 221 ++++++++++ .../src/routes/registerApiV1Routes.ts | 8 +- packages/server-v2/test/modelCatalog.test.ts | 196 ++++++++ 14 files changed, 1282 insertions(+), 16 deletions(-) create mode 100644 packages/agent-core-v2/src/modelCatalog/errors.ts create mode 100644 packages/agent-core-v2/src/modelCatalog/index.ts create mode 100644 packages/agent-core-v2/src/modelCatalog/modelCatalog.ts create mode 100644 packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts create mode 100644 packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts create mode 100644 packages/server-v2/src/routes/modelCatalog.ts create mode 100644 packages/server-v2/test/modelCatalog.test.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 7ab258aab..a35d413ce 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -76,6 +76,7 @@ const DOMAIN_LAYER = new Map([ ['permissionRules', 3], ['plugin', 3], ['modelRuntime', 3], + ['modelCatalog', 3], // L4 — agent behaviour ['context', 4], ['message', 4], @@ -150,10 +151,11 @@ const V1_PACKAGE = '@moonshot-ai/agent-core'; * * Post-rebase-v2 restructuring introduced cross-domain type sharing between * L3 (registries/capabilities) and L4 (agent behaviour). The tool contract - * (`ExecutableTool` / `ToolExecution` / results) now lives in `tool` (L3); the - * remaining L3→L4 imports are `loop`/`turn` tool-execution hook contexts and a - * `loop` error helper — real dependencies surfaced for review rather than - * layering violations to fix here. + * (`ExecutableTool` / `ToolExecution` / results) and the tool-execution hook + * contexts (`ToolExecutionHookContext` / `ToolWillExecuteContext` / …) now + * live in `tool` (L3); the only remaining L3→L4 import is a `loop` error / + * event helper used by `toolExecutor` — surfaced for review rather than a + * layering violation to fix here. */ const ALLOWED_EXCEPTIONS = new Set([ 'permission>approval', @@ -167,22 +169,18 @@ const ALLOWED_EXCEPTIONS = new Set([ 'cron>session-activity', 'session>event', 'wireRecord>hooks', - // L3/L4 type-sharing: tool contract now lives in `tool`; remaining upward - // imports are loop/turn hook contexts and a loop error helper. + // L3/L4 type-sharing: tool contract + execution hook contexts now live in + // `tool`; the remaining upward import is a `loop` error/event helper. 'contextMemory>background', 'llmRequester>session', 'loop>mcp', 'permission>externalHooks', - 'permission>loop', - 'permission>turn', 'permissionMode>contextInjector', 'permissionMode>replayBuilder', 'permissionPolicy>externalHooks', - 'permissionPolicy>loop', 'permissionPolicy>profile', 'permissionRules>replayBuilder', 'plugin>mcp', - 'profile>mcp', 'profile>session', 'replayBuilder>background', 'replayBuilder>rpc', @@ -191,7 +189,6 @@ const ALLOWED_EXCEPTIONS = new Set([ 'skill>prompt', 'swarm>subagentHost', 'toolExecutor>loop', - 'toolExecutor>turn', 'userTool>profile', 'wireRecord>contextMemory', 'wireRecord>loop', diff --git a/packages/agent-core-v2/src/auth/auth.ts b/packages/agent-core-v2/src/auth/auth.ts index 09765cda9..8ccc080cd 100644 --- a/packages/agent-core-v2/src/auth/auth.ts +++ b/packages/agent-core-v2/src/auth/auth.ts @@ -33,6 +33,7 @@ export interface IOAuthService { logout(provider?: string): Promise; status(provider?: string): Promise; resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined; + getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise; } export const IOAuthService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/auth/authService.ts b/packages/agent-core-v2/src/auth/authService.ts index 76ea8d0cb..fcbc48cb9 100644 --- a/packages/agent-core-v2/src/auth/authService.ts +++ b/packages/agent-core-v2/src/auth/authService.ts @@ -146,6 +146,10 @@ export class OAuthService extends Disposable implements IOAuthService { return this.toolkit.tokenProvider(provider, oauthRef); } + getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise { + return this.toolkit.getCachedAccessToken(provider, oauthRef); + } + private readOAuthRef(provider: string): OAuthRef { const oauth = this.providerService.get(provider)?.oauth; if (oauth === undefined) { diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index 4af35fba1..627273353 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -11,12 +11,13 @@ import { CoreErrors } from '#/_base/errors'; import { AgentLifecycleErrors } from '#/agent-lifecycle/errors'; import { AuthErrors } from '#/auth/errors'; import { BackgroundErrors } from '#/background/errors'; +import { ChatProviderErrors } from '#/chatProvider/errors'; import { ConfigErrors } from '#/config/errors'; import { FullCompactionErrors } from '#/fullCompaction/errors'; import { GoalErrors } from '#/goal/errors'; -import { KosongErrors } from '#/kosong/errors'; import { LoopErrors } from '#/loop/errors'; import { McpErrors } from '#/mcp/errors'; +import { ModelCatalogErrors } from '#/modelCatalog/errors'; import { PluginErrors } from '#/plugin/errors'; import { ProfileErrors } from '#/profile/errors'; import { PromptErrors } from '#/prompt/errors'; @@ -30,12 +31,13 @@ export * from '#/_base/errors'; export { AgentLifecycleErrors } from '#/agent-lifecycle/errors'; export { AuthErrors } from '#/auth/errors'; export { BackgroundErrors } from '#/background/errors'; +export { ChatProviderErrors } from '#/chatProvider/errors'; export { ConfigErrors } from '#/config/errors'; export { FullCompactionErrors } from '#/fullCompaction/errors'; export { GoalErrors } from '#/goal/errors'; -export { KosongErrors } from '#/kosong/errors'; export { LoopErrors } from '#/loop/errors'; export { McpErrors } from '#/mcp/errors'; +export { ModelCatalogErrors } from '#/modelCatalog/errors'; export { PluginErrors } from '#/plugin/errors'; export { ProfileErrors } from '#/profile/errors'; export { PromptErrors } from '#/prompt/errors'; @@ -50,12 +52,13 @@ export const ErrorCodes = { ...AgentLifecycleErrors.codes, ...AuthErrors.codes, ...BackgroundErrors.codes, + ...ChatProviderErrors.codes, ...ConfigErrors.codes, ...FullCompactionErrors.codes, ...GoalErrors.codes, - ...KosongErrors.codes, ...LoopErrors.codes, ...McpErrors.codes, + ...ModelCatalogErrors.codes, ...PluginErrors.codes, ...ProfileErrors.codes, ...PromptErrors.codes, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index d2465287b..48114e5b4 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -11,12 +11,15 @@ export * from './telemetry/index'; export * from './bootstrap/index'; export * from './hostFs/index'; export { IEventService, type DomainEvent } from './event/index'; -export * from './kosong/index'; +export * from './chatProvider/index'; export * from './session-index/index'; export * from './session-metadata/index'; export * from './config/index'; export * from './provider/index'; +export * from './model/index'; +export * from './modelCatalog/index'; +export * from './modelRuntime/index'; import './skill/index'; export * from './permission/index'; diff --git a/packages/agent-core-v2/src/modelCatalog/errors.ts b/packages/agent-core-v2/src/modelCatalog/errors.ts new file mode 100644 index 000000000..8a4045ff4 --- /dev/null +++ b/packages/agent-core-v2/src/modelCatalog/errors.ts @@ -0,0 +1,28 @@ +/** + * `modelCatalog` domain error codes — provider/model catalog lookup failures. + */ + +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors'; + +export const ModelCatalogErrors = { + codes: { + PROVIDER_NOT_FOUND: 'provider.not_found', + MODEL_NOT_FOUND: 'model.not_found', + }, + info: { + 'provider.not_found': { + title: 'Provider not found', + retryable: false, + public: true, + action: 'Check the provider id or configure the provider first.', + }, + 'model.not_found': { + title: 'Model not found', + retryable: false, + public: true, + action: 'Check the model alias or configure the model first.', + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(ModelCatalogErrors); diff --git a/packages/agent-core-v2/src/modelCatalog/index.ts b/packages/agent-core-v2/src/modelCatalog/index.ts new file mode 100644 index 000000000..5f45573f7 --- /dev/null +++ b/packages/agent-core-v2/src/modelCatalog/index.ts @@ -0,0 +1,9 @@ +/** + * `modelCatalog` domain barrel — re-exports the model-catalog contract + * (`modelCatalog`) and its scoped service (`modelCatalogService`). Importing + * this barrel registers the `IModelCatalogService` binding into the scope + * registry and self-registers the domain's error codes. + */ + +export * from './modelCatalog'; +export * from './modelCatalogService'; diff --git a/packages/agent-core-v2/src/modelCatalog/modelCatalog.ts b/packages/agent-core-v2/src/modelCatalog/modelCatalog.ts new file mode 100644 index 000000000..d4b7990dd --- /dev/null +++ b/packages/agent-core-v2/src/modelCatalog/modelCatalog.ts @@ -0,0 +1,92 @@ +/** + * `modelCatalog` domain (L3) — read-only catalog over configured providers and + * model aliases, plus the global default-model selection and the Kimi Code + * OAuth model refresh. + * + * Projects the `provider` / `model` configuration registries into the + * protocol `ProviderCatalogItem` / `ModelCatalogItem` wire shapes that the + * edge (`server-v2` `/api/v1` routes) serves. Core-scoped — provider and + * model configuration is global and shared across sessions. This domain is a + * thin facade over `provider`, `model`, `config`, and `auth`; it owns no + * persistence of its own. + */ + +import type { + ModelCatalogItem, + ProviderCatalogItem, + RefreshOAuthProviderModelsResponse, + SetDefaultModelResponse, +} from '@moonshot-ai/protocol'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { ModelAlias } from '#/model/model'; +import type { ProviderConfig } from '#/provider/provider'; + +export interface IModelCatalogService { + readonly _serviceBrand: undefined; + + listModels(): Promise; + listProviders(): Promise; + getProvider(providerId: string): Promise; + setDefaultModel(modelId: string): Promise; + refreshOAuthProviderModels(): Promise; +} + +export const IModelCatalogService: ServiceIdentifier = + createDecorator('modelCatalogService'); + +export interface ProviderCredentialState { + readonly hasApiKey: boolean; + readonly hasOAuthToken: boolean; +} + +export function toProtocolModel(modelId: string, alias: ModelAlias): ModelCatalogItem { + return { + provider: alias.provider, + model: modelId, + display_name: alias.displayName ?? alias.model, + max_context_size: alias.maxContextSize, + capabilities: alias.capabilities, + }; +} + +export function toProtocolProvider( + providerId: string, + provider: ProviderConfig, + models: Readonly>, + globalDefaultModel: string | undefined, + credential: ProviderCredentialState, +): ProviderCatalogItem { + const providerModels = modelIdsForProvider(models, providerId); + const defaultModel = + provider.defaultModel ?? globalDefaultForProvider(models, globalDefaultModel, providerId); + return { + id: providerId, + type: provider.type, + base_url: provider.baseUrl, + default_model: defaultModel, + has_api_key: credential.hasApiKey, + status: credential.hasApiKey || credential.hasOAuthToken ? 'connected' : 'unconfigured', + models: providerModels, + }; +} + +export function modelIdsForProvider( + models: Readonly>, + providerId: string, +): string[] { + return Object.entries(models) + .filter(([, alias]) => alias.provider === providerId) + .map(([modelId]) => modelId); +} + +function globalDefaultForProvider( + models: Readonly>, + globalDefaultModel: string | undefined, + providerId: string, +): string | undefined { + if (globalDefaultModel === undefined) return undefined; + const alias = models[globalDefaultModel]; + return alias?.provider === providerId ? globalDefaultModel : undefined; +} diff --git a/packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts b/packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts new file mode 100644 index 000000000..61c8b5c99 --- /dev/null +++ b/packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts @@ -0,0 +1,417 @@ +/** + * `modelCatalog` domain (L3) — `IModelCatalogService` implementation. + * + * Projects the `provider` / `model` registries into protocol catalog items, + * resolves credential state through `config` and `auth`, persists the global + * default-model selection through `config`, and drives the Kimi Code OAuth + * model refresh against the user-layer config sections. Bound at Core scope. + */ + +import { + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + resolveKimiCodeRuntimeAuth, + type ManagedKimiConfigShape, +} from '@moonshot-ai/kimi-code-oauth'; +import type { + ModelCatalogItem, + ProviderCatalogItem, + RefreshOAuthProviderModelsResponse, + SetDefaultModelResponse, +} from '@moonshot-ai/protocol'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IOAuthService } from '#/auth/auth'; +import { IConfigService } from '#/config/config'; +import { ErrorCodes, KimiError } from '#/errors'; +import { IModelService, type ModelAlias } from '#/model/model'; +import { IProviderService, type OAuthRef, type ProviderConfig } from '#/provider/provider'; + +import { + type ProviderCredentialState, + IModelCatalogService, + modelIdsForProvider, + toProtocolModel, + toProtocolProvider, +} from './modelCatalog'; + +const DEFAULT_MODEL_SECTION = 'defaultModel'; +const DEFAULT_THINKING_SECTION = 'defaultThinking'; +const MODELS_SECTION = 'models'; +const PROVIDERS_SECTION = 'providers'; + +/** Structural view of a managed-config model alias (the fields the refresh reads/writes). */ +interface ManagedModel { + readonly provider: string; + readonly model: string; + readonly maxContextSize: number; + readonly capabilities?: readonly string[]; + readonly displayName?: string; +} + +export class ModelCatalogService implements IModelCatalogService { + declare readonly _serviceBrand: undefined; + + constructor( + @IModelService private readonly modelService: IModelService, + @IProviderService private readonly providerService: IProviderService, + @IConfigService private readonly config: IConfigService, + @IOAuthService private readonly oauth: IOAuthService, + ) {} + + async listModels(): Promise { + const models = this.modelService.list(); + return Object.entries(models).map(([modelId, alias]) => toProtocolModel(modelId, alias)); + } + + async listProviders(): Promise { + const providers = this.providerService.list(); + const models = this.modelService.list(); + const globalDefaultModel = this.config.get(DEFAULT_MODEL_SECTION); + const out: ProviderCatalogItem[] = []; + for (const [providerId, provider] of Object.entries(providers)) { + out.push(await this.toCatalogProvider(providerId, provider, models, globalDefaultModel)); + } + return out; + } + + async getProvider(providerId: string): Promise { + const provider = this.providerService.get(providerId); + if (provider === undefined) { + throw new KimiError(ErrorCodes.PROVIDER_NOT_FOUND, `provider ${providerId} does not exist`); + } + const models = this.modelService.list(); + const globalDefaultModel = this.config.get(DEFAULT_MODEL_SECTION); + return this.toCatalogProvider(providerId, provider, models, globalDefaultModel); + } + + async setDefaultModel(modelId: string): Promise { + const alias = this.modelService.get(modelId); + if (alias === undefined) { + throw new KimiError(ErrorCodes.MODEL_NOT_FOUND, `model ${modelId} does not exist`); + } + await this.config.set(DEFAULT_MODEL_SECTION, modelId); + const updatedAlias = this.modelService.get(modelId) ?? alias; + return { + default_model: modelId, + model: toProtocolModel(modelId, updatedAlias), + }; + } + + async refreshOAuthProviderModels(): Promise { + const changed: RefreshOAuthProviderModelsResponse['changed'] = []; + const unchanged: string[] = []; + const failed: RefreshOAuthProviderModelsResponse['failed'] = []; + + await this.config.reload(); + const current = this.readUserConfigShape(); + const provider = current.providers[KIMI_CODE_PROVIDER_NAME]; + if (!isKimiOAuthProvider(provider)) { + return { changed, unchanged, failed }; + } + + try { + const auth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: provider.baseUrl, + configuredOAuthRef: provider.oauth, + }); + const tokenProvider = this.oauth.resolveTokenProvider(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, + }); + if (models.length === 0) { + return { changed, unchanged, failed }; + } + + const next = structuredClone(current); + applyManagedKimiCodeConfig(next, { + models, + baseUrl: auth.baseUrl, + oauthKey: auth.oauthRef.key, + oauthHost: auth.oauthRef.oauthHost, + preserveDefaultModel: true, + }); + const refreshedAliasKeys = providerRefreshAliasKeys( + current, + next, + KIMI_CODE_PROVIDER_NAME, + `${KIMI_CODE_PLATFORM_ID}/`, + ); + restoreProviderAliases( + next, + preserveUserProviderAliases(current, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), + ); + restoreDefaultSelection(next, current.defaultModel, current.defaultThinking); + clampDanglingDefault(next); + + if (providerModelsEqual(current, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + unchanged.push(KIMI_CODE_PROVIDER_NAME); + } else { + const { added, removed } = computeChanges( + collectModelIdsForAliases(current, refreshedAliasKeys), + collectModelIdsForAliases(next, refreshedAliasKeys), + ); + await this.config.replace(PROVIDERS_SECTION, next.providers); + await this.config.replace(MODELS_SECTION, next.models ?? {}); + await this.config.set(DEFAULT_MODEL_SECTION, next.defaultModel); + await this.config.set(DEFAULT_THINKING_SECTION, 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), + }); + } + + return { changed, unchanged, failed }; + } + + private async toCatalogProvider( + providerId: string, + provider: ProviderConfig, + models: Readonly>, + globalDefaultModel: string | undefined, + ): Promise { + const credential = await this.resolveCredential(providerId, provider); + return toProtocolProvider(providerId, provider, models, globalDefaultModel, credential); + } + + private async resolveCredential( + providerId: string, + provider: ProviderConfig, + ): Promise { + return { + hasApiKey: hasConfiguredApiKey(provider), + hasOAuthToken: await this.hasCachedToken(providerId, provider), + }; + } + + private async hasCachedToken(providerId: string, provider: ProviderConfig): Promise { + if (provider.oauth === undefined) return false; + try { + const token = await this.oauth.getCachedAccessToken(providerId, provider.oauth); + return nonEmpty(token) !== undefined; + } catch { + return false; + } + } + + /** Assemble a v1-style flat config shape from the user-layer config sections. */ + private readUserConfigShape(): ManagedKimiConfigShape { + const providers = + this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; + const models = + this.config.inspect>(MODELS_SECTION).userValue ?? {}; + const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue; + const defaultThinking = this.config.inspect(DEFAULT_THINKING_SECTION).userValue; + return { + providers: { ...providers } as ManagedKimiConfigShape['providers'], + models: { ...models } as ManagedKimiConfigShape['models'], + defaultModel, + defaultThinking, + }; + } +} + +function isKimiOAuthProvider( + provider: ProviderConfig | Record | undefined, +): provider is ProviderConfig & { oauth: OAuthRef } { + return ( + provider !== undefined && + (provider as ProviderConfig).type === 'kimi' && + (provider as ProviderConfig).oauth !== undefined + ); +} + +function hasConfiguredApiKey(provider: ProviderConfig): boolean { + if (nonEmpty(provider.apiKey) !== undefined) return true; + switch (provider.type) { + case 'anthropic': + return nonEmpty(provider.env?.['ANTHROPIC_API_KEY']) !== undefined; + case 'openai': + case 'openai_responses': + return nonEmpty(provider.env?.['OPENAI_API_KEY']) !== undefined; + case 'kimi': + return nonEmpty(provider.env?.['KIMI_API_KEY']) !== undefined; + case 'google-genai': + return nonEmpty(provider.env?.['GOOGLE_API_KEY']) !== undefined; + case 'vertexai': + return ( + nonEmpty(provider.env?.['VERTEXAI_API_KEY']) !== undefined || + nonEmpty(provider.env?.['GOOGLE_API_KEY']) !== undefined + ); + } + return false; +} + +function collectModelIdsForAliases( + config: ManagedKimiConfigShape, + aliasKeys: ReadonlySet, +): Set { + const ids = new Set(); + for (const aliasKey of aliasKeys) { + const alias = managedModel(config, aliasKey); + if (alias !== undefined && alias.model.length > 0) ids.add(alias.model); + } + return ids; +} + +function providerAliasKeys(config: ManagedKimiConfigShape, providerId: string): Set { + const keys = new Set(); + for (const [alias, model] of Object.entries(config.models ?? {})) { + if ((model as ManagedModel).provider === providerId) keys.add(alias); + } + return keys; +} + +function generatedProviderAliasKeys( + config: ManagedKimiConfigShape, + providerId: string, + aliasPrefix: string, +): Set { + const keys = new Set(); + for (const [alias, model] of Object.entries(config.models ?? {})) { + if ((model as ManagedModel).provider === providerId && alias.startsWith(aliasPrefix)) { + keys.add(alias); + } + } + return keys; +} + +function computeChanges( + oldIds: Set, + newIds: Set, +): { added: number; removed: number } { + let added = 0; + for (const id of newIds) { + if (!oldIds.has(id)) added++; + } + let removed = 0; + for (const id of oldIds) { + if (!newIds.has(id)) removed++; + } + return { added, removed }; +} + +function providerModelsEqual( + config: ManagedKimiConfigShape, + nextConfig: ManagedKimiConfigShape, + providerId: string, + aliasKeys: ReadonlySet, +): boolean { + return ( + providerModelSnapshot(config, providerId, aliasKeys) === + providerModelSnapshot(nextConfig, providerId, aliasKeys) + ); +} + +function providerModelSnapshot( + config: ManagedKimiConfigShape, + providerId: string, + aliasKeys: ReadonlySet, +): string { + const snapshots: Array<{ alias: string; model: ManagedModel }> = []; + for (const alias of aliasKeys) { + const model = managedModel(config, alias); + if (model === undefined || model.provider !== providerId) continue; + snapshots.push({ + alias, + model: { + ...model, + capabilities: + model.capabilities === undefined ? undefined : [...model.capabilities].sort(), + }, + }); + } + snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); + return JSON.stringify(snapshots); +} + +function providerRefreshAliasKeys( + config: ManagedKimiConfigShape, + nextConfig: ManagedKimiConfigShape, + providerId: string, + aliasPrefix: string, +): Set { + const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); + for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); + return keys; +} + +function preserveUserProviderAliases( + config: ManagedKimiConfigShape, + providerId: string, + refreshedAliasKeys: ReadonlySet, +): Record { + const preserved: Record = {}; + for (const [alias, model] of Object.entries(config.models ?? {})) { + const entry = model as ManagedModel; + if (entry.provider !== providerId || refreshedAliasKeys.has(alias)) continue; + preserved[alias] = structuredClone(entry); + } + return preserved; +} + +function restoreProviderAliases( + config: ManagedKimiConfigShape, + aliases: Record, +): void { + if (Object.keys(aliases).length === 0) return; + config.models = { + ...config.models, + ...aliases, + } as ManagedKimiConfigShape['models']; +} + +function restoreDefaultSelection( + config: ManagedKimiConfigShape, + defaultModel: string | undefined, + defaultThinking: boolean | undefined, +): void { + if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; + config.defaultModel = defaultModel; + const capabilities = managedModel(config, defaultModel)?.capabilities ?? []; + config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; +} + +function clampDanglingDefault(config: ManagedKimiConfigShape): void { + if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { + config.defaultModel = undefined; + config.defaultThinking = undefined; + } +} + +function managedModel( + config: ManagedKimiConfigShape, + alias: string, +): ManagedModel | undefined { + return config.models?.[alias] as ManagedModel | undefined; +} + +function nonEmpty(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +registerScopedService( + LifecycleScope.Core, + IModelCatalogService, + ModelCatalogService, + InstantiationType.Delayed, + 'modelCatalog', +); diff --git a/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts b/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts new file mode 100644 index 000000000..7c556d041 --- /dev/null +++ b/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts @@ -0,0 +1,285 @@ +/** + * `modelCatalog` domain tests — covers the catalog projection, default-model + * selection, coded not-found errors, and the Kimi Code OAuth model refresh. + * + * Uses the flat `TestInstantiationService` harness with real `ModelService` / + * `ProviderService` collaborators over an in-memory config stub, a stubbed + * `IOAuthService`, and the SUT registered by interface. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { IOAuthService } from '#/auth/auth'; +import { IConfigRegistry, IConfigService } from '#/config/config'; +import { ConfigRegistry } from '#/config/configService'; +import { isKimiError } from '#/errors'; +import { IModelCatalogService } from '#/modelCatalog/modelCatalog'; +import { ModelCatalogService } from '#/modelCatalog/modelCatalogService'; +import { IModelService, type ModelAlias } from '#/model/model'; +import { ModelService } from '#/model/modelService'; +import { IProviderService, type ProviderConfig } from '#/provider/provider'; +import { ProviderService } from '#/provider/providerService'; + +interface Backing { + providers: Record; + models: Record; + defaultModel?: string; + defaultThinking?: boolean; +} + +function seedBacking(): Backing { + return { + providers: { + kimi: { + type: 'kimi', + apiKey: 'sk-test', + baseUrl: 'https://api.example.test/v1', + }, + openai: { type: 'openai' }, + }, + models: { + k2: { + provider: 'kimi', + model: 'kimi-k2', + maxContextSize: 131072, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'kimi', + model: 'kimi-turbo', + maxContextSize: 32768, + displayName: 'Kimi Turbo', + }, + gpt4o: { provider: 'openai', model: 'gpt-4o', maxContextSize: 128000 }, + }, + defaultModel: 'k2', + }; +} + +describe('ModelCatalogService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let backing: Backing; + let configSet: ReturnType; + let configReplace: ReturnType; + let getCachedAccessToken: ReturnType; + let resolveTokenProvider: ReturnType; + + beforeEach(() => { + disposables = new DisposableStore(); + backing = seedBacking(); + configSet = vi.fn().mockImplementation(async (domain: string, patch: unknown) => { + const current = (backing as Record)[domain]; + (backing as Record)[domain] = + current !== null && typeof current === 'object' && typeof patch === 'object' && patch !== null + ? { ...(current as object), ...(patch as object) } + : patch; + }); + configReplace = vi.fn().mockImplementation(async (domain: string, value: unknown) => { + (backing as Record)[domain] = value; + }); + getCachedAccessToken = vi.fn().mockResolvedValue(undefined); + resolveTokenProvider = vi.fn(); + + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.defineInstance(IConfigRegistry, new ConfigRegistry()); + reg.definePartialInstance(IConfigService, { + get: ((domain: string) => (backing as Record)[domain]) as IConfigService['get'], + inspect: ((domain: string) => ({ + value: (backing as Record)[domain], + defaultValue: undefined, + userValue: (backing as Record)[domain], + memoryValue: undefined, + })) as IConfigService['inspect'], + set: configSet as unknown as IConfigService['set'], + replace: configReplace as unknown as IConfigService['replace'], + reload: vi.fn().mockResolvedValue(undefined) as unknown as IConfigService['reload'], + onDidChange: (() => ({ dispose: () => {} })) as IConfigService['onDidChange'], + onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], + }); + reg.definePartialInstance(IOAuthService, { + getCachedAccessToken, + resolveTokenProvider, + }); + reg.define(IModelService, ModelService); + reg.define(IProviderService, ProviderService); + reg.define(IModelCatalogService, ModelCatalogService); + }, + }); + }); + afterEach(() => { + disposables.dispose(); + vi.unstubAllGlobals(); + }); + + function catalog(): IModelCatalogService { + return ix.get(IModelCatalogService); + } + + it('lists configured models as selectable aliases', async () => { + await expect(catalog().listModels()).resolves.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 with per-provider models, default model, and credential state', async () => { + await expect(catalog().listProviders()).resolves.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'], + }, + ]); + }); + + it('gets a single provider by id', async () => { + await expect(catalog().getProvider('kimi')).resolves.toMatchObject({ + id: 'kimi', + default_model: 'k2', + models: ['k2', 'turbo'], + }); + }); + + it('throws provider.not_found for an unknown provider', async () => { + await catalog().getProvider('missing').then( + () => { + throw new Error('expected rejection'); + }, + (err) => { + expect(isKimiError(err)).toBe(true); + expect((err as { code: string }).code).toBe('provider.not_found'); + }, + ); + }); + + it('sets the global default model and returns the selected model', async () => { + await expect(catalog().setDefaultModel('turbo')).resolves.toEqual({ + default_model: 'turbo', + model: { + provider: 'kimi', + model: 'turbo', + display_name: 'Kimi Turbo', + max_context_size: 32768, + }, + }); + expect(configSet).toHaveBeenCalledWith('defaultModel', 'turbo'); + }); + + it('throws model.not_found when setting an unknown default model', async () => { + await catalog().setDefaultModel('missing').then( + () => { + throw new Error('expected rejection'); + }, + (err) => { + expect(isKimiError(err)).toBe(true); + expect((err as { code: string }).code).toBe('model.not_found'); + }, + ); + }); + + it('marks an OAuth provider connected when a cached token exists', async () => { + backing.providers = { + acme: { + type: 'kimi', + oauth: { storage: 'file', key: 'oauth/acme' }, + }, + }; + getCachedAccessToken.mockResolvedValue('cached-token'); + const [provider] = await catalog().listProviders(); + expect(provider).toMatchObject({ id: 'acme', has_api_key: false, status: 'connected' }); + }); + + it('returns an empty refresh result when no Kimi Code provider is configured', async () => { + await expect(catalog().refreshOAuthProviderModels()).resolves.toEqual({ + changed: [], + unchanged: [], + failed: [], + }); + expect(resolveTokenProvider).not.toHaveBeenCalled(); + }); + + it('refreshes Kimi Code models and writes back the changed sections', async () => { + backing.providers = { + 'managed:kimi-code': { + type: 'kimi', + baseUrl: 'https://api.example.test/v1', + apiKey: '', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }; + backing.models = {}; + resolveTokenProvider.mockReturnValue({ + getAccessToken: vi.fn().mockResolvedValue('access-token'), + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { + id: 'kimi-k2', + context_length: 131072, + supports_reasoning: true, + display_name: 'Kimi K2', + }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await catalog().refreshOAuthProviderModels(); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { + provider_id: 'managed:kimi-code', + provider_name: 'Kimi Code', + added: 1, + removed: 0, + }, + ]); + expect(configReplace).toHaveBeenCalledWith( + 'providers', + expect.objectContaining({ 'managed:kimi-code': expect.objectContaining({ type: 'kimi' }) }), + ); + expect(configReplace).toHaveBeenCalledWith( + 'models', + expect.objectContaining({ + 'kimi-code/kimi-k2': expect.objectContaining({ model: 'kimi-k2' }), + }), + ); + expect(configSet).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2'); + }); +}); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 1318bdaee..a6a3ae7c5 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -199,6 +199,7 @@ export type KimiErrorCode = | 'goal.not_resumable' | 'model.not_configured' | 'model.config_invalid' + | 'model.not_found' | 'auth.login_required' | 'context.overflow' | 'loop.max_steps_exceeded' @@ -206,6 +207,7 @@ export type KimiErrorCode = | 'provider.rate_limit' | 'provider.auth_error' | 'provider.connection_error' + | 'provider.not_found' | 'skill.not_found' | 'skill.type_unsupported' | 'skill.name_empty' @@ -831,6 +833,7 @@ export const kimiErrorCodeSchema = z.enum([ 'goal.not_resumable', 'model.not_configured', 'model.config_invalid', + 'model.not_found', 'auth.login_required', 'context.overflow', 'loop.max_steps_exceeded', @@ -838,6 +841,7 @@ export const kimiErrorCodeSchema = z.enum([ 'provider.rate_limit', 'provider.auth_error', 'provider.connection_error', + 'provider.not_found', 'skill.not_found', 'skill.type_unsupported', 'skill.name_empty', diff --git a/packages/server-v2/src/routes/modelCatalog.ts b/packages/server-v2/src/routes/modelCatalog.ts new file mode 100644 index 000000000..db9d48359 --- /dev/null +++ b/packages/server-v2/src/routes/modelCatalog.ts @@ -0,0 +1,221 @@ +/** + * `/models` + `/providers` catalog route handlers — server-v2 port. + * + * Implements the v1 model/provider catalog wire contract on top of + * `agent-core-v2`'s `IModelCatalogService`: + * GET /models — list configured model aliases + * GET /providers — list configured providers + * GET /providers/{provider_id} — get a configured provider by id + * POST /models/{tail} (:set_default) — set the global default model alias + * POST /providers:refresh_oauth — refresh OAuth-backed provider models + * + * **Wire fidelity**: reuses `@moonshot-ai/protocol`'s catalog schemas and the + * numeric `ErrorCode` envelope verbatim, so the response shape and error codes + * (`40412` provider-not-found, `40413` model-not-found, `40001` validation) are + * byte-for-byte compatible with v1's `routes/modelCatalog.ts`. The v2 domain + * throws coded `KimiError`s (`provider.not_found` / `model.not_found`); this + * edge maps them to the numeric protocol codes by `code` (never `instanceof`). + */ + +import { IConfigService, IModelCatalogService, isKimiError, type Scope } from '@moonshot-ai/agent-core-v2'; +import { + ErrorCode, + getProviderResponseSchema, + listModelsResponseSchema, + listProvidersResponseSchema, + refreshOAuthProviderModelsResponseSchema, + setDefaultModelResponseSchema, +} from '@moonshot-ai/protocol'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { parseActionSuffix } from './action-suffix'; + +interface ModelCatalogRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const providerIdParamSchema = z.object({ + provider_id: z.string().min(1), +}); + +const modelActionTailParamSchema = z.object({ + tail: z.string().min(1), +}); + +/** + * Resolve the catalog service after the config layer is ready. Config loads + * asynchronously during bootstrap; mirroring `routes/config.ts`, route handlers + * await `IConfigService.ready` so an immediate request never observes an empty + * (not-yet-loaded) catalog. + */ +async function loadCatalog(core: Scope): Promise { + await core.accessor.get(IConfigService).ready; + return core.accessor.get(IModelCatalogService); +} + +export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Scope): void { + const listModelsRoute = defineRoute( + { + method: 'GET', + path: '/models', + success: { data: listModelsResponseSchema }, + description: 'List configured model aliases', + tags: ['models'], + }, + async (req, reply) => { + const items = await (await loadCatalog(core)).listModels(); + reply.send(okEnvelope({ items }, req.id)); + }, + ); + app.get( + listModelsRoute.path, + listModelsRoute.options, + listModelsRoute.handler as Parameters[2], + ); + + const setDefaultModelRoute = defineRoute( + { + method: 'POST', + path: '/models/{tail}', + params: modelActionTailParamSchema, + success: { data: setDefaultModelResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.MODEL_NOT_FOUND]: {}, + }, + description: 'Set the global default model alias', + tags: ['models'], + operationId: 'setDefaultModel', + }, + async (req, reply) => { + try { + const { tail } = req.params; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['set_default'] as const, + resourceLabel: 'model', + }); + 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 (await loadCatalog(core)).setDefaultModel(parsed.id); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + if (sendMappedError(reply, req.id, err)) return; + throw err; + } + }, + ); + app.post( + setDefaultModelRoute.path, + setDefaultModelRoute.options, + setDefaultModelRoute.handler as Parameters[2], + ); + + const listProvidersRoute = defineRoute( + { + method: 'GET', + path: '/providers', + success: { data: listProvidersResponseSchema }, + description: 'List configured providers', + tags: ['providers'], + }, + async (req, reply) => { + const items = await (await loadCatalog(core)).listProviders(); + reply.send(okEnvelope({ items }, req.id)); + }, + ); + app.get( + listProvidersRoute.path, + listProvidersRoute.options, + listProvidersRoute.handler as Parameters[2], + ); + + const refreshOAuthProvidersRoute = defineRoute( + { + method: 'POST', + path: '/providers:refresh_oauth', + success: { data: refreshOAuthProviderModelsResponseSchema }, + description: 'Refresh OAuth-backed provider model metadata', + tags: ['providers'], + operationId: 'refreshOAuthProviderModels', + }, + async (req, reply) => { + const result = await (await loadCatalog(core)).refreshOAuthProviderModels(); + reply.send(okEnvelope(result, req.id)); + }, + ); + app.post( + refreshOAuthProvidersRoute.path, + refreshOAuthProvidersRoute.options, + refreshOAuthProvidersRoute.handler as Parameters[2], + ); + + const getProviderRoute = defineRoute( + { + method: 'GET', + path: '/providers/{provider_id}', + params: providerIdParamSchema, + success: { data: getProviderResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PROVIDER_NOT_FOUND]: {}, + }, + description: 'Get a configured provider by ID', + tags: ['providers'], + }, + async (req, reply) => { + try { + const { provider_id } = req.params; + const provider = await (await loadCatalog(core)).getProvider(provider_id); + reply.send(okEnvelope(provider, req.id)); + } catch (err) { + if (sendMappedError(reply, req.id, err)) return; + throw err; + } + }, + ); + app.get( + getProviderRoute.path, + getProviderRoute.options, + getProviderRoute.handler as Parameters[2], + ); +} + +/** Map a coded domain error to the numeric protocol envelope. Returns true if handled. */ +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): boolean { + if (!isKimiError(err)) return false; + if (err.code === 'provider.not_found') { + reply.send(errEnvelope(ErrorCode.PROVIDER_NOT_FOUND, err.message, requestId)); + return true; + } + if (err.code === 'model.not_found') { + reply.send(errEnvelope(ErrorCode.MODEL_NOT_FOUND, err.message, requestId)); + return true; + } + return false; +} diff --git a/packages/server-v2/src/routes/registerApiV1Routes.ts b/packages/server-v2/src/routes/registerApiV1Routes.ts index e72734c6b..b6e7d668d 100644 --- a/packages/server-v2/src/routes/registerApiV1Routes.ts +++ b/packages/server-v2/src/routes/registerApiV1Routes.ts @@ -4,7 +4,8 @@ * Mirrors the v1 server's prefixing and per-module delegation, but resolves * services from the `agent-core-v2` Core `Scope` instead of the v1 flat * `IInstantiationService`. v0.1 mounts the subset of routes that v2 can serve - * end-to-end today (health, meta, auth readiness, OAuth device flow, shutdown). + * end-to-end today (health, meta, auth readiness, OAuth device flow, config, + * model/provider catalog, shutdown). */ import type { Scope } from '@moonshot-ai/agent-core-v2'; @@ -15,6 +16,7 @@ import { registerApprovalsRoutes } from './approvals'; import { registerAuthRoute } from './auth'; import { registerConfigRoutes } from './config'; import { registerMetaRoute } from './meta'; +import { registerModelCatalogRoutes } from './modelCatalog'; import { registerOAuthRoutes } from './oauth'; import { registerSessionsRoutes } from './sessions'; import { registerShutdownRoutes } from './shutdown'; @@ -59,6 +61,10 @@ export async function registerApiV1Routes( registerAuthRoute(apiV1 as unknown as Parameters[0], core); registerOAuthRoutes(apiV1 as unknown as Parameters[0], core); registerConfigRoutes(apiV1 as unknown as Parameters[0], core); + registerModelCatalogRoutes( + apiV1 as unknown as Parameters[0], + core, + ); registerSessionsRoutes( apiV1 as unknown as Parameters[0], core, diff --git a/packages/server-v2/test/modelCatalog.test.ts b/packages/server-v2/test/modelCatalog.test.ts new file mode 100644 index 000000000..6c5c40e85 --- /dev/null +++ b/packages/server-v2/test/modelCatalog.test.ts @@ -0,0 +1,196 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +const CATALOG_TOML = [ + '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('server-v2 /api/v1 model/provider catalog', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-model-catalog-')); + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function boot(toml?: string): Promise { + if (toml !== undefined) { + await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); + } + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: body === undefined ? undefined : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('lists configured models as selectable aliases', async () => { + await boot(CATALOG_TOML); + const { status, body } = await getJson<{ items: unknown[] }>('/api/v1/models'); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.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 () => { + await boot(CATALOG_TOML); + const list = await getJson<{ items: unknown[] }>('/api/v1/providers'); + expect(list.body.code).toBe(0); + expect(list.body.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 getJson('/api/v1/providers/kimi'); + expect(single.body.code).toBe(0); + expect(single.body.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', async () => { + await boot(CATALOG_TOML); + const { body } = await postJson('/api/v1/models/turbo:set_default', {}); + expect(body.code).toBe(0); + expect(body.data).toEqual({ + default_model: 'turbo', + model: { + provider: 'kimi', + model: 'turbo', + display_name: 'Kimi Turbo', + max_context_size: 32768, + }, + }); + }); + + it('maps unknown provider and model ids to catalog not-found codes', async () => { + await boot(CATALOG_TOML); + const provider = await getJson('/api/v1/providers/missing'); + expect(provider.body.code).toBe(40412); + + const model = await postJson('/api/v1/models/missing:set_default', {}); + expect(model.body.code).toBe(40413); + }); + + it('returns an empty refresh result through the catalog route', async () => { + await boot(CATALOG_TOML); + const { status, body } = await postJson<{ + changed: unknown[]; + unchanged: unknown[]; + failed: unknown[]; + }>('/api/v1/providers:refresh_oauth', {}); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data).toEqual({ changed: [], unchanged: [], failed: [] }); + }); +});