diff --git a/.changeset/v2-catalog-capability-fallback.md b/.changeset/v2-catalog-capability-fallback.md new file mode 100644 index 0000000000..39d2ae5c4a --- /dev/null +++ b/.changeset/v2-catalog-capability-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Detect model capabilities from the bundled models.dev catalog in the experimental v2 engine, falling back to the built-in capability table when the catalog has no entry for a model. diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index 63386b7a66..0b62d88e47 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -18,6 +18,7 @@ import { startServer, type ServerLogger } from '@moonshot-ai/server'; import chalk from 'chalk'; import { Option, type Command } from 'commander'; +import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; import { isKimiV2Enabled } from '#/cli/experimental-v2'; import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; import { getNativeWebAssetsDir } from '#/native/web-assets'; @@ -411,7 +412,7 @@ async function runServerInProcess( // its agent-core-v2 engine are only resolved when the flag is on. const { createServerLogger: createServerV2Logger, startServer: startServerV2 } = await import('@moonshot-ai/kap-server'); - const { hostRequestHeadersSeed } = await import('@moonshot-ai/agent-core-v2'); + const { hostRequestHeadersSeed, loadBuiltInCatalog } = await import('@moonshot-ai/agent-core-v2'); const logger = createServerV2Logger({ level: options.logLevel }); const v2 = await startServerV2({ host: options.host, @@ -429,6 +430,7 @@ async function runServerInProcess( // X-Msh-* identity as direct CLI runs. seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)), webAssetsDir: serverWebAssetsDir(), + catalog: loadBuiltInCatalog(BUILT_IN_CATALOG_JSON), }); // v2's connection registry exposes no count-change hook, so forward // add/remove to the daemon's idle-shutdown handler (a no-op when `idle` diff --git a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts index 8751ead217..45c402bb6b 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts @@ -159,3 +159,86 @@ export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel .map((model) => catalogModelToCapability(model)) .filter((model): model is CatalogModel => model !== undefined); } + +/** + * models.dev provider ids searched for each wire type when the catalog serves + * as a capability fallback. Only first-party providers are mapped: a relay or + * gateway transparently proxies the upstream model, so the first-party entry + * carries the same boolean capabilities. The `kimi` wire is intentionally + * absent — its capabilities come from config declarations. + */ +const CATALOG_FALLBACK_PROVIDER_IDS: Partial> = { + anthropic: ['anthropic'], + openai: ['openai'], + openai_responses: ['openai'], + 'google-genai': ['google'], + vertexai: ['google-vertex', 'google-vertex-anthropic'], +}; + +/** + * Normalizes a catalog model key or configured model id so platform-specific + * id shapes still compare equal: Bedrock region (`us.`) / vendor (`anthropic.`) + * prefixes, Vertex `@version` suffixes, Bedrock `-v1:0` revisions, and trailing + * date stamps (`-20251001`, `-2024-05-13`). + */ +function normalizeCatalogModelKey(key: string): string { + return key + .toLowerCase() + .replace(/^(?:us|eu|apac|global)\./, '') + .replace(/^(?:anthropic|ai21|amazon|cohere|deepseek|meta|mistral|writer)\./, '') + .replace(/@.*$/, '') + .replace(/-v\d+(?::\d+)?$/, '') + .replace(/-\d{8}$/, '') + .replace(/-\d{4}-\d{2}-\d{2}$/, ''); +} + +/** + * Looks up a model's {@link ModelCapability} in a models.dev catalog snapshot. + * The lookup keys on wire type, not the user's provider id: a provider pointed + * at a relay base URL still proxies the first-party model, whose catalogued + * capabilities apply. An exact catalog key match takes precedence over the + * normalized comparison: several keys normalize to the same string (a base id + * and its dated snapshots), and the exact entry must not lose to JSON key + * order. Returns `undefined` on any miss (no catalog, unmapped wire, absent + * provider entry, unmatched or non-chat model) so callers can fall back to + * the built-in capability table. + */ +export function getCatalogModelCapability( + catalog: Catalog | undefined, + wire: ProviderType, + modelName: string, +): ModelCapability | undefined { + if (catalog === undefined) return undefined; + const providerIds = CATALOG_FALLBACK_PROVIDER_IDS[wire]; + if (providerIds === undefined) return undefined; + const target = normalizeCatalogModelKey(modelName); + for (const providerId of providerIds) { + const models = catalog[providerId]?.models; + if (models === undefined) continue; + const exact = models[modelName] ?? models[modelName.toLowerCase()]; + if (exact !== undefined) { + const model = catalogModelToCapability(exact); + if (model !== undefined) return model.capability; + } + for (const [key, entry] of Object.entries(models)) { + if (normalizeCatalogModelKey(key) !== target) continue; + const model = catalogModelToCapability(entry); + if (model !== undefined) return model.capability; + } + } + return undefined; +} + +/** + * Parses an optional pruned models.dev catalog string — typically the + * `__KIMI_CODE_BUILT_IN_CATALOG__` constant injected by tsdown at build + * time. Returns `undefined` when the argument is missing or invalid. + */ +export function loadBuiltInCatalog(json: string | undefined): Catalog | undefined { + if (typeof json !== 'string' || json.length === 0) return undefined; + try { + return JSON.parse(json) as Catalog; + } catch { + return undefined; + } +} diff --git a/packages/agent-core-v2/src/app/llmProtocol/catalogSnapshot.ts b/packages/agent-core-v2/src/app/llmProtocol/catalogSnapshot.ts new file mode 100644 index 0000000000..e92601a452 --- /dev/null +++ b/packages/agent-core-v2/src/app/llmProtocol/catalogSnapshot.ts @@ -0,0 +1,37 @@ +/** + * `llmProtocol` domain (L0) — models.dev catalog snapshot seeded at startup. + * + * Holds the process-wide models.dev catalog snapshot that model resolution + * uses as a capability fallback ahead of the built-in capability table. + * `catalog` is `undefined` when the host did not embed a snapshot (dev + * builds) or the embedded JSON failed to parse, in which case detection + * falls back to the built-in table alone. Bound at App scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import type { Catalog } from './catalog'; + +export interface ICatalogSnapshot { + readonly _serviceBrand: undefined; + readonly catalog: Catalog | undefined; +} + +export const ICatalogSnapshot: ServiceIdentifier = + createDecorator('catalogSnapshot'); + +export class CatalogSnapshot implements ICatalogSnapshot { + declare readonly _serviceBrand: undefined; + + constructor(readonly catalog: Catalog | undefined) {} +} + +registerScopedService( + LifecycleScope.App, + ICatalogSnapshot, + CatalogSnapshot, + InstantiationType.Delayed, + 'llmProtocol', +); diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/capability-registry.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/capability-registry.ts index 5f0343bd3e..d8d9f425a4 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/capability-registry.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/capability-registry.ts @@ -1,3 +1,10 @@ +/** + * FROZEN: this static prefix table is the legacy last-resort fallback behind + * the models.dev catalog lookup (see ../catalog.ts `getCatalogModelCapability`). + * Do not add new models here — new coverage comes from the catalog snapshot. + * The table only serves hosts/builds without a bundled catalog and model ids + * the catalog does not cover; delete it once a snapshot is always available. + */ import { UNKNOWN_CAPABILITY, type ModelCapability } from '../capability'; type CapabilityMatcher = (normalizedModelName: string) => boolean; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/providers.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/providers.ts index d95e9c58e9..f6e70d9875 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/providers.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/providers.ts @@ -50,6 +50,10 @@ export function createProvider(config: ProviderConfig): ChatProvider { * Unknown / uncatalogued models (and the Kimi wire, whose capabilities come * from the host's catalog/config rather than the model name) return * {@link UNKNOWN_CAPABILITY} so capability checks stay non-fatal. + * + * Legacy fallback only: callers should prefer the models.dev catalog lookup + * (`getCatalogModelCapability`) and treat this table as frozen — see the + * notice in `./capability-registry`. */ export function getModelCapability(wire: ProviderType, modelName: string): ModelCapability { switch (wire) { diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 00d0deea19..e3d8cde0ac 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -3,7 +3,9 @@ * * Reads Model / Provider / Platform config, resolves the auth closure * (Platform.auth or Model-inline override), materializes a runnable - * `Model` god-object via `ModelImpl`. Bound at App scope. + * `Model` god-object via `ModelImpl`. Detected capabilities come from the + * `llmProtocol` catalog snapshot (`ICatalogSnapshot`) first, falling back + * to the built-in capability table. Bound at App scope. * * Two config-driven paths: * - **Structured** — `Model.providerId` points at a `[providers.*]` entry, @@ -23,6 +25,8 @@ import { IOAuthService } from '#/app/auth/auth'; import { IConfigService } from '#/app/config/config'; import { ErrorCodes, Error2 } from '#/errors'; import { type ModelCapability } from '#/app/llmProtocol/capability'; +import { getCatalogModelCapability, type Catalog } from '#/app/llmProtocol/catalog'; +import { ICatalogSnapshot } from '#/app/llmProtocol/catalogSnapshot'; import { type ProviderRequestAuth } from '#/app/llmProtocol/request'; import { type ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; import { getModelCapability } from '#/app/llmProtocol/providers/providers'; @@ -70,6 +74,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { @IProtocolAdapterRegistry private readonly protocolRegistry: IProtocolAdapterRegistry, @IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders, + @ICatalogSnapshot private readonly catalogSnapshot: ICatalogSnapshot, ) { super(); } @@ -119,6 +124,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { protocol, wireName, model.maxContextSize, + this.catalogSnapshot.catalog, ); const providerOptions = buildProtocolProviderOptions( model, @@ -335,9 +341,12 @@ function resolveModelCapabilities( protocol: Protocol, wireName: string, maxContextSize: number, + catalog: Catalog | undefined, ): ModelCapability { const declared = new Set((declaredCapabilities ?? []).map((c) => c.trim().toLowerCase())); - const detected = getModelCapability(protocol, wireName); + const detected = + getCatalogModelCapability(catalog, protocol, wireName) ?? + getModelCapability(protocol, wireName); return { image_in: declared.has('image_in') || detected.image_in, video_in: declared.has('video_in') || detected.video_in, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 94e31c4ef4..093357be05 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -60,6 +60,8 @@ import '#/app/event/eventService'; export { IEventBus, type DomainEvent } from '#/app/event/eventBus'; export { IEventService, type DomainEvent as GlobalEvent } from '#/app/event/event'; export * from '#/app/llmProtocol/capability'; +export * from '#/app/llmProtocol/catalog'; +export * from '#/app/llmProtocol/catalogSnapshot'; export * from '#/app/llmProtocol/errors'; export * from '#/app/llmProtocol/finishReason'; export * from '#/app/llmProtocol/kimiOptions'; diff --git a/packages/agent-core-v2/test/app/llmProtocol/catalog.test.ts b/packages/agent-core-v2/test/app/llmProtocol/catalog.test.ts new file mode 100644 index 0000000000..a2db51a6a6 --- /dev/null +++ b/packages/agent-core-v2/test/app/llmProtocol/catalog.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { + getCatalogModelCapability, + loadBuiltInCatalog, + type Catalog, +} from '#/app/llmProtocol/catalog'; + +describe('getCatalogModelCapability', () => { + const catalog: Catalog = { + anthropic: { + id: 'anthropic', + models: { + 'claude-sonnet-4-5': { + id: 'claude-sonnet-4-5', + limit: { context: 1000000 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + }, + }, + openai: { + id: 'openai', + models: { + 'gpt-4o': { + id: 'gpt-4o', + limit: { context: 128000 }, + tool_call: true, + modalities: { input: ['text', 'image', 'audio'], output: ['text'] }, + }, + }, + }, + 'google-vertex-anthropic': { + id: 'google-vertex-anthropic', + models: { + 'claude-sonnet-4-5@20250929': { + id: 'claude-sonnet-4-5@20250929', + limit: { context: 200000 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + }, + }, + }; + + it('returns undefined without a catalog or for the kimi wire', () => { + expect(getCatalogModelCapability(undefined, 'openai', 'gpt-4o')).toBeUndefined(); + expect(getCatalogModelCapability(catalog, 'kimi', 'kimi-for-coding')).toBeUndefined(); + }); + + it('matches the model id under the wire-mapped first-party provider', () => { + expect(getCatalogModelCapability(catalog, 'openai', 'gpt-4o')).toMatchObject({ + image_in: true, + audio_in: true, + tool_use: true, + }); + }); + + it('serves openai_responses from the openai provider entry', () => { + expect(getCatalogModelCapability(catalog, 'openai_responses', 'gpt-4o')).toBeDefined(); + }); + + it('normalizes platform-specific id shapes', () => { + // Bedrock-style id on the anthropic wire: region + vendor prefix, revision. + expect( + getCatalogModelCapability(catalog, 'anthropic', 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'), + ).toMatchObject({ thinking: true }); + // Vertex claude ids carry an @version suffix; vertexai searches google-vertex-anthropic. + expect( + getCatalogModelCapability(catalog, 'vertexai', 'claude-sonnet-4-5@20250929'), + ).toMatchObject({ thinking: true }); + // Dashed date suffixes match the undated base key. + expect(getCatalogModelCapability(catalog, 'openai', 'gpt-4o-2024-05-13')).toBeDefined(); + }); + + it('prefers an exact key over normalizing-equal entries regardless of JSON order', () => { + const colliding: Catalog = { + anthropic: { + id: 'anthropic', + models: { + // Dated key listed first: JSON key order must not decide the winner. + 'claude-sonnet-4-5-20250929': { + id: 'claude-sonnet-4-5-20250929', + limit: { context: 200000 }, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + }, + 'claude-sonnet-4-5': { + id: 'claude-sonnet-4-5', + limit: { context: 200000 }, + tool_call: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + }, + }, + }; + + // Exact base id wins over the earlier dated key; exact dated id wins over + // the base entry; case-insensitive exact still beats the normalized scan. + expect(getCatalogModelCapability(colliding, 'anthropic', 'claude-sonnet-4-5')).toMatchObject({ + image_in: true, + }); + expect( + getCatalogModelCapability(colliding, 'anthropic', 'claude-sonnet-4-5-20250929'), + ).toMatchObject({ image_in: false }); + expect(getCatalogModelCapability(colliding, 'anthropic', 'Claude-Sonnet-4-5')).toMatchObject({ + image_in: true, + }); + }); + + it('returns undefined for models absent from the catalog', () => { + expect(getCatalogModelCapability(catalog, 'openai', 'gpt-4.1')).toBeUndefined(); + expect(getCatalogModelCapability(catalog, 'google-genai', 'gemini-2.5-pro')).toBeUndefined(); + }); +}); + +describe('loadBuiltInCatalog', () => { + it('parses a pruned models.dev catalog JSON string', () => { + const json = JSON.stringify({ + openai: { + id: 'openai', + models: { 'gpt-4o': { id: 'gpt-4o', limit: { context: 128000 } } }, + }, + }); + + expect(loadBuiltInCatalog(json)).toMatchObject({ + openai: { models: { 'gpt-4o': { id: 'gpt-4o' } } }, + }); + }); + + it('returns undefined for a missing or empty argument', () => { + expect(loadBuiltInCatalog(undefined)).toBeUndefined(); + expect(loadBuiltInCatalog('')).toBeUndefined(); + }); + + it('returns undefined for invalid JSON', () => { + expect(loadBuiltInCatalog('{not json')).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/app/model/modelResolver.test.ts b/packages/agent-core-v2/test/app/model/modelResolver.test.ts index 7fc9eaf64c..d74acc9a6b 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver.test.ts @@ -19,6 +19,8 @@ import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { IOAuthService } from '#/app/auth/auth'; import { IConfigService } from '#/app/config/config'; +import { type Catalog } from '#/app/llmProtocol/catalog'; +import { ICatalogSnapshot } from '#/app/llmProtocol/catalogSnapshot'; import { APIStatusError } from '#/app/llmProtocol/errors'; import { type ModelConfig, IModelService } from '#/app/model/model'; import { HostRequestHeaders, IHostRequestHeaders } from '#/app/model/hostRequestHeaders'; @@ -39,6 +41,7 @@ describe('ModelResolverService', () => { let platforms: Record; let models: Record; let configValues: Record; + let catalogSnapshot: Catalog | undefined; let resolveTokenProvider: ReturnType; let createdProtocolConfigs: Record[]; @@ -48,6 +51,7 @@ describe('ModelResolverService', () => { platforms = {}; models = {}; configValues = {}; + catalogSnapshot = undefined; resolveTokenProvider = vi.fn(); createdProtocolConfigs = []; generateImpl = async () => ({ @@ -91,6 +95,11 @@ describe('ModelResolverService', () => { }); reg.define(IModelResolver, ModelResolverService); reg.defineInstance(IHostRequestHeaders, new HostRequestHeaders()); + reg.definePartialInstance(ICatalogSnapshot, { + get catalog() { + return catalogSnapshot; + }, + }); }, }); }); @@ -719,6 +728,110 @@ describe('ModelResolverService', () => { }); }); + describe('catalog snapshot capabilities', () => { + // gpt-4o in the built-in table: image_in true, audio_in false. The snapshot + // entry below flips both (audio-only input), so a hit is distinguishable + // from the hardcoded fallback. + const openaiCatalog: Catalog = { + openai: { + id: 'openai', + models: { + 'gpt-4o': { + id: 'gpt-4o', + limit: { context: 128000 }, + tool_call: true, + modalities: { input: ['text', 'audio'], output: ['text'] }, + }, + }, + }, + }; + + function configureOpenAIModel(capabilities?: string[]): void { + providers['p'] = { type: 'openai', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'gpt-4o', + maxContextSize: 128000, + capabilities, + }; + } + + it('prefers the catalog snapshot over the built-in capability table', () => { + configureOpenAIModel(); + catalogSnapshot = openaiCatalog; + + expect(ix.get(IModelResolver).resolve('m').capabilities).toEqual({ + image_in: false, + video_in: false, + audio_in: true, + thinking: false, + tool_use: true, + // max_context_tokens stays config-only; the catalog limit never wins. + max_context_tokens: 128000, + select_tools: false, + }); + }); + + it('falls back to the built-in table when the catalog has no matching model', () => { + configureOpenAIModel(); + catalogSnapshot = { + anthropic: { + id: 'anthropic', + models: { + 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', limit: { context: 200000 } }, + }, + }, + }; + + expect(ix.get(IModelResolver).resolve('m').capabilities).toMatchObject({ + image_in: true, + audio_in: false, + }); + }); + + it('falls back to the built-in table when no snapshot is seeded', () => { + configureOpenAIModel(); + + expect(ix.get(IModelResolver).resolve('m').capabilities).toMatchObject({ + image_in: true, + audio_in: false, + }); + }); + + it('ignores the catalog for the kimi wire', () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { provider: 'p', model: 'gpt-4o', maxContextSize: 1000 }; + catalogSnapshot = openaiCatalog; + + expect(ix.get(IModelResolver).resolve('m').capabilities).toEqual({ + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: false, + max_context_tokens: 1000, + select_tools: false, + }); + }); + + it('OR-merges declared capabilities over catalog detection', () => { + configureOpenAIModel(['image_in', 'select_tools']); + catalogSnapshot = openaiCatalog; + + expect(ix.get(IModelResolver).resolve('m').capabilities).toEqual({ + // declared image_in wins over the catalog's false; the catalog's + // audio_in survives because declarations only OR, never clear. + image_in: true, + video_in: false, + audio_in: true, + thinking: false, + tool_use: true, + max_context_tokens: 128000, + select_tools: true, + }); + }); + }); + describe('default thinking', () => { function resolveEffort(capabilities?: string[]): string | null { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index e5443a4ad4..2baa973e14 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -127,6 +127,7 @@ import { type Model } from '#/app/model/modelInstance'; import { IHostRequestHeaders } from '#/app/model/hostRequestHeaders'; import { IModelResolver } from '#/app/model/modelResolver'; import { ModelResolverService } from '#/app/model/modelResolverService'; +import { ICatalogSnapshot } from '#/app/llmProtocol/catalogSnapshot'; import { IPlatformService } from '#/app/platform/platform'; import { IProviderService } from '#/app/provider/provider'; import type { ApprovalResponse } from '#/session/approval/approval'; @@ -839,8 +840,9 @@ class ConfigBackedModelResolver extends ModelResolverService { @IOAuthService oauth: IOAuthService, @IProtocolAdapterRegistry protocolRegistry: IProtocolAdapterRegistry, @IHostRequestHeaders hostRequestHeaders: IHostRequestHeaders, + @ICatalogSnapshot catalogSnapshot: ICatalogSnapshot, ) { - super(config, providers, platforms, models, oauth, protocolRegistry, hostRequestHeaders); + super(config, providers, platforms, models, oauth, protocolRegistry, hostRequestHeaders, catalogSnapshot); } override resolve(id: string): Model { diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 392d97548b..8b71b3bba1 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -9,7 +9,9 @@ import { bootstrap, + CatalogSnapshot, hostRequestHeadersSeed, + ICatalogSnapshot, IConfigService, IModelCatalogService, logSeed, @@ -17,6 +19,7 @@ import { resolveConfigPath, resolveKimiHome, resolveLoggingConfig, + type Catalog, type Scope, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; @@ -101,6 +104,12 @@ export interface ServerStartOptions { readonly rpcToken?: string; /** Extra scope seeds applied at bootstrap (e.g. a host-provided `ISessionModelResolver`). */ readonly seeds?: ScopeSeed; + /** + * models.dev catalog snapshot used as a capability fallback during model + * resolution (ahead of the built-in capability table). `undefined` — e.g. + * dev builds without an embedded snapshot — keeps the built-in table alone. + */ + readonly catalog?: Catalog; /** * Directory of the built Kimi web UI (`dist-web`). When set, `GET /` and the * `/*` SPA fallback serve these assets (auth-exempt, matching v1). Omit to run @@ -219,6 +228,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise