mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 05:35:34 +00:00
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:
parent
fc801c9ea0
commit
420cefbc02
14 changed files with 1282 additions and 16 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
28
packages/agent-core-v2/src/modelCatalog/errors.ts
Normal file
28
packages/agent-core-v2/src/modelCatalog/errors.ts
Normal 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);
|
||||
9
packages/agent-core-v2/src/modelCatalog/index.ts
Normal file
9
packages/agent-core-v2/src/modelCatalog/index.ts
Normal 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';
|
||||
92
packages/agent-core-v2/src/modelCatalog/modelCatalog.ts
Normal file
92
packages/agent-core-v2/src/modelCatalog/modelCatalog.ts
Normal 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;
|
||||
}
|
||||
417
packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts
Normal file
417
packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts
Normal 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',
|
||||
);
|
||||
285
packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts
Normal file
285
packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
221
packages/server-v2/src/routes/modelCatalog.ts
Normal file
221
packages/server-v2/src/routes/modelCatalog.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
196
packages/server-v2/test/modelCatalog.test.ts
Normal file
196
packages/server-v2/test/modelCatalog.test.ts
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue