diff --git a/.changeset/always-thinking-capability.md b/.changeset/always-thinking-capability.md new file mode 100644 index 000000000..1549aedd2 --- /dev/null +++ b/.changeset/always-thinking-capability.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/kosong": minor +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code-oauth": minor +"@moonshot-ai/kimi-code": minor +--- + +Surface models whose thinking cannot be turned off as always-on in the model selector, without requiring a manual capability declaration. Detection covers Claude Fable (including vendor-prefixed ids like `us.anthropic.claude-fable-5-v1:0`), OpenAI o-series, and Gemini 2.5 Pro, and both catalog routes (`always_reasoning` in models.dev-style catalogs and custom api.json registries) can declare it. Capabilities are resolved at read time through a single shared resolver — `resolveAliasCapabilities` — so config files stay pure declarations and write-back paths persist snapshots verbatim. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index d95c02e35..2f55bb266 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -272,6 +272,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.mountEditorReplacement( new TabbedModelSelectorComponent({ models: host.state.appState.availableModels, + providers: host.state.appState.availableProviders, currentValue: host.state.appState.model, selectedValue, currentThinking: host.state.appState.thinking, diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 67fd89a29..1133b8490 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -1,6 +1,7 @@ import { catalogModelToAlias, inferWireType, + resolveAliasCapabilities, type Catalog, type CatalogModel, type ModelAlias, @@ -162,8 +163,11 @@ export function runModelSelector( ): Promise<{ alias: string; thinking: boolean } | undefined> { return new Promise((resolve) => { const firstAlias = Object.keys(modelDict)[0] ?? ''; - const caps = modelDict[firstAlias]?.capabilities ?? []; - const initialThinking = caps.includes('always_thinking') || caps.includes('thinking'); + const first = modelDict[firstAlias]; + // Pre-add flow: the provider isn't in config yet, so capability + // resolution runs on the catalog-declared strings alone. + const initialThinking = + first !== undefined && resolveAliasCapabilities(undefined, first).thinking; const selector = new ModelSelectorComponent({ models: modelDict, currentValue: firstAlias, diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 55f9817fa..d678b2909 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -227,6 +227,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { const selector = new TabbedModelSelectorComponent({ models: mergedModels, + providers: host.state.appState.availableProviders, currentValue: host.state.appState.model, selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), currentThinking: host.state.appState.thinking, @@ -315,6 +316,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise : addedProviderIds[0]; const selector = new TabbedModelSelectorComponent({ models: stateModels, + providers: host.state.appState.availableProviders, currentValue: host.state.appState.model, selectedValue: firstNewAlias, currentThinking: host.state.appState.thinking, diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index b6a9ec6ea..2e99c67d2 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -1,4 +1,5 @@ -import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { resolveAliasCapabilities } from '@moonshot-ai/kimi-code-sdk'; +import type { ModelAlias, ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; import { Container, Key, @@ -54,6 +55,14 @@ export function createModelChoiceOptions( export interface ModelSelectorOptions { readonly models: Record; + /** + * Provider wire types keyed by provider id, used to resolve detected + * capabilities (e.g. claude-fable-5 → always-on thinking) for aliases that + * don't declare them. Only `type` is read — callers can pass their full + * `ProviderConfig` map as-is. Omitted in pre-add flows where aliases carry + * catalog-declared capability strings instead. + */ + readonly providers?: Record>; readonly currentValue: string; readonly selectedValue?: string; readonly currentThinking: boolean; @@ -76,15 +85,20 @@ function createModelChoices(models: Record): readonly ModelC }); } -function thinkingAvailability(model: ModelAlias): ThinkingAvailability { - const caps = model.capabilities ?? []; - if (caps.includes('always_thinking')) return 'always-on'; - if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle'; +function thinkingAvailability( + model: ModelAlias, + providers: Record> | undefined, +): ThinkingAvailability { + const resolved = resolveAliasCapabilities(providers?.[model.provider]?.type, model); + if (resolved.always_thinking) return 'always-on'; + if (resolved.thinking || model.adaptiveThinking === true) return 'toggle'; return 'unsupported'; } -function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { - const availability = thinkingAvailability(model); +function effectiveThinking( + availability: ThinkingAvailability, + thinkingDraft: boolean, +): boolean { if (availability === 'always-on') return true; if (availability === 'unsupported') return false; return thinkingDraft; @@ -120,6 +134,10 @@ export class ModelSelectorComponent extends Container implements Focusable { }); } + private availabilityFor(model: ModelAlias): ThinkingAvailability { + return thinkingAvailability(model, this.opts.providers); + } + /** * Thinking draft for a model: an explicit ←/→ override when set, otherwise * the live thinking state for the active model, otherwise On for any other @@ -129,7 +147,7 @@ export class ModelSelectorComponent extends Container implements Focusable { const override = this.thinkingOverrides.get(choice.alias); if (override !== undefined) return override; if (choice.alias === this.opts.currentValue) return this.opts.currentThinking; - return thinkingAvailability(choice.model) !== 'unsupported'; + return this.availabilityFor(choice.model) !== 'unsupported'; } handleInput(data: string): void { @@ -147,7 +165,7 @@ export class ModelSelectorComponent extends Container implements Focusable { // Left/Right toggle the thinking draft for models that support it. if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { const selected = this.selectedChoice(); - if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') { + if (selected !== undefined && this.availabilityFor(selected.model) === 'toggle') { this.thinkingOverrides.set(selected.alias, !this.draftFor(selected)); } return; @@ -158,7 +176,7 @@ export class ModelSelectorComponent extends Container implements Focusable { if (selected === undefined) return; this.opts.onSelect({ alias: selected.alias, - thinking: effectiveThinking(selected.model, this.draftFor(selected)), + thinking: effectiveThinking(this.availabilityFor(selected.model), this.draftFor(selected)), }); } } @@ -240,7 +258,7 @@ export class ModelSelectorComponent extends Container implements Focusable { lines.push(''); const selected = this.selectedChoice(); if (selected !== undefined) { - const availability = thinkingAvailability(selected.model); + const availability = this.availabilityFor(selected.model); const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking'; lines.push(currentTheme.fg('textMuted', thinkingHeader)); lines.push(this.renderThinkingControl(selected)); @@ -260,7 +278,7 @@ export class ModelSelectorComponent extends Container implements Focusable { ? currentTheme.boldFg('primary', `[ ${label} ]`) : currentTheme.fg('text', ` ${label} `); - const availability = thinkingAvailability(choice.model); + const availability = this.availabilityFor(choice.model); if (availability === 'always-on') { return ` ${segment('Always on', true)}`; } diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 747072a5c..cdacba261 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -13,7 +13,7 @@ * AskUserQuestion dialog's tab strip) — see .agents/skills/write-tui/DESIGN.md. */ -import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import type { ModelAlias, ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; import { Container, Key, @@ -37,6 +37,8 @@ const ALL_TAB_LABEL = 'All'; export interface TabbedModelSelectorOptions { readonly models: Record; + /** Passed through to the inner selectors — see {@link ModelSelectorOptions}. */ + readonly providers?: Record>; readonly currentValue: string; readonly selectedValue?: string; readonly currentThinking: boolean; @@ -244,6 +246,7 @@ function makeSelector( const selectedValue = subset[candidate] !== undefined ? candidate : undefined; const inner: ModelSelectorOptions = { models: subset, + providers: opts.providers, currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), currentThinking: opts.currentThinking, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 71a43fa3b..0e563b512 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -753,6 +753,10 @@ export class KimiTUI { return true; } + // Deliberately NOT resolveAliasCapabilities: this gate is permissive when + // nothing is declared, while the resolver reports `false` for modalities of + // models kosong has no built-in knowledge of — which would wrongly reject + // media on undeclared (e.g. kimi) models. private supportsCurrentModelCapability(capability: string): boolean { const capabilities = this.state.appState.availableModels[this.state.appState.model]?.capabilities; diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index dd4780e64..fefe420ad 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -104,6 +104,34 @@ describe('ModelSelectorComponent', () => { expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); }); + it('renders detected always-thinking models as always-on without a declaration', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + fable: { + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 1_000_000, + displayName: 'Claude Fable 5', + }, + }, + providers: { + // The component only needs the wire type — not credentials. + anthropic: { type: 'anthropic' }, + }, + currentValue: 'fable', + currentThinking: false, + onSelect, + onCancel: vi.fn(), + }); + + expect(text(picker)).toContain('[ Always on ]'); + // ←/→ cannot toggle always-on thinking off. + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'fable', thinking: true }); + }); + it('keeps the thinking draft when moving across models', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ diff --git a/packages/acp-adapter/src/config-options.ts b/packages/acp-adapter/src/config-options.ts index eab73eb0a..45ef8be3a 100644 --- a/packages/acp-adapter/src/config-options.ts +++ b/packages/acp-adapter/src/config-options.ts @@ -93,9 +93,10 @@ export function buildModelOption( * only the wire encoding is `'on'` / `'off'` strings. * * The caller decides whether to include this option at all — when the - * currently-selected model has `thinkingSupported === false`, the - * snapshot omits it entirely (dynamic visibility), so the client never - * shows a toggle that wouldn't do anything. + * currently-selected model has `thinkingSupported === false`, or is + * `alwaysThinking` (the off entry would silently run thinking anyway), + * the snapshot omits it entirely (dynamic visibility), so the client + * never shows a toggle that wouldn't do anything. */ export function buildThinkingOption(enabled: boolean): SessionConfigOption { return { @@ -170,7 +171,11 @@ export async function buildSessionConfigOptions( ): Promise { const models = await listModelsFromHarness(harness); const currentModelEntry = models.find((m) => m.id === currentBaseModelId); - const showThinking = currentModelEntry?.thinkingSupported === true; + // Always-thinking models get no toggle: ACP's select arm has no read-only + // entry, and an "off" choice would misreport a model that reasons (and + // bills thinking tokens) regardless. + const showThinking = + currentModelEntry?.thinkingSupported === true && !currentModelEntry.alwaysThinking; const out: SessionConfigOption[] = [buildModelOption(models, currentBaseModelId)]; if (showThinking) { out.push(buildThinkingOption(currentThinkingEnabled)); diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts index 4992b491b..4f6c1e95b 100644 --- a/packages/acp-adapter/src/model-catalog.ts +++ b/packages/acp-adapter/src/model-catalog.ts @@ -15,14 +15,28 @@ * `for model_key, model in models.items()`. * * `thinkingSupported` is true if any of: - * 1. the alias's declared `capabilities` array contains `'thinking'`, or + * 1. the alias resolves to thinking via `resolveAliasCapabilities` — + * declared `capabilities` (`'thinking'` or `'always_thinking'`, + * case-insensitive) or kosong's built-in detection for the provider + * wire type (e.g. `claude-fable-5`), or * 2. the underlying model name matches `/thinking|reason/i` * (always-thinking variants), or * 3. the underlying model name is on the {@link TOGGLEABLE_THINKING_MODELS} * allow-list (mirrors `kimi-cli/src/kimi_cli/llm.py:derive_model_capabilities`). + * + * `alwaysThinking` is set only by route 1 (capability resolution): the + * name-regex route cannot tell an always-on variant from a toggleable one, + * so it stays a plain `thinkingSupported`. Consumers use it to suppress + * thinking-off controls — offering "off" on such a model would silently + * run (and bill) thinking anyway. */ -import type { KimiHarness, ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { + resolveAliasCapabilities, + type KimiHarness, + type ModelAlias, + type ProviderConfig, +} from '@moonshot-ai/kimi-code-sdk'; /** * One catalog row per configured model alias, suitable for an ACP @@ -35,6 +49,13 @@ export interface AcpModelEntry { readonly name: string; readonly description?: string | undefined; readonly thinkingSupported: boolean; + /** + * The model always reasons and cannot run with thinking turned off + * (kosong's `always_thinking`, e.g. `claude-fable-5`). Implies + * `thinkingSupported`. Consumers must not offer a thinking-off control + * when this is set. + */ + readonly alwaysThinking?: true; } /** @@ -45,13 +66,17 @@ export interface AcpModelEntry { */ const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']); -export function deriveThinkingSupported(alias: ModelAlias): boolean { - const declared = alias.capabilities ?? []; - if (declared.includes('thinking')) return true; +export function deriveThinking( + alias: ModelAlias, + providerType?: ProviderConfig['type'], +): Pick { + const resolved = resolveAliasCapabilities(providerType, alias); + if (resolved.always_thinking) return { thinkingSupported: true, alwaysThinking: true }; + if (resolved.thinking) return { thinkingSupported: true }; const lower = alias.model.toLowerCase(); - if (lower.includes('thinking') || lower.includes('reason')) return true; - if (TOGGLEABLE_THINKING_MODELS.has(alias.model)) return true; - return false; + if (lower.includes('thinking') || lower.includes('reason')) return { thinkingSupported: true }; + if (TOGGLEABLE_THINKING_MODELS.has(alias.model)) return { thinkingSupported: true }; + return { thinkingSupported: false }; } /** @@ -67,9 +92,14 @@ export async function listModelsFromHarness( ): Promise { if (typeof harness.getConfig !== 'function') return []; let models: Record | undefined; + let providers: Record; try { const config = await harness.getConfig(); models = config.models; + // `KimiConfig` types `providers` as required (zod default), but getConfig + // crosses an RPC/stub boundary — a partial harness can omit it, and this + // dereference sits outside the try. + providers = config.providers ?? {}; } catch { return []; } @@ -79,7 +109,7 @@ export async function listModelsFromHarness( out.push({ id, name: alias.displayName ?? alias.model ?? id, - thinkingSupported: deriveThinkingSupported(alias), + ...deriveThinking(alias, providers[alias.provider]?.type), }); } return out; diff --git a/packages/acp-adapter/test/config-options.test.ts b/packages/acp-adapter/test/config-options.test.ts index 89e73717b..dec2e851b 100644 --- a/packages/acp-adapter/test/config-options.test.ts +++ b/packages/acp-adapter/test/config-options.test.ts @@ -25,6 +25,8 @@ function makeHarnessWithModels( ...(entry.capabilities !== undefined ? { capabilities: entry.capabilities } : {}), }; } + // Deliberately omits `providers`: a partial stub exercises the RPC-boundary + // fallback in listModelsFromHarness. const getConfig = vi.fn(async () => ({ models })); return { harness: { getConfig } as unknown as KimiHarness, getConfig }; } @@ -160,6 +162,19 @@ describe('buildSessionConfigOptions', () => { expect(result.map((o) => o.id)).toEqual(['model', 'mode']); }); + it('omits the thinking toggle for always-thinking models — off would be a no-op', async () => { + // Same dynamic-visibility rule as non-thinking models: ACP's select arm + // has no read-only entry, and showing Off/On for a model whose thinking + // cannot be turned off would misreport what actually runs. + const { harness } = makeHarnessWithModels([ + { id: 'fable', model: 'claude-fable-5', capabilities: ['always_thinking'] }, + ]); + + const result = await buildSessionConfigOptions(harness, 'fable', false, 'default'); + + expect(result.map((o) => o.id)).toEqual(['model', 'mode']); + }); + it('reflects the thinking toggle currentValue from the explicit argument', async () => { const { harness } = makeHarnessWithModels([ { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, diff --git a/packages/acp-adapter/test/model-catalog.test.ts b/packages/acp-adapter/test/model-catalog.test.ts new file mode 100644 index 000000000..c15ac4ff6 --- /dev/null +++ b/packages/acp-adapter/test/model-catalog.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveThinking } from '../src/model-catalog'; + +describe('deriveThinking', () => { + it('recognizes declared thinking capabilities, including lone always_thinking', () => { + expect( + deriveThinking({ + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 1000000, + capabilities: ['thinking', 'always_thinking'], + }), + ).toEqual({ thinkingSupported: true, alwaysThinking: true }); + + // `always_thinking` implies `thinking` (kosong ModelCapability contract), + // so a hand-written declaration without the plain string still counts. + expect( + deriveThinking({ + provider: 'custom', + model: 'custom-model', + maxContextSize: 262144, + capabilities: ['always_thinking'], + }), + ).toEqual({ thinkingSupported: true, alwaysThinking: true }); + }); + + it('detects always-thinking models from kosong knowledge when given the wire type', () => { + const fable = { + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 1000000, + }; + // The model name carries no thinking/reason hint and declares nothing — + // only provider-wire-type detection can classify it. + expect(deriveThinking(fable, 'anthropic')).toEqual({ + thinkingSupported: true, + alwaysThinking: true, + }); + expect(deriveThinking(fable)).toEqual({ thinkingSupported: false }); + }); + + it('never marks name-regex or allow-list matches as alwaysThinking', () => { + // The regex cannot tell an always-on variant from a toggleable one, so + // these models keep their thinking toggle. + expect( + deriveThinking({ provider: 'kimi', model: 'kimi-thinking-preview', maxContextSize: 262144 }), + ).toEqual({ thinkingSupported: true }); + expect( + deriveThinking({ provider: 'kimi', model: 'kimi-for-coding', maxContextSize: 262144 }), + ).toEqual({ thinkingSupported: true }); + }); +}); diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts index 41e5ca174..759a17457 100644 --- a/packages/agent-core/src/config/index.ts +++ b/packages/agent-core/src/config/index.ts @@ -1,4 +1,5 @@ export * from './merge'; +export * from './model-capabilities'; export * from './path'; export * from './resolve'; export * from './schema'; diff --git a/packages/agent-core/src/config/model-capabilities.ts b/packages/agent-core/src/config/model-capabilities.ts new file mode 100644 index 000000000..276e0e1d5 --- /dev/null +++ b/packages/agent-core/src/config/model-capabilities.ts @@ -0,0 +1,54 @@ +import { + getProviderModelCapability, + UNKNOWN_CAPABILITY, + type ModelCapability, +} from '@moonshot-ai/kosong'; + +import type { ModelAlias, ProviderType } from './schema'; + +/** + * Resolve the effective capabilities of a model alias: the alias's declared + * capability strings (trimmed, case-insensitive) merged additively with + * kosong's built-in model knowledge for the provider wire type — e.g. + * `claude-fable-5`, whose thinking cannot be turned off, detects as + * `always_thinking` without the user declaring it by hand. + * + * This is where declarations and detection meet, and the merge is a union: + * declarations can add capabilities on top of detection but never veto it. + * No detected value is ever persisted — config objects stay pure + * declarations, nothing is written into `models..capabilities` at load + * time, and write-back paths persist config snapshots verbatim. So when + * kosong's model knowledge is corrected later, the correction takes effect on + * upgrade without any stale materialized copy to migrate. Consumers that + * gate on a capability being PRESENT (session capability resolution, the + * model selector's thinking availability, ACP's model catalog) call this + * instead of interpreting the raw strings themselves. + * + * Caveat — unknown flattens to false: when kosong has no knowledge of the + * model (or `providerType` is undefined), detection is UNKNOWN_CAPABILITY and + * the merge cannot distinguish "detected as unsupported" from "never + * catalogued" (kosong's `isUnknownCapability` marker does not survive the + * merge). Consumers that want to be PERMISSIVE when nothing is declared — + * like the TUI's media-attachment gate — must keep reading the raw declared + * strings; resolving here would wrongly reject modalities on models kosong + * does not know. + */ +export function resolveAliasCapabilities( + providerType: ProviderType | undefined, + alias: ModelAlias, +): ModelCapability { + const declared = new Set((alias.capabilities ?? []).map((c) => c.trim().toLowerCase())); + const detected = + providerType === undefined + ? UNKNOWN_CAPABILITY + : getProviderModelCapability(providerType, alias.model); + return { + image_in: declared.has('image_in') || detected.image_in, + video_in: declared.has('video_in') || detected.video_in, + audio_in: declared.has('audio_in') || detected.audio_in, + thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking, + always_thinking: declared.has('always_thinking') || detected.always_thinking ? true : undefined, + tool_use: declared.has('tool_use') || detected.tool_use, + max_context_tokens: alias.maxContextSize, + }; +} diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 56452e41a..c237f6091 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -75,6 +75,12 @@ export function readConfigFile(filePath: string): KimiConfig { * synthesized from `KIMI_MODEL_*` environment variables. Use this everywhere a * value is assigned to the live runtime config; use the raw `readConfigFile` * for write-back paths so the synthesized model is never persisted. + * + * Model capabilities are deliberately NOT materialized into + * `models..capabilities` here — config objects stay pure declarations + * so getConfig→setConfig round-trips persist snapshots verbatim. Detection + * from kosong's model knowledge is resolved at read time instead, via + * `resolveAliasCapabilities`. */ export function loadRuntimeConfig( filePath: string, diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 38d3e1cd1..873e5ce16 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -1,7 +1,8 @@ import type { Logger } from '#/logging/types'; import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong'; -import { APIStatusError, createProvider, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; -import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig } from '../config'; +import { APIStatusError, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; +import { resolveAliasCapabilities } from '../config'; +import type { KimiConfig, OAuthRef, ProviderConfig } from '../config'; import { ErrorCodes, isKimiError, KimiError } from '../errors'; export interface BearerTokenProvider { @@ -115,7 +116,7 @@ export class ProviderManager implements ModelProvider { return { providerName, provider, - modelCapabilities: resolveModelCapabilities(alias, provider), + modelCapabilities: resolveAliasCapabilities(providerConfig.type, alias), maxOutputSize: alias.maxOutputSize, }; } @@ -191,24 +192,6 @@ export class ProviderManager implements ModelProvider { } } -function resolveModelCapabilities( - alias: ModelAlias, - provider: KosongProviderConfig, -): ModelCapability { - const declared = new Set((alias.capabilities ?? []).map((c) => c.trim().toLowerCase())); - const probe = createProvider(providerForCapabilityProbe(provider)); - const detected = probe.getCapability?.(provider.model) ?? UNKNOWN_CAPABILITY; - - return { - image_in: declared.has('image_in') || detected.image_in, - video_in: declared.has('video_in') || detected.video_in, - audio_in: declared.has('audio_in') || detected.audio_in, - thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking, - tool_use: declared.has('tool_use') || detected.tool_use, - max_context_tokens: alias.maxContextSize, - }; -} - function toKosongProviderConfig( provider: ProviderConfig, model: string, @@ -292,14 +275,6 @@ function defaultHeadersField( return { defaultHeaders: { ...headers } }; } -function providerForCapabilityProbe(provider: KosongProviderConfig): KosongProviderConfig { - const apiKey = provider.apiKey && provider.apiKey.length > 0 ? provider.apiKey : 'capability-probe'; - if (provider.type === 'vertexai') { - return { ...provider, vertexai: false, project: undefined, location: undefined, apiKey }; - } - return { ...provider, apiKey }; -} - function providerApiKey(provider: ProviderConfig): string | undefined { switch (provider.type) { case 'anthropic': diff --git a/packages/agent-core/test/config/model-capabilities.test.ts b/packages/agent-core/test/config/model-capabilities.test.ts new file mode 100644 index 000000000..80d3b6abb --- /dev/null +++ b/packages/agent-core/test/config/model-capabilities.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { join } from 'pathe'; + +import { loadRuntimeConfig, resolveAliasCapabilities } from '../../src/config'; +import type { ModelAlias } from '../../src/config'; + +const fable: ModelAlias = { + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 1_000_000, +}; + +describe('resolveAliasCapabilities', () => { + it('detects always-thinking models from kosong knowledge without a declaration', () => { + const resolved = resolveAliasCapabilities('anthropic', fable); + expect(resolved.always_thinking).toBe(true); + expect(resolved.thinking).toBe(true); + expect(resolved.image_in).toBe(true); + expect(resolved.max_context_tokens).toBe(1_000_000); + }); + + it('leaves toggleable models without always_thinking', () => { + const resolved = resolveAliasCapabilities('anthropic', { + ...fable, + model: 'claude-opus-4-6', + }); + expect(resolved.thinking).toBe(true); + expect(resolved.always_thinking).toBeUndefined(); + }); + + it('honors declared strings case-insensitively when detection knows nothing', () => { + // An uncatalogued model name resolves to UNKNOWN_CAPABILITY, so only the + // declared strings can produce capabilities here. + const resolved = resolveAliasCapabilities('anthropic', { + provider: 'custom', + model: 'uncatalogued-model', + maxContextSize: 262144, + capabilities: [' Always_Thinking '], + }); + expect(resolved.always_thinking).toBe(true); + // always_thinking implies thinking even when only the lone string is declared. + expect(resolved.thinking).toBe(true); + }); + + it('resolves declared-only when the provider type is unknown', () => { + const resolved = resolveAliasCapabilities(undefined, { + ...fable, + capabilities: ['image_in'], + }); + expect(resolved.image_in).toBe(true); + expect(resolved.thinking).toBe(false); + expect(resolved.always_thinking).toBeUndefined(); + }); +}); + +describe('loadRuntimeConfig stays a pure declaration snapshot', () => { + it('does not materialize detected capabilities into model aliases', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-model-caps-')); + try { + const file = join(dir, 'config.toml'); + writeFileSync( + file, + [ + 'default_model = "main"', + '', + '[providers.anthropic]', + 'type = "anthropic"', + 'api_key = "sk-test"', + '', + '[models.main]', + 'provider = "anthropic"', + 'model = "claude-fable-5"', + 'max_context_size = 1000000', + ].join('\n'), + ); + const config = loadRuntimeConfig(file, {}); + // Detection is resolved at read time via resolveAliasCapabilities, never + // by mutating the runtime config — getConfig→setConfig round-trips must + // persist snapshots verbatim (no strip step on the write path). + expect(config.models?.['main']?.capabilities).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/kosong/src/capability.ts b/packages/kosong/src/capability.ts index e5bc24582..6ba9ef9d9 100644 --- a/packages/kosong/src/capability.ts +++ b/packages/kosong/src/capability.ts @@ -13,6 +13,14 @@ export interface ModelCapability { readonly video_in: boolean; readonly audio_in: boolean; readonly thinking: boolean; + /** + * The model always reasons and cannot run with thinking turned off — e.g. + * Claude Fable 5, where a request without a `thinking` field still runs + * adaptive thinking. UIs should not offer a thinking-off toggle for these + * models. Typed `true` (never `false`): absent means toggleable, and + * present implies `thinking` is also `true`. + */ + readonly always_thinking?: true; readonly tool_use: boolean; readonly max_context_tokens: number; } diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index 40975430c..3c9af419e 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -13,6 +13,11 @@ export interface CatalogModelEntry { readonly limit?: { readonly context?: number; readonly output?: number }; readonly tool_call?: boolean; readonly reasoning?: boolean; + /** + * Catalog extension over models.dev: the model always reasons and cannot + * run with thinking turned off. Implies `reasoning`. Absent means `false`. + */ + readonly always_reasoning?: boolean; readonly interleaved?: boolean | { readonly field?: string }; readonly modalities?: { readonly input?: readonly string[]; @@ -133,7 +138,8 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel image_in: inputs.includes('image'), video_in: inputs.includes('video'), audio_in: inputs.includes('audio'), - thinking: Boolean(model.reasoning), + thinking: Boolean(model.reasoning) || model.always_reasoning === true, + always_thinking: model.always_reasoning === true ? true : undefined, tool_use: model.tool_call ?? true, max_context_tokens: context, }, diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 0bc380e89..9b4fb4bfc 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -25,7 +25,7 @@ export type { // Provider interfaces export * from './provider'; -export { createProvider } from './providers'; +export { createProvider, getProviderModelCapability } from './providers'; export type { ProviderConfig, ProviderType } from './providers'; // Kimi provider: exported so callers can narrow a `ChatProvider` to the Kimi // backend (instanceof) and apply Kimi-specific request params (generation diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 9bdbc15dd..95629a5c7 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -39,6 +39,12 @@ import type { } from '@anthropic-ai/sdk/resources/messages/messages.js'; import { getAnthropicModelCapability } from './capability-registry'; +import { + isFableModel, + parseClaudeAliasVersion, + parseClaudeVersion, + type ClaudeVersion, +} from './claude-version'; import { mergeRequestHeaders, requireProviderApiKey, @@ -164,84 +170,6 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = { const FALLBACK_MAX_TOKENS = 32000; -type ClaudeFamily = 'opus' | 'sonnet' | 'haiku' | 'fable'; - -interface ClaudeVersion { - family: ClaudeFamily; - major: number; - minor: number | null; -} - -// Family-first form: "opus-4-7", "sonnet-4.6", "haiku-4-5-20251001", -// "fable-5" (single version component — Fable ids carry no minor). -// Version numbers are capped at 1–2 digits with a non-digit lookahead so -// 8-digit date suffixes (e.g. `-20251001`) don't get consumed as version -// components. -const FAMILY_FIRST_RE = - /(opus|sonnet|haiku|fable)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; -// Legacy version-first form: "3-5-sonnet", "3.7.opus" — used by older -// Anthropic model ids and Bedrock variants of Claude 3.x. -const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; -// Bare family form for base Claude 3 (no minor): "3-opus", "3.haiku". -const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; - -/** - * Extract Claude family + version from a model id. - * - * Designed to survive the naming variants we see across vendors: - * vendor prefixes (`anthropic.`, `aws/`, `openrouter/`, - * `online-`), suffixes (date stamps like `-20251001`, build tags - * like `-construct`, `-v1:0`), and `.` vs `-` separators between - * the family and version components. - * - * Returns `null` when the id contains no Claude marker or no - * recognizable family/version, in which case the resolver should fall - * back to the override or {@link FALLBACK_MAX_TOKENS}. - */ -function parseClaudeVersion(model: string): ClaudeVersion | null { - return parseClaudeFamilyVersion(model, true); -} - -function parseClaudeAliasVersion(model: string): ClaudeVersion | null { - return parseClaudeFamilyVersion(model, false); -} - -function parseClaudeFamilyVersion(model: string, requireClaudeMarker: boolean): ClaudeVersion | null { - const normalized = model.toLowerCase(); - // Guard against false positives on non-Claude models that happen to - // contain an `opus-4-7`-like substring (e.g. fine-tunes named after a - // checkpoint). The Anthropic provider might still be configured for - // non-Claude endpoints, so without this guard we'd quietly apply - // Claude ceilings to unrelated models. - if (requireClaudeMarker && !normalized.includes('claude')) return null; - - const familyFirst = FAMILY_FIRST_RE.exec(normalized); - if (familyFirst !== null) { - return { - family: familyFirst[1] as ClaudeFamily, - major: Number.parseInt(familyFirst[2]!, 10), - minor: familyFirst[3] !== undefined ? Number.parseInt(familyFirst[3], 10) : null, - }; - } - const versionFirst = VERSION_FIRST_RE.exec(normalized); - if (versionFirst !== null) { - return { - major: Number.parseInt(versionFirst[1]!, 10), - minor: Number.parseInt(versionFirst[2]!, 10), - family: versionFirst[3] as ClaudeFamily, - }; - } - const bare = BARE_FAMILY_RE.exec(normalized); - if (bare !== null) { - return { - major: Number.parseInt(bare[1]!, 10), - minor: null, - family: bare[2] as ClaudeFamily, - }; - } - return null; -} - function lookupClaudeCeiling(version: ClaudeVersion): number | undefined { const { family, major, minor } = version; if (minor !== null) { @@ -315,10 +243,6 @@ function isOpus47(model: string): boolean { return version.major === 4 && version.minor === 7; } -function isFableModel(model: string): boolean { - return parseClaudeAliasVersion(model)?.family === 'fable'; -} - function supportsEffortParam(model: string, adaptive: boolean): boolean { if (adaptive) { return true; diff --git a/packages/kosong/src/providers/capability-registry.ts b/packages/kosong/src/providers/capability-registry.ts index c5f415859..3ae5a85d7 100644 --- a/packages/kosong/src/providers/capability-registry.ts +++ b/packages/kosong/src/providers/capability-registry.ts @@ -1,4 +1,5 @@ import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/capability'; +import { isFableModel } from './claude-version'; type CapabilityMatcher = (normalizedModelName: string) => boolean; @@ -29,8 +30,8 @@ const OPENAI_VISION_TOOL_PREFIXES = [ ] as const; // Claude prefixes are grouped by capability set, not by version family: -// a new model joins the group whose capability it matches (e.g. Fable sits -// with Opus/Sonnet/Haiku 4), rather than getting a per-version group. +// a new model joins the group whose capability it matches, rather than +// getting a per-version group. // Vision + tool use, no thinking (-> ANTHROPIC_VISION_TOOL_CAPABILITY). const CLAUDE_VISION_TOOL_PREFIXES = ['claude-3-', 'claude-3.5-', 'claude-3.7-'] as const; @@ -40,7 +41,6 @@ const CLAUDE_THINKING_VISION_TOOL_PREFIXES = [ 'claude-opus-4', 'claude-sonnet-4', 'claude-haiku-4', - 'claude-fable', ] as const; const GEMINI_CATALOGUED_PREFIXES = [ @@ -52,11 +52,16 @@ const GEMINI_CATALOGUED_PREFIXES = [ 'gemini-2.5-flash', ] as const; +// OpenAI o-series reasoning cannot be turned off: `withThinking('off')` omits +// `reasoning_effort`, and pre-gpt-5.1 reasoning models do not support `none` — +// the server still reasons at its default effort. Surface that as +// always_thinking so UIs don't offer a no-op off toggle. const OPENAI_REASONING_CAPABILITY: ModelCapability = Object.freeze({ image_in: false, video_in: false, audio_in: false, thinking: true, + always_thinking: true, tool_use: true, max_context_tokens: 0, }); @@ -97,6 +102,12 @@ const ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY: ModelCapability = Object.freeze max_context_tokens: 0, }); +// Fable: same vision/tool set, but thinking cannot be turned off. +const ANTHROPIC_ALWAYS_THINKING_VISION_TOOL_CAPABILITY: ModelCapability = Object.freeze({ + ...ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY, + always_thinking: true, +}); + const GEMINI_MULTIMODAL_TOOL_CAPABILITY: ModelCapability = Object.freeze({ image_in: true, video_in: true, @@ -115,6 +126,15 @@ const GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY: ModelCapability = Object.freez max_context_tokens: 0, }); +// Gemini 2.5 Pro cannot disable thinking: the API enforces a minimum thinking +// budget (128), so the provider's `withThinking('off')` → `thinking_budget: 0` +// is rejected with a 400. 2.5 Flash / Flash-Lite accept budget 0 and stay in +// the toggleable group. +const GEMINI_ALWAYS_THINKING_MULTIMODAL_TOOL_CAPABILITY: ModelCapability = Object.freeze({ + ...GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY, + always_thinking: true, +}); + const OPENAI_LEGACY_CAPABILITY_CATALOG: readonly CapabilityCatalogEntry[] = [ { matches: isOpenAIReasoningModel, @@ -150,6 +170,15 @@ const ANTHROPIC_CAPABILITY_CATALOG: readonly CapabilityCatalogEntry[] = [ matches: (name) => hasPrefix(name, CLAUDE_THINKING_VISION_TOOL_PREFIXES), capability: ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY, }, + { + // isFableModel is the same predicate the anthropic wire layer uses to + // omit `thinking: disabled`, so the advertised capability and the + // request-building behavior cannot drift — vendor-prefixed + // ("us.anthropic.claude-fable-5-v1:0"), bare ("fable-5"), and + // version-less ("claude-fable-latest") ids all classify identically. + matches: isFableModel, + capability: ANTHROPIC_ALWAYS_THINKING_VISION_TOOL_CAPABILITY, + }, ]; function normalizeModelName(modelName: string): string { @@ -194,6 +223,9 @@ export function getGoogleGenAIModelCapability(modelName: string): ModelCapabilit if (!normalized.startsWith('gemini-')) return UNKNOWN_CAPABILITY; if (!hasPrefix(normalized, GEMINI_CATALOGUED_PREFIXES)) return UNKNOWN_CAPABILITY; + if (normalized.startsWith('gemini-2.5-pro')) { + return GEMINI_ALWAYS_THINKING_MULTIMODAL_TOOL_CAPABILITY; + } if (normalized.startsWith('gemini-2.5-') || normalized.includes('thinking')) { return GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY; } diff --git a/packages/kosong/src/providers/claude-version.ts b/packages/kosong/src/providers/claude-version.ts new file mode 100644 index 000000000..8451c4b54 --- /dev/null +++ b/packages/kosong/src/providers/claude-version.ts @@ -0,0 +1,106 @@ +/** + * Claude model-id parsing shared by the anthropic provider (wire-level + * behavior like omitting `thinking: disabled` on Fable) and the capability + * registry (advertising `always_thinking`). Keeping the parser and the + * model-family predicates in one module means the advertised capability and + * the request-building behavior cannot drift apart across the naming + * variants vendors use for the same model. + */ + +export type ClaudeFamily = 'opus' | 'sonnet' | 'haiku' | 'fable'; + +export interface ClaudeVersion { + family: ClaudeFamily; + major: number; + minor: number | null; +} + +// Family-first form: "opus-4-7", "sonnet-4.6", "haiku-4-5-20251001", +// "fable-5" (single version component — Fable ids carry no minor). +// Version numbers are capped at 1–2 digits with a non-digit lookahead so +// 8-digit date suffixes (e.g. `-20251001`) don't get consumed as version +// components. +const FAMILY_FIRST_RE = + /(opus|sonnet|haiku|fable)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; +// Legacy version-first form: "3-5-sonnet", "3.7.opus" — used by older +// Anthropic model ids and Bedrock variants of Claude 3.x. +const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; +// Bare family form for base Claude 3 (no minor): "3-opus", "3.haiku". +const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; + +/** + * Extract Claude family + version from a model id. + * + * Designed to survive the naming variants we see across vendors: + * vendor prefixes (`anthropic.`, `aws/`, `openrouter/`, + * `online-`), suffixes (date stamps like `-20251001`, build tags + * like `-construct`, `-v1:0`), and `.` vs `-` separators between + * the family and version components. + * + * Returns `null` when the id contains no Claude marker or no + * recognizable family/version, in which case the resolver should fall + * back to the override or the provider's fallback ceiling. + */ +export function parseClaudeVersion(model: string): ClaudeVersion | null { + return parseClaudeFamilyVersion(model, true); +} + +export function parseClaudeAliasVersion(model: string): ClaudeVersion | null { + return parseClaudeFamilyVersion(model, false); +} + +function parseClaudeFamilyVersion(model: string, requireClaudeMarker: boolean): ClaudeVersion | null { + const normalized = model.toLowerCase(); + // Guard against false positives on non-Claude models that happen to + // contain an `opus-4-7`-like substring (e.g. fine-tunes named after a + // checkpoint). The Anthropic provider might still be configured for + // non-Claude endpoints, so without this guard we'd quietly apply + // Claude ceilings to unrelated models. + if (requireClaudeMarker && !normalized.includes('claude')) return null; + + const familyFirst = FAMILY_FIRST_RE.exec(normalized); + if (familyFirst !== null) { + return { + family: familyFirst[1] as ClaudeFamily, + major: Number.parseInt(familyFirst[2]!, 10), + minor: familyFirst[3] !== undefined ? Number.parseInt(familyFirst[3], 10) : null, + }; + } + const versionFirst = VERSION_FIRST_RE.exec(normalized); + if (versionFirst !== null) { + return { + major: Number.parseInt(versionFirst[1]!, 10), + minor: Number.parseInt(versionFirst[2]!, 10), + family: versionFirst[3] as ClaudeFamily, + }; + } + const bare = BARE_FAMILY_RE.exec(normalized); + if (bare !== null) { + return { + major: Number.parseInt(bare[1]!, 10), + minor: null, + family: bare[2] as ClaudeFamily, + }; + } + return null; +} + +/** + * Single Fable predicate shared by the wire layer (omit `thinking: disabled`, + * allow xhigh effort) and the capability registry (advertise + * `always_thinking`) — both sides MUST agree, or the UI would offer states + * the request builder mishandles. The parser covers vendor-prefixed and + * suffixed ids ("us.anthropic.claude-fable-5-v1:0", bare "fable-5"); the + * literal prefix covers version-less ids ("claude-fable-latest") the parser + * has no version component to anchor on. The prefix is anchored at a + * separator so ids merely containing the substring ("claude-fabled-x") + * don't classify. + */ +export function isFableModel(model: string): boolean { + const lower = model.toLowerCase(); + return ( + lower === 'claude-fable' || + lower.startsWith('claude-fable-') || + parseClaudeAliasVersion(model)?.family === 'fable' + ); +} diff --git a/packages/kosong/src/providers/index.ts b/packages/kosong/src/providers/index.ts index c677f6be5..28acf5df5 100644 --- a/packages/kosong/src/providers/index.ts +++ b/packages/kosong/src/providers/index.ts @@ -1,5 +1,13 @@ +import type { ModelCapability } from '../capability'; +import { UNKNOWN_CAPABILITY } from '../capability'; import type { ChatProvider } from '../provider'; import { AnthropicChatProvider, type AnthropicOptions } from './anthropic'; +import { + getAnthropicModelCapability, + getGoogleGenAIModelCapability, + getOpenAILegacyModelCapability, + getOpenAIResponsesModelCapability, +} from './capability-registry'; import { GoogleGenAIChatProvider, type GoogleGenAIOptions } from './google-genai'; import { KimiChatProvider, type KimiOptions } from './kimi'; import { OpenAILegacyChatProvider, type OpenAILegacyOptions } from './openai-legacy'; @@ -35,3 +43,31 @@ export function createProvider(config: ProviderConfig): ChatProvider { } } } + +/** + * Pure lookup of a provider's catalogued model capability — the same mapping + * each `ChatProvider.getCapability` exposes, without constructing a provider. + * Providers with no built-in model knowledge (kimi) yield UNKNOWN_CAPABILITY. + */ +export function getProviderModelCapability( + type: ProviderType, + modelName: string, +): ModelCapability { + switch (type) { + case 'anthropic': + return getAnthropicModelCapability(modelName); + case 'openai': + return getOpenAILegacyModelCapability(modelName); + case 'openai_responses': + return getOpenAIResponsesModelCapability(modelName); + case 'google-genai': + case 'vertexai': + return getGoogleGenAIModelCapability(modelName); + case 'kimi': + return UNKNOWN_CAPABILITY; + default: { + const exhaustive: never = type; + throw new Error(`Unknown provider type: ${String(exhaustive)}`); + } + } +} diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index fe6f2e29c..67f97c816 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1018,11 +1018,16 @@ describe('AnthropicChatProvider', () => { } }); - it('claude-fable-5 with thinking off omits the thinking field entirely', async () => { + it.each([ + 'claude-fable-5', + // Version-less id: the shared isFableModel prefix branch must keep the + // wire layer aligned with the capability registry's always_thinking. + 'claude-fable-latest', + ])('%s with thinking off omits the thinking field entirely', async (model) => { // Fable 400s on an explicit `disabled` thinking config (unlike Opus // 4.7/4.8); the provider must drop the field from the request while // still reporting `off` to callers. - const provider = createProvider('claude-fable-5').withThinking('off'); + const provider = createProvider(model).withThinking('off'); expect(provider.thinkingEffort).toBe('off'); const body = await captureRequestBody(provider, '', [], thinkHistory); diff --git a/packages/kosong/test/capability-providers.test.ts b/packages/kosong/test/capability-providers.test.ts index 4de101dc6..945d5d9e6 100644 --- a/packages/kosong/test/capability-providers.test.ts +++ b/packages/kosong/test/capability-providers.test.ts @@ -18,6 +18,7 @@ import { GoogleGenAIChatProvider } from '#/providers/google-genai'; import { KimiChatProvider } from '#/providers/kimi'; import { OpenAILegacyChatProvider } from '#/providers/openai-legacy'; import { OpenAIResponsesChatProvider } from '#/providers/openai-responses'; +import { createProvider, getProviderModelCapability, type ProviderConfig } from '#/providers/index'; import { describe, expect, it } from 'vitest'; describe('KimiChatProvider.getCapability', () => { function make(model: string): KimiChatProvider { @@ -80,6 +81,17 @@ describe('GoogleGenAIChatProvider.getCapability', () => { expect(cap).toEqual(UNKNOWN_CAPABILITY); }); + it('gemini-2.5-pro cannot disable thinking → always_thinking; 2.5-flash stays toggleable', () => { + // 2.5 Pro enforces a minimum thinking budget (128) and rejects + // thinking_budget: 0; 2.5 Flash accepts budget 0. + const pro = make('gemini-2.5-pro').getCapability(); + expect(pro.thinking).toBe(true); + expect(pro.always_thinking).toBe(true); + const flash = make('gemini-2.5-flash').getCapability(); + expect(flash.thinking).toBe(true); + expect(flash.always_thinking).toBeUndefined(); + }); + it('non-gemini model name → UNKNOWN_CAPABILITY', () => { const cap = make('claude-3-5-sonnet').getCapability(); expect(cap).toEqual(UNKNOWN_CAPABILITY); @@ -121,6 +133,34 @@ describe('AnthropicChatProvider.getCapability', () => { expect(cap.tool_use).toBe(true); }); + it('claude-fable-5 → always_thinking; toggleable Claude 4 models are not', () => { + // Fable cannot run with thinking turned off; Opus 4 can. + expect(make('claude-fable-5').getCapability().always_thinking).toBe(true); + expect(make('claude-opus-4').getCapability().always_thinking).toBeUndefined(); + }); + + it('vendor-prefixed Fable ids detect always_thinking like the wire layer', () => { + // The capability row is driven by the same isFableModel predicate + // generate() uses to omit `thinking: disabled`, so every id that runs + // always-on also advertises it. + for (const id of [ + 'anthropic.claude-fable-5-v1:0', + 'us.anthropic.claude-fable-5-20251101-v1:0', + 'openrouter/anthropic/claude-fable-5', + 'fable-5', + 'claude-fable-latest', // version-less: covered by the prefix branch + ]) { + const cap = make(id).getCapability(); + expect(cap.always_thinking, id).toBe(true); + expect(cap.thinking, id).toBe(true); + } + }); + + it('ids merely containing the fable substring do not classify as Fable', () => { + // The isFableModel prefix branch is separator-anchored. + expect(make('claude-fabled-2').getCapability()).toEqual(UNKNOWN_CAPABILITY); + }); + it('no Anthropic model supports audio_in', () => { // Sanity: Anthropic has no audio-input models today. If one ships later // and this fails, update the table — but make it a conscious decision. @@ -157,6 +197,13 @@ describe('OpenAILegacyChatProvider.getCapability', () => { expect(cap.tool_use).toBe(true); }); + it('o-series reasoning cannot be turned off → always_thinking', () => { + // 'off' omits reasoning_effort and pre-gpt-5.1 reasoning models do not + // support 'none' — the server still reasons at its default effort. + expect(make('o3').getCapability().always_thinking).toBe(true); + expect(make('gpt-4o').getCapability().always_thinking).toBeUndefined(); + }); + it('unknown OpenAI-legacy model → UNKNOWN_CAPABILITY', () => { const cap = make('gpt-mystery').getCapability(); expect(cap).toEqual(UNKNOWN_CAPABILITY); @@ -182,6 +229,7 @@ describe('OpenAIResponsesChatProvider.getCapability', () => { it('o3-mini → thinking=true', () => { const cap = make('o3-mini').getCapability(); expect(cap.thinking).toBe(true); + expect(cap.always_thinking).toBe(true); }); it('unknown Responses model → UNKNOWN_CAPABILITY', () => { @@ -189,3 +237,47 @@ describe('OpenAIResponsesChatProvider.getCapability', () => { expect(cap).toEqual(UNKNOWN_CAPABILITY); }); }); +describe('getProviderModelCapability (pure lookup)', () => { + // Cross-check against the instance path: the pure lookup's switch in + // providers/index.ts and each ChatProvider class's getCapability are two + // copies of the same type→registry mapping. If a provider's getCapability + // implementation ever changes (e.g. kimi gains catalog knowledge), this + // fails instead of the two silently drifting apart. + const CASES: ReadonlyArray<{ config: ProviderConfig; models: readonly string[] }> = [ + { + config: { type: 'anthropic', model: 'claude-fable-5', apiKey: 'test-key' }, + models: ['claude-fable-5', 'claude-opus-4', 'claude-3-5-sonnet', 'claude-not-real'], + }, + { + config: { type: 'openai', model: 'o3', apiKey: 'test-key' }, + models: ['o3', 'gpt-4o', 'gpt-mystery'], + }, + { + config: { type: 'openai_responses', model: 'o3-mini', apiKey: 'test-key' }, + models: ['o3-mini', 'gpt-4.1', 'gpt-mystery'], + }, + { + config: { type: 'google-genai', model: 'gemini-2.5-pro', apiKey: 'test-key' }, + models: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-not-real'], + }, + { + config: { type: 'vertexai', model: 'gemini-2.5-pro', apiKey: 'test-key' }, + models: ['gemini-2.5-pro', 'gemini-not-real'], + }, + { + config: { type: 'kimi', model: 'kimi-for-coding', apiKey: 'test-key' }, + models: ['kimi-for-coding', 'kimi-thinking-preview'], + }, + ]; + + it('agrees with ChatProvider.getCapability for every provider type', () => { + for (const { config, models } of CASES) { + const provider = createProvider(config); + for (const model of models) { + expect(getProviderModelCapability(config.type, model), `${config.type}/${model}`).toEqual( + provider.getCapability?.(model), + ); + } + } + }); +}); diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 5780e5502..278b8d4e0 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -94,6 +94,25 @@ describe('catalogModelToCapability', () => { expect(catalogModelToCapability({ id: 'm', limit: { context: 0 } })).toBeUndefined(); }); + it('maps always_reasoning to always_thinking and implies thinking', () => { + const model = catalogModelToCapability({ + id: 'm', + limit: { context: 1000 }, + always_reasoning: true, + }); + expect(model?.capability.always_thinking).toBe(true); + expect(model?.capability.thinking).toBe(true); + + // Plain reasoning models stay toggleable: no always_thinking field. + const reasoning = catalogModelToCapability({ + id: 'm', + limit: { context: 1000 }, + reasoning: true, + }); + expect(reasoning?.capability.thinking).toBe(true); + expect(reasoning?.capability.always_thinking).toBeUndefined(); + }); + it('skips embedding and non-text-output models that cannot serve as chat defaults', () => { expect( catalogModelToCapability({ diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 86687a960..02b06d1a4 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -46,6 +46,9 @@ function capabilityToStrings(capability: ModelCapability): string[] | undefined if (capability.video_in) caps.push('video_in'); if (capability.audio_in) caps.push('audio_in'); if (capability.thinking) caps.push('thinking'); + // Spelled out alongside `thinking` so consumers checking for plain + // 'thinking' membership keep working. + if (capability.always_thinking) caps.push('always_thinking'); if (capability.tool_use) caps.push('tool_use'); return caps.length > 0 ? caps : undefined; } diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 8e0bfd446..4cfa280da 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -66,6 +66,11 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen // outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. export { installGlobalProxyDispatcher } from '@moonshot-ai/agent-core'; +// Model-alias capability resolution — declared capability strings merged with +// kosong's built-in model knowledge. UIs call this instead of interpreting +// raw `models..capabilities` strings themselves. +export { resolveAliasCapabilities } from '@moonshot-ai/agent-core'; + // Experimental feature flags — types only. Resolved values come from // `KimiHarness.getExperimentalFeatures()` over RPC, not from a re-exported runtime value. export type { diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 42dca028e..62f5d0cfd 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -65,6 +65,19 @@ describe('catalogModelToAlias', () => { displayName: 'M1', }); }); + + it('materializes always_thinking alongside thinking for always-reasoning models', () => { + const alwaysThinking: CatalogModel = { + ...model, + capability: { ...model.capability, always_thinking: true }, + }; + expect(catalogModelToAlias('custom', alwaysThinking).capabilities).toEqual([ + 'image_in', + 'thinking', + 'always_thinking', + 'tool_use', + ]); + }); }); describe('applyCatalogProvider', () => { diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index cad5fd9f2..40b369cae 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -33,6 +33,12 @@ export interface CustomRegistryModelEntry { readonly limit?: { context?: number; output?: number }; readonly tool_call?: boolean; readonly reasoning?: boolean; + /** + * Same extension as kosong's `CatalogModelEntry.always_reasoning`: the + * model always reasons and cannot run with thinking turned off. Implies + * `reasoning`. Absent means `false`. + */ + readonly always_reasoning?: boolean; readonly modalities?: { input?: readonly string[]; output?: readonly string[]; @@ -99,6 +105,7 @@ function toModelEntry(value: unknown): CustomRegistryModelEntry | undefined { limit?: { context?: number; output?: number }; tool_call?: boolean; reasoning?: boolean; + always_reasoning?: boolean; modalities?: { input?: readonly string[]; output?: readonly string[] }; } = { id }; @@ -123,6 +130,9 @@ function toModelEntry(value: unknown): CustomRegistryModelEntry | undefined { if (typeof value['tool_call'] === 'boolean') entry.tool_call = value['tool_call']; if (typeof value['reasoning'] === 'boolean') entry.reasoning = value['reasoning']; + if (typeof value['always_reasoning'] === 'boolean') { + entry.always_reasoning = value['always_reasoning']; + } const modalities = value['modalities']; if (isRecord(modalities)) { @@ -236,7 +246,10 @@ export async function fetchCustomRegistry( export function capabilitiesFromCustomEntry(model: CustomRegistryModelEntry): string[] { const caps = new Set(); if (model.tool_call === true) caps.add('tool_use'); - if (model.reasoning === true) caps.add('thinking'); + if (model.reasoning === true || model.always_reasoning === true) caps.add('thinking'); + // Spelled out alongside 'thinking' so consumers checking plain membership + // keep working (same contract as node-sdk's capabilityToStrings). + if (model.always_reasoning === true) caps.add('always_thinking'); if (model.modalities?.input?.includes('image') === true) caps.add('image_in'); if (model.modalities?.input?.includes('video') === true) caps.add('video_in'); if (model.modalities?.output?.includes('image') === true) caps.add('image_out'); @@ -248,6 +261,7 @@ function hasRichCapabilityHints(model: CustomRegistryModelEntry): boolean { return ( typeof model.tool_call === 'boolean' || typeof model.reasoning === 'boolean' || + typeof model.always_reasoning === 'boolean' || model.modalities !== undefined ); } diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index f781ce5a4..9b37333c3 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -180,6 +180,34 @@ describe('fetchCustomRegistry', () => { warnSpy.mockRestore(); } }); + + it('parses always_reasoning through api.json ingestion', async () => { + const fetchMock = vi.fn( + async () => + makeJsonResponse({ + reg: { + id: 'reg', + name: 'Reg', + api: 'https://reg.example/v1', + type: 'anthropic', + models: { + 'fable-5-preview': { + id: 'fable-5-preview', + reasoning: true, + always_reasoning: true, + }, + }, + }, + }), + ); + + const result = await fetchCustomRegistry( + KOKUB_SOURCE, + fetchMock as unknown as typeof fetch, + ); + + expect(result['reg']?.models['fable-5-preview']?.always_reasoning).toBe(true); + }); }); describe('applyCustomRegistryProvider', () => { @@ -589,4 +617,13 @@ describe('capabilitiesFromCustomEntry', () => { }), ).toEqual([]); }); + + it('maps always_reasoning to always_thinking alongside thinking', () => { + expect(capabilitiesFromCustomEntry({ id: 'm', always_reasoning: true })).toEqual([ + 'thinking', + 'always_thinking', + ]); + // Plain reasoning models stay toggleable: no always_thinking string. + expect(capabilitiesFromCustomEntry({ id: 'm', reasoning: true })).toEqual(['thinking']); + }); });