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
This commit is contained in:
haozhe.yang 2026-06-29 20:10:12 +08:00
parent fc801c9ea0
commit 420cefbc02
14 changed files with 1282 additions and 16 deletions

View file

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

View file

@ -33,6 +33,7 @@ export interface IOAuthService {
logout(provider?: string): Promise<OAuthLogoutResponse>;
status(provider?: string): Promise<AuthStatus>;
resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined;
getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise<string | undefined>;
}
export const IOAuthService: ServiceIdentifier<IOAuthService> =

View file

@ -146,6 +146,10 @@ export class OAuthService extends Disposable implements IOAuthService {
return this.toolkit.tokenProvider(provider, oauthRef);
}
getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise<string | undefined> {
return this.toolkit.getCachedAccessToken(provider, oauthRef);
}
private readOAuthRef(provider: string): OAuthRef {
const oauth = this.providerService.get(provider)?.oauth;
if (oauth === undefined) {

View file

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

View file

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

View file

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

View file

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

View file

@ -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<readonly ModelCatalogItem[]>;
listProviders(): Promise<readonly ProviderCatalogItem[]>;
getProvider(providerId: string): Promise<ProviderCatalogItem>;
setDefaultModel(modelId: string): Promise<SetDefaultModelResponse>;
refreshOAuthProviderModels(): Promise<RefreshOAuthProviderModelsResponse>;
}
export const IModelCatalogService: ServiceIdentifier<IModelCatalogService> =
createDecorator<IModelCatalogService>('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<Record<string, ModelAlias>>,
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<Record<string, ModelAlias>>,
providerId: string,
): string[] {
return Object.entries(models)
.filter(([, alias]) => alias.provider === providerId)
.map(([modelId]) => modelId);
}
function globalDefaultForProvider(
models: Readonly<Record<string, ModelAlias>>,
globalDefaultModel: string | undefined,
providerId: string,
): string | undefined {
if (globalDefaultModel === undefined) return undefined;
const alias = models[globalDefaultModel];
return alias?.provider === providerId ? globalDefaultModel : undefined;
}

View file

@ -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<readonly ModelCatalogItem[]> {
const models = this.modelService.list();
return Object.entries(models).map(([modelId, alias]) => toProtocolModel(modelId, alias));
}
async listProviders(): Promise<readonly ProviderCatalogItem[]> {
const providers = this.providerService.list();
const models = this.modelService.list();
const globalDefaultModel = this.config.get<string>(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<ProviderCatalogItem> {
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<string>(DEFAULT_MODEL_SECTION);
return this.toCatalogProvider(providerId, provider, models, globalDefaultModel);
}
async setDefaultModel(modelId: string): Promise<SetDefaultModelResponse> {
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<RefreshOAuthProviderModelsResponse> {
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<Record<string, ModelAlias>>,
globalDefaultModel: string | undefined,
): Promise<ProviderCatalogItem> {
const credential = await this.resolveCredential(providerId, provider);
return toProtocolProvider(providerId, provider, models, globalDefaultModel, credential);
}
private async resolveCredential(
providerId: string,
provider: ProviderConfig,
): Promise<ProviderCredentialState> {
return {
hasApiKey: hasConfiguredApiKey(provider),
hasOAuthToken: await this.hasCachedToken(providerId, provider),
};
}
private async hasCachedToken(providerId: string, provider: ProviderConfig): Promise<boolean> {
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<Record<string, ProviderConfig>>(PROVIDERS_SECTION).userValue ?? {};
const models =
this.config.inspect<Record<string, ModelAlias>>(MODELS_SECTION).userValue ?? {};
const defaultModel = this.config.inspect<string>(DEFAULT_MODEL_SECTION).userValue;
const defaultThinking = this.config.inspect<boolean>(DEFAULT_THINKING_SECTION).userValue;
return {
providers: { ...providers } as ManagedKimiConfigShape['providers'],
models: { ...models } as ManagedKimiConfigShape['models'],
defaultModel,
defaultThinking,
};
}
}
function isKimiOAuthProvider(
provider: ProviderConfig | Record<string, unknown> | 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<string>,
): Set<string> {
const ids = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string> {
const keys = new Set<string>();
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<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: ManagedKimiConfigShape,
nextConfig: ManagedKimiConfigShape,
providerId: string,
aliasKeys: ReadonlySet<string>,
): boolean {
return (
providerModelSnapshot(config, providerId, aliasKeys) ===
providerModelSnapshot(nextConfig, providerId, aliasKeys)
);
}
function providerModelSnapshot(
config: ManagedKimiConfigShape,
providerId: string,
aliasKeys: ReadonlySet<string>,
): 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<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, ManagedModel> {
const preserved: Record<string, ManagedModel> = {};
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<string, ManagedModel>,
): 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',
);

View file

@ -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<string, ProviderConfig>;
models: Record<string, ModelAlias>;
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<typeof vi.fn>;
let configReplace: ReturnType<typeof vi.fn>;
let getCachedAccessToken: ReturnType<typeof vi.fn>;
let resolveTokenProvider: ReturnType<typeof vi.fn>;
beforeEach(() => {
disposables = new DisposableStore();
backing = seedBacking();
configSet = vi.fn().mockImplementation(async (domain: string, patch: unknown) => {
const current = (backing as Record<string, unknown>)[domain];
(backing as Record<string, unknown>)[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<string, unknown>)[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<string, unknown>)[domain]) as IConfigService['get'],
inspect: ((domain: string) => ({
value: (backing as Record<string, unknown>)[domain],
defaultValue: undefined,
userValue: (backing as Record<string, unknown>)[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');
});
});

View file

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

View file

@ -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<string, unknown> },
handler: (
req: { id: string; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | 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<IModelCatalogService> {
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<ModelCatalogRouteHost['get']>[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<ModelCatalogRouteHost['post']>[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<ModelCatalogRouteHost['get']>[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<ModelCatalogRouteHost['post']>[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<ModelCatalogRouteHost['get']>[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;
}

View file

@ -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<typeof registerAuthRoute>[0], core);
registerOAuthRoutes(apiV1 as unknown as Parameters<typeof registerOAuthRoutes>[0], core);
registerConfigRoutes(apiV1 as unknown as Parameters<typeof registerConfigRoutes>[0], core);
registerModelCatalogRoutes(
apiV1 as unknown as Parameters<typeof registerModelCatalogRoutes>[0],
core,
);
registerSessionsRoutes(
apiV1 as unknown as Parameters<typeof registerSessionsRoutes>[0],
core,

View file

@ -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<T> {
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<void> {
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<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function postJson<T>(
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
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<T> };
}
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<unknown>('/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<unknown>('/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<unknown>('/api/v1/providers/missing');
expect(provider.body.code).toBe(40412);
const model = await postJson<unknown>('/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: [] });
});
});