Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/v2-catalog-capability-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion apps/kimi-code/src/cli/sub/server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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`
Expand Down
83 changes: 83 additions & 0 deletions packages/agent-core-v2/src/app/llmProtocol/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<ProviderType, readonly string[]>> = {
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;
}
}
37 changes: 37 additions & 0 deletions packages/agent-core-v2/src/app/llmProtocol/catalogSnapshot.ts
Original file line number Diff line number Diff line change
@@ -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<ICatalogSnapshot> =
createDecorator<ICatalogSnapshot>('catalogSnapshot');

export class CatalogSnapshot implements ICatalogSnapshot {
declare readonly _serviceBrand: undefined;

constructor(readonly catalog: Catalog | undefined) {}
}

registerScopedService(
LifecycleScope.App,
ICatalogSnapshot,
CatalogSnapshot,
InstantiationType.Delayed,
'llmProtocol',
);
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 11 additions & 2 deletions packages/agent-core-v2/src/app/model/modelResolverService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -119,6 +124,7 @@ export class ModelResolverService extends Disposable implements IModelResolver {
protocol,
wireName,
model.maxContextSize,
this.catalogSnapshot.catalog,
);
const providerOptions = buildProtocolProviderOptions(
model,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
141 changes: 141 additions & 0 deletions packages/agent-core-v2/test/app/llmProtocol/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading