diff --git a/.changeset/catalog-import-broader-and-safer.md b/.changeset/catalog-import-broader-and-safer.md new file mode 100644 index 0000000000..890e7d5b58 --- /dev/null +++ b/.changeset/catalog-import-broader-and-safer.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/kimi-code": patch +--- + +Import many more providers from the models.dev catalog: vendor SDKs like xai and openrouter now import instead of being refused (with a "guessed" note), deprecated and alpha models are filtered out, per-model gateway protocol and endpoint overrides are honored, and context limits are correct (input limit for compaction, total window for completion). Imports lacking a usable endpoint now ask for one via `--base-url` or a prompt. diff --git a/.changeset/thinking-levels-from-declared-capabilities.md b/.changeset/thinking-levels-from-declared-capabilities.md new file mode 100644 index 0000000000..ceaaf7bc15 --- /dev/null +++ b/.changeset/thinking-levels-from-declared-capabilities.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix thinking levels being offered for models that do not support them (e.g. phantom levels on Kimi K3): levels now come from each model's declared capabilities. Models that cannot disable reasoning (e.g. gpt-5) no longer offer an Off option, and turning thinking Off on models that support it (e.g. xai grok) now truly disables reasoning. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 509ec2d220..3ade36aa42 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -21,13 +21,12 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, - catalogBaseUrl, catalogProviderModels, CatalogFetchError, createKimiHarness, DEFAULT_CATALOG_URL, fetchCatalog, - inferWireType, + resolveCatalogImport, type Catalog, type CatalogProviderEntry, type KimiConfig, @@ -67,6 +66,7 @@ interface CatalogAddOptions { readonly apiKey?: string; readonly defaultModel?: string; readonly url?: string; + readonly baseUrl?: string; } export async function handleProviderAdd( @@ -276,9 +276,15 @@ export async function handleCatalogList( for (const [id, entry] of entries) { const modelCount = entry.models === undefined ? 0 : Object.keys(entry.models).length; - const wire = inferWireType(entry) ?? '?'; + const resolution = resolveCatalogImport(entry); + const wireLabel = + resolution.kind === 'invalid' + ? '?' + : resolution.guessed + ? `${resolution.wire} (guessed)` + : resolution.wire; deps.stdout.write( - `${id} wire=${wire} models=${String(modelCount)} ${entry.name ?? ''}\n`, + `${id} wire=${wireLabel} models=${String(modelCount)} ${entry.name ?? ''}\n`, ); } } @@ -310,11 +316,37 @@ export async function handleCatalogAdd( deps.exit(1); } - const wire = inferWireType(entry); - if (wire === undefined) { - deps.stderr.write(`Provider "${providerId}" has an unsupported wire type in the catalog.\n`); + const resolution = resolveCatalogImport(entry, opts.baseUrl); + if (resolution.kind === 'invalid') { + switch (resolution.reason) { + case 'unknown-explicit-type': + deps.stderr.write( + `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.\n`, + ); + break; + case 'proprietary-sdk': + deps.stderr.write( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.\n`, + ); + break; + case 'empty-base-url': + deps.stderr.write('--base-url cannot be empty.\n'); + break; + case 'placeholder-base-url': + deps.stderr.write( + `Base URL "${opts.baseUrl}" contains an env placeholder. Pass --base-url with the resolved value.\n`, + ); + break; + } deps.exit(1); } + if (resolution.kind === 'needs-base-url') { + deps.stderr.write( + `The catalog does not declare an endpoint for "${providerId}". Pass --base-url (e.g. the vendor's OpenAI-compatible base URL).\n`, + ); + deps.exit(1); + } + const { wire, baseUrl } = resolution; const models = catalogProviderModels(entry); if (models.length === 0) { @@ -346,7 +378,6 @@ export async function handleCatalogAdd( config = await harness.removeProvider(providerId); } - const baseUrl = catalogBaseUrl(entry, wire); // `applyCatalogProvider` always overwrites both `defaultModel` and // `[thinking]`. The values we pass here are temporary; we restore // a consistent state in the post-apply block below. @@ -391,6 +422,11 @@ export async function handleCatalogAdd( deps.stdout.write( `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, ); + if (resolution.guessed) { + deps.stdout.write( + `Note: the catalog does not declare a protocol for "${providerId}"; guessed "openai". Edit "type" in config.toml if requests fail.\n`, + ); + } if (opts.defaultModel !== undefined) { deps.stdout.write(`Default model set to ${providerId}/${opts.defaultModel}.\n`); } @@ -481,11 +517,15 @@ export function registerProviderCommand(parent: Command, deps?: Partial', 'API key for the provider. Falls back to KIMI_REGISTRY_API_KEY.') .option('--default-model ', 'Mark the imported model as default_model after import.') + .option( + '--base-url ', + 'Override the catalog endpoint. Required when the catalog declares none (or an env placeholder).', + ) .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) .action( async ( providerId: string, - options: { apiKey?: string; defaultModel?: string; url?: string }, + options: { apiKey?: string; defaultModel?: string; url?: string; baseUrl?: string }, ) => { const resolved = resolveDeps(deps); await runAction(resolved, () => @@ -493,6 +533,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial { + return new Promise((resolve) => { + const dialog = new ApiKeyInputDialogComponent( + platformName, + ['The catalog declares no endpoint for this provider — enter its base URL.'], + (result: ApiKeyInputResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + { + title: `Enter base URL for ${platformName}`, + mask: false, + emptyHint: 'Base URL cannot be empty.', + }, + ); + host.mountEditorReplacement(dialog); + }); +} + export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: Catalog): Promise { return new Promise((resolve) => { const options: ChoiceOption[] = Object.entries(catalog) - .filter(([, entry]) => inferWireType(entry) !== undefined) + .filter(([, entry]) => resolveCatalogImport(entry).kind !== 'invalid') .map(([id, entry]) => ({ value: id, label: entry.name ?? id, diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 6fe6e93186..23bef02ba1 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -6,12 +6,11 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, - catalogBaseUrl, catalogProviderModels, CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, - inferWireType, + resolveCatalogImport, type Catalog, type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; @@ -33,6 +32,7 @@ import { thinkingEffortToConfig } from '../utils/thinking-config'; import { effectiveModelForHost } from './config'; import { promptApiKey, + promptBaseUrl, promptCatalogProviderSelection, } from './prompts'; import type { SlashCommandHost } from './dispatch'; @@ -192,15 +192,34 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { return; } - const apiKey = await promptApiKey(host, entry.name ?? providerId); - if (apiKey === undefined) return; - - const wire = inferWireType(entry); - if (wire === undefined) { - host.showError(`Provider "${providerId}" has unsupported wire type.`); + let resolution = resolveCatalogImport(entry); + if (resolution.kind === 'needs-base-url') { + const entered = await promptBaseUrl(host, entry.name ?? providerId); + if (entered === undefined) return; + resolution = resolveCatalogImport(entry, entered); + } + if (resolution.kind !== 'ok') { + if (resolution.kind === 'invalid') { + if (resolution.reason === 'unknown-explicit-type') { + host.showError( + `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.`, + ); + } else if (resolution.reason === 'proprietary-sdk') { + host.showError( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.`, + ); + } else { + host.showError( + `Base URL contains an env placeholder or is empty. Enter the resolved URL instead.`, + ); + } + } return; } - const baseUrl = catalogBaseUrl(entry, wire); + const { wire, baseUrl } = resolution; + + const apiKey = await promptApiKey(host, entry.name ?? providerId); + if (apiKey === undefined) return; // Persist the provider and all its models immediately after the api key is // entered. The model selector that follows is just a convenience to pick the @@ -229,6 +248,11 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { await host.authFlow.refreshConfigAfterLogin(); host.track('connect', { provider: providerId, method: 'catalog' }); host.showStatus(`Provider added: ${entry.name ?? providerId}`); + if (resolution.guessed) { + host.showStatus( + `Protocol guessed as "openai" for ${providerId} — edit "type" in config.toml if requests fail.`, + ); + } // Build a merged model dictionary that includes existing models plus the // newly-persisted provider's models, so the tabbed selector shows every diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index d95d9ff932..2165dc48da 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -48,6 +48,8 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly onDone: (result: ApiKeyInputResult) => void; private readonly title: string; private readonly subtitleLines: readonly string[]; + private readonly mask: boolean; + private readonly emptyHint: string; private done = false; private emptyHinted = false; @@ -55,11 +57,14 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, + options?: { title?: string; mask?: boolean; emptyHint?: string }, ) { super(); this.onDone = onDone; - this.title = `Enter API key for ${platformName}`; + this.title = options?.title ?? `Enter API key for ${platformName}`; this.subtitleLines = subtitleLines; + this.mask = options?.mask ?? true; + this.emptyHint = options?.emptyHint ?? 'API key cannot be empty.'; this.input.onSubmit = (value) => { this.submit(value); }; @@ -96,7 +101,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { const border = (s: string): string => currentTheme.fg('primary', s); const titleStyled = currentTheme.boldFg('textStrong', this.title); - const subtitleSource = this.emptyHinted ? ['API key cannot be empty.'] : this.subtitleLines; + const subtitleSource = this.emptyHinted ? [this.emptyHint] : this.subtitleLines; const subtitleLines = subtitleSource.map((line) => truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'), ); @@ -105,7 +110,8 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); const rawInputLine = this.input.render(innerWidth)[0] ?? '> '; - const inputLine = this.input.getValue() === '' ? rawInputLine : maskInputLine(rawInputLine); + const inputLine = + this.mask && this.input.getValue() !== '' ? maskInputLine(rawInputLine) : rawInputLine; const contentLines: string[] = [ titleLine, diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index 2e4aeece4b..ce2951e640 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -888,6 +888,93 @@ describe('kimi provider catalog add', () => { expect(current().providers['openai']).toMatchObject({ apiKey: 'sk-env' }); }); + it('lets --base-url override the catalog-declared endpoint', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'openai', { + apiKey: 'sk-o', + baseUrl: 'https://proxy.example.test/v1', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().providers['openai']).toMatchObject({ + type: 'openai', + baseUrl: 'https://proxy.example.test/v1', + }); + }); + + it('strips a trailing /v1 from --base-url for Anthropic-wire imports', async () => { + mockRegistryFetch({ + 'claude-gateway': { + id: 'claude-gateway', + name: 'Claude Gateway', + npm: '@custom/claude-gateway', + models: { 'claude-x': { id: 'claude-x', limit: { context: 1000 } } }, + }, + }); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'claude-gateway', { + apiKey: 'sk-gw', + baseUrl: 'https://claude-gateway.example.test/v1', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().providers['claude-gateway']).toMatchObject({ + type: 'anthropic', + // The Anthropic SDK appends /v1/messages itself — persisting the /v1 + // would double it (/v1/v1/messages). + baseUrl: 'https://claude-gateway.example.test', + }); + }); + + it('rejects an empty --base-url instead of persisting a blank endpoint', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'openai', { apiKey: 'sk-o', baseUrl: ' ' })); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('--base-url cannot be empty'); + await expect(harness.getConfig().then((c) => c.providers['openai'])).resolves.toBeUndefined(); + }); + + it('requires --base-url for a non-official Anthropic-compatible vendor without one', async () => { + mockRegistryFetch({ + 'claude-gateway': { + id: 'claude-gateway', + name: 'Claude Gateway', + npm: '@custom/claude-gateway', + models: { 'claude-x': { id: 'claude-x', limit: { context: 1000 } } }, + }, + }); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'claude-gateway', { apiKey: 'sk-gw' })); + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('--base-url'); + + await tryRun(() => + handleCatalogAdd(deps, 'claude-gateway', { + apiKey: 'sk-gw', + baseUrl: 'https://claude-gateway.example.test', + }), + ); + expect(current().providers['claude-gateway']).toMatchObject({ + type: 'anthropic', + baseUrl: 'https://claude-gateway.example.test', + }); + }); + it('exits 1 when the api key is missing and skips the network', async () => { const fetchMock = mockRegistryFetch(CATALOG_BODY); const { harness } = makeHarness({ providers: {} } as KimiConfig); @@ -912,4 +999,97 @@ describe('kimi provider catalog add', () => { expect(exitCodes).toEqual([1]); expect(stderr.join('')).toContain('Provider "no-such-id" not found in catalog'); }); + + const GUESS_CATALOG_BODY = { + xai: { + id: 'xai', + name: 'xAI', + npm: '@ai-sdk/xai', + env: ['XAI_API_KEY'], + models: { + 'grok-4': { + id: 'grok-4', + limit: { context: 256_000 }, + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }], + }, + }, + }, + bedrock: { + id: 'amazon-bedrock', + name: 'Amazon Bedrock', + npm: '@ai-sdk/amazon-bedrock', + models: { 'claude-x': { id: 'claude-x', limit: { context: 1000 } } }, + }, + azure: { + id: 'azure', + name: 'Azure', + npm: '@ai-sdk/azure', + env: ['AZURE_API_KEY'], + models: { 'gpt-x': { id: 'gpt-x', limit: { context: 1000 } } }, + }, + }; + + it('guesses openai for a vendor-specific SDK and requires --base-url', async () => { + mockRegistryFetch(GUESS_CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'xai', { apiKey: 'sk-xai' })); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('--base-url'); + await expect(harness.getConfig().then((c) => c.providers['xai'])).resolves.toBeUndefined(); + }); + + it('imports a guessed vendor with --base-url, carrying off_effort and a guess note', async () => { + mockRegistryFetch(GUESS_CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'xai', { apiKey: 'sk-xai', baseUrl: 'https://api.x.ai/v1' }), + ); + + expect(exitCodes).toEqual([]); + expect(current().providers['xai']).toMatchObject({ + type: 'openai', + baseUrl: 'https://api.x.ai/v1', + apiKey: 'sk-xai', + }); + expect(current().models?.['xai/grok-4']).toMatchObject({ + supportEfforts: ['low', 'medium', 'high'], + offEffort: 'none', + }); + expect(stdout.join('')).toContain('guessed "openai"'); + }); + + it('refuses a proprietary SDK (bedrock) instead of guessing', async () => { + mockRegistryFetch(GUESS_CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'bedrock', { apiKey: 'sk-x' })); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('proprietary'); + }); + + it('requires --base-url for a vendor with no catalog endpoint (azure shape)', async () => { + mockRegistryFetch(GUESS_CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'azure', { apiKey: 'sk-az' })); + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('--base-url'); + + await tryRun(() => + handleCatalogAdd(deps, 'azure', { apiKey: 'sk-az', baseUrl: 'https://res.example.test/openai/v1' }), + ); + expect(current().providers['azure']).toMatchObject({ + type: 'openai', + baseUrl: 'https://res.example.test/openai/v1', + }); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 9fe501d9e0..87c0735501 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -5381,7 +5381,7 @@ describe('/effort support_efforts override', () => { expect(transcript).toContain('Thinking set to max.'); }); - it('offers the latest Opus efforts for an unknown Anthropic-compatible model', async () => { + it('offers the latest Opus efforts for an unknown Claude-marked Anthropic-compatible model', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ providers: { @@ -5390,7 +5390,7 @@ describe('/effort support_efforts override', () => { models: { k2: { provider: 'compatible', - model: 'compatible-model', + model: 'compatible-claude-model', maxContextSize: 100, }, }, @@ -5407,6 +5407,32 @@ describe('/effort support_efforts override', () => { expect(picker.render(80).join('\n')).toContain('Max'); }); + it('offers no fallback efforts for a clearly non-Claude Anthropic-compatible model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).not.toContain('Max'); + }); + it('offers no fallback efforts for an unknown model on a Kimi provider using the Anthropic protocol', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ @@ -5434,14 +5460,14 @@ describe('/effort support_efforts override', () => { expect(picker.render(80).join('\n')).not.toContain('Max'); }); - it('offers the latest Opus efforts for a flat providerless Anthropic model', async () => { + it('offers the latest Opus efforts for a flat providerless Claude-marked Anthropic model', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ providers: {}, models: { // v2 flat model shape: no named provider, inline endpoint + protocol. k2: { - model: 'compatible-model', + model: 'compatible-claude-model', baseUrl: 'https://anthropic.example.test', protocol: 'anthropic', maxContextSize: 100, diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index dd2d4b0ba5..bc99820f77 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -181,7 +181,7 @@ max_context_size = 131072 display_name = "Kimi for Coding (custom)" ``` -`[models."".overrides]` accepts ordinary model fields such as `max_context_size`, `max_output_size`, `capabilities`, `display_name`, `reasoning_key`, `adaptive_thinking`, `support_efforts`, and `default_effort`. It does not accept identity / routing fields: `provider`, `model`, `protocol`, and `beta_api`. +`[models."".overrides]` accepts ordinary model fields such as `max_context_size`, `max_output_size`, `capabilities`, `display_name`, `reasoning_key`, `adaptive_thinking`, `support_efforts`, and `default_effort`. It does not accept identity / routing fields: `provider`, `model`, `protocol`, `beta_api`, and `base_url`. You can also switch models temporarily without touching the config file — by setting `KIMI_MODEL_*` environment variables, the CLI synthesizes a temporary provider in memory that does not persist after restart. See [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-kimi_model). diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 7e20bf74de..70953a3fb7 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -359,13 +359,14 @@ kimi provider catalog list anthropic #### `kimi provider catalog add ` -Import a known provider directly from the catalog by ID. The protocol type, base URL, and model information are all supplied by the catalog — only an API key is required. +Import a known provider directly from the catalog by ID. The protocol type, base URL, and model information are all supplied by the catalog — only an API key is required. Vendors whose protocol the catalog does not declare (e.g. xai, openrouter, and other vendor-specific SDKs) are imported as OpenAI-compatible and the output notes the guess; when the catalog provides no usable endpoint, `--base-url` is required. Proprietary protocols (e.g. Amazon Bedrock) cannot be imported. | Parameter / Option | Description | | --- | --- | | `` | Provider ID in the catalog, e.g., `anthropic`, `openai` | | `--api-key ` | Provider API key. Falls back to `KIMI_REGISTRY_API_KEY` if not provided; required | | `--default-model ` | Optional — set `default_model` to `/` after import | +| `--base-url ` | Override the catalog endpoint; required when the catalog declares none (or only an env placeholder) | | `--url ` | Override the catalog URL; defaults to `https://models.dev/api.json` | ```sh diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 0a71711ace..932d9283de 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -181,7 +181,7 @@ max_context_size = 131072 display_name = "Kimi for Coding (custom)" ``` -`[models."".overrides]` 接受普通模型字段,例如 `max_context_size`、`max_output_size`、`capabilities`、`display_name`、`reasoning_key`、`adaptive_thinking`、`support_efforts` 和 `default_effort`。不接受身份 / 路由字段:`provider`、`model`、`protocol` 和 `beta_api`。 +`[models."".overrides]` 接受普通模型字段,例如 `max_context_size`、`max_output_size`、`capabilities`、`display_name`、`reasoning_key`、`adaptive_thinking`、`support_efforts` 和 `default_effort`。不接受身份 / 路由字段:`provider`、`model`、`protocol`、`beta_api` 和 `base_url`。 无需修改配置文件也可以临时切换模型——通过 `KIMI_MODEL_*` 环境变量在内存里合成一个临时供应商,详见[用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index b4abcd2c3e..5d2abeb507 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -359,13 +359,14 @@ kimi provider catalog list anthropic #### `kimi provider catalog add ` -按 id 从 catalog 直接导入一个已知供应商,协议类型、base URL、模型信息均由 catalog 提供,只需提供 API key。 +按 id 从 catalog 直接导入一个已知供应商,协议类型、base URL、模型信息均由 catalog 提供,只需提供 API key。catalog 未声明协议的供应商(如 xai、openrouter 这类专有 SDK)按 OpenAI 兼容协议导入,并在输出中标注 "guessed";catalog 未提供可用端点时需用 `--base-url` 显式指定。专有协议(如 Amazon Bedrock)无法导入。 | 参数 / 选项 | 说明 | | --- | --- | | `` | catalog 中的供应商 id,如 `anthropic`、`openai` | | `--api-key ` | 供应商 API key。未传时回退到 `KIMI_REGISTRY_API_KEY`,必填 | | `--default-model ` | 可选,导入后把 `default_model` 设为 `/` | +| `--base-url ` | 覆盖 catalog 声明的端点;catalog 未提供端点(或仅有环境变量占位符)时必填 | | `--url ` | 覆盖 catalog 地址,默认 `https://models.dev/api.json` | ```sh diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts index adce5ea722..61239bdec6 100644 --- a/packages/acp-adapter/src/model-catalog.ts +++ b/packages/acp-adapter/src/model-catalog.ts @@ -168,9 +168,11 @@ export async function listModelsFromHarness( * The alias's provider type, resolved like * `ProviderManager.resolveProviderConfig` does: the alias's provider (falling * back to the configured default provider). The Anthropic fallback profile in - * `effectiveModelAlias` only applies to non-Kimi providers, so a custom-named - * model on a `type = "anthropic"` provider still gets an inferred effort list - * while managed Kimi models keep only their catalog-declared efforts. + * `effectiveModelAlias` only applies to non-Kimi providers, and then only to + * model names that still carry a Claude marker — a custom-named Claude model + * on a `type = "anthropic"` provider still gets an inferred effort list, + * while managed Kimi models and clearly non-Claude names keep only their + * catalog-declared efforts. */ function providerTypeOf( alias: ModelAlias, diff --git a/packages/acp-adapter/test/config-options.test.ts b/packages/acp-adapter/test/config-options.test.ts index c67137b872..c249250950 100644 --- a/packages/acp-adapter/test/config-options.test.ts +++ b/packages/acp-adapter/test/config-options.test.ts @@ -238,11 +238,11 @@ describe('buildSessionConfigOptions', () => { } }); - it('shows the thinking control for an unknown model using the Anthropic protocol', async () => { + it('shows the thinking control for an unknown Claude-marked model using the Anthropic protocol', async () => { const { harness } = makeHarnessWithModels([ { id: 'custom', - model: 'custom-anthropic-model', + model: 'custom-claude-model', protocol: 'anthropic', providerType: 'anthropic', }, @@ -253,6 +253,21 @@ describe('buildSessionConfigOptions', () => { expect(result.map((option) => option.id)).toEqual(['model', 'thinking', 'mode']); }); + it('hides the thinking control for a clearly non-Claude model using the Anthropic protocol', async () => { + const { harness } = makeHarnessWithModels([ + { + id: 'custom', + model: 'custom-anthropic-model', + protocol: 'anthropic', + providerType: 'anthropic', + }, + ]); + + const result = await buildSessionConfigOptions(harness, 'custom', 'off', 'default'); + + expect(result.map((option) => option.id)).toEqual(['model', 'mode']); + }); + it('hides the thinking control for an unknown model on a Kimi provider using the Anthropic protocol', async () => { const { harness } = makeHarnessWithModels([ { diff --git a/packages/acp-adapter/test/model-catalog.test.ts b/packages/acp-adapter/test/model-catalog.test.ts index 21bc87fe50..604d48a83c 100644 --- a/packages/acp-adapter/test/model-catalog.test.ts +++ b/packages/acp-adapter/test/model-catalog.test.ts @@ -75,7 +75,7 @@ describe('deriveSupportEfforts', () => { }); describe('listModelsFromHarness', () => { - it('advertises thinking with a high default for an unknown model using the Anthropic protocol', async () => { + it('advertises thinking with a high default for an unknown Claude-marked model using the Anthropic protocol', async () => { const harness = { getConfig: async () => ({ providers: { @@ -84,7 +84,7 @@ describe('listModelsFromHarness', () => { models: { custom: { provider: 'custom', - model: 'custom-anthropic-model', + model: 'custom-claude-model', maxContextSize: 200000, protocol: 'anthropic', }, @@ -95,7 +95,7 @@ describe('listModelsFromHarness', () => { await expect(listModelsFromHarness(harness)).resolves.toEqual([ { id: 'custom', - name: 'custom-anthropic-model', + name: 'custom-claude-model', thinkingSupported: true, alwaysThinking: false, supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], @@ -104,11 +104,15 @@ describe('listModelsFromHarness', () => { ]); }); - it('advertises thinking for a flat providerless model using the Anthropic protocol', async () => { + it('does not advertise thinking for a clearly non-Claude model using the Anthropic protocol', async () => { const harness = { getConfig: async () => ({ + providers: { + custom: { type: 'anthropic' }, + }, models: { custom: { + provider: 'custom', model: 'custom-anthropic-model', maxContextSize: 200000, protocol: 'anthropic', @@ -121,6 +125,31 @@ describe('listModelsFromHarness', () => { { id: 'custom', name: 'custom-anthropic-model', + thinkingSupported: false, + alwaysThinking: false, + supportEfforts: [], + defaultThinkingEffort: 'on', + }, + ]); + }); + + it('advertises thinking for a flat providerless Claude-marked model using the Anthropic protocol', async () => { + const harness = { + getConfig: async () => ({ + models: { + custom: { + model: 'custom-claude-model', + maxContextSize: 200000, + protocol: 'anthropic', + }, + }, + }), + } as unknown as KimiHarness; + + await expect(listModelsFromHarness(harness)).resolves.toEqual([ + { + id: 'custom', + name: 'custom-claude-model', thinkingSupported: true, alwaysThinking: false, supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], @@ -160,9 +189,10 @@ describe('listModelsFromHarness', () => { it('derives thinking support from the provider type when the alias omits protocol', async () => { // Same shape the runtime sees for `[providers.compat] type = "anthropic"` - // + a custom-named model with no alias-level protocol: the provider - // context must make the catalog agree with ProviderManager, which infers - // the latest Anthropic profile (thinking-capable, default effort high). + // + a Claude-marked custom model with no alias-level protocol: the + // provider context must make the catalog agree with ProviderManager, + // which infers the latest Anthropic profile (thinking-capable, default + // effort high). Clearly non-Claude names get no inferred profile. const harness = { getConfig: async () => ({ defaultProvider: 'compat', @@ -172,7 +202,7 @@ describe('listModelsFromHarness', () => { models: { custom: { provider: 'compat', - model: 'joint-model-0714-vibe', + model: 'joint-claude-0714-vibe', maxContextSize: 200000, }, }, @@ -182,7 +212,7 @@ describe('listModelsFromHarness', () => { await expect(listModelsFromHarness(harness)).resolves.toEqual([ { id: 'custom', - name: 'joint-model-0714-vibe', + name: 'joint-claude-0714-vibe', thinkingSupported: true, alwaysThinking: false, supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 2e328f95a3..d375a2e5c7 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -172,7 +172,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } private getEffectiveMaxContextTokens(): number { - const configured = this.profile.data().modelCapabilities.max_context_tokens; + const capability = this.profile.data().modelCapabilities; + const configured = capability.max_input_tokens ?? capability.max_context_tokens; const modelAlias = this.profile.data().modelAlias; const observed = modelAlias === undefined ? undefined : this.observedMaxContextTokensByModel.get(modelAlias); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts index 4ca899b2c5..c8ebb8e579 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts @@ -71,14 +71,14 @@ export class RuntimeCompactionStrategy implements CompactionStrategy { private delegate(): DefaultCompactionStrategy { const model = this.context(); return new DefaultCompactionStrategy( - () => model.modelCapabilities.max_context_tokens, + () => model.modelCapabilities.max_input_tokens ?? model.modelCapabilities.max_context_tokens, this.config(model), ); } private windowDelegate(): DefaultCompactionStrategy { return new DefaultCompactionStrategy( - () => this.context().modelCapabilities.max_context_tokens, + () => this.context().modelCapabilities.max_input_tokens ?? this.context().modelCapabilities.max_context_tokens, DEFAULT_COMPACTION_CONFIG, ); } diff --git a/packages/agent-core-v2/src/kosong/contract/capability.ts b/packages/agent-core-v2/src/kosong/contract/capability.ts index 4decd5ca7f..fa75b8187e 100644 --- a/packages/agent-core-v2/src/kosong/contract/capability.ts +++ b/packages/agent-core-v2/src/kosong/contract/capability.ts @@ -17,6 +17,7 @@ export interface ModelCapability { readonly thinking: boolean; readonly tool_use: boolean; readonly max_context_tokens: number; + readonly max_input_tokens?: number; readonly dynamically_loaded_tools?: boolean; } diff --git a/packages/agent-core-v2/src/kosong/model/catalog.ts b/packages/agent-core-v2/src/kosong/model/catalog.ts index d90f9c6dc7..225729ddf0 100644 --- a/packages/agent-core-v2/src/kosong/model/catalog.ts +++ b/packages/agent-core-v2/src/kosong/model/catalog.ts @@ -85,6 +85,7 @@ export interface Model { readonly capabilities: ModelCapability; readonly maxContextSize: number; + readonly maxInputSize?: number; readonly maxOutputSize?: number; readonly displayName?: string; readonly reasoningKey?: string; diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index 1ec3ab0189..7921d00dfb 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -68,6 +68,7 @@ import { ConfigErrors } from '../../app/config/errors'; import { LATEST_OPUS_PROFILE, matchKnownAnthropicModelProfile, + matchUnknownClaudeProfile, } from '../provider/bases/anthropic/anthropic-profile'; import { DEFAULT_PROVIDER_SECTION, @@ -397,6 +398,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { model.capabilities, explainedCapability.capability, model.maxContextSize, + model.maxInputSize, ); const providerOptions = buildProtocolProviderOptions( model, @@ -423,6 +425,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { ), capabilities, maxContextSize: model.maxContextSize, + maxInputSize: model.maxInputSize, maxOutputSize: model.maxOutputSize, displayName: model.displayName, reasoningKey: model.reasoningKey, @@ -616,6 +619,7 @@ function resolveModelCapabilities( declaredCapabilities: readonly string[] | undefined, detected: ModelCapability, maxContextSize: number, + maxInputSize: number | undefined, ): ModelCapability { const declared = new Set((declaredCapabilities ?? []).map((c) => c.trim().toLowerCase())); return { @@ -625,6 +629,7 @@ function resolveModelCapabilities( thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking, tool_use: declared.has('tool_use') || detected.tool_use, max_context_tokens: maxContextSize, + max_input_tokens: maxInputSize, dynamically_loaded_tools: declared.has('dynamically_loaded_tools') || detected.dynamically_loaded_tools === true, @@ -653,6 +658,7 @@ function buildProtocolProviderOptions( case 'openai': { const reasoningKey = nonEmpty(model.reasoningKey); if (reasoningKey !== undefined) options.reasoningKey = reasoningKey; + if (model.offEffort !== undefined) options.offEffort = model.offEffort; break; } case 'google-genai': { @@ -669,6 +675,7 @@ function buildProtocolProviderOptions( break; } case 'openai_responses': + if (model.offEffort !== undefined) options.offEffort = model.offEffort; break; default: { const exhaustive: never = protocol; @@ -681,12 +688,6 @@ function buildProtocolProviderOptions( : undefined; } -/** - * The Anthropic effort profile the effective pass applies, recomputed for - * attribution only — mirrors `withAnthropicProfile`'s gate exactly (the - * trait-driven vendor check routes through the registry, never a string - * compare). `inferred` marks the unknown-name fallback to LATEST_OPUS_PROFILE. - */ function profileForAttribution( configuredModel: ModelRecord, providerConfig: ProviderConfig | undefined, @@ -700,7 +701,10 @@ function profileForAttribution( profileArg !== undefined && !drivesThinkingThroughTraits(profileArg) && gateProtocol === 'anthropic'; - if (infer) return { profile: known ?? LATEST_OPUS_PROFILE, inferred: known === undefined }; + if (infer) { + const fallback = known ?? matchUnknownClaudeProfile(wireName); + return { profile: fallback, inferred: known === undefined && fallback !== undefined }; + } return { profile: known, inferred: false }; } diff --git a/packages/agent-core-v2/src/kosong/model/inspection.ts b/packages/agent-core-v2/src/kosong/model/inspection.ts index cb463520c0..346f404828 100644 --- a/packages/agent-core-v2/src/kosong/model/inspection.ts +++ b/packages/agent-core-v2/src/kosong/model/inspection.ts @@ -52,6 +52,7 @@ export interface InspectedResolvedModel { readonly auth: InspectedAuth; readonly capabilities: ModelCapability; readonly maxContextSize: number; + readonly maxInputSize?: number; readonly maxOutputSize?: number; readonly displayName?: string; readonly reasoningKey?: string; @@ -258,6 +259,7 @@ interface ResolvedModelLike { readonly aliases: readonly string[]; readonly capabilities: ModelCapability; readonly maxContextSize: number; + readonly maxInputSize?: number; readonly maxOutputSize?: number; readonly displayName?: string; readonly reasoningKey?: string; @@ -431,6 +433,7 @@ export function assembleModelInspection(args: { auth, capabilities: model.capabilities, maxContextSize: model.maxContextSize, + maxInputSize: model.maxInputSize, maxOutputSize: model.maxOutputSize, displayName: model.displayName, reasoningKey: model.reasoningKey, diff --git a/packages/agent-core-v2/src/kosong/model/model.ts b/packages/agent-core-v2/src/kosong/model/model.ts index f1ab223056..7e11d83909 100644 --- a/packages/agent-core-v2/src/kosong/model/model.ts +++ b/packages/agent-core-v2/src/kosong/model/model.ts @@ -59,6 +59,7 @@ const ModelBaseSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), maxContextSize: z.number().int().min(1).optional(), + maxInputSize: z.number().int().min(1).optional(), maxOutputSize: z.number().int().min(1).optional(), capabilities: z.array(z.string()).optional(), displayName: z.string().optional(), @@ -67,6 +68,7 @@ const ModelBaseSchema = z.object({ betaApi: z.boolean().optional(), supportEfforts: z.array(z.string()).optional(), defaultEffort: z.string().optional(), + offEffort: z.string().optional(), }); export const ModelOverrideSchema = ModelBaseSchema.omit({ diff --git a/packages/agent-core-v2/src/kosong/model/modelAuth.ts b/packages/agent-core-v2/src/kosong/model/modelAuth.ts index 5079dd8765..3a33b9bb3a 100644 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ b/packages/agent-core-v2/src/kosong/model/modelAuth.ts @@ -14,6 +14,10 @@ * managed models routed through protocol `anthropic` — keep only * catalog-declared effort metadata. The verdict comes from the registry * (`drivesThinkingThroughTraits`), not from a vendor string compare. + * The unknown-name fallback within that inference only applies to names + * that still carry a Claude marker (a `claude` substring or a bare family + * word like `sonnet-latest`); clearly non-Claude names served over the + * Anthropic protocol get no synthesized effort metadata. */ import { Error2 } from '#/_base/errors/errors'; @@ -22,8 +26,8 @@ import type { ResolutionTrace } from '#/kosong/contract/inspection'; import { ConfigErrors } from '../../app/config/errors'; import { BUDGET_THINKING_EFFORTS, - inferAnthropicModelProfile, matchKnownAnthropicModelProfile, + matchUnknownClaudeProfile, } from '../provider/bases/anthropic/anthropic-profile'; import type { ProviderConfig } from '../provider/provider'; import { explainProviderEndpoint } from '../provider/providerDefinition'; @@ -125,7 +129,7 @@ function withAnthropicProfile(model: ModelRecord, providerType?: string): ModelR wireName === undefined ? undefined : providerType !== undefined && !drivesThinkingThroughTraits(providerType) && protocol === 'anthropic' - ? inferAnthropicModelProfile(wireName) + ? (matchKnownAnthropicModelProfile(wireName) ?? matchUnknownClaudeProfile(wireName)) : matchKnownAnthropicModelProfile(wireName); if (profile === undefined) return model; const capability = profile.canDisableThinking ? 'thinking' : 'always_thinking'; diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index fb75ee0875..18b22a57bc 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -268,8 +268,11 @@ export function resolveThinkingEffortForModel( effort = configured ?? defaultThinkingEffortForModel(model); } - if (strictValidation && effort === 'off' && model?.alwaysThinking === true) { - effort = configured ?? defaultThinkingEffortForModel(model); + if (effort === 'off' && model?.alwaysThinking === true) { + effort = + configured !== undefined && configured !== 'off' + ? configured + : defaultThinkingEffortForModel(model); } return normalizeThinkingEffortForModel(effort, model, strictValidation); } diff --git a/packages/agent-core-v2/src/kosong/protocol/protocol.ts b/packages/agent-core-v2/src/kosong/protocol/protocol.ts index c7b4540597..2a318642f2 100644 --- a/packages/agent-core-v2/src/kosong/protocol/protocol.ts +++ b/packages/agent-core-v2/src/kosong/protocol/protocol.ts @@ -57,6 +57,7 @@ export interface ProtocolProviderOptions { readonly reasoningKey?: string; readonly defaultMaxTokens?: number; readonly supportEfforts?: readonly string[]; + readonly offEffort?: string; readonly adaptiveThinking?: boolean; readonly betaApi?: boolean; readonly metadata?: Readonly>; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts index 516fe0e9ad..5a15dbae9b 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts @@ -143,3 +143,12 @@ export function matchKnownAnthropicModelProfile(model: string): AnthropicModelPr export function inferAnthropicModelProfile(model: string): AnthropicModelProfile { return matchKnownAnthropicModelProfile(model) ?? LATEST_OPUS_PROFILE; } + +export function matchUnknownClaudeProfile(model: string): AnthropicModelProfile | undefined { + const normalized = model.toLowerCase(); + return normalized.includes('claude') || CLAUDE_FAMILY_WORD_RE.test(normalized) + ? LATEST_OPUS_PROFILE + : undefined; +} + +const CLAUDE_FAMILY_WORD_RE = /\b(?:opus|sonnet|haiku|fable|mythos)\b/; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts index 6314935e49..73547dbb9e 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts @@ -48,6 +48,7 @@ registerProtocolBase({ defaultHeaders: traitDefaultHeaders(traits), maxTokens: config.providerOptions?.defaultMaxTokens, reasoningKey: config.providerOptions?.reasoningKey, + offEffort: config.providerOptions?.offEffort, hooks: composeOpenAIChatHooks(traits), }), }); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index 4363aaaf17..aa28bca110 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -130,6 +130,7 @@ export interface OpenAILegacyOptions { stream?: boolean | undefined; maxTokens?: number | undefined; reasoningKey?: string | undefined; + offEffort?: string | undefined; thinkingEffort?: ThinkingEffort | undefined; httpClient?: unknown; defaultHeaders?: Record; @@ -528,6 +529,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { private readonly _baseUrl: string | undefined; private readonly _defaultHeaders: Record | undefined; private readonly _reasoningKey: string | undefined; + private readonly _offEffort: string | undefined; private readonly _thinkingEffort: ThinkingEffort | undefined; private readonly _generationKwargs: OpenAILegacyGenerationKwargs; private readonly _toolMessageConversion: ToolMessageConversion; @@ -560,6 +562,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { ? normalizedReasoningKey : this._hooks?.reasoningKey?.(); this._thinkingEffort = options.thinkingEffort; + this._offEffort = options.offEffort; this._generationKwargs = normalizeGenerationKwargs( this._model, options.maxTokens !== undefined ? completionTokenKwargs(this._model, options.maxTokens) : {}, @@ -722,16 +725,12 @@ export class OpenAILegacyChatProvider implements ChatProvider { } } - // 'off' and 'on' have no wire encoding; only a concrete effort is passed - // through. An explicit 'off' must also suppress the history-based - // auto-enable below (issue #1616), so it stays distinguishable from - // "never configured". let reasoningEffort: string | undefined = - explicitThinkingEffort === undefined || - explicitThinkingEffort === 'off' || - explicitThinkingEffort === 'on' - ? undefined - : explicitThinkingEffort; + explicitThinkingEffort === 'off' + ? this._offEffort + : explicitThinkingEffort === undefined || explicitThinkingEffort === 'on' + ? undefined + : explicitThinkingEffort; // issue #1616 history scan — disabled entirely when a withThinking hook // exists: once a trait takes over thinking, the base must not interfere. diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts index 50b46e4b7c..863cd9ccc8 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts @@ -33,6 +33,7 @@ registerProtocolBase({ config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, defaultHeaders: traitDefaultHeaders(traits), maxOutputTokens: config.providerOptions?.defaultMaxTokens, + offEffort: config.providerOptions?.offEffort, }), }); }, diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index b2cb4d3a7f..dfb482f2dd 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -350,6 +350,7 @@ export interface OpenAIResponsesOptions { baseUrl?: string | undefined; model: string; maxOutputTokens?: number | undefined; + offEffort?: string | undefined; thinkingEffort?: ThinkingEffort | undefined; httpClient?: unknown; defaultHeaders?: Record; @@ -1000,6 +1001,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { private readonly _baseUrl: string | undefined; private readonly _defaultHeaders: Record | undefined; private readonly _thinkingEffort: ThinkingEffort | undefined; + private readonly _offEffort: string | undefined; private readonly _generationKwargs: OpenAIResponsesGenerationKwargs; private readonly _toolMessageConversion: ToolMessageConversion; private readonly _client: OpenAI | undefined; @@ -1014,6 +1016,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._model = options.model; this._stream = true; this._thinkingEffort = options.thinkingEffort; + this._offEffort = options.offEffort; this._generationKwargs = {}; this._toolMessageConversion = options.toolMessageConversion ?? null; this._httpClient = options.httpClient; @@ -1071,9 +1074,12 @@ export class OpenAIResponsesChatProvider implements ChatProvider { options?.thinking ?? (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); if (thinking !== undefined) { - // 'off' and 'on' have no wire encoding and suppress any seeded effort. const effort = - thinking.effort === 'off' || thinking.effort === 'on' ? undefined : thinking.effort; + thinking.effort === 'off' + ? this._offEffort + : thinking.effort === 'on' + ? undefined + : thinking.effort; kwargs = { ...kwargs, reasoning_effort: effort }; } diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 77d61dcdcd..96fd02a552 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -400,7 +400,7 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('max'); }); - it('preserves unlisted and off efforts for Kimi-managed Anthropic models', () => { + it('preserves unlisted efforts with a warning for Kimi-managed Anthropic models', () => { profile.update({ modelAlias: 'kimi-code/compatible', thinkingLevel: 'max' }); expect(() => { @@ -416,20 +416,19 @@ describe('ConfigState thinking clamp for always-thinking models', () => { 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', }, }); + }); + + it('clamps off to the model default for always-on models, on any transport', () => { + // A model declared always-on never resolves to off: the clamp turns the + // request into the model default ('max') instead of sending a dishonest + // off upstream. (The always-on warning path remains as a defensive layer + // for off values that bypass resolution.) + profile.update({ modelAlias: 'kimi-code/compatible', thinkingLevel: 'max' }); expect(() => { profile.setThinking('off'); }).not.toThrow(); - expect(profile.data().thinkingLevel).toBe('off'); - expect(ctx.allEvents).toContainEqual({ - type: '[rpc]', - event: 'warning', - args: { - code: 'anthropic-thinking-cannot-disable', - message: - 'Model "compatible-model" declares always-on thinking. The configured effort "off" will be sent unchanged to the Anthropic-compatible backend.', - }, - }); + expect(profile.data().thinkingLevel).toBe('max'); }); }); diff --git a/packages/agent-core-v2/test/agent/profile/thinking.test.ts b/packages/agent-core-v2/test/agent/profile/thinking.test.ts index 74dfb14bff..0728dbe84d 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -144,10 +144,30 @@ describe('resolveThinkingEffortForModel', () => { expect(resolveThinkingEffortForModel(undefined, { enabled: false }, booleanModel)).toBe('off'); }); - it('preserves off for Kimi-managed always-thinking models using Anthropic protocol', () => { + it('clamps always-thinking models to their default effort even without strict validation', () => { + // A model declared always-on never resolves to off, on any wire — claiming + // off while upstream keeps reasoning at its default would be a lie. This + // covers Kimi-managed models routed through the Anthropic transport and + // catalog-imported always-thinking models (e.g. gpt-5) alike. expect( resolveThinkingEffortForModel('off', undefined, alwaysThinkingAnthropicEffortModel), - ).toBe('off'); + ).toBe('high'); + expect( + resolveThinkingEffortForModel(undefined, { enabled: false }, alwaysThinkingAnthropicEffortModel), + ).toBe('high'); + expect(resolveThinkingEffortForModel('off', undefined, alwaysThinkingModel)).toBe('on'); + }); + + it('treats a configured off as absent when clamping always-thinking models', () => { + expect(resolveThinkingEffortForModel(undefined, { effort: 'off' }, alwaysThinkingEffortModel)).toBe( + 'high', + ); + expect( + resolveThinkingEffortForModel(undefined, { enabled: false, effort: 'off' }, alwaysThinkingEffortModel), + ).toBe('high'); + expect( + resolveThinkingEffortForModel(undefined, { enabled: false, effort: 'max' }, alwaysThinkingEffortModel), + ).toBe('max'); }); it('carries custom requested efforts through', () => { diff --git a/packages/agent-core-v2/test/app/model/model.test.ts b/packages/agent-core-v2/test/app/model/model.test.ts index 5c7dc9cbad..471ae03437 100644 --- a/packages/agent-core-v2/test/app/model/model.test.ts +++ b/packages/agent-core-v2/test/app/model/model.test.ts @@ -42,12 +42,30 @@ describe('effectiveModelConfig', () => { }); }); - it('infers Anthropic effort metadata for an unknown model on a non-Kimi Anthropic provider', () => { + it('infers Anthropic effort metadata for an unknown Claude-marked model on a non-Kimi Anthropic provider', () => { expect( effectiveModelConfig( { provider: 'custom', - model: 'custom-anthropic-model', + model: 'custom-claude-model', + maxContextSize: 200000, + protocol: 'anthropic', + }, + 'anthropic', + ), + ).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'high', + }); + }); + + it('infers Anthropic effort metadata for a bare Claude family alias on a non-Kimi Anthropic provider', () => { + expect( + effectiveModelConfig( + { + provider: 'custom', + model: 'sonnet-latest', maxContextSize: 200000, protocol: 'anthropic', }, @@ -60,6 +78,25 @@ describe('effectiveModelConfig', () => { }); }); + it('does not infer Anthropic effort metadata for a clearly non-Claude model on a non-Kimi Anthropic provider', () => { + expect( + effectiveModelConfig( + { + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + }, + 'anthropic', + ), + ).toEqual({ + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + }); + }); + it('does not infer Anthropic effort metadata for a Kimi provider routed through the Anthropic protocol', () => { const model: ModelRecord = { provider: 'managed:kimi-code', @@ -89,7 +126,7 @@ describe('effectiveModelConfig', () => { effectiveModelConfig( { provider: 'custom', - model: 'custom-anthropic-model', + model: 'custom-claude-model', maxContextSize: 200000, protocol: 'anthropic', adaptiveThinking: false, 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 6bd410a3a4..c51ac33db8 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -216,6 +216,38 @@ describe('Model assembly (pure data)', () => { } }); + it('passes a declared offEffort through providerOptions for the OpenAI wires', () => { + const { host, catalog } = createHost({ + providers: { + gateway: { type: 'openai', apiKey: 'sk-gw', baseUrl: 'https://gateway.example.test/v1' }, + responses: { type: 'openai_responses', apiKey: 'sk-r' }, + }, + models: { + grok: { + provider: 'gateway', + model: 'grok-4', + maxContextSize: 256000, + supportEfforts: ['low', 'medium', 'high'], + offEffort: 'none', + }, + grokResponses: { + provider: 'responses', + model: 'grok-4', + maxContextSize: 256000, + offEffort: 'none', + }, + plain: { provider: 'gateway', model: 'gpt-4.1', maxContextSize: 1000 }, + }, + }); + try { + expect(catalog.get('grok').providerOptions).toEqual({ offEffort: 'none' }); + expect(catalog.get('grokResponses').providerOptions).toEqual({ offEffort: 'none' }); + expect(catalog.get('plain').providerOptions).toBeUndefined(); + } finally { + host.dispose(); + } + }); + it('enables google-genai vertex mode through providerOptions when project and location resolve', () => { const { host, catalog } = createHost({ providers: { @@ -898,12 +930,12 @@ describe('ModelCatalog enumeration', () => { } }); - it('projects latest Opus efforts for unknown Anthropic-compatible models', async () => { + it('projects latest Opus efforts for unknown Claude-marked Anthropic-compatible models', async () => { const sections = structuredClone(catalogSections); (sections['providers'] as Record)['custom'] = { type: 'anthropic' }; (sections['models'] as Record)['compatible'] = { provider: 'custom', - model: 'compatible-model', + model: 'custom-claude-model', maxContextSize: 128000, }; const { host, catalog } = createHost(sections); @@ -919,12 +951,31 @@ describe('ModelCatalog enumeration', () => { } }); - it('projects latest Opus efforts for a flat providerless Anthropic model', async () => { + it('does not project fallback efforts for clearly non-Claude Anthropic-compatible models', async () => { + const sections = structuredClone(catalogSections); + (sections['providers'] as Record)['custom'] = { type: 'anthropic' }; + (sections['models'] as Record)['compatible'] = { + provider: 'custom', + model: 'compatible-model', + maxContextSize: 128000, + }; + const { host, catalog } = createHost(sections); + try { + const compatible = (await catalog.listModels()).find((model) => model.model === 'compatible'); + expect(compatible?.capabilities).toBeUndefined(); + expect(compatible?.support_efforts).toBeUndefined(); + expect(compatible?.default_effort).toBeUndefined(); + } finally { + host.dispose(); + } + }); + + it('projects latest Opus efforts for a flat providerless Claude-marked Anthropic model', async () => { const { host, catalog } = createHost({ providers: {}, models: { compatible: { - model: 'compatible-model', + model: 'custom-claude-model', baseUrl: 'https://anthropic.example.test', protocol: 'anthropic', maxContextSize: 128000, @@ -943,6 +994,28 @@ describe('ModelCatalog enumeration', () => { } }); + it('does not project fallback efforts for a flat providerless non-Claude Anthropic model', async () => { + const { host, catalog } = createHost({ + providers: {}, + models: { + compatible: { + model: 'compatible-model', + baseUrl: 'https://anthropic.example.test', + protocol: 'anthropic', + maxContextSize: 128000, + }, + }, + }); + try { + const compatible = (await catalog.listModels()).find((model) => model.model === 'compatible'); + expect(compatible?.capabilities).toBeUndefined(); + expect(compatible?.support_efforts).toBeUndefined(); + expect(compatible?.default_effort).toBeUndefined(); + } finally { + host.dispose(); + } + }); + it('does not project fallback efforts for unknown Kimi-managed Anthropic models', async () => { const sections = structuredClone(catalogSections); (sections['models'] as Record)['compatible'] = { 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 1d7df5540c..e6293bd978 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -1038,6 +1038,31 @@ describe('OpenAI reasoning_effort path (issue #1616)', () => { expect(body).not.toHaveProperty('reasoning_effort'); }); + it('encodes an explicit off as the configured offEffort for models that reason by default', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'grok-4', + apiKey: 'sk-probe', + stream: false, + offEffort: 'none', + }); + + const body = await captureOpenAIBody(provider, { thinking: { effort: 'off' } }, THINK_HISTORY); + + expect(body['reasoning_effort']).toBe('none'); + }); + + it('encodes an explicit off as the configured offEffort on the Responses wire', async () => { + const provider = new OpenAIResponsesChatProvider({ + model: 'grok-4', + apiKey: 'sk-probe', + offEffort: 'none', + }); + + const body = await captureResponsesBody(provider, { thinking: { effort: 'off' } }); + + expect(body['reasoning']).toEqual({ effort: 'none', summary: 'auto' }); + }); + it('disables the auto-enable entirely once a withThinking hook exists (load-bearing)', async () => { // A hook that defers (returns undefined) still counts as "a trait took // thinking over": the base's history scan must not fire, but an explicit diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 52f2864dd4..f56c7bc919 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -124,7 +124,8 @@ export class FullCompaction { } getEffectiveMaxContextTokens(): number { - const configured = this.agent.config.modelCapabilities.max_context_tokens; + const capability = this.agent.config.modelCapabilities; + const configured = capability.max_input_tokens ?? capability.max_context_tokens; const modelAlias = this.agent.config.modelAlias; const observed = modelAlias === undefined ? undefined : this.observedMaxContextTokensByModel.get(modelAlias); diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 727063b6dc..c54e6b515a 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -91,9 +91,11 @@ function normalizeThinkingEffortForModel( * 2. `thinking.enabled === false` forces `'off'`; * 3. otherwise `thinking.effort` when set, else the model's default effort. * - * The `always_thinking` constraint is enforced locally only for the Kimi wire - * protocol. Compatible protocols receive the requested value unchanged so - * their backend can make the final capability decision. + * A model that declares `always_thinking` can never resolve to `'off'`, on + * any wire — a claimed off state would be a lie, since upstream keeps + * reasoning at its default when no off encoding exists. (Compatible + * protocols still receive every other requested value unchanged so their + * backend can make the final capability decision.) */ export function resolveThinkingEffort( requested: ThinkingEffort | undefined, @@ -111,16 +113,15 @@ export function resolveThinkingEffort( effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel); } - if ( - kimiProtocol && - effort === 'off' && - effectiveModel?.capabilities?.includes('always_thinking') === true - ) { + if (effort === 'off' && effectiveModel?.capabilities?.includes('always_thinking') === true) { // always_thinking forces thinking on, but an explicitly configured effort // is still honored — `enabled = false` only expresses the intent to - // disable, it should not also discard a chosen effort. Fall back to the - // model default only when no effort is configured. - effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel); + // disable, it should not also discard a chosen effort. A configured + // 'off' is treated as absent: the model default applies instead. + effort = + config?.effort !== undefined && config.effort.trim().toLowerCase() !== 'off' + ? config.effort + : defaultThinkingEffortFor(effectiveModel); } return normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol); diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index f02ad31292..2ac5096e76 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -219,7 +219,8 @@ export class ContextMemory { const currentTokenCount = this.tokenCountWithPending; const importTokenCount = estimateTokensForMessages([message]); const totalTokenCount = currentTokenCount + importTokenCount; - const maxContextTokens = this.agent.config.modelCapabilities.max_context_tokens; + const capability = this.agent.config.modelCapabilities; + const maxContextTokens = capability.max_input_tokens ?? capability.max_context_tokens; if (maxContextTokens > 0 && totalTokenCount > maxContextTokens) { throw new KimiError( ErrorCodes.CONTEXT_OVERFLOW, diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index cc57dc1462..90444d709a 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -681,7 +681,8 @@ export class Agent { if (!this.config.hasModel) return; const contextTokens = this.context.tokenCount; - const maxContextTokens = this.config.modelCapabilities.max_context_tokens; + const capability = this.config.modelCapabilities; + const maxContextTokens = capability.max_input_tokens ?? capability.max_context_tokens; const contextUsage = maxContextTokens !== undefined && maxContextTokens > 0 ? contextTokens / maxContextTokens diff --git a/packages/agent-core/src/config/model.ts b/packages/agent-core/src/config/model.ts index b8a19f5a31..2a64842e94 100644 --- a/packages/agent-core/src/config/model.ts +++ b/packages/agent-core/src/config/model.ts @@ -1,7 +1,7 @@ import { BUDGET_THINKING_EFFORTS, - inferAnthropicModelProfile, matchKnownAnthropicModelProfile, + matchUnknownClaudeProfile, } from '@moonshot-ai/kosong/providers/anthropic-profile'; import type { ModelAlias, ProviderType } from './schema'; @@ -28,13 +28,17 @@ export function effectiveModelAlias( function withAnthropicProfile(model: ModelAlias, providerType?: ProviderType): ModelAlias { const protocol = model.protocol ?? providerType; // The inferred fallback profile exists for third-party Anthropic-compatible - // endpoints whose model name encodes no known Claude version. Kimi providers - // — including managed models routed through protocol = "anthropic" — declare - // thinking efforts via the catalog, so they never receive the fallback. - // Callers without provider context fall back to name matching only. + // endpoints whose model name encodes no known Claude version. It only + // applies to names that still carry a Claude marker (e.g. a proxied + // `claude-latest`): clearly non-Claude models served over the Anthropic + // protocol (catalog-imported Kimi `k3`, GLM, …) must not advertise Claude + // effort levels. Kimi providers — including managed models routed through + // protocol = "anthropic" — declare thinking efforts via the catalog, so + // they never receive the fallback. Callers without provider context fall + // back to name matching only. const profile = providerType !== undefined && providerType !== 'kimi' && protocol === 'anthropic' - ? inferAnthropicModelProfile(model.model) + ? (matchKnownAnthropicModelProfile(model.model) ?? matchUnknownClaudeProfile(model.model)) : matchKnownAnthropicModelProfile(model.model); if (profile === undefined) return model; diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 06e7c9d307..16c54fd558 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -41,6 +41,10 @@ const ModelAliasBaseSchema = z.object({ provider: z.string(), model: z.string(), maxContextSize: z.number().int().min(1), + // Declared prompt/input cap when below the total window (e.g. gpt-5: 400k + // window, 272k input). Compaction and other prompt-budget checks prefer it + // over max_context_size; completion budgeting keeps the total window. + maxInputSize: z.number().int().min(1).optional(), maxOutputSize: z.number().int().min(1).optional(), capabilities: z.array(z.string()).optional(), displayName: z.string().optional(), @@ -56,10 +60,19 @@ const ModelAliasBaseSchema = z.object({ // config.toml. The user's chosen effort is stored globally in thinking.effort. supportEfforts: z.array(z.string()).optional(), defaultEffort: z.string().optional(), + // The effort value that encodes "thinking off" on the wire for this model + // (models.dev declares it as the "none" entry, e.g. xai grok). When set, + // turning thinking off sends this value instead of omitting the effort + // field — required by models whose default is to reason. + offEffort: z.string().optional(), // Route the Anthropic transport through the beta Messages API // (`POST /v1/messages?beta=true`) instead of the standard endpoint. Used by // managed Kimi Code models that declare `protocol: 'anthropic'`. betaApi: z.boolean().optional(), + // Per-model endpoint override, paired with `protocol`. Catalog imports set + // it when a gateway provider serves this model over a different endpoint + // than the provider default. + baseUrl: z.string().optional(), }); export const ModelAliasOverrideSchema = ModelAliasBaseSchema.omit({ @@ -67,6 +80,7 @@ export const ModelAliasOverrideSchema = ModelAliasBaseSchema.omit({ model: true, protocol: true, betaApi: true, + baseUrl: true, }).partial(); export type ModelAliasOverrides = z.infer; diff --git a/packages/agent-core/src/services/session/sessionService.ts b/packages/agent-core/src/services/session/sessionService.ts index 636ae39458..fa0b64adfa 100644 --- a/packages/agent-core/src/services/session/sessionService.ts +++ b/packages/agent-core/src/services/session/sessionService.ts @@ -472,7 +472,8 @@ export class SessionService extends Disposable implements ISessionService { this.core.rpc.getPlan({ sessionId: id, agentId: 'main' }), ]); - const maxContextTokens = config.modelCapabilities?.max_context_tokens ?? 0; + const capability = config.modelCapabilities; + const maxContextTokens = capability?.max_input_tokens ?? capability?.max_context_tokens ?? 0; const contextTokens = context.tokenCount; const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index d746b677d1..4bc63dccdc 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -126,11 +126,13 @@ export class ProviderManager implements ModelProvider { providerConfig, alias.model, alias.protocol, + alias.baseUrl, this.options.kimiRequestHeaders, effectiveAlias.maxOutputSize, effectiveAlias.reasoningKey, this.options.promptCacheKey, effectiveAlias.supportEfforts, + effectiveAlias.offEffort, effectiveAlias.adaptiveThinking, alias.betaApi, ); @@ -239,6 +241,7 @@ function resolveModelCapabilities( thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking, tool_use: declared.has('tool_use') || detected.tool_use, max_context_tokens: alias.maxContextSize, + max_input_tokens: alias.maxInputSize, // Message-level tool declarations ("dynamically loaded tools"). Every // field here must be merged explicitly — a capability registered in // kosong that is not forwarded here never reaches the agent. @@ -252,11 +255,13 @@ function toKosongProviderConfig( provider: ProviderConfig, model: string, modelProtocol: ModelAlias['protocol'], + modelBaseUrl: string | undefined, kimiRequestHeaders: Record | undefined, maxOutputSize: number | undefined, reasoningKey: string | undefined, promptCacheKey: string | undefined, supportEfforts: readonly string[] | undefined, + offEffort: string | undefined, adaptiveThinking: boolean | undefined, betaApi: boolean | undefined, ): KosongProviderConfig { @@ -264,7 +269,10 @@ function toKosongProviderConfig( const envCustomHeaders = parseKimiCodeCustomHeaders(); switch (effectiveType) { case 'anthropic': { - const baseUrl = providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL'); + // A per-model endpoint (catalog gateway override) wins over the + // provider-level base URL; it is already adapted to the wire convention. + const baseUrl = + modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL'); return { type: 'anthropic', model, @@ -299,9 +307,13 @@ function toKosongProviderConfig( return { type: 'openai', model, - baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), + // A per-model endpoint (catalog gateway override) wins over the + // provider-level base URL, same as the Anthropic branch. + baseUrl: + modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), reasoningKey, + offEffort, // Session affinity: route every request of this session through the // same provider-side prompt cache (the OpenAI analog of Anthropic // `metadata.user_id` above). Undefined values are stripped at @@ -342,8 +354,10 @@ function toKosongProviderConfig( return { type: 'openai_responses', model, - baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), + baseUrl: + modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), + offEffort, // Session affinity: same `prompt_cache_key` intent as the `openai` // branch; the Responses API accepts it as a top-level request field. generationKwargs: { prompt_cache_key: promptCacheKey }, diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index aa642d8e4b..154088a7b0 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -128,8 +128,32 @@ describe('resolveThinkingEffort', () => { ).toBe('high'); }); - it('preserves off for always-thinking models on compatible protocols', () => { - expect(resolveThinkingEffort('off', undefined, alwaysThinkingEffortModel, false)).toBe('off'); + it('clamps always-thinking models to their default effort on every protocol', () => { + // A model declared always-on never resolves to off — claiming off while + // upstream keeps reasoning at its default would be a lie. + expect(resolveThinkingEffort('off', undefined, alwaysThinkingEffortModel, false)).toBe( + 'high', + ); + expect( + resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingEffortModel, false), + ).toBe('high'); + expect(resolveThinkingEffort('off', undefined, alwaysThinkingModel, false)).toBe('on'); + }); + + it('treats a configured off as absent when clamping always-thinking models', () => { + expect(resolveThinkingEffort(undefined, { effort: 'off' }, alwaysThinkingEffortModel, false)).toBe( + 'high', + ); + expect( + resolveThinkingEffort(undefined, { enabled: false, effort: 'off' }, alwaysThinkingEffortModel, false), + ).toBe('high'); + expect( + resolveThinkingEffort(undefined, { enabled: false, effort: ' OFF ' }, alwaysThinkingEffortModel, false), + ).toBe('high'); + // … while an explicitly configured concrete effort is still honored. + expect( + resolveThinkingEffort(undefined, { enabled: false, effort: 'max' }, alwaysThinkingEffortModel, true), + ).toBe('max'); }); it('does not force on for models that are not always-thinking', () => { diff --git a/packages/agent-core/test/config/model-overrides.test.ts b/packages/agent-core/test/config/model-overrides.test.ts index 9c06e37bba..5e878a1c49 100644 --- a/packages/agent-core/test/config/model-overrides.test.ts +++ b/packages/agent-core/test/config/model-overrides.test.ts @@ -60,10 +60,10 @@ describe('effectiveModelAlias', () => { }); }); - it('infers Anthropic effort metadata for an unknown model on a non-Kimi Anthropic provider', () => { + it('infers Anthropic effort metadata for an unknown Claude-marked model on a non-Kimi Anthropic provider', () => { const model: ModelAlias = { provider: 'custom', - model: 'custom-anthropic-model', + model: 'custom-claude-model', maxContextSize: 200000, protocol: 'anthropic', }; @@ -75,6 +75,32 @@ describe('effectiveModelAlias', () => { }); }); + it('infers Anthropic effort metadata for a bare Claude family alias on a non-Kimi Anthropic provider', () => { + const model: ModelAlias = { + provider: 'custom', + model: 'sonnet-latest', + maxContextSize: 200000, + protocol: 'anthropic', + }; + + expect(effectiveModelAlias(model, 'anthropic')).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'high', + }); + }); + + it('does not infer Anthropic effort metadata for a clearly non-Claude model on a non-Kimi Anthropic provider', () => { + const model: ModelAlias = { + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + }; + + expect(effectiveModelAlias(model, 'anthropic')).toEqual(model); + }); + it('does not infer Anthropic effort metadata for a Kimi provider routed through the Anthropic protocol', () => { const model: ModelAlias = { provider: 'managed:kimi-code', @@ -102,7 +128,7 @@ describe('effectiveModelAlias', () => { it('limits an adaptive_thinking=false model to budget efforts', () => { const model: ModelAlias = { provider: 'custom', - model: 'custom-anthropic-model', + model: 'custom-claude-model', maxContextSize: 200000, protocol: 'anthropic', adaptiveThinking: false, @@ -118,7 +144,7 @@ describe('effectiveModelAlias', () => { it('keeps a declared supportEfforts list authoritative when adaptive_thinking=false', () => { const model: ModelAlias = { provider: 'custom', - model: 'custom-anthropic-model', + model: 'custom-claude-model', maxContextSize: 200000, protocol: 'anthropic', adaptiveThinking: false, diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 50ad562a36..35d914a38b 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -117,10 +117,11 @@ default_effort = "${defaultEffort}" }); it('resolves the initial effort with provider context for an Anthropic-typed provider', async () => { - // The model name is unknown to the Anthropic profile matrix and the alias - // declares no protocol/capabilities itself; the provider's - // `type = "anthropic"` must still route the default resolution through - // the inferred profile (default effort "high"), not fall back to "off". + // The model name is unknown to the Anthropic profile matrix but still + // carries a Claude marker, and the alias declares no protocol/capabilities + // itself; the provider's `type = "anthropic"` must still route the + // default resolution through the inferred profile (default effort + // "high"), not fall back to "off". await writeFile( configPath, ` @@ -133,7 +134,7 @@ base_url = "https://api.example.test" [models."compat/custom"] provider = "compat" -model = "joint-model-0714-vibe" +model = "joint-claude-0714-vibe" max_context_size = 200000 `, ); diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 92cad17f15..32848e9a3b 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -283,6 +283,177 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { }); }); + it('forwards alias.offEffort to the openai and openai_responses provider configs', () => { + const config = { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + gateway: { type: 'openai', apiKey: 'sk-gateway' } as const, + responses: { type: 'openai_responses', apiKey: 'sk-responses' } as const, + }, + models: { + ...BASE_CONFIG.models!, + 'gateway/grok': { + provider: 'gateway', + model: 'grok-4', + maxContextSize: 256000, + supportEfforts: ['low', 'medium', 'high'], + offEffort: 'none', + }, + 'responses/grok': { + provider: 'responses', + model: 'grok-4', + maxContextSize: 256000, + offEffort: 'none', + }, + }, + } as KimiConfig; + + expect(resolveRuntimeProvider({ config, model: 'gateway/grok' }).provider).toMatchObject({ + type: 'openai', + offEffort: 'none', + }); + expect(resolveRuntimeProvider({ config, model: 'responses/grok' }).provider).toMatchObject({ + type: 'openai_responses', + offEffort: 'none', + }); + }); + + it('maps alias.maxInputSize onto the resolved capability while keeping the total window', () => { + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + gateway: { type: 'openai', apiKey: 'sk-gateway' } as const, + }, + models: { + ...BASE_CONFIG.models!, + 'gateway/gpt5': { + provider: 'gateway', + model: 'gpt-5', + maxContextSize: 400000, + maxInputSize: 272000, + }, + }, + }, + model: 'gateway/gpt5', + }); + + expect(resolved.modelCapabilities).toMatchObject({ + max_context_tokens: 400000, + max_input_tokens: 272000, + }); + }); + + it('prefers alias.baseUrl over the provider base URL for the openai wire', () => { + // Catalog gateway shape: a model whose same-wire override endpoint + // differs from the provider's default. + const config = { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + gateway: { + type: 'openai', + apiKey: 'sk-gateway', + baseUrl: 'https://gateway.example.test/api/v1', + } as const, + }, + models: { + ...BASE_CONFIG.models!, + 'gateway/tenant-model': { + provider: 'gateway', + model: 'vendor/tenant-model', + maxContextSize: 1000, + baseUrl: 'https://tenant.example.test/v1', + }, + 'gateway/shared-model': { + provider: 'gateway', + model: 'vendor/shared-model', + maxContextSize: 1000, + }, + }, + } as KimiConfig; + + expect(resolveRuntimeProvider({ config, model: 'gateway/tenant-model' }).provider).toMatchObject( + { type: 'openai', baseUrl: 'https://tenant.example.test/v1' }, + ); + expect(resolveRuntimeProvider({ config, model: 'gateway/shared-model' }).provider).toMatchObject( + { type: 'openai', baseUrl: 'https://gateway.example.test/api/v1' }, + ); + }); + + it('prefers alias.baseUrl over the provider base URL for the anthropic wire', () => { + // Catalog gateway shape: provider default is the OpenAI wire, one model + // carries an Anthropic protocol + endpoint override. + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + gateway: { + type: 'openai', + apiKey: 'sk-gateway', + baseUrl: 'https://gateway.example.test/api/v1', + }, + }, + models: { + ...BASE_CONFIG.models!, + 'gateway/claude-model': { + provider: 'gateway', + model: 'vendor/claude-model', + maxContextSize: 200000, + protocol: 'anthropic', + baseUrl: 'https://gateway.example.test/api/anthropic', + }, + 'gateway/plain-model': { + provider: 'gateway', + model: 'vendor/plain-model', + maxContextSize: 1000, + protocol: 'anthropic', + }, + }, + }, + model: 'gateway/claude-model', + }); + + expect(resolved.provider).toMatchObject({ + type: 'anthropic', + baseUrl: 'https://gateway.example.test/api/anthropic', + }); + + const fallback = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + gateway: { + type: 'openai', + apiKey: 'sk-gateway', + baseUrl: 'https://gateway.example.test/api/v1', + }, + }, + models: { + ...BASE_CONFIG.models!, + 'gateway/plain-model': { + provider: 'gateway', + model: 'vendor/plain-model', + maxContextSize: 1000, + protocol: 'anthropic', + }, + }, + }, + model: 'gateway/plain-model', + }); + + // Without an alias endpoint the provider base URL applies (stripped of + // the trailing /v1 for the Anthropic SDK, as before). + expect(fallback.provider).toMatchObject({ + type: 'anthropic', + baseUrl: 'https://gateway.example.test/api', + }); + }); + it('omits defaultMaxTokens when alias.maxOutputSize is unset', () => { const resolved = resolveRuntimeProvider({ config: { diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index 96a0d013ab..9c93d86479 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -209,12 +209,12 @@ describe('ModelCatalogService', () => { expect(getCalls).toEqual([{ reload: true }, { reload: true }]); }); - it('projects latest Opus efforts for unknown Anthropic-compatible models', async () => { + it('projects latest Opus efforts for unknown Claude-marked Anthropic-compatible models', async () => { const configRef = { current: catalogConfig() }; configRef.current.providers['custom'] = { type: 'anthropic' }; configRef.current.models!['compatible'] = { provider: 'custom', - model: 'compatible-model', + model: 'custom-claude-model', maxContextSize: 128000, }; const { core } = makeCore(configRef); @@ -228,6 +228,23 @@ describe('ModelCatalogService', () => { }); }); + it('does not project fallback efforts for clearly non-Claude Anthropic-compatible models', async () => { + const configRef = { current: catalogConfig() }; + configRef.current.providers['custom'] = { type: 'anthropic' }; + configRef.current.models!['compatible'] = { + provider: 'custom', + model: 'compatible-model', + maxContextSize: 128000, + }; + const { core } = makeCore(configRef); + const svc = new ModelCatalogService(makeEnv(), core, makeEventService().svc); + + const compatible = (await svc.listModels()).find((model) => model.model === 'compatible'); + expect(compatible?.capabilities).toBeUndefined(); + expect(compatible?.support_efforts).toBeUndefined(); + expect(compatible?.default_effort).toBeUndefined(); + }); + it('does not project fallback efforts for a Kimi provider routed through the Anthropic protocol', async () => { const configRef = { current: catalogConfig() }; configRef.current.models!['compatible'] = { diff --git a/packages/kosong/src/capability.ts b/packages/kosong/src/capability.ts index 2f2299b88f..f2d8249908 100644 --- a/packages/kosong/src/capability.ts +++ b/packages/kosong/src/capability.ts @@ -14,7 +14,15 @@ export interface ModelCapability { readonly audio_in: boolean; readonly thinking: boolean; readonly tool_use: boolean; + /** Total context window (input + output), used for completion budgeting. */ readonly max_context_tokens: number; + /** + * Maximum prompt/input tokens when the model declares one below the total + * window (e.g. gpt-5: 400k window, 272k input cap). Compaction and other + * prompt-budget checks use this in preference to the total window; absent + * means the total window is the only known ceiling. + */ + readonly max_input_tokens?: number; /** * Model accepts message-level tool declarations (`messages[].tools`) — the * "dynamically loaded tools" wire feature that clients can drive with diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index ebb2e1daad..2e3467f97a 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -10,9 +10,24 @@ export interface CatalogModelEntry { readonly id?: string; readonly name?: string; readonly family?: string; - readonly limit?: { readonly context?: number; readonly output?: number }; + readonly limit?: { readonly context?: number; readonly input?: number; readonly output?: number }; readonly tool_call?: boolean; readonly reasoning?: boolean; + /** + * models.dev reasoning declaration: `[{ type: 'toggle' }, ...]` entries. + * Only `{ type: 'effort', values: [...] }` maps onto concrete thinking + * effort levels; `toggle` is the boolean form and `budget_tokens` a token + * budget — neither yields an effort list. + */ + readonly reasoning_options?: readonly CatalogReasoningOption[]; + /** Lifecycle marker: `'deprecated'` models are dropped at import. */ + readonly status?: string; + /** + * Per-model serving override on gateway providers (zenmux, opencode, …): + * the model speaks a different protocol (`npm`) and/or lives on a different + * endpoint (`api`) than the provider default. + */ + readonly provider?: CatalogModelProviderOverride; /** Accepts message-level tool declarations (`messages[].tools`). Defaults to false. */ readonly dynamically_loaded_tools?: boolean; readonly interleaved?: boolean | { readonly field?: string }; @@ -22,6 +37,16 @@ export interface CatalogModelEntry { }; } +export interface CatalogReasoningOption { + readonly type?: string; + readonly values?: unknown; +} + +export interface CatalogModelProviderOverride { + readonly npm?: string; + readonly api?: string; +} + export interface CatalogProviderEntry { readonly id?: string; readonly name?: string; @@ -45,6 +70,27 @@ export interface CatalogModel { readonly name?: string; readonly maxOutputSize?: number; readonly reasoningKey?: string; + /** Declared thinking effort levels from `reasoning_options`, when present. */ + readonly supportEfforts?: readonly string[]; + /** + * The effort value that encodes "thinking off" for this model (models.dev + * declares it as the `'none'` entry in `reasoning_options`). Undefined when + * the model has no such value — then `off` simply sends no effort field. + */ + readonly offEffort?: string; + /** + * True when the model declares effort levels without any way to disable + * thinking (no `toggle` entry and no `'none'` value) — it always reasons at + * some level, so the UI must not offer an off option. + */ + readonly alwaysThinking?: boolean; + /** + * Per-model protocol override from the catalog entry's `provider` field + * (gateway providers serving this model over the Anthropic protocol). + */ + readonly protocol?: 'anthropic'; + /** Endpoint paired with {@link protocol}, adapted to the wire's SDK convention. */ + readonly baseUrl?: string; readonly capability: ModelCapability; } @@ -70,6 +116,11 @@ function hasEmbeddingMarker(value: string | undefined): boolean { function isUsableChatModel(model: CatalogModelEntry): boolean { const outputModalities = model.modalities?.output; if (outputModalities !== undefined && !outputModalities.includes('text')) return false; + // Deprecated models are shut down or scheduled for removal upstream, and + // alpha models are pre-release (the reference consumer hides both by + // default); do not offer them for new imports. Existing configs are + // cleaned up on refresh because the alias is no longer listed upstream. + if (model.status === 'deprecated' || model.status === 'alpha') return false; return ( !hasEmbeddingMarker(model.family) && !hasEmbeddingMarker(model.id) && @@ -77,12 +128,119 @@ function isUsableChatModel(model: CatalogModelEntry): boolean { ); } +/** Why a catalog import cannot proceed at all. */ +export type CatalogImportInvalidReason = + /** `type` is present but names a protocol this client version does not know. */ + | 'unknown-explicit-type' + /** SDK known to be non-OpenAI proprietary (Amazon Bedrock, Cohere). */ + | 'proprietary-sdk' + /** A base URL was supplied but is blank. */ + | 'empty-base-url' + /** The endpoint contains an env placeholder (`${VAR}`) the config cannot express. */ + | 'placeholder-base-url'; + /** - * Resolves a catalog provider entry to a supported wire type. Honors an - * explicit `type`, otherwise infers from `npm`/`id`. Unknown providers return - * `undefined` so callers can omit them instead of writing an invalid config. + * The outcome of resolving a catalog provider for import — the single + * decision point for "which wire, which endpoint, or exactly why not". + * Pattern-match on {@link CatalogImportResolution.kind}: + * - `ok`: persist `wire` (and `baseUrl` when present — absent means the + * wire's official-SDK default endpoint applies); surface `guessed` so the + * user knows the protocol came from the OpenAI-compatible fallback. + * - `needs-base-url`: the catalog supplies no usable endpoint and the + * wire's default would point at the wrong host — ask the user for one + * (`--base-url` on the CLI, a prompt in the TUI), then re-resolve with it. + * - `invalid`: refuse with the reason. + */ +export type CatalogImportResolution = + | { + readonly kind: 'ok'; + readonly wire: ProviderType; + readonly guessed: boolean; + readonly baseUrl?: string; + } + | { + readonly kind: 'needs-base-url'; + readonly wire: ProviderType; + readonly guessed: boolean; + } + | { + readonly kind: 'invalid'; + readonly reason: CatalogImportInvalidReason; + }; + +/** + * Resolves a catalog provider entry into an import decision. + * + * Wire: an explicit `type` is authoritative (honored when known, refused + * when not); otherwise `npm`/`id` heuristics; otherwise the + * OpenAI-compatible fallback (`guessed: true`) — except SDKs known to be + * non-OpenAI proprietary, which are refused outright. + * + * Endpoint: a user-supplied URL wins over the catalog's `api` (after trim; + * blank and `${VAR}` placeholders are rejected). Without one, the catalog + * `api` applies; with neither, only wires whose default endpoint belongs to + * the vendor's official SDK (`@ai-sdk/openai`, `@ai-sdk/anthropic`, or + * env-resolved vertex/google) resolve without asking — everything else is + * `needs-base-url`, because persisting no endpoint would silently send the + * key to the wrong host. URLs are adapted to the wire's SDK convention + * (trailing `/v1` stripped for Anthropic). + */ +export function resolveCatalogImport( + entry: CatalogProviderEntry, + userBaseUrl?: string, +): CatalogImportResolution { + const wire = resolveCatalogWire(entry); + if (wire === undefined) { + return { + kind: 'invalid', + reason: + typeof entry.type === 'string' && entry.type.length > 0 + ? 'unknown-explicit-type' + : 'proprietary-sdk', + }; + } + const guessed = inferDeclaredWireType(entry) === undefined; + + if (userBaseUrl !== undefined) { + const trimmed = userBaseUrl.trim(); + if (trimmed.length === 0) return { kind: 'invalid', reason: 'empty-base-url' }; + if (trimmed.includes('${')) return { kind: 'invalid', reason: 'placeholder-base-url' }; + return { kind: 'ok', wire, guessed, baseUrl: adaptBaseUrlForWire(trimmed, wire) }; + } + + const catalogUrl = catalogBaseUrl(entry, wire); + if (catalogUrl !== undefined) return { kind: 'ok', wire, guessed, baseUrl: catalogUrl }; + if (catalogEndpointRequired(entry, wire)) return { kind: 'needs-base-url', wire, guessed }; + return { kind: 'ok', wire, guessed }; +} + +/** + * The wire part of {@link resolveCatalogImport}, also used when listing + * models (where import eligibility is not the question). `undefined` means + * the entry is not importable: an explicit type this client does not know, + * or a proprietary non-OpenAI SDK (Bedrock, Cohere). + */ +function resolveCatalogWire(entry: CatalogProviderEntry): ProviderType | undefined { + if (isWireType(entry.type)) return entry.type; + if (typeof entry.type === 'string' && entry.type.length > 0) return undefined; + const declared = inferDeclaredWireType(entry); + if (declared !== undefined) return declared; + const npm = (entry.npm ?? '').toLowerCase(); + if (npm.includes('amazon-bedrock') || npm.includes('cohere')) return undefined; + return 'openai'; +} + +/** + * @deprecated Use {@link resolveCatalogImport}. This compatibility wrapper + * answers only the wire (`undefined` when the entry is not importable) and + * is kept until the next major release for downstream consumers of the + * previous public API. */ export function inferWireType(entry: CatalogProviderEntry): ProviderType | undefined { + return resolveCatalogWire(entry); +} + +function inferDeclaredWireType(entry: CatalogProviderEntry): ProviderType | undefined { if (isWireType(entry.type)) return entry.type; const npm = (entry.npm ?? '').toLowerCase(); const id = (entry.id ?? '').toLowerCase(); @@ -107,15 +265,46 @@ export function inferWireType(entry: CatalogProviderEntry): ProviderType | undef * appends `/v1/messages` itself — so a catalog `api` ending in `/v1` would POST * to `/v1/v1/messages` (404). Strip the trailing `/v1` for anthropic. OpenAI * family SDKs append `/chat/completions` to a `/v1` base, so those pass through. + * URLs containing `${VAR}` are SDK-side env interpolations the config cannot + * express; they resolve to `undefined` so callers can ask for a URL instead. */ export function catalogBaseUrl( entry: CatalogProviderEntry, wire: ProviderType, ): string | undefined { const api = entry.api; - if (typeof api !== 'string' || api.length === 0) return undefined; - if (wire === 'anthropic') return api.replace(/\/v1\/?$/, ''); - return api; + if (typeof api !== 'string' || api.length === 0 || api.includes('${')) return undefined; + return adaptBaseUrlForWire(api, wire); +} + +/** + * Adapts a base URL to the wire's SDK convention: the Anthropic SDK appends + * `/v1/messages` itself, so a trailing `/v1` is stripped (otherwise requests + * land on `/v1/v1/messages`); other wires pass through unchanged. Applied to + * catalog-declared and user-supplied URLs alike. + */ +export function adaptBaseUrlForWire(baseUrl: string, wire: ProviderType): string { + return wire === 'anthropic' ? baseUrl.replace(/\/v1\/?$/, '') : baseUrl; +} + +/** + * True when a missing catalog endpoint cannot fall back to a built-in + * default: an explicitly declared endpoint the config cannot express (an + * env placeholder) always requires asking — silently defaulting would send + * the credential to the public vendor host instead of the declared one. + * Without any declaration, the wire's default endpoint only belongs to the + * vendor's official SDK package — for every other npm it would silently + * point at the wrong host (e.g. an xai key sent to api.openai.com, or a + * gateway's Anthropic-compatible key sent to api.anthropic.com). + * Vertex/google wires resolve their endpoint from env coordinates and + * official SDKs, so they never need the prompt. + */ +function catalogEndpointRequired(entry: CatalogProviderEntry, wire: ProviderType): boolean { + if (typeof entry.api === 'string' && entry.api.length > 0) return true; + const npm = (entry.npm ?? '').toLowerCase(); + if (wire === 'openai' || wire === 'openai_responses') return npm !== '@ai-sdk/openai'; + if (wire === 'anthropic') return npm !== '@ai-sdk/anthropic'; + return false; } /** Normalizes one catalog model entry into a {@link CatalogModel}; skips invalid entries. */ @@ -126,28 +315,92 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel if (!isUsableChatModel(model)) return undefined; const inputs = model.modalities?.input ?? []; const output = model.limit?.output; + const thinking = catalogThinkingOptions(model.reasoning_options); + // `limit.input` is the true prompt cap when declared (e.g. gpt-5: 400k + // context window but a 272k input limit); it is tracked separately from the + // total window so prompt-budget checks (compaction) use the cap while + // completion budgeting keeps the full window. + const input = model.limit?.input; + const maxInputTokens = + typeof input === 'number' && Number.isInteger(input) && input > 0 + ? Math.min(input, context) + : undefined; return { id: model.id, name: typeof model.name === 'string' && model.name.length > 0 ? model.name : undefined, maxOutputSize: typeof output === 'number' && output > 0 ? output : undefined, reasoningKey: catalogReasoningKey(model.interleaved), + supportEfforts: thinking.efforts, + offEffort: thinking.offEffort, + alwaysThinking: thinking.alwaysThinking, capability: { image_in: inputs.includes('image'), video_in: inputs.includes('video'), audio_in: inputs.includes('audio'), - thinking: Boolean(model.reasoning), + // Declaring concrete effort levels (or a toggle) implies thinking + // support even when the `reasoning` boolean is absent (mirrors the + // api.json importer). + thinking: + Boolean(model.reasoning) || thinking.efforts !== undefined || thinking.hasToggle, tool_use: model.tool_call ?? true, max_context_tokens: context, + max_input_tokens: maxInputTokens, dynamically_loaded_tools: model.dynamically_loaded_tools === true, }, }; } +/** + * Reads a `reasoning_options` list: the `{ type: 'effort', values: [...] }` + * levels, the `'none'` pseudo-level, and the `{ type: 'toggle' }` boolean + * form. `'none'` is not a selectable level — it is the wire encoding for + * disabling thinking (e.g. xai grok) and becomes {@link CatalogModel.offEffort}; + * the UI keeps using its own `off` entry for it. A model that declares levels + * with neither a toggle nor `'none'` always reasons — it cannot be turned off. + */ +function catalogThinkingOptions(options: CatalogModelEntry['reasoning_options']): { + readonly efforts: readonly string[] | undefined; + readonly offEffort: string | undefined; + readonly hasToggle: boolean; + readonly alwaysThinking: boolean | undefined; +} { + if (!Array.isArray(options)) { + return { efforts: undefined, offEffort: undefined, hasToggle: false, alwaysThinking: undefined }; + } + let efforts: readonly string[] | undefined; + let offEffort: string | undefined; + let hasToggle = false; + for (const option of options) { + if (option?.type === 'toggle') { + hasToggle = true; + continue; + } + if (option?.type !== 'effort' || !Array.isArray(option.values)) continue; + // models.dev writes the disable tier either as the string 'none' or as + // JSON null (the TOML source spells it "null"); both encode the same + // wire value (`reasoning_effort: 'none'`). + const hasNullTier = (option.values as unknown[]).some((value) => value === null); + const levels = (option.values as unknown[]).filter( + (value: unknown): value is string => typeof value === 'string' && value.length > 0, + ); + const off = levels.find((value) => value.toLowerCase() === 'none'); + if (off !== undefined) offEffort = off; + else if (hasNullTier) offEffort = 'none'; + const selectable = levels.filter((value) => value.toLowerCase() !== 'none'); + if (selectable.length > 0) efforts = selectable; + } + const alwaysThinking = + efforts !== undefined && offEffort === undefined && !hasToggle ? true : undefined; + return { efforts, offEffort, hasToggle, alwaysThinking }; +} + function catalogReasoningKey(interleaved: CatalogModelEntry['interleaved']): string | undefined { - // models.dev allows `interleaved: true` as "general support" — read it as - // the default `reasoning_content` field so providers without an explicit - // field name (e.g. some openai-compatible gateways) still round-trip. - if (interleaved === true) return 'reasoning_content'; + // Only the object form carries a field name. `interleaved: true` is just + // "general support": the provider already defaults to scanning + // `reasoning_content` / `reasoning_details` / `reasoning` inbound and to + // `reasoning_content` outbound, so pinning a key here would only narrow the + // inbound scan to one field — strictly worse for gateways that answer with + // one of the other names. if (typeof interleaved !== 'object' || interleaved === null) return undefined; const field = interleaved.field?.trim(); return field !== undefined && field.length > 0 ? field : undefined; @@ -155,8 +408,72 @@ function catalogReasoningKey(interleaved: CatalogModelEntry['interleaved']): str /** Extracts the valid, normalized models from a catalog provider entry. */ export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel[] { - const models = entry.models ?? {}; - return Object.values(models) - .map((model) => catalogModelToCapability(model)) + const providerWire = resolveCatalogWire(entry); + return Object.values(entry.models ?? {}) + .map((raw) => applyModelProviderOverride(catalogModelToCapability(raw), raw, entry, providerWire)) .filter((model): model is CatalogModel => model !== undefined); } + +/** + * Gateway providers (zenmux, opencode, azure, …) may declare a per-model + * `provider` override when a model is served over a different protocol or + * endpoint than the provider default. Overrides targeting Anthropic with a + * usable endpoint are materialized into a per-model protocol + base URL; + * overrides pointing at a different wire that cannot be materialized cause + * the model to be skipped — importing it under the provider's wire would be + * the silently wrong protocol. Overrides on the provider's own wire keep the + * model but still carry their own endpoint when it differs from the + * provider's. + */ +function applyModelProviderOverride( + model: CatalogModel | undefined, + raw: CatalogModelEntry, + entry: CatalogProviderEntry, + providerWire: ProviderType | undefined, +): CatalogModel | undefined { + if (model === undefined) return undefined; + const override = raw.provider; + if (override === undefined) return model; + // An api-only override keeps the provider's wire; an npm override points at + // a (possibly different) one. Unidentified npm keeps the benefit of doubt. + const overrideWire = + typeof override.npm === 'string' ? inferOverrideWire(override.npm) : providerWire; + if (overrideWire === undefined) return model; + const rawApi = override.api; + const api = rawApi ?? entry.api; + const usableApi = + typeof api === 'string' && api.length > 0 && !api.includes('${') ? api : undefined; + + if (overrideWire === providerWire) { + // An explicitly declared endpoint the config cannot express (env + // placeholder): the model belongs elsewhere we cannot persist or prompt + // for — skip it rather than silently reroute to the provider endpoint. + if (typeof rawApi === 'string' && rawApi.includes('${')) return undefined; + // A distinct usable endpoint applies to this model specifically. + if (usableApi !== undefined && usableApi !== entry.api) { + return { ...model, baseUrl: adaptBaseUrlForWire(usableApi, overrideWire) }; + } + return model; + } + + // Only Anthropic-direction overrides are materializable (the alias schema + // cannot express other per-model protocols), and only with a usable + // endpoint. Anything else would be imported under the provider's wire — + // the silently wrong protocol — so the model is skipped instead. Examples: + // freemodel's gpt entries on an Anthropic provider, Claude models on + // google-vertex (whose wire here is Gemini-mode Vertex), or a google-genai + // override on an OpenAI gateway. + if (overrideWire === 'anthropic' && usableApi !== undefined) { + return { ...model, protocol: 'anthropic', baseUrl: adaptBaseUrlForWire(usableApi, 'anthropic') }; + } + return undefined; +} + +function inferOverrideWire(npm: string): ProviderType | undefined { + const normalized = npm.toLowerCase(); + if (normalized.includes('anthropic')) return 'anthropic'; + if (normalized.includes('vertex')) return 'vertexai'; + if (normalized.includes('google')) return 'google-genai'; + if (normalized.includes('openai')) return 'openai'; + return undefined; +} diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index b219c6bd4f..d999a02bc6 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -43,8 +43,16 @@ export { catalogModelToCapability, catalogProviderModels, inferWireType, + resolveCatalogImport, +} from './catalog'; +export type { + Catalog, + CatalogModel, + CatalogModelEntry, + CatalogProviderEntry, + CatalogImportInvalidReason, + CatalogImportResolution, } from './catalog'; -export type { Catalog, CatalogModel, CatalogModelEntry, CatalogProviderEntry } from './catalog'; // Core functions export { generate } from './generate'; diff --git a/packages/kosong/src/providers/anthropic-profile.ts b/packages/kosong/src/providers/anthropic-profile.ts index 0650bd8fb7..b5d05934ea 100644 --- a/packages/kosong/src/providers/anthropic-profile.ts +++ b/packages/kosong/src/providers/anthropic-profile.ts @@ -152,3 +152,23 @@ export function matchKnownAnthropicModelProfile( export function inferAnthropicModelProfile(model: string): AnthropicModelProfile { return matchKnownAnthropicModelProfile(model) ?? LATEST_OPUS_PROFILE; } + +/** + * Fallback profile for Anthropic-compatible endpoints whose model name is + * recognizably a Claude model but encodes no known version — either a + * `claude` marker (e.g. a proxied `claude-latest`) or a bare family word + * (`sonnet-latest`, `opus-latest`, …). Clearly non-Claude names (Kimi `k3`, + * GLM, DeepSeek, … served over the Anthropic protocol) return undefined so + * the catalog never advertises Claude effort levels for them. The wire-path + * counterpart {@link inferAnthropicModelProfile} keeps its unconditional + * fallback: an Anthropic-protocol endpoint still needs some profile to shape + * requests. + */ +export function matchUnknownClaudeProfile(model: string): AnthropicModelProfile | undefined { + const normalized = model.toLowerCase(); + return normalized.includes('claude') || CLAUDE_FAMILY_WORD_RE.test(normalized) + ? LATEST_OPUS_PROFILE + : undefined; +} + +const CLAUDE_FAMILY_WORD_RE = /\b(?:opus|sonnet|haiku|fable|mythos)\b/; diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 2a55203bbb..f47f4c7fb6 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -95,6 +95,13 @@ export interface OpenAILegacyOptions { stream?: boolean | undefined; maxTokens?: number | undefined; reasoningKey?: string | undefined; + /** + * The effort value that encodes "thinking off" on this wire (e.g. `'none'` + * for xai grok). When set, `withThinking('off')` sends it as + * `reasoning_effort` instead of omitting the field — required by models + * whose default is to reason. + */ + offEffort?: string | undefined; httpClient?: unknown; defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; @@ -490,6 +497,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _reasoningKey: string | undefined; private _thinkingEffort: ThinkingEffort | undefined; + private _offEffort: string | undefined; private _generationKwargs: OpenAILegacyGenerationKwargs; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; @@ -513,6 +521,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { ? normalizedReasoningKey : undefined; this._thinkingEffort = undefined; + this._offEffort = options.offEffort; this._generationKwargs = { ...options.generationKwargs, ...(options.maxTokens !== undefined @@ -565,12 +574,19 @@ export class OpenAILegacyChatProvider implements ChatProvider { this._generationKwargs, ); - // Determine reasoning_effort. 'off' and 'on' have no wire encoding on - // chat-completions APIs, so they send no reasoning_effort field; only a + // Determine reasoning_effort. 'on' has no wire encoding on + // chat-completions APIs, so it sends no reasoning_effort field; only a // concrete effort (low/medium/high/...) is passed through verbatim. + // 'off' sends the model's declared off value (e.g. 'none') when one is + // configured — models that reason by default need the explicit value to + // actually disable reasoning; otherwise the field is omitted as before. const effort = this._thinkingEffort; let reasoningEffort: string | undefined = - effort === undefined || effort === 'off' || effort === 'on' ? undefined : effort; + effort === 'off' + ? this._offEffort + : effort === undefined || effort === 'on' + ? undefined + : effort; // Auto-enable reasoning_effort when the history contains ThinkPart but reasoning // was not explicitly configured. This prevents server validation errors from APIs diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 9b827aa29d..66770f1cdd 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -344,6 +344,13 @@ export interface OpenAIResponsesOptions { baseUrl?: string | undefined; model: string; maxOutputTokens?: number | undefined; + /** + * The effort value that encodes "thinking off" on this wire (e.g. `'none'` + * for xai grok). When set, `withThinking('off')` sends it as + * `reasoning_effort` instead of omitting the field — required by models + * whose default is to reason. + */ + offEffort?: string | undefined; httpClient?: unknown; defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; @@ -1032,6 +1039,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { private _baseUrl: string | undefined; private _defaultHeaders: Record | undefined; private _generationKwargs: OpenAIResponsesGenerationKwargs; + private _offEffort: string | undefined; private _toolMessageConversion: ToolMessageConversion; private _client: OpenAI | undefined; private _httpClient: unknown; @@ -1045,6 +1053,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._model = options.model; this._stream = true; // Responses API always supports streaming this._generationKwargs = { ...options.generationKwargs }; + this._offEffort = options.offEffort; this._toolMessageConversion = options.toolMessageConversion ?? null; this._httpClient = options.httpClient; this._clientFactory = options.clientFactory; @@ -1152,7 +1161,10 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAIResponsesChatProvider { - const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort; + // 'on' sends no effort field; 'off' sends the model's declared off value + // (e.g. 'none') when one is configured, and omits the field otherwise. + const reasoningEffort = + effort === 'off' ? this._offEffort : effort === 'on' ? undefined : effort; const clone = this._clone(); clone._generationKwargs = { ...clone._generationKwargs, diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 012aa701d2..6b079a75a6 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -7,7 +7,7 @@ import { ChatProviderError } from '#/errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { AnthropicChatProvider, resolveDefaultMaxTokens } from '#/providers/anthropic'; -import { matchKnownAnthropicModelProfile } from '#/providers/anthropic-profile'; +import { matchKnownAnthropicModelProfile, matchUnknownClaudeProfile, LATEST_OPUS_PROFILE } from '#/providers/anthropic-profile'; import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; @@ -81,6 +81,23 @@ describe('Anthropic model profile matching', () => { it('does not claim an official profile for an unrecognized compatible model', () => { expect(matchKnownAnthropicModelProfile('Example Compatible Model')).toBeUndefined(); }); + + it.each([ + 'claude-latest', + 'bedrock/claude-next', + 'sonnet-latest', + 'opus-latest', + 'gateway/haiku-proxy', + ])('claims the unknown-Claude fallback for %s', (model) => { + expect(matchUnknownClaudeProfile(model)).toEqual(LATEST_OPUS_PROFILE); + }); + + it.each(['k3', 'kimi-for-coding', 'glm-5.2', 'deepseek-v4-pro', 'Example Compatible Model'])( + 'does not claim the unknown-Claude fallback for %s', + (model) => { + expect(matchUnknownClaudeProfile(model)).toBeUndefined(); + }, + ); }); type AnthropicGenerationState = { diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 87538b92a3..0cfb8cf2de 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -1,31 +1,220 @@ import { describe, expect, it } from 'vitest'; import { + adaptBaseUrlForWire, catalogBaseUrl, catalogModelToCapability, catalogProviderModels, inferWireType, + resolveCatalogImport, type CatalogModelEntry, } from '../src/catalog'; -describe('inferWireType', () => { - it('honors an explicit valid type', () => { +describe('inferWireType (deprecated compatibility wrapper)', () => { + it('answers only the wire, or undefined when the entry is not importable', () => { + expect(inferWireType({ id: 'xai', npm: '@ai-sdk/xai' })).toBe('openai'); expect(inferWireType({ id: 'x', type: 'openai_responses' })).toBe('openai_responses'); + expect(inferWireType({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' })).toBeUndefined(); + expect(inferWireType({ id: 'cohere', npm: '@ai-sdk/cohere' })).toBeUndefined(); + expect(inferWireType({ id: 'x', type: 'kokub', npm: '@ai-sdk/openai-compatible' })).toBeUndefined(); + }); +}); + +describe('resolveCatalogImport — wire resolution', () => { + it('honors an explicit valid type', () => { + // Explicit type with no npm/api: the wire is honored, but the endpoint + // cannot default to the vendor host — it must be asked for. + expect(resolveCatalogImport({ id: 'x', type: 'openai_responses' })).toEqual({ + kind: 'needs-base-url', + wire: 'openai_responses', + guessed: false, + }); }); it('infers anthropic from npm or id', () => { - expect(inferWireType({ id: 'anthropic', npm: '@ai-sdk/anthropic' })).toBe('anthropic'); - expect(inferWireType({ id: 'my-claude' })).toBe('anthropic'); + expect(resolveCatalogImport({ id: 'anthropic', npm: '@ai-sdk/anthropic' })).toMatchObject({ + kind: 'ok', + wire: 'anthropic', + guessed: false, + }); + expect(resolveCatalogImport({ id: 'my-claude' })).toMatchObject({ + kind: 'needs-base-url', + wire: 'anthropic', + guessed: false, + }); }); it('infers google-genai and vertexai', () => { - expect(inferWireType({ id: 'gemini', npm: '@ai-sdk/google' })).toBe('google-genai'); - expect(inferWireType({ id: 'google-vertex' })).toBe('vertexai'); + expect(resolveCatalogImport({ id: 'gemini', npm: '@ai-sdk/google' })).toMatchObject({ + kind: 'ok', + wire: 'google-genai', + }); + expect(resolveCatalogImport({ id: 'google-vertex' })).toMatchObject({ + kind: 'ok', + wire: 'vertexai', + }); + }); + + it('refuses an explicit but unrecognized type instead of guessing', () => { + expect(resolveCatalogImport({ id: 'x', type: 'not-a-wire' })).toEqual({ + kind: 'invalid', + reason: 'unknown-explicit-type', + }); + // … even when npm/id would have been inferable: the explicit declaration + // is authoritative, so a future catalog protocol is never miswired. + expect( + resolveCatalogImport({ id: 'x', type: 'kokub', npm: '@ai-sdk/openai-compatible' }), + ).toEqual({ kind: 'invalid', reason: 'unknown-explicit-type' }); }); - it('returns undefined for unknown / invalid wire types', () => { - expect(inferWireType({ id: 'some-proxy' })).toBeUndefined(); - expect(inferWireType({ id: 'x', type: 'not-a-wire' })).toBeUndefined(); + it('falls back to openai for vendor-specific SDKs models.dev does not type', () => { + // xai shape: vendor npm, no explicit type, no api → guessed, needs a URL. + expect(resolveCatalogImport({ id: 'xai', npm: '@ai-sdk/xai' })).toEqual({ + kind: 'needs-base-url', + wire: 'openai', + guessed: true, + }); + // openrouter shape: vendor npm with its own api — guessed but usable. + expect( + resolveCatalogImport({ + id: 'openrouter', + npm: '@openrouter/ai-sdk-provider', + api: 'https://openrouter.ai/api/v1', + }), + ).toEqual({ + kind: 'ok', + wire: 'openai', + guessed: true, + baseUrl: 'https://openrouter.ai/api/v1', + }); + // Recognized SDKs are never guesses. + expect( + resolveCatalogImport({ + id: 'zenmux', + npm: '@ai-sdk/openai-compatible', + api: 'https://zenmux.example.test/api/v1', + }), + ).toMatchObject({ kind: 'ok', wire: 'openai', guessed: false }); + expect(resolveCatalogImport({ id: 'x', type: 'openai_responses' })).toMatchObject({ + guessed: false, + }); + }); + + it('refuses SDKs known to be proprietary instead of guessing', () => { + expect(resolveCatalogImport({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' })).toEqual({ + kind: 'invalid', + reason: 'proprietary-sdk', + }); + expect(resolveCatalogImport({ id: 'cohere', npm: '@ai-sdk/cohere' })).toEqual({ + kind: 'invalid', + reason: 'proprietary-sdk', + }); + }); +}); + +describe('resolveCatalogImport — endpoint resolution', () => { + it('resolves without asking for official SDKs and env-resolved wires', () => { + expect(resolveCatalogImport({ id: 'openai', npm: '@ai-sdk/openai' })).toEqual({ + kind: 'ok', + wire: 'openai', + guessed: false, + }); + expect(resolveCatalogImport({ id: 'anthropic', npm: '@ai-sdk/anthropic' })).toMatchObject({ + kind: 'ok', + wire: 'anthropic', + }); + expect( + resolveCatalogImport({ id: 'google-vertex', npm: '@ai-sdk/google-vertex' }), + ).toMatchObject({ kind: 'ok', wire: 'vertexai' }); + }); + + it('needs a URL for non-official vendors without one', () => { + // google-vertex-anthropic shape: Anthropic wire, vendor npm, no api — + // without a prompt the key would be sent to api.anthropic.com. + expect( + resolveCatalogImport({ id: 'google-vertex-anthropic', npm: '@ai-sdk/google-vertex/anthropic' }), + ).toEqual({ kind: 'needs-base-url', wire: 'anthropic', guessed: false }); + // kimi-for-coding declares a concrete api — no prompt needed. + expect( + resolveCatalogImport({ + id: 'kimi-for-coding', + npm: '@ai-sdk/anthropic', + api: 'https://api.kimi.com/coding/v1', + }), + ).toEqual({ + kind: 'ok', + wire: 'anthropic', + guessed: false, + baseUrl: 'https://api.kimi.com/coding', + }); + }); + + it('needs a URL when the catalog api is an env placeholder', () => { + expect( + resolveCatalogImport({ id: 'neon', npm: '@ai-sdk/openai-compatible', api: '${NEON_BASE_URL}/v1' }), + ).toEqual({ kind: 'needs-base-url', wire: 'openai', guessed: false }); + expect( + resolveCatalogImport({ + id: 'azure-claude', + npm: '@ai-sdk/azure/anthropic', + api: 'https://${AZURE_RESOURCE_NAME}.example.test/anthropic/v1', + }), + ).toEqual({ kind: 'needs-base-url', wire: 'anthropic', guessed: false }); + }); + + it('needs a URL whenever the declared endpoint is a placeholder, official SDK or not', () => { + expect( + resolveCatalogImport({ id: 'openai', npm: '@ai-sdk/openai', api: '${OPENAI_BASE_URL}/v1' }), + ).toEqual({ kind: 'needs-base-url', wire: 'openai', guessed: false }); + expect( + resolveCatalogImport({ id: 'anthropic', npm: '@ai-sdk/anthropic', api: '${ANTHROPIC_BASE_URL}' }), + ).toEqual({ kind: 'needs-base-url', wire: 'anthropic', guessed: false }); + // A concrete endpoint on the official SDK still resolves without asking. + expect( + resolveCatalogImport({ id: 'openai', npm: '@ai-sdk/openai', api: 'https://api.openai.com/v1' }), + ).toMatchObject({ kind: 'ok', wire: 'openai' }); + }); + + it('adapts a user-supplied URL to the wire (Anthropic strips a trailing /v1)', () => { + expect( + resolveCatalogImport({ id: 'xai', npm: '@ai-sdk/xai' }, 'https://api.x.ai/v1'), + ).toEqual({ kind: 'ok', wire: 'openai', guessed: true, baseUrl: 'https://api.x.ai/v1' }); + expect( + resolveCatalogImport( + { id: 'google-vertex-anthropic', npm: '@ai-sdk/google-vertex/anthropic' }, + 'https://gateway.example.test/v1', + ), + ).toEqual({ + kind: 'ok', + wire: 'anthropic', + guessed: false, + baseUrl: 'https://gateway.example.test', + }); + }); + + it('lets a user-supplied URL override the catalog endpoint', () => { + expect( + resolveCatalogImport( + { id: 'openai', npm: '@ai-sdk/openai', api: 'https://api.openai.com/v1' }, + 'https://proxy.example.test/v1', + ), + ).toEqual({ + kind: 'ok', + wire: 'openai', + guessed: false, + baseUrl: 'https://proxy.example.test/v1', + }); + }); + + it('rejects blank and placeholder user URLs', () => { + expect(resolveCatalogImport({ id: 'xai', npm: '@ai-sdk/xai' }, ' ')).toEqual({ + kind: 'invalid', + reason: 'empty-base-url', + }); + expect(resolveCatalogImport({ id: 'xai', npm: '@ai-sdk/xai' }, '${XAI_BASE_URL}/v1')).toEqual({ + kind: 'invalid', + reason: 'placeholder-base-url', + }); }); }); @@ -58,6 +247,26 @@ describe('catalogBaseUrl', () => { expect(catalogBaseUrl({ id: 'x' }, 'anthropic')).toBeUndefined(); expect(catalogBaseUrl({ id: 'x', api: '' }, 'openai')).toBeUndefined(); }); + + it('returns undefined for env-placeholder URLs the config cannot express', () => { + expect(catalogBaseUrl({ id: 'neon', api: '${NEON_BASE_URL}/v1' }, 'openai')).toBeUndefined(); + expect( + catalogBaseUrl({ id: 'azure', api: 'https://${AZURE_RESOURCE_NAME}.example.test/anthropic/v1' }, 'anthropic'), + ).toBeUndefined(); + }); + + it('adaptBaseUrlForWire strips a trailing /v1 only for the Anthropic wire', () => { + expect(adaptBaseUrlForWire('https://gateway.example.test/v1', 'anthropic')).toBe( + 'https://gateway.example.test', + ); + expect(adaptBaseUrlForWire('https://gateway.example.test/v1/', 'anthropic')).toBe( + 'https://gateway.example.test', + ); + expect(adaptBaseUrlForWire('https://gateway.example.test/v1', 'openai')).toBe( + 'https://gateway.example.test/v1', + ); + expect(adaptBaseUrlForWire('https://host/v1beta', 'anthropic')).toBe('https://host/v1beta'); + }); }); describe('catalogModelToCapability', () => { @@ -127,7 +336,9 @@ describe('catalogModelToCapability', () => { it.each<[CatalogModelEntry['interleaved'], string | undefined]>([ [undefined, undefined], - [true, 'reasoning_content'], + // `true` carries no field name; the provider's default multi-field scan + // is the correct (and wider) behavior, so no key is pinned. + [true, undefined], [false, undefined], [{}, undefined], [{ field: '' }, undefined], @@ -138,6 +349,139 @@ describe('catalogModelToCapability', () => { const model = catalogModelToCapability({ id: 'm', limit: { context: 1000 }, interleaved }); expect(model?.reasoningKey).toBe(expected); }); + + it('extracts declared effort levels from reasoning_options', () => { + // The models.dev `kimi-for-coding`/`k3` shape: toggle plus effort values. + const model = catalogModelToCapability({ + id: 'k3', + reasoning: true, + reasoning_options: [ + { type: 'toggle' }, + { type: 'effort', values: ['low', 'high', 'max'] }, + ], + limit: { context: 1048576 }, + }); + expect(model?.supportEfforts).toEqual(['low', 'high', 'max']); + expect(model?.capability.thinking).toBe(true); + }); + + it("reads the 'none' entry as the off encoding, not a selectable level", () => { + const model = catalogModelToCapability({ + id: 'grok', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }], + limit: { context: 1000 }, + }); + expect(model?.supportEfforts).toEqual(['low', 'medium', 'high']); + expect(model?.offEffort).toBe('none'); + expect(model?.alwaysThinking).toBeUndefined(); + expect(model?.capability.thinking).toBe(true); + + const upper = catalogModelToCapability({ + id: 'grok', + reasoning_options: [{ type: 'effort', values: ['None', 'high'] }], + limit: { context: 1000 }, + }); + expect(upper?.supportEfforts).toEqual(['high']); + expect(upper?.offEffort).toBe('None'); + }); + + it('treats a JSON null tier as the none off encoding (sarvam shape)', () => { + const model = catalogModelToCapability({ + id: 'sarvam-105b', + reasoning: true, + reasoning_options: [{ type: 'effort', values: [null, 'low', 'medium', 'high'] }], + limit: { context: 1000 }, + }); + expect(model?.supportEfforts).toEqual(['low', 'medium', 'high']); + expect(model?.offEffort).toBe('none'); + expect(model?.alwaysThinking).toBeUndefined(); + expect(model?.capability.thinking).toBe(true); + }); + + it('marks effort models with no toggle and no none value as always-thinking', () => { + // gpt-5 shape: levels exist but reasoning cannot be disabled. + const model = catalogModelToCapability({ + id: 'gpt-5', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['low', 'medium', 'high'] }], + limit: { context: 400000 }, + }); + expect(model?.supportEfforts).toEqual(['low', 'medium', 'high']); + expect(model?.offEffort).toBeUndefined(); + expect(model?.alwaysThinking).toBe(true); + + // A toggle entry makes thinking disable-able again (the k3 shape). + const toggleable = catalogModelToCapability({ + id: 'k3', + reasoning: true, + reasoning_options: [ + { type: 'toggle' }, + { type: 'effort', values: ['low', 'high', 'max'] }, + ], + limit: { context: 1000 }, + }); + expect(toggleable?.alwaysThinking).toBeUndefined(); + expect(toggleable?.offEffort).toBeUndefined(); + }); + + it('yields no effort list for toggle-only, budget_tokens, or empty reasoning_options', () => { + for (const reasoning_options of [ + [{ type: 'toggle' }], + [{ type: 'budget_tokens', min: 1024, max: 32768 }], + [], + ] as const) { + const model = catalogModelToCapability({ + id: 'm', + reasoning: true, + reasoning_options, + limit: { context: 1000 }, + }); + expect(model?.supportEfforts).toBeUndefined(); + expect(model?.capability.thinking).toBe(true); + } + }); + + it('treats declared effort levels as thinking support when reasoning is absent', () => { + const model = catalogModelToCapability({ + id: 'm', + reasoning_options: [{ type: 'effort', values: ['low', 'high'] }], + limit: { context: 1000 }, + }); + expect(model?.supportEfforts).toEqual(['low', 'high']); + expect(model?.capability.thinking).toBe(true); + }); + + it('tracks limit.input separately from the total context window', () => { + // The gpt-5 shape on models.dev: 400k total window, 272k input cap. The + // total window stays the context budget (completion clamping needs it); + // the input cap is tracked for prompt-budget checks (compaction). + const model = catalogModelToCapability({ id: 'gpt-5', limit: { context: 400000, input: 272000 } }); + expect(model?.capability.max_context_tokens).toBe(400000); + expect(model?.capability.max_input_tokens).toBe(272000); + // A bogus or inconsistent input limit never exceeds the total window. + const weird = catalogModelToCapability({ id: 'm', limit: { context: 1000, input: 5000 } }); + expect(weird?.capability.max_context_tokens).toBe(1000); + expect(weird?.capability.max_input_tokens).toBe(1000); + // No declared input limit: the total window is the only ceiling. + const plain = catalogModelToCapability({ id: 'm', limit: { context: 1000 } }); + expect(plain?.capability.max_context_tokens).toBe(1000); + expect(plain?.capability.max_input_tokens).toBeUndefined(); + const zero = catalogModelToCapability({ id: 'm', limit: { context: 1000, input: 0 } }); + expect(zero?.capability.max_input_tokens).toBeUndefined(); + }); + + it('skips deprecated and alpha models but keeps beta ones', () => { + expect( + catalogModelToCapability({ id: 'old', status: 'deprecated', limit: { context: 1000 } }), + ).toBeUndefined(); + expect( + catalogModelToCapability({ id: 'pre', status: 'alpha', limit: { context: 1000 } }), + ).toBeUndefined(); + expect( + catalogModelToCapability({ id: 'new', status: 'beta', limit: { context: 1000 } })?.id, + ).toBe('new'); + }); }); describe('catalogProviderModels', () => { @@ -152,4 +496,207 @@ describe('catalogProviderModels', () => { expect(models).toHaveLength(1); expect(models[0]?.id).toBe('good'); }); + + it('materializes a per-model Anthropic override with its own endpoint (zenmux shape)', () => { + const models = catalogProviderModels({ + id: 'zenmux', + npm: '@ai-sdk/openai-compatible', + api: 'https://zenmux.example.test/api/v1', + models: { + 'vendor/claude-model': { + id: 'vendor/claude-model', + limit: { context: 200000 }, + provider: { npm: '@ai-sdk/anthropic', api: 'https://zenmux.example.test/api/anthropic/v1' }, + }, + 'vendor/plain-model': { id: 'vendor/plain-model', limit: { context: 1000 } }, + }, + }); + expect(models[0]).toMatchObject({ + id: 'vendor/claude-model', + protocol: 'anthropic', + baseUrl: 'https://zenmux.example.test/api/anthropic', + }); + expect(models[1]).toMatchObject({ id: 'vendor/plain-model' }); + expect(models[1]?.protocol).toBeUndefined(); + expect(models[1]?.baseUrl).toBeUndefined(); + }); + + it('falls back to the provider api when the override declares only npm (opencode shape)', () => { + const models = catalogProviderModels({ + id: 'opencode', + npm: '@ai-sdk/openai-compatible', + api: 'https://opencode.example.test/zen/v1', + models: { + 'vendor/claude-model': { + id: 'vendor/claude-model', + limit: { context: 200000 }, + provider: { npm: '@ai-sdk/anthropic' }, + }, + }, + }); + expect(models[0]).toMatchObject({ + protocol: 'anthropic', + baseUrl: 'https://opencode.example.test/zen', + }); + }); + + it('skips models whose override targets a different wire it cannot express', () => { + // freemodel shape: provider is Anthropic, model override targets OpenAI — + // importing under the provider wire would be the wrong protocol. + const reverse = catalogProviderModels({ + id: 'freemodel', + npm: '@ai-sdk/anthropic', + api: 'https://freemodel.example.test/v1', + models: { + 'vendor/gpt': { + id: 'vendor/gpt', + limit: { context: 1000 }, + provider: { npm: '@ai-sdk/openai-compatible' }, + }, + }, + }); + expect(reverse).toHaveLength(0); + + // google-vertex shape: Claude models need Anthropic-over-Vertex, which the + // vertexai (Gemini-mode) wire is not — skipped, not mis-wired. + const noEndpoint = catalogProviderModels({ + id: 'google-vertex', + npm: '@ai-sdk/google-vertex', + models: { + 'claude-model': { + id: 'claude-model', + limit: { context: 200000 }, + provider: { npm: '@ai-sdk/google-vertex/anthropic' }, + }, + }, + }); + expect(noEndpoint).toHaveLength(0); + + // Env-placeholder URLs are SDK-side interpolations the config cannot express. + const placeholder = catalogProviderModels({ + id: 'neon', + npm: '@ai-sdk/openai-compatible', + api: '${NEON_BASE_URL}/v1', + models: { + 'vendor/claude-model': { + id: 'vendor/claude-model', + limit: { context: 200000 }, + provider: { npm: '@ai-sdk/anthropic', api: '${NEON_BASE_URL}/anthropic/v1' }, + }, + }, + }); + expect(placeholder).toHaveLength(0); + }); + + it('keeps models whose override matches the provider wire', () => { + // vivgrid shape: provider and model override are both OpenAI-family. + const models = catalogProviderModels({ + id: 'vivgrid', + npm: '@ai-sdk/openai', + api: 'https://api.vivgrid.com/v1', + models: { + 'gpt-5.4': { + id: 'gpt-5.4', + limit: { context: 400000 }, + provider: { npm: '@ai-sdk/openai-compatible' }, + }, + }, + }); + expect(models).toHaveLength(1); + expect(models[0]?.protocol).toBeUndefined(); + }); + + it('carries a same-wire override endpoint that differs from the provider api', () => { + const models = catalogProviderModels({ + id: 'gateway', + npm: '@ai-sdk/openai-compatible', + api: 'https://gateway.example.test/v1', + models: { + 'tenant-model': { + id: 'tenant-model', + limit: { context: 1000 }, + provider: { npm: '@ai-sdk/openai-compatible', api: 'https://tenant.example.test/v1' }, + }, + // Same endpoint as the provider — nothing to carry. + 'shared-model': { + id: 'shared-model', + limit: { context: 1000 }, + provider: { npm: '@ai-sdk/openai-compatible', api: 'https://gateway.example.test/v1' }, + }, + }, + }); + expect(models[0]).toMatchObject({ baseUrl: 'https://tenant.example.test/v1' }); + expect(models[0]?.protocol).toBeUndefined(); + expect(models[1]?.baseUrl).toBeUndefined(); + }); + + it('carries a same-wire Anthropic override endpoint, adapted to the SDK convention', () => { + const models = catalogProviderModels({ + id: 'claude-gw', + npm: '@ai-sdk/anthropic', + api: 'https://gw.example.test', + models: { + 'tenant-claude': { + id: 'tenant-claude', + limit: { context: 200000 }, + provider: { npm: '@ai-sdk/anthropic', api: 'https://tenant.example.test/v1' }, + }, + }, + }); + expect(models[0]).toMatchObject({ baseUrl: 'https://tenant.example.test' }); + expect(models[0]?.protocol).toBeUndefined(); + }); + + it('honors api-only overrides as same-wire endpoint changes', () => { + const models = catalogProviderModels({ + id: 'gateway', + npm: '@ai-sdk/openai-compatible', + api: 'https://gateway.example.test/v1', + models: { + 'tenant-model': { + id: 'tenant-model', + limit: { context: 1000 }, + provider: { api: 'https://tenant.example.test/v1' }, + }, + 'plain-model': { id: 'plain-model', limit: { context: 1000 } }, + }, + }); + expect(models[0]).toMatchObject({ baseUrl: 'https://tenant.example.test/v1' }); + expect(models[0]?.protocol).toBeUndefined(); + expect(models[1]?.baseUrl).toBeUndefined(); + }); + + it('skips overrides targeting another known wire the alias cannot express', () => { + // A google-genai model on an OpenAI gateway: the override explicitly + // requires a different protocol — imported under OpenAI it would just fail. + const models = catalogProviderModels({ + id: 'gateway', + npm: '@ai-sdk/openai-compatible', + api: 'https://gateway.example.test/v1', + models: { + 'google/gemini-x': { + id: 'google/gemini-x', + limit: { context: 1000 }, + provider: { npm: '@ai-sdk/google' }, + }, + }, + }); + expect(models).toHaveLength(0); + }); + + it('skips same-wire models whose declared endpoint is an env placeholder', () => { + const models = catalogProviderModels({ + id: 'gateway', + npm: '@ai-sdk/openai-compatible', + api: 'https://gateway.example.test/v1', + models: { + 'tenant-model': { + id: 'tenant-model', + limit: { context: 1000 }, + provider: { api: '${TENANT_BASE_URL}/v1' }, + }, + }, + }); + expect(models).toHaveLength(0); + }); }); diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 353a9ed913..cd95d18a12 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -27,6 +27,7 @@ function createProvider( stream: boolean; reasoningKey: string; model: string; + offEffort: string; }>, ): OpenAILegacyChatProvider { return new OpenAILegacyChatProvider({ @@ -34,6 +35,7 @@ function createProvider( apiKey: 'test-key', stream: options?.stream ?? false, reasoningKey: options?.reasoningKey, + offEffort: options?.offEffort, }); } @@ -1037,6 +1039,17 @@ describe('OpenAILegacyChatProvider', () => { expect(provider.thinkingEffort).toBe('off'); }); + it('.withThinking("off") sends the configured offEffort for models that reason by default', async () => { + const provider = createProvider({ offEffort: 'none' }).withThinking('off'); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect(body['reasoning_effort']).toBe('none'); + expect(provider.thinkingEffort).toBe('off'); + }); + it('.withThinking("on") sends no reasoning_effort without ThinkPart history and reports "on"', async () => { const provider = createProvider().withThinking('on'); const history: Message[] = [ diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index ff4a9f7042..c7f9b3ee97 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -1041,6 +1041,22 @@ describe('OpenAIResponsesChatProvider', () => { expect(body['include']).toBeUndefined(); }); + it('with_thinking("off") sends the configured offEffort for models that reason by default', async () => { + const provider = new OpenAIResponsesChatProvider({ + model: 'grok-4', + apiKey: 'test-key', + offEffort: 'none', + }).withThinking('off'); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect(body['reasoning']).toEqual({ effort: 'none', summary: 'auto' }); + expect(body['include']).toEqual(['reasoning.encrypted_content']); + expect(provider.thinkingEffort).toBe('off'); + }); + it('with_thinking("low") sends reasoning with effort=low', async () => { const provider = createProvider().withThinking('low'); const history: Message[] = [ diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 63a6f0bfe7..78cb2afd31 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -3,14 +3,18 @@ import { catalogBaseUrl, catalogProviderModels, inferWireType, + resolveCatalogImport, type Catalog, + type CatalogImportInvalidReason, + type CatalogImportResolution, type CatalogModel, type CatalogProviderEntry, type ModelCapability, type ProviderType, } from '@moonshot-ai/kosong'; -export { catalogBaseUrl, catalogProviderModels, inferWireType }; +export { catalogBaseUrl, catalogProviderModels, inferWireType, resolveCatalogImport }; +export type { CatalogImportInvalidReason, CatalogImportResolution }; export type { Catalog, CatalogModel, CatalogProviderEntry }; export const DEFAULT_CATALOG_URL = 'https://models.dev/api.json'; @@ -65,14 +69,25 @@ function capabilityToStrings(capability: ModelCapability): string[] | undefined /** Builds a kimi-code model alias from a normalized catalog model. */ export function catalogModelToAlias(providerId: string, model: CatalogModel): ModelAlias { + const caps = capabilityToStrings(model.capability); return { provider: providerId, model: model.id, maxContextSize: model.capability.max_context_tokens, + maxInputSize: model.capability.max_input_tokens, maxOutputSize: model.maxOutputSize, - capabilities: capabilityToStrings(model.capability), + // A model that always reasons advertises `always_thinking` instead of + // `thinking`, so the UI locks thinking on and offers no off option. + capabilities: + model.alwaysThinking === true + ? caps?.map((cap) => (cap === 'thinking' ? 'always_thinking' : cap)) + : caps, displayName: model.name, reasoningKey: model.reasoningKey, + supportEfforts: model.supportEfforts === undefined ? undefined : [...model.supportEfforts], + offEffort: model.offEffort, + protocol: model.protocol, + baseUrl: model.baseUrl, }; } diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 84992c2631..cab37997d5 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -26,10 +26,13 @@ export { fetchCatalog, inferWireType, loadBuiltInCatalog, + resolveCatalogImport, } from '#/catalog'; export type { ApplyCatalogProviderOptions, Catalog, + CatalogImportInvalidReason, + CatalogImportResolution, CatalogModel, CatalogProviderEntry, FetchCatalogOptions, diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 106c3c64aa..d1bc5a6ea9 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -150,6 +150,139 @@ describe('applyCatalogProvider', () => { }); }); + it('writes declared effort levels from reasoning_options into the model alias', () => { + // The models.dev `kimi-for-coding` provider shape for `k3`. + const models = catalogProviderModels({ + id: 'kimi-for-coding', + models: { + k3: { + id: 'k3', + name: 'Kimi K3', + limit: { context: 1048576, output: 131072 }, + reasoning: true, + reasoning_options: [ + { type: 'toggle' }, + { type: 'effort', values: ['low', 'high', 'max'] }, + ], + tool_call: true, + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + }, + }, + }); + const config = { providers: {} } as KimiConfig; + + applyCatalogProvider(config, { + providerId: 'kimi-for-coding', + wire: 'anthropic', + baseUrl: 'https://api.kimi.com/coding', + apiKey: 'sk', + models, + selectedModelId: 'k3', + thinking: true, + }); + + expect(config.models?.['kimi-for-coding/k3']).toMatchObject({ + provider: 'kimi-for-coding', + model: 'k3', + capabilities: ['image_in', 'video_in', 'thinking', 'tool_use'], + supportEfforts: ['low', 'high', 'max'], + }); + }); + + it('writes per-model protocol/baseUrl overrides and the input-limited context size', () => { + // The zenmux gateway shape: provider defaults to the OpenAI wire, one + // model is served over Anthropic on its own endpoint; plus a gpt-5-style + // input cap below the total context window. + const models = catalogProviderModels({ + id: 'gateway', + npm: '@ai-sdk/openai-compatible', + api: 'https://gateway.example.test/api/v1', + models: { + 'vendor/claude-model': { + id: 'vendor/claude-model', + name: 'Gateway Claude', + limit: { context: 200000 }, + provider: { + npm: '@ai-sdk/anthropic', + api: 'https://gateway.example.test/api/anthropic/v1', + }, + }, + 'vendor/gpt-model': { + id: 'vendor/gpt-model', + limit: { context: 400000, input: 272000, output: 128000 }, + }, + }, + }); + const config = { providers: {} } as KimiConfig; + + applyCatalogProvider(config, { + providerId: 'gateway', + wire: 'openai', + baseUrl: 'https://gateway.example.test/api/v1', + apiKey: 'sk', + models, + selectedModelId: 'vendor/claude-model', + thinking: false, + }); + + expect(config.models?.['gateway/vendor/claude-model']).toMatchObject({ + provider: 'gateway', + model: 'vendor/claude-model', + protocol: 'anthropic', + baseUrl: 'https://gateway.example.test/api/anthropic', + }); + const plain = config.models?.['gateway/vendor/gpt-model']; + expect(plain).toMatchObject({ maxContextSize: 400000, maxInputSize: 272000 }); + expect(plain?.protocol).toBeUndefined(); + expect(plain?.baseUrl).toBeUndefined(); + }); + + it('maps always-thinking models to always_thinking and carries the off encoding', () => { + const models = catalogProviderModels({ + id: 'gateway', + models: { + 'gpt-5': { + id: 'gpt-5', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['low', 'medium', 'high'] }], + limit: { context: 400000, input: 272000 }, + }, + 'grok-4': { + id: 'grok-4', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }], + limit: { context: 256000 }, + }, + }, + }); + const config = { providers: {} } as KimiConfig; + + applyCatalogProvider(config, { + providerId: 'gateway', + wire: 'openai', + baseUrl: 'https://gateway.example.test/v1', + apiKey: 'sk', + models, + selectedModelId: 'gpt-5', + thinking: true, + }); + + // No off option: thinking is locked on for a model that always reasons. + expect(config.models?.['gateway/gpt-5']).toMatchObject({ + capabilities: ['thinking', 'tool_use'].map((c) => (c === 'thinking' ? 'always_thinking' : c)), + supportEfforts: ['low', 'medium', 'high'], + }); + expect(config.models?.['gateway/gpt-5']?.capabilities).not.toContain('thinking'); + expect(config.models?.['gateway/gpt-5']?.offEffort).toBeUndefined(); + + // 'none' becomes the off encoding; the level list stays selectable-only. + expect(config.models?.['gateway/grok-4']).toMatchObject({ + capabilities: ['thinking', 'tool_use'], + supportEfforts: ['low', 'medium', 'high'], + offEffort: 'none', + }); + }); + it('clears stale aliases for the same provider but keeps others', () => { const config = { providers: { anthropic: { type: 'anthropic', apiKey: 'old' } },