diff --git a/.changeset/fallback-bundled-model-capabilities.md b/.changeset/fallback-bundled-model-capabilities.md new file mode 100644 index 0000000000..76599ead8b --- /dev/null +++ b/.changeset/fallback-bundled-model-capabilities.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix capability detection for configured models by using bundled catalog metadata, with an explicit catalog provider override for compatible endpoints. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index e8df6e99cf..0fbbdf3edf 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -124,6 +124,7 @@ Each entry in the `providers` table defines an API provider, keyed by a unique n | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | `string` | Yes | Provider type: `kimi`, `anthropic`, `openai`, `openai_responses`, `google-genai`, `vertexai` | +| `catalog_provider` | `string` | No | Exact provider id in the bundled [models.dev](https://models.dev/) snapshot used for capability metadata. It does not change the API protocol selected by `type`. Usually written automatically by `/provider`; set it by hand for an OpenAI-compatible third-party provider such as `deepseek` | | `api_key` | `string` | No | API key, written in plain text in the config file | | `base_url` | `string` | No | API base URL | | `oauth` | `table` | No | OAuth credential reference (`storage` and `key` fields); injected automatically by the login flow — normally no need to write this by hand | diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index 7ba39009e0..714c2fb431 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -15,7 +15,7 @@ The `type` field in the `providers` table determines which protocol implementati | `google-genai` | Google GenAI | Gemini API | | `vertexai` | Google GenAI on Vertex | Google Cloud Vertex AI | -All providers communicate with models in streaming mode by default. Capabilities such as thinking, vision, and tool use are matched automatically by model name prefix — you typically do not need to declare them manually. +All providers communicate with models in streaming mode by default. Kimi Code first reads capability metadata from its bundled models.dev snapshot, then falls back to built-in model matchers. For an official endpoint, `type` selects the corresponding catalog provider automatically. For a third-party endpoint that only shares a protocol, set `catalog_provider` explicitly; `type` continues to control the API protocol. **Credential priority**: `api_key` direct field > `[providers..env]` sub-table key > if both are absent, startup fails with an error. The CLI does not fall back to shell environment variables for credentials — see [Config overrides: provider credentials](./overrides.md#provider-credentials). @@ -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.]`. +For connecting to the Claude API. The default `anthropic` catalog identity lets Kimi Code auto-detect vision, tool call, and Thinking capabilities from the bundled models.dev snapshot before falling back to its built-in model matchers. Only custom models that neither source covers need `capabilities` declared explicitly on `[models.]`. - Default `base_url`: follows Anthropic SDK default - Credential key names: `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL` @@ -93,6 +93,23 @@ base_url = "https://api.openai.com/v1" api_key = "sk-xxxxx" ``` +For an OpenAI-compatible third-party endpoint, keep `type = "openai"` because it controls the wire protocol, and set `catalog_provider` to the exact models.dev provider id used for capability metadata: + +```toml +[providers.deepseek] +type = "openai" +catalog_provider = "deepseek" +base_url = "https://api.deepseek.com" +api_key = "sk-xxxxx" + +[models."deepseek/v4-pro"] +provider = "deepseek" +model = "deepseek-v4-pro" +max_context_size = 1000000 +``` + +Kimi Code looks up the configured `model` within the selected catalog provider. It does not guess a vendor from the local provider name or scan unrelated providers for a matching model id. If a gateway replaces the canonical model id with an arbitrary deployment alias, declare capabilities explicitly instead. + ## `openai_responses` Corresponds to OpenAI's newer Responses API, always operating in streaming mode. Configuration is the same as `openai`. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index be8757e99b..0282c1c2bc 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -124,6 +124,7 @@ timeout = 5 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `type` | `string` | 是 | 供应商类型:`kimi`、`anthropic`、`openai`、`openai_responses`、`google-genai`、`vertexai` | +| `catalog_provider` | `string` | 否 | 随 CLI 发布的 [models.dev](https://models.dev/) 快照中的精确供应商 ID,用于读取能力元数据,不会改变 `type` 选择的 API 协议。通常由 `/provider` 自动写入;手动配置 DeepSeek 等 OpenAI 兼容供应商时可显式设置,例如 `deepseek` | | `api_key` | `string` | 否 | API 密钥,明文写在配置文件里 | | `base_url` | `string` | 否 | API 基础 URL | | `oauth` | `table` | 否 | OAuth 凭据引用(`storage`、`key` 两个字段),由登录流程自动注入,通常无需手写 | diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index b062ff9938..23a0fc0f92 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -15,7 +15,7 @@ Kimi Code CLI 支持同时接入多家 LLM 平台——用 Kimi Code 托管服 | `google-genai` | Google GenAI | Gemini API | | `vertexai` | Google GenAI on Vertex | Google Cloud Vertex AI | -所有供应商默认以流式方式与模型交互。thinking、视觉、工具调用等能力按模型名前缀自动匹配,通常不需要手动声明。 +所有供应商默认以流式方式与模型交互。Kimi Code 会先从随 CLI 发布的 models.dev 快照读取能力元数据,再回退到内置模型匹配规则。对官方端点,`type` 会自动选择对应的目录供应商;对只是共用协议的第三方端点,需要显式设置 `catalog_provider`,而 `type` 仍只负责选择 API 协议。 **凭证优先级**:`api_key` 直接字段 > `[providers..env]` 子表键 > 两者都缺时启动报错。CLI 不会从 shell 环境变量自动取凭证——详见[配置覆盖:供应商凭证](./overrides.md#供应商凭证)。 @@ -59,7 +59,7 @@ api_key = "sk-xxxxx" ## `anthropic` -用于对接 Claude API。标准 Claude 模型自动启用视觉、工具调用及 Thinking(如支持);自定义或未覆盖的模型需在 `[models.]` 里显式声明 `capabilities`。 +用于对接 Claude API。默认的 `anthropic` 目录身份让 Kimi Code 先从随 CLI 发布的 models.dev 快照中自动识别视觉、工具调用及 Thinking 能力,再回退到内置模型匹配规则;只有两者都无法覆盖的自定义模型,才需要在 `[models.]` 里显式声明 `capabilities`。 - 默认 `base_url`:跟随 Anthropic SDK 默认值 - 凭证键名:`ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL` @@ -93,6 +93,23 @@ base_url = "https://api.openai.com/v1" api_key = "sk-xxxxx" ``` +配置 OpenAI 兼容的第三方端点时,保留 `type = "openai"`,因为它决定在线协议;同时把 `catalog_provider` 设为 models.dev 中用于读取能力元数据的精确供应商 ID: + +```toml +[providers.deepseek] +type = "openai" +catalog_provider = "deepseek" +base_url = "https://api.deepseek.com" +api_key = "sk-xxxxx" + +[models."deepseek/v4-pro"] +provider = "deepseek" +model = "deepseek-v4-pro" +max_context_size = 1000000 +``` + +Kimi Code 会在指定的目录供应商中查找配置的 `model`。它不会根据本地供应商名称猜厂商,也不会扫描其他供应商寻找同名模型。如果网关把规范模型 ID 换成任意部署别名,请改为显式声明能力。 + ## `openai_responses` 对应 OpenAI 较新的 Responses API,始终以流式方式工作。配置方式与 `openai` 相同。 diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index d89be92abe..eddd4610a0 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -276,6 +276,7 @@ merge_all_available_skills = true # base_url: string # custom_headers: record # default_model: string + # catalog_provider: string # type: string # api_key: string # oauth: object diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index 4c89da4cc4..a84cc271fe 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -72,6 +72,7 @@ export const ProviderConfigSchema = z.object({ baseUrl: z.string().optional(), customHeaders: StringRecordSchema.optional(), defaultModel: z.string().optional(), + catalogProvider: z.string().min(1).optional(), type: ProviderTypeSchema.optional(), apiKey: z.string().optional(), diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts index 82b964bf09..965cac3726 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts @@ -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 @@ -123,6 +124,15 @@ const KNOWN_WIRE_TYPES = [ /** The enumerated subset of {@link ProviderType} the models.dev import knows. */ type KnownWireType = (typeof KNOWN_WIRE_TYPES)[number]; +const DEFAULT_MODELS_DEV_PROVIDER_BY_TYPE: Readonly> = { + anthropic: 'anthropic', + openai: 'openai', + openai_responses: 'openai', + 'google-genai': 'google', + vertexai: 'google-vertex', + kimi: 'moonshotai', +}; + function isWireType(value: unknown): value is KnownWireType { return typeof value === 'string' && (KNOWN_WIRE_TYPES as readonly string[]).includes(value); } @@ -444,6 +454,71 @@ 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 resolveModelsDevCapabilityProvider(query: { + readonly protocol: ProviderType; + readonly providerType?: string; + readonly catalogProvider?: string; + readonly vertexai?: boolean; +}): string | undefined { + return ( + query.catalogProvider ?? + (query.vertexai === true + ? 'google-vertex' + : DEFAULT_MODELS_DEV_PROVIDER_BY_TYPE[query.providerType ?? query.protocol]) + ); +} + +export function getModelsDevModelCapability( + catalog: ModelsDevCatalog | undefined, + providerId: string, + modelName: string, +): ModelsDevCapabilityMatch | undefined { + if (catalog === undefined) return undefined; + const models = catalog[providerId]?.models; + if (models === undefined) return undefined; + 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 [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 diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevCapability.contrib.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevCapability.contrib.ts new file mode 100644 index 0000000000..b5995073f6 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevCapability.contrib.ts @@ -0,0 +1,40 @@ +/** + * `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, + resolveModelsDevCapabilityProvider, +} from './modelsDev'; +import { loadBuiltInModelsDevCatalog } from './modelsDevUpstream'; + +let catalog: ModelsDevCatalog | undefined | null = null; + +registerModelCapabilityResolver((query) => { + if (catalog === null) { + catalog = loadBuiltInModelsDevCatalog(BUILT_IN_MODELS_DEV_JSON); + } + const providerId = resolveModelsDevCapabilityProvider({ + protocol: query.protocol, + providerType: query.providerType, + catalogProvider: query.catalogProvider, + vertexai: query.providerOptions?.vertexai, + }); + if (providerId === undefined) return undefined; + const match = getModelsDevModelCapability(catalog, providerId, query.modelName); + if (match === undefined) return undefined; + return { + capability: match.capability, + source: { + kind: 'builtin', + detail: `models.dev built-in snapshot provider '${match.providerId}'`, + }, + }; +}); diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts index 559d636f50..b759cbd53e 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts @@ -194,9 +194,12 @@ export class ModelsDevImportService implements IModelsDevImportService { // `undefined` when the wire needs none, so a stale on-disk value is // really cleared. The global default pointers are deliberately left // alone. - const provider: ProviderConfig = { type: resolution.wire }; - provider.baseUrl = resolution.baseUrl; - provider.apiKey = options.apiKey ?? existing?.apiKey; + const provider: ProviderConfig = { + type: resolution.wire, + catalogProvider: catalogId, + baseUrl: resolution.baseUrl, + apiKey: options.apiKey ?? existing?.apiKey, + }; await config.replace(PROVIDERS_SECTION, { ...providers, [targetId]: provider }); // Two-pass alias swap: pass 1 drops the provider's aliases for real diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e11416f137..dd14a6130e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index 5251c4ffeb..5bda3d7f89 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -21,8 +21,8 @@ * referenced provider vendor's declared `baseProtocol`; endpoint and * credential env fallbacks resolve through `resolveProviderEndpoint` against * the config env bag; host-header forwarding follows the vendor definition's - * `hostHeaders`; capability detection is `resolveCapability(protocol, name, - * providerType)`. + * `hostHeaders`; capability detection receives the resolved provider mode and + * explicit models.dev provider identity separately from wire identity. * * Caching (load-bearing): assembled entries are invalidated ONLY by the * model/provider config-change events. Tests that mutate config @@ -379,10 +379,20 @@ export class ModelCatalog extends Disposable implements IModelCatalog { ); } + const providerOptions = buildProtocolProviderOptions( + model, + protocol, + providerConfig, + resolvedBaseUrl, + ); const explainedCapability = this.protocolRegistry.explainCapability( protocol, wireName, providerType, + { + catalogProvider: providerConfig?.catalogProvider, + providerOptions, + }, ); trace.capture(TRACE.detectedCapability, explainedCapability.capability); trace.capture(TRACE.capabilitySource, explainedCapability.source); @@ -392,12 +402,6 @@ export class ModelCatalog extends Disposable implements IModelCatalog { model.maxContextSize, model.maxInputSize, ); - const providerOptions = buildProtocolProviderOptions( - model, - protocol, - providerConfig, - resolvedBaseUrl, - ); if (providerOptions !== undefined) { attributeProviderOptions(trace, providerOptions, providerConfig?.env); } diff --git a/packages/agent-core-v2/src/kosong/model/inspection.ts b/packages/agent-core-v2/src/kosong/model/inspection.ts index fa5bcd96eb..4301b178d6 100644 --- a/packages/agent-core-v2/src/kosong/model/inspection.ts +++ b/packages/agent-core-v2/src/kosong/model/inspection.ts @@ -491,7 +491,7 @@ function attributeCapabilities( ); continue; } - if (detected?.[key] === true) { + if (detected !== undefined && detectedSource.kind !== 'none') { sources.set(path, detectedSource); continue; } diff --git a/packages/agent-core-v2/src/kosong/protocol/protocol.ts b/packages/agent-core-v2/src/kosong/protocol/protocol.ts index 2a318642f2..ae6c2a94a6 100644 --- a/packages/agent-core-v2/src/kosong/protocol/protocol.ts +++ b/packages/agent-core-v2/src/kosong/protocol/protocol.ts @@ -88,6 +88,11 @@ export interface ExplainedCapability { readonly source: InspectionSource; } +export interface CapabilityResolutionContext { + readonly catalogProvider?: string; + readonly providerOptions?: ProtocolProviderOptions; +} + export interface IProtocolAdapterRegistry { readonly _serviceBrand: undefined; @@ -120,27 +125,27 @@ 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, modelName: string, providerType?: string, + context?: CapabilityResolutionContext, ): ModelCapability; /** * 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, modelName: string, providerType?: string, + context?: CapabilityResolutionContext, ): ExplainedCapability; /** diff --git a/packages/agent-core-v2/src/kosong/provider/modelCapabilityResolver.ts b/packages/agent-core-v2/src/kosong/provider/modelCapabilityResolver.ts new file mode 100644 index 0000000000..63ff4c55c3 --- /dev/null +++ b/packages/agent-core-v2/src/kosong/provider/modelCapabilityResolver.ts @@ -0,0 +1,45 @@ +/** + * `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 { + CapabilityResolutionContext, + ExplainedCapability, + Protocol, +} from '#/kosong/protocol/protocol'; + +export interface ModelCapabilityQuery extends CapabilityResolutionContext { + 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; +} diff --git a/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts b/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts index f03c6a5833..bfdd328e23 100644 --- a/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts +++ b/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts @@ -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. */ @@ -32,6 +33,7 @@ import { ChatProviderError } from '#/kosong/contract/errors'; import type { ChatProvider } from '#/kosong/contract/provider'; import { IProtocolAdapterRegistry, + type CapabilityResolutionContext, type ExplainedCapability, type Protocol, type ProtocolAdapterConfig, @@ -44,6 +46,7 @@ import { } from '#/kosong/protocol/protocolBase'; import type { ProtocolTrait, ResolvedTrait, TraitContext } from '#/kosong/protocol/protocolTrait'; +import { explainRegisteredModelCapability } from './modelCapabilityResolver'; import { getProviderDefinition } from './providerDefinition'; /** @@ -96,14 +99,20 @@ export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry { return protocol; } - resolveCapability(protocol: Protocol, modelName: string, providerType?: string): ModelCapability { - return this.explainCapability(protocol, modelName, providerType).capability; + resolveCapability( + protocol: Protocol, + modelName: string, + providerType?: string, + context?: CapabilityResolutionContext, + ): ModelCapability { + return this.explainCapability(protocol, modelName, providerType, context).capability; } explainCapability( protocol: Protocol, modelName: string, providerType?: string, + context?: CapabilityResolutionContext, ): ExplainedCapability { const identity = this.resolveAdapterIdentity(protocol, providerType); let traitCapability: ModelCapability | undefined; @@ -124,6 +133,15 @@ export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry { }; } + const registeredCapability = explainRegisteredModelCapability({ + protocol, + providerType, + modelName, + catalogProvider: context?.catalogProvider, + providerOptions: context?.providerOptions, + }); + if (registeredCapability !== undefined) return registeredCapability; + const baseCapability = getProtocolBase(identity.baseId)?.capability?.(modelName); if (baseCapability !== undefined) { return { diff --git a/packages/agent-core-v2/src/kosong/provider/provider.ts b/packages/agent-core-v2/src/kosong/provider/provider.ts index ba68d9b114..3904c68e4b 100644 --- a/packages/agent-core-v2/src/kosong/provider/provider.ts +++ b/packages/agent-core-v2/src/kosong/provider/provider.ts @@ -11,7 +11,8 @@ * enumerated at the type level. Validation happens at resolve time against * the provider-definition registry (`getProviderDefinition`), which is what * allows external packages to register new vendors without touching this - * contract. + * contract. `catalogProvider` is a separate, optional models.dev provider id: + * it selects metadata only and never changes the wire protocol. * * Owns the `ProviderConfig` / `OAuthRef` types and the in-memory provider * registry contract; App-scoped. Kosong has no persistence — it defines @@ -46,6 +47,7 @@ export interface ProviderConfig { baseUrl?: string; customHeaders?: Record; defaultModel?: string; + catalogProvider?: string; type?: ProviderType; apiKey?: string; diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index fe51e6af4b..7f0c3cc4da 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -47,11 +47,14 @@ import { } from '#/agent/loop/configSection'; import { MODELS_SECTION, + PROVIDERS_SECTION, SECONDARY_MODEL_EFFORT_ENV, SECONDARY_MODEL_ENV, SECONDARY_MODEL_SECTION, THINKING_SECTION, } from '#/app/kosongConfig/configSection'; +import type { ModelRecord } from '#/kosong/model/model'; +import type { ProviderConfig } from '#/kosong/provider/provider'; import { type ThinkingConfig } from '#/kosong/model/thinking'; import { KEEP_ALIVE_ON_EXIT_ENV, @@ -405,6 +408,43 @@ describe('Agent config', () => { }); describe('ConfigService env overlay (live)', () => { + it('loads an explicit catalog provider from snake-case TOML', async () => { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + await storage.write( + '', + 'config.toml', + new TextEncoder().encode( + '[providers.deepseek]\ntype = "openai"\ncatalog_provider = "deepseek"\n' + + '[models.production]\nprovider = "deepseek"\nmodel = "deepseek-reasoner"\n', + ), + ); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg')); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + + expect( + config.get>(PROVIDERS_SECTION)['deepseek'], + ).toMatchObject({ + type: 'openai', + catalogProvider: 'deepseek', + }); + expect( + config.get>(MODELS_SECTION)['production'], + ).toMatchObject({ + provider: 'deepseek', + model: 'deepseek-reasoner', + }); + + disposables.dispose(); + }); + it('re-applies env bindings on every get()', async () => { const env: Record = { KIMI_DISABLE_CRON: '0' }; const disposables = new DisposableStore(); diff --git a/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts b/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts index 138e972dea..2e15650abd 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts @@ -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 @@ -21,6 +23,10 @@ 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, + resolveModelsDevCapabilityProvider, +} from '#/app/kosongConfig/modelsDev'; import { resetModelsDevUpstreamForTest, setModelsDevUpstreamForTest, @@ -101,6 +107,136 @@ 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(); + }); +}); + +describe('resolveModelsDevCapabilityProvider', () => { + it('uses an explicit catalog provider before protocol defaults', () => { + expect( + resolveModelsDevCapabilityProvider({ + protocol: 'openai', + providerType: 'openai', + catalogProvider: 'deepseek', + }), + ).toBe('deepseek'); + }); + + it('maps compatible protocols to their official catalog providers', () => { + expect(resolveModelsDevCapabilityProvider({ protocol: 'openai' })).toBe('openai'); + expect( + resolveModelsDevCapabilityProvider({ + protocol: 'openai', + providerType: 'kimi', + }), + ).toBe('moonshotai'); + }); + + it('uses the Vertex catalog when the google-genai provider runs in Vertex mode', () => { + expect( + resolveModelsDevCapabilityProvider({ + protocol: 'google-genai', + providerType: 'google-genai', + vertexai: true, + }), + ).toBe('google-vertex'); + }); +}); + function fetchJson(doc: unknown): typeof fetch { return (async () => new Response(JSON.stringify(doc), { @@ -219,6 +355,7 @@ describe('IModelsDevImportService', () => { const providers = config.inspect(PROVIDERS_SECTION).userValue ?? {}; expect(providers['openai']).toMatchObject({ type: 'openai', + catalogProvider: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-test', }); diff --git a/packages/agent-core-v2/test/kosong/model/catalog.test.ts b/packages/agent-core-v2/test/kosong/model/catalog.test.ts index dc108c531b..7700861cca 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -33,6 +33,10 @@ import '#/kosong/provider/bases/anthropic/index'; import '#/kosong/provider/bases/google-genai/index'; import '#/kosong/provider/bases/openai/index'; import '#/kosong/provider/protocolAdapterRegistry'; +import { + type ModelCapabilityQuery, + registerModelCapabilityResolver, +} from '#/kosong/provider/modelCapabilityResolver'; import '#/kosong/provider/providers/kimi/kimi.contrib'; import '#/kosong/provider/providers/standard.contrib'; import { @@ -315,6 +319,58 @@ describe('Model assembly (pure data)', () => { } }); + it('passes Vertex mode and explicit catalog provider to capability sources', () => { + let captured: ModelCapabilityQuery | undefined; + const registration = registerModelCapabilityResolver((query) => { + captured = query; + return { + capability: { + image_in: true, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 1_000, + }, + source: { kind: 'builtin', detail: 'test catalog source' }, + }; + }); + const { host, catalog } = createHost({ + providers: { + vertex: { + type: 'google-genai', + catalogProvider: 'google-vertex', + env: { GOOGLE_CLOUD_PROJECT: 'my-project', GOOGLE_CLOUD_LOCATION: 'us-central1' }, + }, + }, + models: { + v: { + provider: 'vertex', + model: 'gemini-canonical', + maxContextSize: 1_000, + }, + }, + }); + + try { + expect(catalog.get('v').capabilities.thinking).toBe(true); + expect(captured).toMatchObject({ + protocol: 'google-genai', + providerType: 'google-genai', + modelName: 'gemini-canonical', + catalogProvider: 'google-vertex', + providerOptions: { + vertexai: true, + project: 'my-project', + location: 'us-central1', + }, + }); + } finally { + host.dispose(); + registration.dispose(); + } + }); + it('supports flat models with an inline baseUrl (provider synthesized from the origin)', () => { const { host, catalog } = createHost({ models: { @@ -513,6 +569,78 @@ describe('headers merge order', () => { }); describe('ModelCatalog inspect', () => { + it('attributes snapshot false values to the snapshot instead of none', () => { + const registration = registerModelCapabilityResolver(({ modelName }) => + modelName === 'gpt-4o' + ? { + capability: { + image_in: false, + video_in: false, + audio_in: true, + thinking: false, + tool_use: false, + max_context_tokens: 128_000, + }, + source: { kind: 'builtin', detail: 'test models.dev snapshot' }, + } + : undefined, + ); + const { host, catalog } = createHost({ + providers: { openai: { type: 'openai', apiKey: 'sk-test' } }, + models: { + model: { provider: 'openai', model: 'gpt-4o', maxContextSize: 128_000 }, + }, + }); + try { + const view = catalog.inspect('model'); + expect(view.resolved.capabilities.image_in).toBe(false); + expect(view.sources['resolved.capabilities.image_in']).toMatchObject({ + kind: 'builtin', + detail: 'test models.dev snapshot', + }); + } finally { + host.dispose(); + registration.dispose(); + } + }); + + it('attributes a user capability above a snapshot false value', () => { + const registration = registerModelCapabilityResolver(({ modelName }) => + modelName === 'gpt-4o' + ? { + capability: { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: false, + max_context_tokens: 128_000, + }, + source: { kind: 'builtin', detail: 'test models.dev snapshot' }, + } + : undefined, + ); + const { host, catalog } = createHost({ + providers: { openai: { type: 'openai', apiKey: 'sk-test' } }, + models: { + model: { + provider: 'openai', + model: 'gpt-4o', + maxContextSize: 128_000, + capabilities: ['image_in'], + }, + }, + }); + try { + const view = catalog.inspect('model'); + expect(view.resolved.capabilities.image_in).toBe(true); + expect(view.sources['resolved.capabilities.image_in']).toMatchObject({ kind: 'config' }); + } finally { + host.dispose(); + registration.dispose(); + } + }); + it('builds the god object with per-field provenance (kimi structured model)', () => { const { host, catalog } = createHost(kimiSections); try { diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index 331281ef5a..8fcff8d3c7 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -60,6 +60,7 @@ import '#/kosong/provider/bases/openai/index'; import { OpenAIResponsesChatProvider } from '#/kosong/provider/bases/openai/openai-responses'; import { OpenAILegacyChatProvider } from '#/kosong/provider/bases/openai/openai-legacy'; import { ProtocolAdapterRegistry } from '#/kosong/provider/protocolAdapterRegistry'; +import { registerModelCapabilityResolver } from '#/kosong/provider/modelCapabilityResolver'; import { getProviderDefinition, getProviderDefinitions, @@ -305,6 +306,57 @@ describe('resolveCapability', () => { ); expect(registry.resolveCapability('openai', 'gpt-4o', 'kimi').image_in).toBe(true); }); + + it('uses a registered capability source before the base catalog', () => { + const registration = registerModelCapabilityResolver(({ modelName }) => + modelName === 'gpt-4o' + ? { + capability: { + image_in: false, + video_in: false, + audio_in: true, + thinking: false, + tool_use: false, + max_context_tokens: 128_000, + }, + source: { kind: 'builtin', detail: 'test catalog snapshot' }, + } + : undefined, + ); + + try { + expect(registry.resolveCapability('openai', 'gpt-4o')).toMatchObject({ + image_in: false, + audio_in: true, + }); + } finally { + registration.dispose(); + } + }); + + it('forwards explicit catalog identity and provider mode to capability sources', () => { + let captured: Parameters[0]>[0] | undefined; + const registration = registerModelCapabilityResolver((query) => { + captured = query; + return undefined; + }); + + try { + registry.resolveCapability('google-genai', 'deployment-name', 'google-genai', { + catalogProvider: 'google-vertex', + providerOptions: { vertexai: true, project: 'example-project' }, + }); + expect(captured).toMatchObject({ + protocol: 'google-genai', + providerType: 'google-genai', + modelName: 'deployment-name', + catalogProvider: 'google-vertex', + providerOptions: { vertexai: true, project: 'example-project' }, + }); + } finally { + registration.dispose(); + } + }); }); describe('explainCapability', () => { diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 8b7baea6ca..3935cf6785 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -26,6 +26,7 @@ const StringRecordSchema = z.record(z.string(), z.string()); export const ProviderConfigSchema = z.object({ type: ProviderTypeSchema, + catalogProvider: z.string().min(1).optional(), apiKey: z.string().optional(), baseUrl: z.string().optional(), defaultModel: z.string().optional(), diff --git a/packages/agent-core/src/session/built-in-catalog.ts b/packages/agent-core/src/session/built-in-catalog.ts new file mode 100644 index 0000000000..0d0757ee89 --- /dev/null +++ b/packages/agent-core/src/session/built-in-catalog.ts @@ -0,0 +1,27 @@ +/** + * Lazily parses the models.dev snapshot injected into the final CLI bundle. + * Package builds and source tests have no injected value and return undefined. + */ + +import type { Catalog } from '@moonshot-ai/kosong'; + +declare const __KIMI_CODE_BUILT_IN_CATALOG__: string | undefined; + +let catalog: Catalog | undefined | null = null; + +export function loadBuiltInCatalog(): Catalog | undefined { + if (catalog !== null) return catalog; + if ( + typeof __KIMI_CODE_BUILT_IN_CATALOG__ !== 'string' || + __KIMI_CODE_BUILT_IN_CATALOG__.length === 0 + ) { + catalog = undefined; + return catalog; + } + try { + catalog = JSON.parse(__KIMI_CODE_BUILT_IN_CATALOG__) as Catalog; + } catch { + catalog = undefined; + } + return catalog; +} diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index e8028c76c1..52325c4feb 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -1,6 +1,16 @@ import type { Logger } from '#/logging/types'; -import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong'; -import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; +import type { + Catalog, + ProviderConfig as KosongProviderConfig, + ModelCapability, + ProviderRequestAuth, +} from '@moonshot-ai/kosong'; +import { + APIStatusError, + getCatalogModelCapability, + getModelCapability, + UNKNOWN_CAPABILITY, +} from '@moonshot-ai/kosong'; import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; import { effectiveModelAlias, @@ -12,6 +22,17 @@ import { } from '../config'; import { ErrorCodes, isKimiError, KimiError } from '../errors'; +import { loadBuiltInCatalog } from './built-in-catalog'; + +const DEFAULT_CATALOG_PROVIDER_BY_TYPE: Readonly>> = { + anthropic: 'anthropic', + openai: 'openai', + openai_responses: 'openai', + 'google-genai': 'google', + vertexai: 'google-vertex', + kimi: 'moonshotai', +}; + export interface BearerTokenProvider { getAccessToken(options?: { readonly force?: boolean }): Promise; } @@ -38,6 +59,7 @@ export interface ResolvedRuntimeProvider { interface ProviderManagerOptions { readonly config: KimiConfig | (() => KimiConfig); + readonly catalog?: Catalog | (() => Catalog | undefined); readonly kimiRequestHeaders?: Record; readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver; readonly promptCacheKey?: string; @@ -83,6 +105,11 @@ export class SingleModelProvider implements ModelProvider { export class ProviderManager implements ModelProvider { constructor(private readonly options: ProviderManagerOptions) {} + private get catalog(): Catalog | undefined { + const { catalog = loadBuiltInCatalog } = this.options; + return typeof catalog === 'function' ? catalog() : catalog; + } + private get config(): KimiConfig { const { config } = this.options; return typeof config === 'function' ? config() : config; @@ -140,7 +167,12 @@ export class ProviderManager implements ModelProvider { return { providerName, provider, - modelCapabilities: resolveModelCapabilities(effectiveAlias, provider), + modelCapabilities: resolveModelCapabilities( + effectiveAlias, + provider, + this.catalog, + providerConfig.catalogProvider ?? DEFAULT_CATALOG_PROVIDER_BY_TYPE[providerConfig.type], + ), alwaysThinking: (effectiveAlias.capabilities ?? []).some( (c) => c.trim().toLowerCase() === 'always_thinking', ), @@ -230,9 +262,15 @@ export class ProviderManager implements ModelProvider { function resolveModelCapabilities( alias: ModelAlias, provider: KosongProviderConfig, + catalog?: Catalog, + catalogProvider?: string, ): ModelCapability { const declared = new Set((alias.capabilities ?? []).map((c) => c.trim().toLowerCase())); - const detected = getModelCapability(provider.type, provider.model); + const detected = + (catalogProvider === undefined + ? undefined + : getCatalogModelCapability(catalog, catalogProvider, provider.model)) ?? + getModelCapability(provider.type, provider.model); return { image_in: declared.has('image_in') || detected.image_in, diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 39612d53aa..7b7430f54f 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -71,6 +71,7 @@ theme = "dark" [providers."managed:kimi-code"] type = "kimi" +catalog_provider = "moonshotai" base_url = "https://api.kimi.com/coding/v1" api_key = "sk-file" custom_headers = { "X-Test" = "1" } @@ -163,6 +164,7 @@ describe('harness config TOML loader', () => { expect(config.telemetry).toBe(false); expect(config.providers['managed:kimi-code']).toMatchObject({ type: 'kimi', + catalogProvider: 'moonshotai', baseUrl: 'https://api.kimi.com/coding/v1', apiKey: 'sk-file', env: { GOOGLE_CLOUD_PROJECT: 'project-1' }, @@ -370,6 +372,7 @@ removed_flag = true const text = await readFile(configPath, 'utf-8'); expect(text).toContain('default_model = "kimi-code/kimi-for-coding"'); expect(text).toContain('default_permission_mode = "auto"'); + expect(text).toContain('catalog_provider = "moonshotai"'); expect(text).toContain('extra_skill_dirs = [ "~/team-skills", ".agents/team-skills" ]'); expect(text).toContain('telemetry = false'); expect(text).not.toContain('default_yolo'); diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 1d20903234..c41296ba7a 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import type { Catalog } from '@moonshot-ai/kosong'; import type { KimiConfig, ModelAlias } from '../../src/config'; import { ErrorCodes, KimiError } from '../../src/errors'; @@ -13,9 +14,11 @@ function resolveRuntimeProvider(input: { readonly model?: string; readonly kimiRequestHeaders?: Record; readonly promptCacheKey?: string; + readonly catalog?: Catalog; }): ReturnType { const manager = new ProviderManager({ config: input.config, + catalog: input.catalog, kimiRequestHeaders: input.kimiRequestHeaders, promptCacheKey: input.promptCacheKey, }); @@ -108,6 +111,150 @@ describe('resolveRuntimeProvider model metadata', () => { }); }); + it('uses catalog capabilities before the legacy static table', () => { + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + openai: { type: 'openai', apiKey: 'test-key' }, + }, + models: { + custom: { + provider: 'openai', + model: 'gpt-4o', + maxContextSize: 123_456, + }, + }, + }, + model: 'custom', + catalog: { + openai: { + models: { + 'gpt-4o': { + id: 'gpt-4o', + limit: { context: 128_000 }, + tool_call: false, + modalities: { input: ['text', 'audio'], output: ['text'] }, + }, + }, + }, + }, + }); + + expect(resolved.modelCapabilities).toMatchObject({ + image_in: false, + audio_in: true, + tool_use: false, + max_context_tokens: 123_456, + }); + }); + + it('uses an explicit catalog provider for an OpenAI-compatible provider', () => { + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + deepseek: { + type: 'openai', + catalogProvider: 'deepseek', + apiKey: 'test-key', + }, + }, + models: { + custom: { + provider: 'deepseek', + model: 'deepseek-reasoner', + maxContextSize: 128_000, + }, + }, + }, + model: 'custom', + catalog: { + deepseek: { + models: { + 'deepseek-reasoner': { + id: 'deepseek-reasoner', + limit: { context: 128_000 }, + reasoning: true, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }, + }, + }); + + expect(resolved.provider).toMatchObject({ + type: 'openai', + model: 'deepseek-reasoner', + }); + expect(resolved.modelCapabilities).toMatchObject({ + thinking: true, + tool_use: true, + max_context_tokens: 128_000, + }); + }); + + it('falls back to the legacy static table when the catalog misses', () => { + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + openai: { type: 'openai', apiKey: 'test-key' }, + }, + models: { + custom: { + provider: 'openai', + model: 'gpt-4o', + maxContextSize: 128_000, + }, + }, + }, + model: 'custom', + catalog: {}, + }); + + expect(resolved.modelCapabilities).toMatchObject({ + image_in: true, + tool_use: true, + max_context_tokens: 128_000, + }); + }); + + it('keeps user-declared capabilities above the catalog snapshot', () => { + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + anthropic: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + custom: { + provider: 'anthropic', + model: 'claude-example', + maxContextSize: 200_000, + capabilities: ['tool_use'], + }, + }, + }, + model: 'custom', + catalog: { + anthropic: { + models: { + 'claude-example': { + id: 'claude-example', + limit: { context: 200_000 }, + tool_call: false, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }, + }, + }); + + expect(resolved.modelCapabilities.tool_use).toBe(true); + }); + it('uses config Kimi capabilities without requiring an api key during OAuth setup', () => { const resolved = resolveRuntimeProvider({ config: { diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index 6c7c611c81..45eec3bfad 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -435,6 +435,57 @@ export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel }); } +function normalizeCatalogPlatformKey(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 normalizeCatalogDeploymentKey(key: string): string { + return normalizeCatalogPlatformKey(key).replace(/@.*$/, ''); +} + +function normalizeCatalogFamilyKey(key: string): string { + return normalizeCatalogDeploymentKey(key) + .replace(/-\d{8}$/, '') + .replace(/-\d{4}-\d{2}-\d{2}$/, ''); +} + +/** + * Looks up a model's capability in a models.dev-style catalog snapshot. + * Exact keys win over normalized platform variants. A miss stays undefined + * so runtime callers can continue to their built-in table. + */ +export function getCatalogModelCapability( + catalog: Catalog | undefined, + providerId: string, + modelName: string, +): ModelCapability | undefined { + if (catalog === undefined) return undefined; + const models = catalog[providerId]?.models; + if (models === undefined) return undefined; + const exact = models[modelName] ?? models[modelName.toLowerCase()]; + if (exact !== undefined) { + const model = catalogModelToCapability(exact); + if (model !== undefined) return model.capability; + } + for (const normalize of [ + normalizeCatalogPlatformKey, + normalizeCatalogDeploymentKey, + normalizeCatalogFamilyKey, + ]) { + const target = normalize(modelName); + for (const [key, entry] of Object.entries(models)) { + if (normalize(key) !== target) continue; + const model = catalogModelToCapability(entry); + if (model !== undefined) return model.capability; + } + } + return undefined; +} + /** * Gateway providers (zenmux, opencode, azure, …) may declare a per-model * `provider` override when a model is served over a different protocol or diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index d999a02bc6..2a27979d11 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -40,6 +40,7 @@ export type { ModelCapability } from './capability'; // Model catalog (models.dev-style) metadata export { catalogBaseUrl, + getCatalogModelCapability, catalogModelToCapability, catalogProviderModels, inferWireType, diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index ace6298362..e64a0a6a54 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { adaptBaseUrlForWire, catalogBaseUrl, + getCatalogModelCapability, catalogModelToCapability, catalogProviderModels, inferWireType, @@ -575,6 +576,97 @@ describe('catalogModelToCapability', () => { }); }); +describe('getCatalogModelCapability', () => { + it('prefers an exact model key over normalized platform variants', () => { + const capability = getCatalogModelCapability( + { + 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'] }, + }, + }, + }, + }, + 'anthropic', + 'claude-example-20260724', + ); + + expect(capability).toMatchObject({ thinking: true, max_context_tokens: 200_000 }); + }); + + it('normalizes Bedrock 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( + getCatalogModelCapability( + catalog, + 'anthropic', + 'us.anthropic.claude-example-v1:0', + ), + ).toMatchObject({ image_in: true, tool_use: true }); + }); + + it('preserves an explicit date while normalizing a Bedrock model id', () => { + const capability = getCatalogModelCapability( + { + 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(capability).toMatchObject({ image_in: false, audio_in: true }); + }); + + it('returns undefined for an unknown provider id', () => { + const catalog = { + anthropic: { + models: { + 'claude-example': { + id: 'claude-example', + limit: { context: 200_000 }, + }, + }, + }, + }; + + expect(getCatalogModelCapability(catalog, 'missing-provider', 'claude-example')).toBeUndefined(); + }); +}); + describe('catalogProviderModels', () => { it('extracts only valid models from a provider entry', () => { const models = catalogProviderModels({ diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 78cb2afd31..b455b90595 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -133,6 +133,7 @@ export function applyCatalogProvider( ): { defaultModel: string } { config.providers[options.providerId] = { type: options.wire, + catalogProvider: options.providerId, baseUrl: options.baseUrl, apiKey: options.apiKey, }; diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index d1bc5a6ea9..d2a8e06dbe 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -106,7 +106,11 @@ describe('applyCatalogProvider', () => { }); expect(result.defaultModel).toBe('anthropic/m1'); - expect(config.providers['anthropic']).toMatchObject({ type: 'anthropic', apiKey: 'sk' }); + expect(config.providers['anthropic']).toMatchObject({ + type: 'anthropic', + catalogProvider: 'anthropic', + apiKey: 'sk', + }); expect(config.models?.['anthropic/m1']).toMatchObject({ provider: 'anthropic', model: 'm1',