Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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/fallback-bundled-model-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix capability detection for manually configured models by falling back to the bundled model catalog.
2 changes: 1 addition & 1 deletion docs/en/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ api_key = "sk-xxxxx"

## `anthropic`

For connecting to the Claude API. Standard Claude models automatically enable vision, tool use, and Thinking (where supported); custom or uncovered models need `capabilities` declared explicitly on `[models.<alias>]`.
For connecting to the Claude API. Kimi Code auto-detects vision, tool call, and Thinking capabilities from the bundled models.dev catalog, then falls back to its built-in model matchers. Only custom models that neither source covers need `capabilities` declared explicitly on `[models.<alias>]`.

- Default `base_url`: follows Anthropic SDK default
- Credential key names: `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ api_key = "sk-xxxxx"

## `anthropic`

用于对接 Claude API。标准 Claude 模型自动启用视觉、工具调用及 Thinking(如支持);自定义或未覆盖的模型需在 `[models.<alias>]` 里显式声明 `capabilities`。
用于对接 Claude API。Kimi Code 会先从随 CLI 发布的 models.dev 目录快照中自动识别视觉、工具调用及 Thinking 能力,再回退到内置模型匹配规则;只有两者都无法覆盖的自定义模型,才需要在 `[models.<alias>]` 里显式声明 `capabilities`。

- 默认 `base_url`:跟随 Anthropic SDK 默认值
- 凭证键名:`ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`
Expand Down
70 changes: 69 additions & 1 deletion packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* `kosongConfig` domain (L3) — the third-party models.dev directory: its
* api.json schema mirrored as types, plus the normalization that turns a
* directory entry into an import decision.
* directory entry into an import decision or resolves one model's capability
* from a bundled snapshot.
*
* models.dev is an EXTERNAL schema that evolves on its own, so its mirror
* lives here in the app layer, NOT in kosong — kosong's type surface stays
Expand Down Expand Up @@ -123,6 +124,14 @@ const KNOWN_WIRE_TYPES = [
/** The enumerated subset of {@link ProviderType} the models.dev import knows. */
type KnownWireType = (typeof KNOWN_WIRE_TYPES)[number];

const MODELS_DEV_CAPABILITY_PROVIDER_IDS: Partial<Record<KnownWireType, readonly string[]>> = {
anthropic: ['anthropic'],
openai: ['openai'],
openai_responses: ['openai'],
'google-genai': ['google'],
vertexai: ['google-vertex', 'google-vertex-anthropic'],
};

function isWireType(value: unknown): value is KnownWireType {
return typeof value === 'string' && (KNOWN_WIRE_TYPES as readonly string[]).includes(value);
}
Expand Down Expand Up @@ -444,6 +453,65 @@ export function modelsDevProviderModels(entry: ModelsDevProviderEntry): ModelsDe
});
}

function normalizeModelsDevPlatformKey(key: string): string {
return key
.toLowerCase()
.replace(/^(?:us|eu|apac|global)\./, '')
.replace(/^(?:anthropic|ai21|amazon|cohere|deepseek|meta|mistral|writer)\./, '')
.replace(/-v\d+(?::\d+)?$/, '');
}

function normalizeModelsDevDeploymentKey(key: string): string {
return normalizeModelsDevPlatformKey(key).replace(/@.*$/, '');
}

function normalizeModelsDevFamilyKey(key: string): string {
return normalizeModelsDevDeploymentKey(key)
.replace(/-\d{8}$/, '')
.replace(/-\d{4}-\d{2}-\d{2}$/, '');
}

export interface ModelsDevCapabilityMatch {
readonly capability: ModelCapability;
readonly providerId: string;
}

export function getModelsDevModelCapability(
catalog: ModelsDevCatalog | undefined,
wire: ProviderType,
modelName: string,
): ModelsDevCapabilityMatch | undefined {
if (catalog === undefined || !isWireType(wire)) return undefined;
const providerIds = MODELS_DEV_CAPABILITY_PROVIDER_IDS[wire];
if (providerIds === undefined) return undefined;
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 = modelsDevModelToCapability(exact);
if (model !== undefined) return { capability: model.capability, providerId };
}
}
for (const normalize of [
normalizeModelsDevPlatformKey,
normalizeModelsDevDeploymentKey,
normalizeModelsDevFamilyKey,
]) {
const target = normalize(modelName);
for (const providerId of providerIds) {
const models = catalog[providerId]?.models;
if (models === undefined) continue;
for (const [key, entry] of Object.entries(models)) {
if (normalize(key) !== target) continue;
const model = modelsDevModelToCapability(entry);
if (model !== undefined) return { capability: model.capability, providerId };
}
}
}
return undefined;
}

/**
* Gateway providers (zenmux, opencode, azure, …) may declare a per-model
* `provider` override when a model is served over a different protocol or
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* `app/kosongConfig` domain (L3) — contributes the bundled models.dev
* snapshot as a capability source without making the L2 provider domain
* depend on app configuration. The multi-megabyte snapshot is parsed lazily
* on the first lookup and never refreshed from the network.
*/

import { registerModelCapabilityResolver } from '#/kosong/provider/modelCapabilityResolver';

import { BUILT_IN_MODELS_DEV_JSON } from './builtInModelsDev';
import {
getModelsDevModelCapability,
type ModelsDevCatalog,
} from './modelsDev';
import { loadBuiltInModelsDevCatalog } from './modelsDevUpstream';

let catalog: ModelsDevCatalog | undefined | null = null;

registerModelCapabilityResolver(({ protocol, providerType, modelName }) => {
if (catalog === null) {
catalog = loadBuiltInModelsDevCatalog(BUILT_IN_MODELS_DEV_JSON);
}
const wire = providerType === 'vertexai' ? providerType : protocol;
Comment thread
NarwhalChen marked this conversation as resolved.
Outdated
const match = getModelsDevModelCapability(catalog, wire, modelName);
if (match === undefined) return undefined;
return {
capability: match.capability,
source: {
kind: 'builtin',
detail: `models.dev built-in snapshot provider '${match.providerId}'`,
},
};
});
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export * from '#/kosong/protocol/protocolBase';
export * from '#/kosong/protocol/protocolTrait';
import '#/app/kosongConfig/envOverlay';
import '#/app/kosongConfig/secondaryModelOverlay';
import '#/app/kosongConfig/modelsDevCapability.contrib';
export * from '#/kosong/model/completionBudget';
export * from '#/kosong/model/hostRequestHeaders';
export * from '#/kosong/model/model';
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/kosong/model/inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ function attributeCapabilities(
);
continue;
}
if (detected?.[key] === true) {
if (detected !== undefined && detectedSource.kind !== 'none') {
sources.set(path, detectedSource);
continue;
}
Expand Down
12 changes: 5 additions & 7 deletions packages/agent-core-v2/src/kosong/protocol/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,9 @@ export interface IProtocolAdapterRegistry {
resolveProviderBaseId(protocol: Protocol, providerType?: string): ProtocolBaseId;

/**
* Capability resolution with the fixed fallback chain: pair definition's
* declared capability → trait `capability` hooks (last declarer wins) →
* the base's own catalog. `UNKNOWN_CAPABILITY` when nothing knows the
* model.
* Capability resolution with the fixed fallback chain: trait `capability`
* hooks (last declarer wins) → registered external sources → the base's own
* catalog. `UNKNOWN_CAPABILITY` when nothing knows the model.
*/
resolveCapability(
protocol: Protocol,
Expand All @@ -133,9 +132,8 @@ export interface IProtocolAdapterRegistry {

/**
* The provenance-preserving twin of `resolveCapability` — the same chain,
* but reports which level answered (definition / trait / base / none), so
* inspection views can attribute a detected capability instead of just
* serving it.
* but reports which source answered, so inspection views can attribute a
* detected capability instead of just serving it.
*/
explainCapability(
protocol: Protocol,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* `kosong/provider` domain (L2) — extension point for capability sources
* owned by higher layers. Resolvers run after protocol traits but before
* protocol-base static catalogs. Registration returns a disposable so tests
* and embedding hosts can restore process-global state.
*/

import { toDisposable, type IDisposable } from '#/_base/di/lifecycle';
import type { ExplainedCapability, Protocol } from '#/kosong/protocol/protocol';

export interface ModelCapabilityQuery {
readonly protocol: Protocol;
readonly providerType?: string;
readonly modelName: string;
}

export type ModelCapabilityResolver = (
query: ModelCapabilityQuery,
) => ExplainedCapability | undefined;

const resolvers: ModelCapabilityResolver[] = [];

export function registerModelCapabilityResolver(
resolver: ModelCapabilityResolver,
): IDisposable {
resolvers.push(resolver);
return toDisposable(() => {
const index = resolvers.lastIndexOf(resolver);
if (index >= 0) resolvers.splice(index, 1);
});
}

export function explainRegisteredModelCapability(
query: ModelCapabilityQuery,
): ExplainedCapability | undefined {
for (let index = resolvers.length - 1; index >= 0; index -= 1) {
const explained = resolvers[index]?.(query);
if (explained !== undefined) return explained;
}
return undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
* `(protocol, providerType)`; composition needs the real config) and
* delegates to the registered base's contrib factory.
* - `resolveCapability` — the fixed fallback chain: trait capability hooks
* (last declarer wins) → the base's own catalog → `UNKNOWN_CAPABILITY`.
* (last declarer wins) → registered external sources →
* the base's own catalog → `UNKNOWN_CAPABILITY`.
*
* Bound at App scope, eager.
*/
Expand All @@ -44,6 +45,7 @@ import {
} from '#/kosong/protocol/protocolBase';
import type { ProtocolTrait, ResolvedTrait, TraitContext } from '#/kosong/protocol/protocolTrait';

import { explainRegisteredModelCapability } from './modelCapabilityResolver';
import { getProviderDefinition } from './providerDefinition';

/**
Expand Down Expand Up @@ -124,6 +126,13 @@ export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry {
};
}

const registeredCapability = explainRegisteredModelCapability({
protocol,
providerType,
modelName,
});
if (registeredCapability !== undefined) return registeredCapability;

const baseCapability = getProtocolBase(identity.baseId)?.capability?.(modelName);
if (baseCapability !== undefined) {
return {
Expand Down
101 changes: 101 additions & 0 deletions packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* `provider.catalog_entry_not_found`;
* - a failed upstream fetch with no built-in snapshot throws
* `provider.catalog_unavailable`;
* - capability lookup prefers exact keys, normalizes platform model ids,
* and leaves misses to lower fallback sources;
* - `importModelsDevProvider` writes the provider + model aliases through
* `config.replace` (never the default pointers), keeps the stored api_key
* on a re-import without one, and rejects OAuth-managed providers and
Expand All @@ -21,6 +23,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { createScopedTestHost } from '#/_base/di/test';
import { Error2, isError2 } from '#/_base/errors/errors';
import { IConfigService } from '#/app/config/config';
import { getModelsDevModelCapability } from '#/app/kosongConfig/modelsDev';
import {
resetModelsDevUpstreamForTest,
setModelsDevUpstreamForTest,
Expand Down Expand Up @@ -101,6 +104,104 @@ const REGISTRY_DOC = {
},
} as const;

describe('getModelsDevModelCapability', () => {
it('prefers an exact model key over normalized variants', () => {
const catalog = {
anthropic: {
models: {
'claude-example': {
id: 'claude-example',
limit: { context: 100_000 },
modalities: { input: ['text'], output: ['text'] },
},
'claude-example-20260724': {
id: 'claude-example-20260724',
limit: { context: 200_000 },
reasoning: true,
modalities: { input: ['text'], output: ['text'] },
},
},
},
};

expect(
getModelsDevModelCapability(catalog, 'anthropic', 'claude-example-20260724'),
).toMatchObject({
providerId: 'anthropic',
capability: { thinking: true, max_context_tokens: 200_000 },
});
});

it('normalizes platform model ids', () => {
const catalog = {
anthropic: {
models: {
'claude-example': {
id: 'claude-example',
limit: { context: 200_000 },
tool_call: true,
modalities: { input: ['text', 'image'], output: ['text'] },
},
},
},
};

expect(
getModelsDevModelCapability(
catalog,
'anthropic',
'us.anthropic.claude-example-v1:0',
),
).toMatchObject({
providerId: 'anthropic',
capability: { image_in: true, tool_use: true },
});
});

it('preserves an explicit date while normalizing a platform model id', () => {
const match = getModelsDevModelCapability(
{
anthropic: {
models: {
'claude-example-20250101': {
id: 'claude-example-20250101',
limit: { context: 200_000 },
modalities: { input: ['text', 'image'], output: ['text'] },
},
'claude-example-20250202': {
id: 'claude-example-20250202',
limit: { context: 200_000 },
modalities: { input: ['text', 'audio'], output: ['text'] },
},
},
},
},
'anthropic',
'us.anthropic.claude-example-20250202-v1:0',
);

expect(match).toMatchObject({
providerId: 'anthropic',
capability: { image_in: false, audio_in: true },
});
});

it('leaves a miss unresolved', () => {
const catalog = {
anthropic: {
models: {
'claude-example': {
id: 'claude-example',
limit: { context: 200_000 },
},
},
},
};

expect(getModelsDevModelCapability(catalog, 'openai', 'claude-example')).toBeUndefined();
});
});

function fetchJson(doc: unknown): typeof fetch {
return (async () =>
new Response(JSON.stringify(doc), {
Expand Down
Loading