From 120ac572b563d757b6f174f85e747627da6dbd93 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 20:55:45 +0800 Subject: [PATCH 01/13] fix: stop advertising Claude thinking efforts for non-Claude models Models served over the Anthropic protocol whose names carry no Claude marker (e.g. a catalog-imported Kimi K3) no longer inherit the latest Opus effort list, so the model selector stops offering levels the model does not accept. The models.dev catalog import now also parses reasoning_options and records the declared effort levels on the model alias, so K3 offers its real levels (low / high / max). --- .../anthropic-fallback-thinking-efforts.md | 9 +++ .../test/tui/kimi-tui-message-flow.test.ts | 34 +++++++++-- packages/acp-adapter/src/model-catalog.ts | 8 ++- .../acp-adapter/test/config-options.test.ts | 19 +++++- .../acp-adapter/test/model-catalog.test.ts | 48 ++++++++++++--- .../src/kosong/model/catalogService.ts | 9 ++- .../src/kosong/model/modelAuth.ts | 8 ++- .../bases/anthropic/anthropic-profile.ts | 13 +++++ .../test/app/model/model.test.ts | 25 +++++++- .../test/kosong/model/catalog.test.ts | 49 ++++++++++++++-- packages/agent-core/src/config/model.ts | 16 +++-- .../test/config/model-overrides.test.ts | 19 ++++-- .../test/harness/model-alias-session.test.ts | 11 ++-- .../services/model-catalog-service.test.ts | 21 ++++++- packages/kosong/src/catalog.ts | 41 ++++++++++++- .../kosong/src/providers/anthropic-profile.ts | 13 +++++ packages/kosong/test/catalog.test.ts | 58 +++++++++++++++++++ packages/node-sdk/src/catalog.ts | 1 + packages/node-sdk/test/catalog.test.ts | 39 +++++++++++++ 19 files changed, 394 insertions(+), 47 deletions(-) create mode 100644 .changeset/anthropic-fallback-thinking-efforts.md diff --git a/.changeset/anthropic-fallback-thinking-efforts.md b/.changeset/anthropic-fallback-thinking-efforts.md new file mode 100644 index 0000000000..68df8eac28 --- /dev/null +++ b/.changeset/anthropic-fallback-thinking-efforts.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix the model selector offering thinking effort levels a model does not support: non-Claude models on Anthropic-compatible providers (e.g. a catalog-imported Kimi K3) no longer inherit Claude levels such as xhigh/max, and models imported from the models.dev catalog now advertise the effort levels the catalog declares. 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/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/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index 1ec3ab0189..f9bb14ae50 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, @@ -685,7 +686,8 @@ function buildProtocolProviderOptions( * 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. + * compare). `inferred` marks the unknown-name fallback, which only applies + * to names still carrying a Claude marker (see `matchUnknownClaudeProfile`). */ function profileForAttribution( configuredModel: ModelRecord, @@ -700,7 +702,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/modelAuth.ts b/packages/agent-core-v2/src/kosong/model/modelAuth.ts index 5079dd8765..6f8afed701 100644 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ b/packages/agent-core-v2/src/kosong/model/modelAuth.ts @@ -22,8 +22,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'; @@ -121,11 +121,15 @@ export function effectiveModelConfig( function withAnthropicProfile(model: ModelRecord, providerType?: string): ModelRecord { const wireName = model.name ?? model.model; const protocol = model.protocol ?? providerType; + // The inferred fallback 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. const profile = 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/provider/bases/anthropic/anthropic-profile.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts index 516fe0e9ad..c4c7641ae4 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,16 @@ export function matchKnownAnthropicModelProfile(model: string): AnthropicModelPr 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 (e.g. a proxied + * `claude-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 { + return model.toLowerCase().includes('claude') ? LATEST_OPUS_PROFILE : undefined; +} 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..7519e5f612 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,12 @@ 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', }, @@ -60,6 +60,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 +108,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..023dd67f2c 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -898,12 +898,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 +919,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 +962,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/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/test/config/model-overrides.test.ts b/packages/agent-core/test/config/model-overrides.test.ts index 9c06e37bba..972a5f194d 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,17 @@ describe('effectiveModelAlias', () => { }); }); + 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 +113,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 +129,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/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/catalog.ts b/packages/kosong/src/catalog.ts index ebb2e1daad..d61788092b 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -13,6 +13,13 @@ export interface CatalogModelEntry { readonly limit?: { readonly context?: 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[]; /** Accepts message-level tool declarations (`messages[].tools`). Defaults to false. */ readonly dynamically_loaded_tools?: boolean; readonly interleaved?: boolean | { readonly field?: string }; @@ -22,6 +29,11 @@ export interface CatalogModelEntry { }; } +export interface CatalogReasoningOption { + readonly type?: string; + readonly values?: unknown; +} + export interface CatalogProviderEntry { readonly id?: string; readonly name?: string; @@ -45,6 +57,8 @@ 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[]; readonly capability: ModelCapability; } @@ -126,16 +140,20 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel if (!isUsableChatModel(model)) return undefined; const inputs = model.modalities?.input ?? []; const output = model.limit?.output; + const supportEfforts = catalogSupportEfforts(model.reasoning_options); 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, capability: { image_in: inputs.includes('image'), video_in: inputs.includes('video'), audio_in: inputs.includes('audio'), - thinking: Boolean(model.reasoning), + // Declaring concrete effort levels implies thinking support even when + // the `reasoning` boolean is absent (mirrors the api.json importer). + thinking: Boolean(model.reasoning) || supportEfforts !== undefined, tool_use: model.tool_call ?? true, max_context_tokens: context, dynamically_loaded_tools: model.dynamically_loaded_tools === true, @@ -143,6 +161,27 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel }; } +/** + * Extracts the declared effort levels from a `reasoning_options` list — the + * `{ type: 'effort', values: [...] }` entry. The value `'none'` means + * "reasoning can be disabled" (e.g. xai grok); the UI already expresses that + * with its own `off` entry, so it is not a selectable level and is dropped. + */ +function catalogSupportEfforts( + options: CatalogModelEntry['reasoning_options'], +): readonly string[] | undefined { + if (!Array.isArray(options)) return undefined; + for (const option of options) { + if (option?.type !== 'effort' || !Array.isArray(option.values)) continue; + const efforts = option.values.filter( + (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value.toLowerCase() !== 'none', + ); + if (efforts.length > 0) return efforts; + } + return undefined; +} + 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 diff --git a/packages/kosong/src/providers/anthropic-profile.ts b/packages/kosong/src/providers/anthropic-profile.ts index 0650bd8fb7..cb16be6ded 100644 --- a/packages/kosong/src/providers/anthropic-profile.ts +++ b/packages/kosong/src/providers/anthropic-profile.ts @@ -152,3 +152,16 @@ 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 (e.g. a proxied + * `claude-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 { + return model.toLowerCase().includes('claude') ? LATEST_OPUS_PROFILE : undefined; +} diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 87538b92a3..6c30b8fb73 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -138,6 +138,64 @@ 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("drops the 'none' pseudo-effort (the UI already offers 'off')", () => { + const model = catalogModelToCapability({ + id: 'grok', + reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }], + limit: { context: 1000 }, + }); + expect(model?.supportEfforts).toEqual(['low', 'medium', 'high']); + + const upper = catalogModelToCapability({ + id: 'grok', + reasoning_options: [{ type: 'effort', values: ['None', 'high'] }], + limit: { context: 1000 }, + }); + expect(upper?.supportEfforts).toEqual(['high']); + }); + + 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); + }); }); describe('catalogProviderModels', () => { diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 63a6f0bfe7..a98b1595d6 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -73,6 +73,7 @@ export function catalogModelToAlias(providerId: string, model: CatalogModel): Mo capabilities: capabilityToStrings(model.capability), displayName: model.name, reasoningKey: model.reasoningKey, + supportEfforts: model.supportEfforts === undefined ? undefined : [...model.supportEfforts], }; } diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 106c3c64aa..8e4a26eb68 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -150,6 +150,45 @@ 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('clears stale aliases for the same provider but keeps others', () => { const config = { providers: { anthropic: { type: 'anthropic', apiKey: 'old' } }, From 9ffeb5db9da3d6267e5f7d64a3f762047d1f6c2d Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 21:11:35 +0800 Subject: [PATCH 02/13] fix: consume deprecated, override, and input-limit metadata from the models.dev catalog - Models declared status=deprecated in the catalog are no longer offered for import. - Per-model provider overrides on gateway providers (an npm package targeting an Anthropic SDK plus a usable endpoint) now land as alias protocol and base_url, so those models are served over the right protocol and endpoint; overrides without a usable URL are skipped. - A declared limit.input now sizes the context budget instead of the larger total context window (e.g. gpt-5: 272k instead of 400k). The model alias schema gains an optional base_url field (not accepted in overrides) that Anthropic wire resolution prefers over the provider-level base URL. --- .changeset/catalog-import-metadata-fixes.md | 8 ++ docs/en/configuration/config-files.md | 2 +- docs/zh/configuration/config-files.md | 2 +- packages/agent-core/src/config/schema.ts | 5 + .../src/session/provider-manager.ts | 7 +- .../test/harness/runtime-provider.test.ts | 71 ++++++++++ packages/kosong/src/catalog.ts | 69 +++++++++- packages/kosong/test/catalog.test.ts | 123 ++++++++++++++++++ packages/node-sdk/src/catalog.ts | 2 + packages/node-sdk/test/catalog.test.ts | 48 +++++++ 10 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 .changeset/catalog-import-metadata-fixes.md diff --git a/.changeset/catalog-import-metadata-fixes.md b/.changeset/catalog-import-metadata-fixes.md new file mode 100644 index 0000000000..4c588ba19e --- /dev/null +++ b/.changeset/catalog-import-metadata-fixes.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Consume more of the metadata the models.dev catalog declares: deprecated models are no longer offered for import, per-model Anthropic protocol and endpoint overrides on gateway providers are honored, and a model's declared input token limit (e.g. gpt-5) now sizes the context budget instead of the larger total window. 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/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/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 06e7c9d307..f701351a62 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -60,6 +60,10 @@ const ModelAliasBaseSchema = z.object({ // (`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 +71,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/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index d746b677d1..d6d1cb8df2 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -126,6 +126,7 @@ export class ProviderManager implements ModelProvider { providerConfig, alias.model, alias.protocol, + alias.baseUrl, this.options.kimiRequestHeaders, effectiveAlias.maxOutputSize, effectiveAlias.reasoningKey, @@ -252,6 +253,7 @@ function toKosongProviderConfig( provider: ProviderConfig, model: string, modelProtocol: ModelAlias['protocol'], + modelBaseUrl: string | undefined, kimiRequestHeaders: Record | undefined, maxOutputSize: number | undefined, reasoningKey: string | undefined, @@ -264,7 +266,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, diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 92cad17f15..fd8370bedf 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -283,6 +283,77 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { }); }); + 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/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index d61788092b..0412f83ab1 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -10,7 +10,7 @@ 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; /** @@ -20,6 +20,14 @@ export interface CatalogModelEntry { * 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 }; @@ -34,6 +42,11 @@ export interface CatalogReasoningOption { readonly values?: unknown; } +export interface CatalogModelProviderOverride { + readonly npm?: string; + readonly api?: string; +} + export interface CatalogProviderEntry { readonly id?: string; readonly name?: string; @@ -59,6 +72,13 @@ export interface CatalogModel { readonly reasoningKey?: string; /** Declared thinking effort levels from `reasoning_options`, when present. */ readonly supportEfforts?: readonly string[]; + /** + * 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; } @@ -84,6 +104,10 @@ 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; 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') return false; return ( !hasEmbeddingMarker(model.family) && !hasEmbeddingMarker(model.id) && @@ -141,6 +165,14 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel const inputs = model.modalities?.input ?? []; const output = model.limit?.output; const supportEfforts = catalogSupportEfforts(model.reasoning_options); + // `limit.input` is the true prompt cap when declared (e.g. gpt-5: 400k + // context window but a 272k input limit); sizing the context budget to the + // total window would let the prompt overflow before compaction kicks in. + const input = model.limit?.input; + const maxContextTokens = + typeof input === 'number' && Number.isInteger(input) && input > 0 + ? Math.min(input, context) + : context; return { id: model.id, name: typeof model.name === 'string' && model.name.length > 0 ? model.name : undefined, @@ -155,7 +187,7 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel // the `reasoning` boolean is absent (mirrors the api.json importer). thinking: Boolean(model.reasoning) || supportEfforts !== undefined, tool_use: model.tool_call ?? true, - max_context_tokens: context, + max_context_tokens: maxContextTokens, dynamically_loaded_tools: model.dynamically_loaded_tools === true, }, }; @@ -194,8 +226,35 @@ 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 = inferWireType(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 than + * the provider default. Only overrides whose `npm` explicitly targets an + * Anthropic SDK are materialized — the model-alias schema cannot express + * other per-model protocols yet, and a partial override is worse than none. + * Overrides without a usable endpoint (env-placeholder URLs like `${VAR}`, + * or no URL on either level) are skipped so provider-level resolution keeps + * applying. + */ +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 || typeof override.npm !== 'string') return model; + if (!override.npm.toLowerCase().includes('anthropic') || providerWire === 'anthropic') { + return model; + } + const api = override.api ?? entry.api; + if (typeof api !== 'string' || api.length === 0 || api.includes('${')) return model; + return { ...model, protocol: 'anthropic', baseUrl: catalogBaseUrl({ api }, 'anthropic') }; +} diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 6c30b8fb73..c88e15e6a2 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -196,6 +196,39 @@ describe('catalogModelToCapability', () => { expect(model?.supportEfforts).toEqual(['low', 'high']); expect(model?.capability.thinking).toBe(true); }); + + it('prefers limit.input over limit.context for the context budget', () => { + // The gpt-5 shape on models.dev: 400k window but a 272k input cap. + expect( + catalogModelToCapability({ id: 'm', limit: { context: 400000, input: 272000 } })?.capability + .max_context_tokens, + ).toBe(272000); + // A bogus or inconsistent input limit never exceeds the total window. + expect( + catalogModelToCapability({ id: 'm', limit: { context: 1000, input: 5000 } })?.capability + .max_context_tokens, + ).toBe(1000); + expect( + catalogModelToCapability({ id: 'm', limit: { context: 1000, input: 0 } })?.capability + .max_context_tokens, + ).toBe(1000); + expect( + catalogModelToCapability({ id: 'm', limit: { context: 1000 } })?.capability + .max_context_tokens, + ).toBe(1000); + }); + + it('skips deprecated models but keeps beta and alpha ones', () => { + expect( + catalogModelToCapability({ id: 'old', status: 'deprecated', limit: { context: 1000 } }), + ).toBeUndefined(); + expect( + catalogModelToCapability({ id: 'new', status: 'beta', limit: { context: 1000 } })?.id, + ).toBe('new'); + expect( + catalogModelToCapability({ id: 'newer', status: 'alpha', limit: { context: 1000 } })?.id, + ).toBe('newer'); + }); }); describe('catalogProviderModels', () => { @@ -210,4 +243,94 @@ 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 the override when the provider already speaks Anthropic, the npm is not Anthropic, or the URL is unusable', () => { + // freemodel shape: provider is Anthropic, model override targets OpenAI — + // not expressible per-model, left to provider-level resolution. + 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[0]?.protocol).toBeUndefined(); + + // google-vertex shape: no api anywhere — the vertex wire keeps applying. + 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[0]?.protocol).toBeUndefined(); + + // 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[0]?.protocol).toBeUndefined(); + }); }); diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index a98b1595d6..456aca7b1c 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -74,6 +74,8 @@ export function catalogModelToAlias(providerId: string, model: CatalogModel): Mo displayName: model.name, reasoningKey: model.reasoningKey, supportEfforts: model.supportEfforts === undefined ? undefined : [...model.supportEfforts], + protocol: model.protocol, + baseUrl: model.baseUrl, }; } diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 8e4a26eb68..cdf3a15d93 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -189,6 +189,54 @@ describe('applyCatalogProvider', () => { }); }); + 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: 272000 }); + expect(plain?.protocol).toBeUndefined(); + expect(plain?.baseUrl).toBeUndefined(); + }); + it('clears stale aliases for the same provider but keeps others', () => { const config = { providers: { anthropic: { type: 'anthropic', apiKey: 'old' } }, From 39d1dd30ec7c9555fdfaf3c7a24769d88052c578 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 21:59:28 +0800 Subject: [PATCH 03/13] fix: honor thinking-disable semantics and the OpenAI-compatible fallback in catalog imports - reasoning_options 'none' is the model's off encoding: off_effort flows from the catalog through the model alias to the OpenAI wire providers, so turning thinking off sends 'none' instead of omitting the effort field; models with effort levels but no way to disable thinking are imported as always_thinking and no longer offer an Off option. - Bare Claude family aliases (e.g. sonnet-latest) recover the inferred Anthropic effort profile; v2 comment conventions restored. - Providers whose SDK the catalog does not type now fall back to the OpenAI-compatible wire (with a visible "guessed" note) instead of being refused; imports lacking a usable endpoint ask for one (--base-url on the CLI, a prompt in the TUI). Proprietary SDKs (Amazon Bedrock), unrecognized explicit types, and env-placeholder URLs are refused with a clear reason. --- .../catalog-off-effort-and-family-aliases.md | 9 ++ .changeset/catalog-openai-fallback.md | 7 + apps/kimi-code/src/cli/sub/provider.ts | 40 +++++- apps/kimi-code/src/tui/commands/prompts.ts | 25 ++++ apps/kimi-code/src/tui/commands/provider.ts | 25 +++- .../dialogs/api-key-input-dialog.ts | 12 +- apps/kimi-code/test/cli/provider.test.ts | 93 ++++++++++++++ docs/en/reference/kimi-command.md | 3 +- docs/zh/reference/kimi-command.md | 3 +- .../src/kosong/model/catalogService.ts | 9 +- .../agent-core-v2/src/kosong/model/model.ts | 1 + .../src/kosong/model/modelAuth.ts | 8 +- .../src/kosong/protocol/protocol.ts | 1 + .../bases/anthropic/anthropic-profile.ts | 16 +-- .../bases/openai/openai-legacy.contrib.ts | 1 + .../provider/bases/openai/openai-legacy.ts | 21 +-- .../bases/openai/openai-responses.contrib.ts | 1 + .../provider/bases/openai/openai-responses.ts | 13 +- .../test/app/model/model.test.ts | 18 +++ .../test/kosong/model/catalog.test.ts | 32 +++++ .../test/kosong/provider/composition.test.ts | 25 ++++ packages/agent-core/src/config/schema.ts | 5 + .../src/session/provider-manager.ts | 4 + .../test/config/model-overrides.test.ts | 15 +++ .../test/harness/runtime-provider.test.ts | 36 ++++++ packages/kosong/src/catalog.ts | 121 +++++++++++++++--- packages/kosong/src/index.ts | 2 + .../kosong/src/providers/anthropic-profile.ts | 21 ++- .../kosong/src/providers/openai-legacy.ts | 22 +++- .../kosong/src/providers/openai-responses.ts | 14 +- packages/kosong/test/anthropic.test.ts | 19 ++- packages/kosong/test/catalog.test.ts | 97 +++++++++++++- packages/kosong/test/openai-legacy.test.ts | 13 ++ packages/kosong/test/openai-responses.test.ts | 16 +++ packages/node-sdk/src/catalog.ts | 13 +- packages/node-sdk/src/index.ts | 2 + packages/node-sdk/test/catalog.test.ts | 46 +++++++ 37 files changed, 724 insertions(+), 85 deletions(-) create mode 100644 .changeset/catalog-off-effort-and-family-aliases.md create mode 100644 .changeset/catalog-openai-fallback.md diff --git a/.changeset/catalog-off-effort-and-family-aliases.md b/.changeset/catalog-off-effort-and-family-aliases.md new file mode 100644 index 0000000000..d80261d6a0 --- /dev/null +++ b/.changeset/catalog-off-effort-and-family-aliases.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Honor the thinking-disable semantics declared by the models.dev catalog: selecting Off on a model whose effort levels include `none` (e.g. xai grok) now sends `none` on the wire instead of omitting the effort field, so reasoning actually turns off; models with effort levels but no way to disable thinking are treated as always-thinking and no longer offer an Off option. Bare Claude family aliases such as `sonnet-latest` on Anthropic-compatible providers also get their inferred effort levels back. diff --git a/.changeset/catalog-openai-fallback.md b/.changeset/catalog-openai-fallback.md new file mode 100644 index 0000000000..6e7b7f5971 --- /dev/null +++ b/.changeset/catalog-openai-fallback.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/kimi-code": patch +--- + +Import previously unsupported vendors from the models.dev catalog: providers whose SDK is not explicitly recognized now fall back to the OpenAI-compatible wire (with a visible "guessed" note) instead of being refused, and imports that lack a usable endpoint ask for one — `--base-url` on the CLI, a prompt in the TUI. Truly proprietary SDKs (Amazon Bedrock) and env-placeholder URLs are still refused with a clear reason. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 509ec2d220..e3c99dd28f 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -23,11 +23,13 @@ import { applyCatalogProvider, catalogBaseUrl, catalogProviderModels, + catalogProviderNeedsBaseUrl, CatalogFetchError, createKimiHarness, DEFAULT_CATALOG_URL, fetchCatalog, inferWireType, + isGuessedWireType, type Catalog, type CatalogProviderEntry, type KimiConfig, @@ -67,6 +69,7 @@ interface CatalogAddOptions { readonly apiKey?: string; readonly defaultModel?: string; readonly url?: string; + readonly baseUrl?: string; } export async function handleProviderAdd( @@ -276,9 +279,11 @@ 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 wire = inferWireType(entry); + const wireLabel = + wire === undefined ? '?' : isGuessedWireType(entry) ? `${wire} (guessed)` : wire; deps.stdout.write( - `${id} wire=${wire} models=${String(modelCount)} ${entry.name ?? ''}\n`, + `${id} wire=${wireLabel} models=${String(modelCount)} ${entry.name ?? ''}\n`, ); } } @@ -312,7 +317,9 @@ export async function handleCatalogAdd( const wire = inferWireType(entry); if (wire === undefined) { - deps.stderr.write(`Provider "${providerId}" has an unsupported wire type in the catalog.\n`); + deps.stderr.write( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock); it cannot be imported from the catalog.\n`, + ); deps.exit(1); } @@ -329,6 +336,20 @@ export async function handleCatalogAdd( deps.exit(1); } + let baseUrl = catalogBaseUrl(entry, wire) ?? opts.baseUrl; + if (baseUrl === undefined && catalogProviderNeedsBaseUrl(entry, wire)) { + 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); + } + if (baseUrl !== undefined && baseUrl.includes('${')) { + deps.stderr.write( + `Base URL "${baseUrl}" contains an env placeholder. Pass --base-url with the resolved value.\n`, + ); + deps.exit(1); + } + const harness = deps.getHarness(); await harness.ensureConfigFile(); @@ -346,7 +367,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 +411,11 @@ export async function handleCatalogAdd( deps.stdout.write( `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, ); + if (isGuessedWireType(entry)) { + 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 +506,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 +522,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) diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 6fe6e93186..2706af18f0 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -8,10 +8,12 @@ import { applyCatalogProvider, catalogBaseUrl, catalogProviderModels, + catalogProviderNeedsBaseUrl, CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, inferWireType, + isGuessedWireType, type Catalog, type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; @@ -33,6 +35,7 @@ import { thinkingEffortToConfig } from '../utils/thinking-config'; import { effectiveModelForHost } from './config'; import { promptApiKey, + promptBaseUrl, promptCatalogProviderSelection, } from './prompts'; import type { SlashCommandHost } from './dispatch'; @@ -192,15 +195,22 @@ 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.`); + host.showError( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock); it cannot be imported from the catalog.`, + ); return; } - const baseUrl = catalogBaseUrl(entry, wire); + + let baseUrl = catalogBaseUrl(entry, wire); + if (baseUrl === undefined && catalogProviderNeedsBaseUrl(entry, wire)) { + baseUrl = await promptBaseUrl(host, entry.name ?? providerId); + if (baseUrl === undefined) return; + } + + 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 +239,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 (isGuessedWireType(entry)) { + 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..502dd8059e 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -912,4 +912,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'); + 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/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/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/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index f9bb14ae50..b25cd6fe75 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -654,6 +654,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': { @@ -670,6 +671,7 @@ function buildProtocolProviderOptions( break; } case 'openai_responses': + if (model.offEffort !== undefined) options.offEffort = model.offEffort; break; default: { const exhaustive: never = protocol; @@ -682,13 +684,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, which only applies - * to names still carrying a Claude marker (see `matchUnknownClaudeProfile`). - */ function profileForAttribution( configuredModel: ModelRecord, providerConfig: ProviderConfig | undefined, diff --git a/packages/agent-core-v2/src/kosong/model/model.ts b/packages/agent-core-v2/src/kosong/model/model.ts index f1ab223056..5fc320e6f8 100644 --- a/packages/agent-core-v2/src/kosong/model/model.ts +++ b/packages/agent-core-v2/src/kosong/model/model.ts @@ -67,6 +67,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 6f8afed701..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'; @@ -121,10 +125,6 @@ export function effectiveModelConfig( function withAnthropicProfile(model: ModelRecord, providerType?: string): ModelRecord { const wireName = model.name ?? model.model; const protocol = model.protocol ?? providerType; - // The inferred fallback 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. const profile = wireName === undefined ? undefined 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 c4c7641ae4..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 @@ -144,15 +144,11 @@ 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 (e.g. a proxied - * `claude-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 { - return model.toLowerCase().includes('claude') ? LATEST_OPUS_PROFILE : 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..91825e57d5 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,16 @@ 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". + // 'on' has no wire encoding; only a concrete effort is passed through. + // 'off' sends the model's declared off value (e.g. 'none') when one is + // configured and 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..463f252d26 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,15 @@ 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. + // '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. Both 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/app/model/model.test.ts b/packages/agent-core-v2/test/app/model/model.test.ts index 7519e5f612..471ae03437 100644 --- a/packages/agent-core-v2/test/app/model/model.test.ts +++ b/packages/agent-core-v2/test/app/model/model.test.ts @@ -60,6 +60,24 @@ describe('effectiveModelConfig', () => { }); }); + 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', + }, + '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', () => { expect( effectiveModelConfig( 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 023dd67f2c..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: { 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/config/schema.ts b/packages/agent-core/src/config/schema.ts index f701351a62..fc2f1f77e3 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -56,6 +56,11 @@ 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'`. diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index d6d1cb8df2..1164ead199 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -132,6 +132,7 @@ export class ProviderManager implements ModelProvider { effectiveAlias.reasoningKey, this.options.promptCacheKey, effectiveAlias.supportEfforts, + effectiveAlias.offEffort, effectiveAlias.adaptiveThinking, alias.betaApi, ); @@ -259,6 +260,7 @@ function toKosongProviderConfig( reasoningKey: string | undefined, promptCacheKey: string | undefined, supportEfforts: readonly string[] | undefined, + offEffort: string | undefined, adaptiveThinking: boolean | undefined, betaApi: boolean | undefined, ): KosongProviderConfig { @@ -307,6 +309,7 @@ function toKosongProviderConfig( baseUrl: 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 @@ -349,6 +352,7 @@ function toKosongProviderConfig( model, baseUrl: 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/config/model-overrides.test.ts b/packages/agent-core/test/config/model-overrides.test.ts index 972a5f194d..5e878a1c49 100644 --- a/packages/agent-core/test/config/model-overrides.test.ts +++ b/packages/agent-core/test/config/model-overrides.test.ts @@ -75,6 +75,21 @@ 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', diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index fd8370bedf..274de2a0a8 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -283,6 +283,42 @@ 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('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. diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index 0412f83ab1..2589740af0 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -72,6 +72,18 @@ export interface CatalogModel { 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). @@ -117,10 +129,36 @@ function isUsableChatModel(model: CatalogModelEntry): boolean { /** * 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. + * explicit `type`, otherwise infers from `npm`/`id`. models.dev omits `type` + * for vendor-specific SDKs; most of those speak OpenAI-compatible chat + * completions, so unknown providers fall back to `'openai'` (reported via + * {@link isGuessedWireType} so callers can surface the guess). The only + * `undefined` results are SDKs known to be non-OpenAI proprietary (e.g. + * Amazon Bedrock), where the fallback would write a config that can never + * work — callers keep refusing those instead. */ export function inferWireType(entry: CatalogProviderEntry): ProviderType | undefined { + const declared = inferDeclaredWireType(entry); + if (declared !== undefined) return declared; + // An explicit but unrecognized `type` is a protocol this client version + // does not know — refuse it rather than guessing (a future kokub protocol + // must not be silently wired as OpenAI). + if (typeof entry.type === 'string' && entry.type.length > 0) return undefined; + const npm = (entry.npm ?? '').toLowerCase(); + if (npm.includes('amazon-bedrock')) return undefined; + return 'openai'; +} + +/** + * True when the wire comes from the blanket `'openai'` fallback rather than + * an explicit `type` or a recognized `npm`/`id` — callers should tell the + * user the protocol was guessed (and may need a manual `base_url`). + */ +export function isGuessedWireType(entry: CatalogProviderEntry): boolean { + return inferDeclaredWireType(entry) === undefined && inferWireType(entry) !== undefined; +} + +function inferDeclaredWireType(entry: CatalogProviderEntry): ProviderType | undefined { if (isWireType(entry.type)) return entry.type; const npm = (entry.npm ?? '').toLowerCase(); const id = (entry.id ?? '').toLowerCase(); @@ -145,17 +183,36 @@ 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 (typeof api !== 'string' || api.length === 0 || api.includes('${')) return undefined; if (wire === 'anthropic') return api.replace(/\/v1\/?$/, ''); return api; } +/** + * True when importing this provider needs a base URL from the user: the + * catalog supplies none (or only an env placeholder), and the wire's built-in + * default endpoint only applies 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). Vertex/google wires resolve their endpoint from + * env coordinates and official SDKs, so they never need the prompt. + */ +export function catalogProviderNeedsBaseUrl( + entry: CatalogProviderEntry, + wire: ProviderType, +): boolean { + if (wire !== 'openai' && wire !== 'openai_responses') return false; + if (catalogBaseUrl(entry, wire) !== undefined) return false; + return (entry.npm ?? '').toLowerCase() !== '@ai-sdk/openai'; +} + /** Normalizes one catalog model entry into a {@link CatalogModel}; skips invalid entries. */ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel | undefined { if (typeof model.id !== 'string' || model.id.length === 0) return undefined; @@ -164,7 +221,7 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel if (!isUsableChatModel(model)) return undefined; const inputs = model.modalities?.input ?? []; const output = model.limit?.output; - const supportEfforts = catalogSupportEfforts(model.reasoning_options); + 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); sizing the context budget to the // total window would let the prompt overflow before compaction kicks in. @@ -178,14 +235,18 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel 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, + 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'), - // Declaring concrete effort levels implies thinking support even when - // the `reasoning` boolean is absent (mirrors the api.json importer). - thinking: Boolean(model.reasoning) || supportEfforts !== undefined, + // 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: maxContextTokens, dynamically_loaded_tools: model.dynamically_loaded_tools === true, @@ -194,24 +255,42 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel } /** - * Extracts the declared effort levels from a `reasoning_options` list — the - * `{ type: 'effort', values: [...] }` entry. The value `'none'` means - * "reasoning can be disabled" (e.g. xai grok); the UI already expresses that - * with its own `off` entry, so it is not a selectable level and is dropped. + * 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 catalogSupportEfforts( - options: CatalogModelEntry['reasoning_options'], -): readonly string[] | undefined { - if (!Array.isArray(options)) return undefined; +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; - const efforts = option.values.filter( - (value: unknown): value is string => - typeof value === 'string' && value.length > 0 && value.toLowerCase() !== 'none', + const levels = (option.values as unknown[]).filter( + (value: unknown): value is string => typeof value === 'string' && value.length > 0, ); - if (efforts.length > 0) return efforts; + const off = levels.find((value) => value.toLowerCase() === 'none'); + if (off !== undefined) offEffort = off; + const selectable = levels.filter((value) => value.toLowerCase() !== 'none'); + if (selectable.length > 0) efforts = selectable; } - return undefined; + const alwaysThinking = + efforts !== undefined && offEffort === undefined && !hasToggle ? true : undefined; + return { efforts, offEffort, hasToggle, alwaysThinking }; } function catalogReasoningKey(interleaved: CatalogModelEntry['interleaved']): string | undefined { diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index b219c6bd4f..6006d168da 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -42,7 +42,9 @@ export { catalogBaseUrl, catalogModelToCapability, catalogProviderModels, + catalogProviderNeedsBaseUrl, inferWireType, + isGuessedWireType, } from './catalog'; export type { Catalog, CatalogModel, CatalogModelEntry, CatalogProviderEntry } from './catalog'; diff --git a/packages/kosong/src/providers/anthropic-profile.ts b/packages/kosong/src/providers/anthropic-profile.ts index cb16be6ded..b5d05934ea 100644 --- a/packages/kosong/src/providers/anthropic-profile.ts +++ b/packages/kosong/src/providers/anthropic-profile.ts @@ -155,13 +155,20 @@ export function inferAnthropicModelProfile(model: string): AnthropicModelProfile /** * Fallback profile for Anthropic-compatible endpoints whose model name is - * recognizably a Claude model but encodes no known version (e.g. a proxied - * `claude-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. + * 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 { - return model.toLowerCase().includes('claude') ? LATEST_OPUS_PROFILE : 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 c88e15e6a2..d3b0f0f065 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -4,7 +4,9 @@ import { catalogBaseUrl, catalogModelToCapability, catalogProviderModels, + catalogProviderNeedsBaseUrl, inferWireType, + isGuessedWireType, type CatalogModelEntry, } from '../src/catalog'; @@ -23,9 +25,60 @@ describe('inferWireType', () => { expect(inferWireType({ id: 'google-vertex' })).toBe('vertexai'); }); - it('returns undefined for unknown / invalid wire types', () => { - expect(inferWireType({ id: 'some-proxy' })).toBeUndefined(); + it('refuses an explicit but unrecognized type instead of guessing', () => { expect(inferWireType({ id: 'x', type: 'not-a-wire' })).toBeUndefined(); + expect(isGuessedWireType({ id: 'x', type: 'not-a-wire' })).toBe(false); + }); + + it('falls back to openai for vendor-specific SDKs models.dev does not type', () => { + // xai shape: vendor npm, no explicit type, no api. + expect(inferWireType({ id: 'xai', npm: '@ai-sdk/xai' })).toBe('openai'); + expect(isGuessedWireType({ id: 'xai', npm: '@ai-sdk/xai' })).toBe(true); + // openrouter shape: vendor npm with its own api — still a guess, but a usable one. + expect(inferWireType({ id: 'openrouter', npm: '@openrouter/ai-sdk-provider' })).toBe('openai'); + expect(isGuessedWireType({ id: 'openrouter', npm: '@openrouter/ai-sdk-provider' })).toBe(true); + // Recognized SDKs are never guesses. + expect(isGuessedWireType({ id: 'zenmux', npm: '@ai-sdk/openai-compatible' })).toBe(false); + expect(isGuessedWireType({ id: 'x', type: 'openai_responses' })).toBe(false); + }); + + it('refuses SDKs known to be proprietary instead of guessing', () => { + expect(inferWireType({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' })).toBeUndefined(); + expect(isGuessedWireType({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' })).toBe(false); + }); +}); + +describe('catalogProviderNeedsBaseUrl', () => { + it('requires a URL for guessed vendors without one (xai shape)', () => { + expect( + catalogProviderNeedsBaseUrl({ id: 'xai', npm: '@ai-sdk/xai' }, 'openai'), + ).toBe(true); + }); + + it('does not require a URL for the official OpenAI SDK or a declared api', () => { + expect(catalogProviderNeedsBaseUrl({ id: 'openai', npm: '@ai-sdk/openai' }, 'openai')).toBe( + false, + ); + expect( + catalogProviderNeedsBaseUrl( + { id: 'openrouter', npm: '@openrouter/ai-sdk-provider', api: 'https://openrouter.ai/api/v1' }, + 'openai', + ), + ).toBe(false); + }); + + it('requires a URL when the catalog api is an env placeholder', () => { + expect( + catalogProviderNeedsBaseUrl( + { id: 'neon', npm: '@ai-sdk/openai-compatible', api: '${NEON_BASE_URL}/v1' }, + 'openai', + ), + ).toBe(true); + }); + + it('never requires a URL for non-OpenAI wires', () => { + expect(catalogProviderNeedsBaseUrl({ id: 'anthropic', npm: '@ai-sdk/anthropic' }, 'anthropic')).toBe(false); + expect(catalogProviderNeedsBaseUrl({ id: 'google-vertex', npm: '@ai-sdk/google-vertex' }, 'vertexai')).toBe(false); }); }); @@ -58,6 +111,13 @@ 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(); + }); }); describe('catalogModelToCapability', () => { @@ -154,13 +214,17 @@ describe('catalogModelToCapability', () => { expect(model?.capability.thinking).toBe(true); }); - it("drops the 'none' pseudo-effort (the UI already offers 'off')", () => { + 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', @@ -168,6 +232,33 @@ describe('catalogModelToCapability', () => { limit: { context: 1000 }, }); expect(upper?.supportEfforts).toEqual(['high']); + expect(upper?.offEffort).toBe('None'); + }); + + 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', () => { 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 456aca7b1c..9112a2e8e1 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -2,7 +2,9 @@ import type { KimiConfig, ModelAlias } from '@moonshot-ai/agent-core'; import { catalogBaseUrl, catalogProviderModels, + catalogProviderNeedsBaseUrl, inferWireType, + isGuessedWireType, type Catalog, type CatalogModel, type CatalogProviderEntry, @@ -10,7 +12,7 @@ import { type ProviderType, } from '@moonshot-ai/kosong'; -export { catalogBaseUrl, catalogProviderModels, inferWireType }; +export { catalogBaseUrl, catalogProviderModels, catalogProviderNeedsBaseUrl, inferWireType, isGuessedWireType }; export type { Catalog, CatalogModel, CatalogProviderEntry }; export const DEFAULT_CATALOG_URL = 'https://models.dev/api.json'; @@ -65,15 +67,22 @@ 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, 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..645b371b93 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -21,10 +21,12 @@ export { catalogBaseUrl, catalogModelToAlias, catalogProviderModels, + catalogProviderNeedsBaseUrl, CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, inferWireType, + isGuessedWireType, loadBuiltInCatalog, } from '#/catalog'; export type { diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index cdf3a15d93..c25f677856 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -237,6 +237,52 @@ describe('applyCatalogProvider', () => { 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' } }, From bbf8c2c915ce7cd562ba917b47e0102fd0684fe5 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 22:28:27 +0800 Subject: [PATCH 04/13] fix: align catalog imports with the reference models.dev consumer - A JSON null tier in declared effort values is now read as the 'none' off-encoding (previously such models were wrongly imported as always-thinking with no way to turn reasoning off). - Alpha-status models are filtered out alongside deprecated ones. - Models whose per-model provider override targets a wire that cannot be expressed per-model (e.g. Claude on google-vertex, whose wire here is Gemini-mode Vertex, or gpt entries on an Anthropic provider) are skipped instead of being imported under the silently wrong protocol. - interleaved: true no longer pins reasoning_content: the provider's default three-field scan is wider and the pinned key only narrowed reasoning parsing for gateways answering with another field name. --- .changeset/catalog-interleaved-true-no-pin.md | 6 ++ .../catalog-null-tier-and-override-skips.md | 6 ++ packages/kosong/src/catalog.ts | 63 +++++++++++++------ packages/kosong/test/catalog.test.ts | 56 +++++++++++++---- 4 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 .changeset/catalog-interleaved-true-no-pin.md create mode 100644 .changeset/catalog-null-tier-and-override-skips.md diff --git a/.changeset/catalog-interleaved-true-no-pin.md b/.changeset/catalog-interleaved-true-no-pin.md new file mode 100644 index 0000000000..e8c2e5ff5a --- /dev/null +++ b/.changeset/catalog-interleaved-true-no-pin.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Stop pinning `reasoning_content` for catalog models that declare `interleaved: true`. The provider already scans `reasoning_content` / `reasoning_details` / `reasoning` inbound and writes `reasoning_content` outbound by default, so the pinned key only narrowed reasoning-content parsing for gateways that answer with a different field name (e.g. OpenRouter-style gateways). diff --git a/.changeset/catalog-null-tier-and-override-skips.md b/.changeset/catalog-null-tier-and-override-skips.md new file mode 100644 index 0000000000..5838109bc6 --- /dev/null +++ b/.changeset/catalog-null-tier-and-override-skips.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Align catalog imports with the reference models.dev consumer: a JSON `null` entry in declared effort values is now read as the `none` off-encoding (previously such models were wrongly treated as always-thinking), alpha-status models are filtered out alongside deprecated ones, and models whose per-model provider override targets a protocol that cannot be expressed (e.g. Claude models on google-vertex, gpt models on an Anthropic provider) are skipped instead of being imported under the silently wrong wire. diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index 2589740af0..d5e17bacd9 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -116,10 +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; 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') 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) && @@ -280,11 +281,16 @@ function catalogThinkingOptions(options: CatalogModelEntry['reasoning_options']) 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; } @@ -294,10 +300,12 @@ function catalogThinkingOptions(options: CatalogModelEntry['reasoning_options']) } 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; @@ -314,12 +322,12 @@ export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel /** * Gateway providers (zenmux, opencode, azure, …) may declare a per-model * `provider` override when a model is served over a different protocol than - * the provider default. Only overrides whose `npm` explicitly targets an - * Anthropic SDK are materialized — the model-alias schema cannot express - * other per-model protocols yet, and a partial override is worse than none. - * Overrides without a usable endpoint (env-placeholder URLs like `${VAR}`, - * or no URL on either level) are skipped so provider-level resolution keeps - * applying. + * 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 matching the provider's wire (or that we cannot + * identify) leave the model untouched. */ function applyModelProviderOverride( model: CatalogModel | undefined, @@ -330,10 +338,27 @@ function applyModelProviderOverride( if (model === undefined) return undefined; const override = raw.provider; if (override === undefined || typeof override.npm !== 'string') return model; - if (!override.npm.toLowerCase().includes('anthropic') || providerWire === 'anthropic') { - return model; + const npm = override.npm.toLowerCase(); + const overrideWire = npm.includes('anthropic') + ? 'anthropic' + : npm.includes('openai') + ? 'openai' + : undefined; + // Nothing to express when the override targets the wire the provider + // already resolves to (or one we cannot identify). + if (overrideWire === undefined || overrideWire === providerWire) 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, or Claude models on + // google-vertex (whose wire here is Gemini-mode Vertex, not + // Anthropic-over-Vertex). + if (overrideWire === 'anthropic') { + const api = override.api ?? entry.api; + if (typeof api === 'string' && api.length > 0 && !api.includes('${')) { + return { ...model, protocol: 'anthropic', baseUrl: catalogBaseUrl({ api }, 'anthropic') }; + } } - const api = override.api ?? entry.api; - if (typeof api !== 'string' || api.length === 0 || api.includes('${')) return model; - return { ...model, protocol: 'anthropic', baseUrl: catalogBaseUrl({ api }, 'anthropic') }; + return undefined; } diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index d3b0f0f065..5cc4d9da23 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -187,7 +187,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], @@ -235,6 +237,19 @@ describe('catalogModelToCapability', () => { 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({ @@ -309,16 +324,16 @@ describe('catalogModelToCapability', () => { ).toBe(1000); }); - it('skips deprecated models but keeps beta and alpha ones', () => { + 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'); - expect( - catalogModelToCapability({ id: 'newer', status: 'alpha', limit: { context: 1000 } })?.id, - ).toBe('newer'); }); }); @@ -378,9 +393,9 @@ describe('catalogProviderModels', () => { }); }); - it('skips the override when the provider already speaks Anthropic, the npm is not Anthropic, or the URL is unusable', () => { + it('skips models whose override targets a different wire it cannot express', () => { // freemodel shape: provider is Anthropic, model override targets OpenAI — - // not expressible per-model, left to provider-level resolution. + // importing under the provider wire would be the wrong protocol. const reverse = catalogProviderModels({ id: 'freemodel', npm: '@ai-sdk/anthropic', @@ -393,9 +408,10 @@ describe('catalogProviderModels', () => { }, }, }); - expect(reverse[0]?.protocol).toBeUndefined(); + expect(reverse).toHaveLength(0); - // google-vertex shape: no api anywhere — the vertex wire keeps applying. + // 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', @@ -407,7 +423,7 @@ describe('catalogProviderModels', () => { }, }, }); - expect(noEndpoint[0]?.protocol).toBeUndefined(); + expect(noEndpoint).toHaveLength(0); // Env-placeholder URLs are SDK-side interpolations the config cannot express. const placeholder = catalogProviderModels({ @@ -422,6 +438,24 @@ describe('catalogProviderModels', () => { }, }, }); - expect(placeholder[0]?.protocol).toBeUndefined(); + 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(); }); }); From d83b4e4771b113e417c258484bee02150d193637 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 22:53:40 +0800 Subject: [PATCH 05/13] fix: require endpoints for Anthropic-compatible catalog imports and honor --base-url - catalogProviderNeedsBaseUrl now covers the Anthropic wire: a non-official Anthropic-compatible vendor without a concrete catalog endpoint (e.g. google-vertex-anthropic) must supply --base-url / the TUI prompt instead of silently falling back to the default Anthropic endpoint. - --base-url now takes precedence over the catalog-declared endpoint, and an empty --base-url is rejected instead of persisting a blank endpoint. --- .changeset/catalog-anthropic-base-url.md | 6 +++ apps/kimi-code/src/cli/sub/provider.ts | 10 +++- apps/kimi-code/test/cli/provider.test.ts | 59 ++++++++++++++++++++++++ packages/kosong/src/catalog.ts | 11 +++-- packages/kosong/test/catalog.test.ts | 27 ++++++++++- 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 .changeset/catalog-anthropic-base-url.md diff --git a/.changeset/catalog-anthropic-base-url.md b/.changeset/catalog-anthropic-base-url.md new file mode 100644 index 0000000000..52d2ee9798 --- /dev/null +++ b/.changeset/catalog-anthropic-base-url.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Require an endpoint when importing non-official Anthropic-compatible vendors that lack one: `kimi provider catalog add` (and the TUI import flow) now asks for a base URL instead of silently falling back to the default Anthropic endpoint. `--base-url` now takes precedence over the catalog-declared endpoint, and an empty `--base-url` is rejected instead of persisting a blank endpoint. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index e3c99dd28f..98dd34862e 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -336,7 +336,15 @@ export async function handleCatalogAdd( deps.exit(1); } - let baseUrl = catalogBaseUrl(entry, wire) ?? opts.baseUrl; + const trimmedBaseUrl = opts.baseUrl?.trim(); + if (trimmedBaseUrl !== undefined && trimmedBaseUrl.length === 0) { + deps.stderr.write('--base-url cannot be empty.\n'); + deps.exit(1); + } + let baseUrl = + trimmedBaseUrl !== undefined && trimmedBaseUrl.length > 0 + ? trimmedBaseUrl + : catalogBaseUrl(entry, wire); if (baseUrl === undefined && catalogProviderNeedsBaseUrl(entry, wire)) { 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`, diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index 502dd8059e..f717ecc767 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -888,6 +888,65 @@ 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('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); diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index d5e17bacd9..b782b485fa 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -202,16 +202,19 @@ export function catalogBaseUrl( * catalog supplies none (or only an env placeholder), and the wire's built-in * default endpoint only applies 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). Vertex/google wires resolve their endpoint from - * env coordinates and official SDKs, so they never need the prompt. + * 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. */ export function catalogProviderNeedsBaseUrl( entry: CatalogProviderEntry, wire: ProviderType, ): boolean { - if (wire !== 'openai' && wire !== 'openai_responses') return false; if (catalogBaseUrl(entry, wire) !== undefined) return false; - return (entry.npm ?? '').toLowerCase() !== '@ai-sdk/openai'; + 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. */ diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 5cc4d9da23..43290d7a04 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -76,10 +76,35 @@ describe('catalogProviderNeedsBaseUrl', () => { ).toBe(true); }); - it('never requires a URL for non-OpenAI wires', () => { + it('never requires a URL for non-OpenAI wires with an official SDK or env-resolved endpoint', () => { expect(catalogProviderNeedsBaseUrl({ id: 'anthropic', npm: '@ai-sdk/anthropic' }, 'anthropic')).toBe(false); expect(catalogProviderNeedsBaseUrl({ id: 'google-vertex', npm: '@ai-sdk/google-vertex' }, 'vertexai')).toBe(false); }); + + it('requires a URL for non-official Anthropic-compatible 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( + catalogProviderNeedsBaseUrl( + { id: 'google-vertex-anthropic', npm: '@ai-sdk/google-vertex/anthropic' }, + 'anthropic', + ), + ).toBe(true); + // A declared concrete api still satisfies the requirement. + expect( + catalogProviderNeedsBaseUrl( + { id: 'kimi-for-coding', npm: '@ai-sdk/anthropic', api: 'https://api.kimi.com/coding/v1' }, + 'anthropic', + ), + ).toBe(false); + // An env-placeholder api does not. + expect( + catalogProviderNeedsBaseUrl( + { id: 'azure-claude', npm: '@ai-sdk/azure/anthropic', api: 'https://${AZURE_RESOURCE_NAME}.example.test/anthropic/v1' }, + 'anthropic', + ), + ).toBe(true); + }); }); describe('catalogBaseUrl', () => { From 362688d9c946c479041c10cf11a7df96acf4af39 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 23:10:00 +0800 Subject: [PATCH 06/13] fix: enforce always-on thinking on every wire and refuse Cohere at import A model that declares always_thinking (e.g. a catalog-imported gpt-5) no longer resolves to a dishonest off state via thinking.enabled=false or an SDK/ACP off request: resolution clamps to the model's default effort on every wire instead of letting upstream keep reasoning while the UI reports Off. The Anthropic warn-and-send path for unlisted effort levels is unchanged. Cohere's proprietary SDK joins Amazon Bedrock on the import-refusal list instead of being guessed as OpenAI-compatible. --- .../always-thinking-clamp-and-cohere.md | 8 +++++++ .../src/kosong/model/thinking.ts | 2 +- .../test/agent/profile/config-state.test.ts | 21 +++++++++---------- .../test/agent/profile/thinking.test.ts | 12 +++++++++-- .../agent-core/src/agent/config/thinking.ts | 14 ++++++------- .../test/agent/config/thinking.test.ts | 12 +++++++++-- packages/kosong/src/catalog.ts | 5 ++++- packages/kosong/test/catalog.test.ts | 2 ++ 8 files changed, 51 insertions(+), 25 deletions(-) create mode 100644 .changeset/always-thinking-clamp-and-cohere.md diff --git a/.changeset/always-thinking-clamp-and-cohere.md b/.changeset/always-thinking-clamp-and-cohere.md new file mode 100644 index 0000000000..3ad7c55238 --- /dev/null +++ b/.changeset/always-thinking-clamp-and-cohere.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 +--- + +Enforce always-on thinking on every wire: a model that declares `always_thinking` (e.g. a catalog-imported gpt-5) no longer resolves to a dishonest Off via `thinking.enabled = false` or an SDK/ACP off request — it clamps to the model's default effort instead of letting upstream keep reasoning while the UI reports Off. Cohere's proprietary SDK is now refused at catalog import instead of being guessed as OpenAI-compatible. diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index fb75ee0875..6d8eef46cd 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -268,7 +268,7 @@ export function resolveThinkingEffortForModel( effort = configured ?? defaultThinkingEffortForModel(model); } - if (strictValidation && effort === 'off' && model?.alwaysThinking === true) { + if (effort === 'off' && model?.alwaysThinking === true) { effort = configured ?? defaultThinkingEffortForModel(model); } return normalizeThinkingEffortForModel(effort, model, strictValidation); 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..4b761189e7 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,18 @@ 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('carries custom requested efforts through', () => { diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 727063b6dc..1d14932ff0 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,11 +113,7 @@ 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 diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index aa642d8e4b..0b981c500f 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -128,8 +128,16 @@ 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('does not force on for models that are not always-thinking', () => { diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index b782b485fa..b2103c91b8 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -145,8 +145,11 @@ export function inferWireType(entry: CatalogProviderEntry): ProviderType | undef // does not know — refuse it rather than guessing (a future kokub protocol // must not be silently wired as OpenAI). if (typeof entry.type === 'string' && entry.type.length > 0) return undefined; + // SDKs known to be non-OpenAI proprietary — the fallback below would write + // a config that can never work (Bedrock Converse API, Cohere's native chat + // API), so callers keep refusing those instead. const npm = (entry.npm ?? '').toLowerCase(); - if (npm.includes('amazon-bedrock')) return undefined; + if (npm.includes('amazon-bedrock') || npm.includes('cohere')) return undefined; return 'openai'; } diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 43290d7a04..53cc29b4f3 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -45,6 +45,8 @@ describe('inferWireType', () => { it('refuses SDKs known to be proprietary instead of guessing', () => { expect(inferWireType({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' })).toBeUndefined(); expect(isGuessedWireType({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' })).toBe(false); + expect(inferWireType({ id: 'cohere', npm: '@ai-sdk/cohere' })).toBeUndefined(); + expect(isGuessedWireType({ id: 'cohere', npm: '@ai-sdk/cohere' })).toBe(false); }); }); From 68dcef5c1ef1d66f333ff3f900e880afabea5b44 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 23:14:43 +0800 Subject: [PATCH 07/13] fix: harden catalog import edge cases - An explicit but unrecognized catalog type is now refused before npm/id inference, so a future catalog protocol is never silently miswired through the OpenAI fallback. - User-supplied --base-url values for Anthropic-wire providers get the same trailing-/v1 normalization as catalog endpoints, avoiding /v1/v1/messages requests. - The TUI import prompt rejects env-placeholder base URLs like the CLI does. --- .changeset/catalog-import-hardening.md | 8 ++++++ apps/kimi-code/src/cli/sub/provider.ts | 3 +- apps/kimi-code/src/tui/commands/provider.ts | 12 ++++++-- apps/kimi-code/test/cli/provider.test.ts | 28 +++++++++++++++++++ .../provider/bases/openai/openai-legacy.ts | 4 --- .../provider/bases/openai/openai-responses.ts | 3 -- packages/kosong/src/catalog.ts | 22 +++++++++++---- packages/kosong/src/index.ts | 1 + packages/kosong/test/catalog.test.ts | 17 +++++++++++ packages/node-sdk/src/catalog.ts | 3 +- packages/node-sdk/src/index.ts | 1 + 11 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 .changeset/catalog-import-hardening.md diff --git a/.changeset/catalog-import-hardening.md b/.changeset/catalog-import-hardening.md new file mode 100644 index 0000000000..38b34d7565 --- /dev/null +++ b/.changeset/catalog-import-hardening.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Harden catalog import edge cases: an explicit but unrecognized catalog `type` is now refused before npm/id inference (a future protocol is never silently miswired), user-supplied `--base-url` values for Anthropic-wire providers get the same trailing-`/v1` normalization as catalog endpoints (no more `/v1/v1/messages`), and the TUI import prompt rejects env-placeholder URLs like the CLI does. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 98dd34862e..3b33c9ff1c 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -24,6 +24,7 @@ import { catalogBaseUrl, catalogProviderModels, catalogProviderNeedsBaseUrl, + adaptBaseUrlForWire, CatalogFetchError, createKimiHarness, DEFAULT_CATALOG_URL, @@ -343,7 +344,7 @@ export async function handleCatalogAdd( } let baseUrl = trimmedBaseUrl !== undefined && trimmedBaseUrl.length > 0 - ? trimmedBaseUrl + ? adaptBaseUrlForWire(trimmedBaseUrl, wire) : catalogBaseUrl(entry, wire); if (baseUrl === undefined && catalogProviderNeedsBaseUrl(entry, wire)) { deps.stderr.write( diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 2706af18f0..132e0715e2 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -9,6 +9,7 @@ import { catalogBaseUrl, catalogProviderModels, catalogProviderNeedsBaseUrl, + adaptBaseUrlForWire, CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, @@ -205,8 +206,15 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { let baseUrl = catalogBaseUrl(entry, wire); if (baseUrl === undefined && catalogProviderNeedsBaseUrl(entry, wire)) { - baseUrl = await promptBaseUrl(host, entry.name ?? providerId); - if (baseUrl === undefined) return; + const entered = await promptBaseUrl(host, entry.name ?? providerId); + if (entered === undefined) return; + if (entered.includes('${')) { + host.showError( + `Base URL "${entered}" contains an env placeholder. Enter the resolved URL instead.`, + ); + return; + } + baseUrl = adaptBaseUrlForWire(entered, wire); } const apiKey = await promptApiKey(host, entry.name ?? providerId); diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index f717ecc767..444f773627 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -907,6 +907,34 @@ describe('kimi provider catalog add', () => { }); }); + 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); 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 91825e57d5..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 @@ -725,10 +725,6 @@ export class OpenAILegacyChatProvider implements ChatProvider { } } - // 'on' has no wire encoding; only a concrete effort is passed through. - // 'off' sends the model's declared off value (e.g. 'none') when one is - // configured and must also suppress the history-based auto-enable below - // (issue #1616), so it stays distinguishable from "never configured". let reasoningEffort: string | undefined = explicitThinkingEffort === 'off' ? this._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 463f252d26..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 @@ -1074,9 +1074,6 @@ export class OpenAIResponsesChatProvider implements ChatProvider { options?.thinking ?? (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); if (thinking !== undefined) { - // '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. Both suppress any seeded effort. const effort = thinking.effort === 'off' ? this._offEffort diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index b2103c91b8..1fa47e6971 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -139,12 +139,13 @@ function isUsableChatModel(model: CatalogModelEntry): boolean { * work — callers keep refusing those instead. */ export function inferWireType(entry: CatalogProviderEntry): ProviderType | undefined { + // An explicit `type` is authoritative: honored when known, refused when + // not — a future catalog protocol must never be silently rewired through + // npm/id inference or the fallback below. + 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; - // An explicit but unrecognized `type` is a protocol this client version - // does not know — refuse it rather than guessing (a future kokub protocol - // must not be silently wired as OpenAI). - if (typeof entry.type === 'string' && entry.type.length > 0) return undefined; // SDKs known to be non-OpenAI proprietary — the fallback below would write // a config that can never work (Bedrock Converse API, Cohere's native chat // API), so callers keep refusing those instead. @@ -196,8 +197,17 @@ export function catalogBaseUrl( ): string | undefined { const api = entry.api; if (typeof api !== 'string' || api.length === 0 || api.includes('${')) return undefined; - if (wire === 'anthropic') return api.replace(/\/v1\/?$/, ''); - return api; + 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; } /** diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 6006d168da..1b0ba59399 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -39,6 +39,7 @@ export type { ModelCapability } from './capability'; // Model catalog (models.dev-style) metadata export { + adaptBaseUrlForWire, catalogBaseUrl, catalogModelToCapability, catalogProviderModels, diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 53cc29b4f3..7e28cfb089 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + adaptBaseUrlForWire, catalogBaseUrl, catalogModelToCapability, catalogProviderModels, @@ -28,6 +29,9 @@ describe('inferWireType', () => { it('refuses an explicit but unrecognized type instead of guessing', () => { expect(inferWireType({ id: 'x', type: 'not-a-wire' })).toBeUndefined(); expect(isGuessedWireType({ id: 'x', type: 'not-a-wire' })).toBe(false); + // … even when npm/id would have been inferable: the explicit declaration + // is authoritative, so a future catalog protocol is never miswired. + expect(inferWireType({ id: 'x', type: 'kokub', npm: '@ai-sdk/openai-compatible' })).toBeUndefined(); }); it('falls back to openai for vendor-specific SDKs models.dev does not type', () => { @@ -145,6 +149,19 @@ describe('catalogBaseUrl', () => { 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', () => { diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 9112a2e8e1..4c40d7b3cd 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -3,6 +3,7 @@ import { catalogBaseUrl, catalogProviderModels, catalogProviderNeedsBaseUrl, + adaptBaseUrlForWire, inferWireType, isGuessedWireType, type Catalog, @@ -12,7 +13,7 @@ import { type ProviderType, } from '@moonshot-ai/kosong'; -export { catalogBaseUrl, catalogProviderModels, catalogProviderNeedsBaseUrl, inferWireType, isGuessedWireType }; +export { catalogBaseUrl, catalogProviderModels, catalogProviderNeedsBaseUrl, adaptBaseUrlForWire, inferWireType, isGuessedWireType }; export type { Catalog, CatalogModel, CatalogProviderEntry }; export const DEFAULT_CATALOG_URL = 'https://models.dev/api.json'; diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 645b371b93..98133799e0 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -22,6 +22,7 @@ export { catalogModelToAlias, catalogProviderModels, catalogProviderNeedsBaseUrl, + adaptBaseUrlForWire, CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, From 2f9ac6c62b1f371eb2796663494c3f61b9f59e42 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 23:20:39 +0800 Subject: [PATCH 08/13] fix: await the floating assertion promise in the catalog add CLI test --- apps/kimi-code/test/cli/provider.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index 444f773627..ce2951e640 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -1039,7 +1039,7 @@ describe('kimi provider catalog add', () => { expect(exitCodes).toEqual([1]); expect(stderr.join('')).toContain('--base-url'); - expect(harness.getConfig().then((c) => c.providers['xai'])).resolves.toBeUndefined(); + 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 () => { From ed0569a146306d1833a3245878dfb6d40a776f6b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 23:57:06 +0800 Subject: [PATCH 09/13] refactor: unify catalog import resolution into a single decision function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire-type inference, the OpenAI-compatible fallback, proprietary-SDK refusal, endpoint adaptation, and the base-URL requirement are now produced together by resolveCatalogImport, one pure resolver consumed by both the CLI and the TUI — replacing the cooperating predicates (inferWireType, isGuessedWireType, catalogProviderNeedsBaseUrl) whose permutations kept producing edge cases. No behavior change. --- .changeset/catalog-import-resolver.md | 7 + apps/kimi-code/src/cli/sub/provider.ts | 68 ++++--- apps/kimi-code/src/tui/commands/prompts.ts | 4 +- apps/kimi-code/src/tui/commands/provider.ts | 45 +++-- packages/kosong/src/catalog.ts | 137 +++++++++---- packages/kosong/src/index.ts | 14 +- packages/kosong/test/catalog.test.ts | 213 ++++++++++++++------ packages/node-sdk/src/catalog.ts | 10 +- packages/node-sdk/src/index.ts | 7 +- 9 files changed, 331 insertions(+), 174 deletions(-) create mode 100644 .changeset/catalog-import-resolver.md diff --git a/.changeset/catalog-import-resolver.md b/.changeset/catalog-import-resolver.md new file mode 100644 index 0000000000..47adc52537 --- /dev/null +++ b/.changeset/catalog-import-resolver.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/kimi-code": patch +--- + +Unify catalog import resolution into a single decision function: wire-type inference, the OpenAI-compatible fallback, proprietary-SDK refusal, endpoint adaptation, and the base-URL requirement are now produced together by one pure resolver consumed by both the CLI and the TUI, replacing a set of cooperating predicates. No behavior change. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 3b33c9ff1c..3ade36aa42 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -21,16 +21,12 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, - catalogBaseUrl, catalogProviderModels, - catalogProviderNeedsBaseUrl, - adaptBaseUrlForWire, CatalogFetchError, createKimiHarness, DEFAULT_CATALOG_URL, fetchCatalog, - inferWireType, - isGuessedWireType, + resolveCatalogImport, type Catalog, type CatalogProviderEntry, type KimiConfig, @@ -280,9 +276,13 @@ 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 = - wire === undefined ? '?' : isGuessedWireType(entry) ? `${wire} (guessed)` : wire; + resolution.kind === 'invalid' + ? '?' + : resolution.guessed + ? `${resolution.wire} (guessed)` + : resolution.wire; deps.stdout.write( `${id} wire=${wireLabel} models=${String(modelCount)} ${entry.name ?? ''}\n`, ); @@ -316,13 +316,37 @@ export async function handleCatalogAdd( deps.exit(1); } - const wire = inferWireType(entry); - if (wire === undefined) { + 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( - `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock); it cannot be imported from the catalog.\n`, + `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) { @@ -337,28 +361,6 @@ export async function handleCatalogAdd( deps.exit(1); } - const trimmedBaseUrl = opts.baseUrl?.trim(); - if (trimmedBaseUrl !== undefined && trimmedBaseUrl.length === 0) { - deps.stderr.write('--base-url cannot be empty.\n'); - deps.exit(1); - } - let baseUrl = - trimmedBaseUrl !== undefined && trimmedBaseUrl.length > 0 - ? adaptBaseUrlForWire(trimmedBaseUrl, wire) - : catalogBaseUrl(entry, wire); - if (baseUrl === undefined && catalogProviderNeedsBaseUrl(entry, wire)) { - 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); - } - if (baseUrl !== undefined && baseUrl.includes('${')) { - deps.stderr.write( - `Base URL "${baseUrl}" contains an env placeholder. Pass --base-url with the resolved value.\n`, - ); - deps.exit(1); - } - const harness = deps.getHarness(); await harness.ensureConfigFile(); @@ -420,7 +422,7 @@ export async function handleCatalogAdd( deps.stdout.write( `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, ); - if (isGuessedWireType(entry)) { + 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`, ); diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 61583b9500..cbfc33072f 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -1,6 +1,6 @@ import { catalogModelToAlias, - inferWireType, + resolveCatalogImport, type Catalog, type CatalogModel, type ModelAlias, @@ -156,7 +156,7 @@ export function promptBaseUrl(host: SlashCommandHost, platformName: string): Pro 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 132e0715e2..23bef02ba1 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -6,15 +6,11 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, - catalogBaseUrl, catalogProviderModels, - catalogProviderNeedsBaseUrl, - adaptBaseUrlForWire, CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, - inferWireType, - isGuessedWireType, + resolveCatalogImport, type Catalog, type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; @@ -196,26 +192,31 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { return; } - const wire = inferWireType(entry); - if (wire === undefined) { - host.showError( - `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock); it cannot be imported from the catalog.`, - ); - return; - } - - let baseUrl = catalogBaseUrl(entry, wire); - if (baseUrl === undefined && catalogProviderNeedsBaseUrl(entry, wire)) { + let resolution = resolveCatalogImport(entry); + if (resolution.kind === 'needs-base-url') { const entered = await promptBaseUrl(host, entry.name ?? providerId); if (entered === undefined) return; - if (entered.includes('${')) { - host.showError( - `Base URL "${entered}" contains an env placeholder. Enter the resolved URL instead.`, - ); - 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.`, + ); + } } - baseUrl = adaptBaseUrlForWire(entered, wire); + return; } + const { wire, baseUrl } = resolution; const apiKey = await promptApiKey(host, entry.name ?? providerId); if (apiKey === undefined) return; @@ -247,7 +248,7 @@ 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 (isGuessedWireType(entry)) { + if (resolution.guessed) { host.showStatus( `Protocol guessed as "openai" for ${providerId} — edit "type" in config.toml if requests fail.`, ); diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index 1fa47e6971..e079fa3d81 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -128,41 +128,108 @@ 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'; + +/** + * 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 to a supported wire type. Honors an - * explicit `type`, otherwise infers from `npm`/`id`. models.dev omits `type` - * for vendor-specific SDKs; most of those speak OpenAI-compatible chat - * completions, so unknown providers fall back to `'openai'` (reported via - * {@link isGuessedWireType} so callers can surface the guess). The only - * `undefined` results are SDKs known to be non-OpenAI proprietary (e.g. - * Amazon Bedrock), where the fallback would write a config that can never - * work — callers keep refusing those instead. + * 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 inferWireType(entry: CatalogProviderEntry): ProviderType | undefined { - // An explicit `type` is authoritative: honored when known, refused when - // not — a future catalog protocol must never be silently rewired through - // npm/id inference or the fallback below. +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; - // SDKs known to be non-OpenAI proprietary — the fallback below would write - // a config that can never work (Bedrock Converse API, Cohere's native chat - // API), so callers keep refusing those instead. const npm = (entry.npm ?? '').toLowerCase(); if (npm.includes('amazon-bedrock') || npm.includes('cohere')) return undefined; return 'openai'; } -/** - * True when the wire comes from the blanket `'openai'` fallback rather than - * an explicit `type` or a recognized `npm`/`id` — callers should tell the - * user the protocol was guessed (and may need a manual `base_url`). - */ -export function isGuessedWireType(entry: CatalogProviderEntry): boolean { - return inferDeclaredWireType(entry) === undefined && inferWireType(entry) !== undefined; -} - function inferDeclaredWireType(entry: CatalogProviderEntry): ProviderType | undefined { if (isWireType(entry.type)) return entry.type; const npm = (entry.npm ?? '').toLowerCase(); @@ -211,19 +278,15 @@ export function adaptBaseUrlForWire(baseUrl: string, wire: ProviderType): string } /** - * True when importing this provider needs a base URL from the user: the - * catalog supplies none (or only an env placeholder), and the wire's built-in - * default endpoint only applies 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. + * True when a missing catalog endpoint cannot fall back to a built-in + * default: 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. */ -export function catalogProviderNeedsBaseUrl( - entry: CatalogProviderEntry, - wire: ProviderType, -): boolean { - if (catalogBaseUrl(entry, wire) !== undefined) return false; +function catalogEndpointRequired(entry: CatalogProviderEntry, wire: ProviderType): boolean { const npm = (entry.npm ?? '').toLowerCase(); if (wire === 'openai' || wire === 'openai_responses') return npm !== '@ai-sdk/openai'; if (wire === 'anthropic') return npm !== '@ai-sdk/anthropic'; @@ -329,7 +392,7 @@ function catalogReasoningKey(interleaved: CatalogModelEntry['interleaved']): str /** Extracts the valid, normalized models from a catalog provider entry. */ export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel[] { - const providerWire = inferWireType(entry); + const providerWire = resolveCatalogWire(entry); return Object.values(entry.models ?? {}) .map((raw) => applyModelProviderOverride(catalogModelToCapability(raw), raw, entry, providerWire)) .filter((model): model is CatalogModel => model !== undefined); diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 1b0ba59399..d652facc8e 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -39,15 +39,19 @@ export type { ModelCapability } from './capability'; // Model catalog (models.dev-style) metadata export { - adaptBaseUrlForWire, catalogBaseUrl, catalogModelToCapability, catalogProviderModels, - catalogProviderNeedsBaseUrl, - inferWireType, - isGuessedWireType, + 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/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 7e28cfb089..94b57782f6 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -5,111 +5,192 @@ import { catalogBaseUrl, catalogModelToCapability, catalogProviderModels, - catalogProviderNeedsBaseUrl, - inferWireType, - isGuessedWireType, + resolveCatalogImport, type CatalogModelEntry, } from '../src/catalog'; -describe('inferWireType', () => { +describe('resolveCatalogImport — wire resolution', () => { it('honors an explicit valid type', () => { - expect(inferWireType({ id: 'x', type: 'openai_responses' })).toBe('openai_responses'); + // 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(inferWireType({ id: 'x', type: 'not-a-wire' })).toBeUndefined(); - expect(isGuessedWireType({ id: 'x', type: 'not-a-wire' })).toBe(false); + 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(inferWireType({ id: 'x', type: 'kokub', npm: '@ai-sdk/openai-compatible' })).toBeUndefined(); + expect( + resolveCatalogImport({ id: 'x', type: 'kokub', npm: '@ai-sdk/openai-compatible' }), + ).toEqual({ kind: 'invalid', reason: 'unknown-explicit-type' }); }); it('falls back to openai for vendor-specific SDKs models.dev does not type', () => { - // xai shape: vendor npm, no explicit type, no api. - expect(inferWireType({ id: 'xai', npm: '@ai-sdk/xai' })).toBe('openai'); - expect(isGuessedWireType({ id: 'xai', npm: '@ai-sdk/xai' })).toBe(true); - // openrouter shape: vendor npm with its own api — still a guess, but a usable one. - expect(inferWireType({ id: 'openrouter', npm: '@openrouter/ai-sdk-provider' })).toBe('openai'); - expect(isGuessedWireType({ id: 'openrouter', npm: '@openrouter/ai-sdk-provider' })).toBe(true); + // 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(isGuessedWireType({ id: 'zenmux', npm: '@ai-sdk/openai-compatible' })).toBe(false); - expect(isGuessedWireType({ id: 'x', type: 'openai_responses' })).toBe(false); + 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(inferWireType({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' })).toBeUndefined(); - expect(isGuessedWireType({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' })).toBe(false); - expect(inferWireType({ id: 'cohere', npm: '@ai-sdk/cohere' })).toBeUndefined(); - expect(isGuessedWireType({ id: 'cohere', npm: '@ai-sdk/cohere' })).toBe(false); + 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('catalogProviderNeedsBaseUrl', () => { - it('requires a URL for guessed vendors without one (xai shape)', () => { +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( - catalogProviderNeedsBaseUrl({ id: 'xai', npm: '@ai-sdk/xai' }, 'openai'), - ).toBe(true); + resolveCatalogImport({ id: 'google-vertex', npm: '@ai-sdk/google-vertex' }), + ).toMatchObject({ kind: 'ok', wire: 'vertexai' }); }); - it('does not require a URL for the official OpenAI SDK or a declared api', () => { - expect(catalogProviderNeedsBaseUrl({ id: 'openai', npm: '@ai-sdk/openai' }, 'openai')).toBe( - false, - ); + 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( - catalogProviderNeedsBaseUrl( - { id: 'openrouter', npm: '@openrouter/ai-sdk-provider', api: 'https://openrouter.ai/api/v1' }, - 'openai', - ), - ).toBe(false); - }); - - it('requires a URL when the catalog api is an env placeholder', () => { + 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( - catalogProviderNeedsBaseUrl( - { id: 'neon', npm: '@ai-sdk/openai-compatible', api: '${NEON_BASE_URL}/v1' }, - 'openai', - ), - ).toBe(true); + 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('never requires a URL for non-OpenAI wires with an official SDK or env-resolved endpoint', () => { - expect(catalogProviderNeedsBaseUrl({ id: 'anthropic', npm: '@ai-sdk/anthropic' }, 'anthropic')).toBe(false); - expect(catalogProviderNeedsBaseUrl({ id: 'google-vertex', npm: '@ai-sdk/google-vertex' }, 'vertexai')).toBe(false); + 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('requires a URL for non-official Anthropic-compatible 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. + it('adapts a user-supplied URL to the wire (Anthropic strips a trailing /v1)', () => { expect( - catalogProviderNeedsBaseUrl( - { id: 'google-vertex-anthropic', npm: '@ai-sdk/google-vertex/anthropic' }, - 'anthropic', - ), - ).toBe(true); - // A declared concrete api still satisfies the requirement. + 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( - catalogProviderNeedsBaseUrl( - { id: 'kimi-for-coding', npm: '@ai-sdk/anthropic', api: 'https://api.kimi.com/coding/v1' }, - 'anthropic', + resolveCatalogImport( + { id: 'google-vertex-anthropic', npm: '@ai-sdk/google-vertex/anthropic' }, + 'https://gateway.example.test/v1', ), - ).toBe(false); - // An env-placeholder api does not. + ).toEqual({ + kind: 'ok', + wire: 'anthropic', + guessed: false, + baseUrl: 'https://gateway.example.test', + }); + }); + + it('lets a user-supplied URL override the catalog endpoint', () => { expect( - catalogProviderNeedsBaseUrl( - { id: 'azure-claude', npm: '@ai-sdk/azure/anthropic', api: 'https://${AZURE_RESOURCE_NAME}.example.test/anthropic/v1' }, - 'anthropic', + resolveCatalogImport( + { id: 'openai', npm: '@ai-sdk/openai', api: 'https://api.openai.com/v1' }, + 'https://proxy.example.test/v1', ), - ).toBe(true); + ).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', + }); }); }); diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 4c40d7b3cd..c2ee1b1f4a 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -2,18 +2,18 @@ import type { KimiConfig, ModelAlias } from '@moonshot-ai/agent-core'; import { catalogBaseUrl, catalogProviderModels, - catalogProviderNeedsBaseUrl, - adaptBaseUrlForWire, - inferWireType, - isGuessedWireType, + resolveCatalogImport, type Catalog, + type CatalogImportInvalidReason, + type CatalogImportResolution, type CatalogModel, type CatalogProviderEntry, type ModelCapability, type ProviderType, } from '@moonshot-ai/kosong'; -export { catalogBaseUrl, catalogProviderModels, catalogProviderNeedsBaseUrl, adaptBaseUrlForWire, inferWireType, isGuessedWireType }; +export { catalogBaseUrl, catalogProviderModels, resolveCatalogImport }; +export type { CatalogImportInvalidReason, CatalogImportResolution }; export type { Catalog, CatalogModel, CatalogProviderEntry }; export const DEFAULT_CATALOG_URL = 'https://models.dev/api.json'; diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 98133799e0..144fb305ad 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -21,18 +21,17 @@ export { catalogBaseUrl, catalogModelToAlias, catalogProviderModels, - catalogProviderNeedsBaseUrl, - adaptBaseUrlForWire, CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, - inferWireType, - isGuessedWireType, loadBuiltInCatalog, + resolveCatalogImport, } from '#/catalog'; export type { ApplyCatalogProviderOptions, Catalog, + CatalogImportInvalidReason, + CatalogImportResolution, CatalogModel, CatalogProviderEntry, FetchCatalogOptions, From 6da7351fbdbbaef67d8ce6bec389311eedbab597 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 22 Jul 2026 00:35:58 +0800 Subject: [PATCH 10/13] fix: close configured-off clamp hole, keep inferWireType compat, carry same-wire override endpoints - A configured thinking.effort = "off" no longer bypasses the always-on clamp: it is treated as absent and the model default applies, mirrored on both engines. - The previously public inferWireType stays as a deprecated compatibility wrapper over resolveCatalogImport so existing SDK consumers do not break on a patch release. - Catalog model overrides that stay on the provider's wire but declare their own endpoint now persist it on the alias (and the v1 OpenAI wire branches honor alias-level base URLs like the Anthropic branch). --- ...amp-off-config-compat-override-endpoint.md | 9 ++++ .../src/kosong/model/thinking.ts | 6 ++- .../test/agent/profile/thinking.test.ts | 12 +++++ .../agent-core/src/agent/config/thinking.ts | 9 ++-- .../src/session/provider-manager.ts | 8 ++- .../test/agent/config/thinking.test.ts | 16 ++++++ .../test/harness/runtime-provider.test.ts | 37 +++++++++++++ packages/kosong/src/catalog.ts | 47 +++++++++++------ packages/kosong/src/index.ts | 1 + packages/kosong/test/catalog.test.ts | 52 +++++++++++++++++++ packages/node-sdk/src/catalog.ts | 3 +- packages/node-sdk/src/index.ts | 1 + 12 files changed, 179 insertions(+), 22 deletions(-) create mode 100644 .changeset/clamp-off-config-compat-override-endpoint.md diff --git a/.changeset/clamp-off-config-compat-override-endpoint.md b/.changeset/clamp-off-config-compat-override-endpoint.md new file mode 100644 index 0000000000..19cd11c82b --- /dev/null +++ b/.changeset/clamp-off-config-compat-override-endpoint.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Close three follow-up gaps: a configured `thinking.effort = "off"` no longer bypasses the always-on clamp (it is treated as absent and the model default applies, on both engines); the previously public `inferWireType` is kept as a deprecated compatibility wrapper over the new catalog import resolver; and catalog model overrides that stay on the provider's wire but declare their own endpoint now persist that endpoint on the alias instead of being silently routed to the provider's base URL. diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 6d8eef46cd..1ee7a8f3b6 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -269,7 +269,11 @@ export function resolveThinkingEffortForModel( } if (effort === 'off' && model?.alwaysThinking === true) { - effort = configured ?? defaultThinkingEffortForModel(model); + // A configured 'off' is treated as absent: the model default applies. + effort = + configured !== undefined && configured !== 'off' + ? configured + : defaultThinkingEffortForModel(model); } return normalizeThinkingEffortForModel(effort, model, strictValidation); } 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 4b761189e7..0728dbe84d 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -158,6 +158,18 @@ describe('resolveThinkingEffortForModel', () => { 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', () => { expect(resolveThinkingEffortForModel('xhigh', undefined, undefined)).toBe('xhigh'); expect(resolveThinkingEffortForModel('bogus', { effort: 'low' }, undefined)).toBe('bogus'); diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 1d14932ff0..c54e6b515a 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -116,9 +116,12 @@ export function resolveThinkingEffort( 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/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 1164ead199..fb9272a72e 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -306,7 +306,10 @@ 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, @@ -350,7 +353,8 @@ 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` diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index 0b981c500f..154088a7b0 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -140,6 +140,22 @@ describe('resolveThinkingEffort', () => { 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', () => { expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off'); expect(resolveThinkingEffort(undefined, { enabled: false }, booleanModel)).toBe('off'); diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 274de2a0a8..3f21e4b46f 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -319,6 +319,43 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { }); }); + 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. diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index e079fa3d81..3b75bf6bc2 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -230,6 +230,16 @@ function resolveCatalogWire(entry: CatalogProviderEntry): ProviderType | undefin 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(); @@ -400,13 +410,14 @@ export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel /** * Gateway providers (zenmux, opencode, azure, …) may declare a per-model - * `provider` override when a model is served over a different protocol 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 matching the provider's wire (or that we cannot - * identify) leave the model untouched. + * `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, @@ -423,9 +434,18 @@ function applyModelProviderOverride( : npm.includes('openai') ? 'openai' : undefined; - // Nothing to express when the override targets the wire the provider - // already resolves to (or one we cannot identify). - if (overrideWire === undefined || overrideWire === providerWire) return model; + if (overrideWire === undefined) return model; + const api = override.api ?? entry.api; + const usableApi = + typeof api === 'string' && api.length > 0 && !api.includes('${') ? api : undefined; + // Same wire as the provider: no protocol to re-route, but a distinct usable + // endpoint still applies to this model specifically. + if (overrideWire === providerWire) { + 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 — @@ -433,11 +453,8 @@ function applyModelProviderOverride( // freemodel's gpt entries on an Anthropic provider, or Claude models on // google-vertex (whose wire here is Gemini-mode Vertex, not // Anthropic-over-Vertex). - if (overrideWire === 'anthropic') { - const api = override.api ?? entry.api; - if (typeof api === 'string' && api.length > 0 && !api.includes('${')) { - return { ...model, protocol: 'anthropic', baseUrl: catalogBaseUrl({ api }, 'anthropic') }; - } + if (overrideWire === 'anthropic' && usableApi !== undefined) { + return { ...model, protocol: 'anthropic', baseUrl: adaptBaseUrlForWire(usableApi, 'anthropic') }; } return undefined; } diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index d652facc8e..d999a02bc6 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -42,6 +42,7 @@ export { catalogBaseUrl, catalogModelToCapability, catalogProviderModels, + inferWireType, resolveCatalogImport, } from './catalog'; export type { diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 94b57782f6..34bf70b5c5 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -5,10 +5,21 @@ import { catalogBaseUrl, catalogModelToCapability, catalogProviderModels, + inferWireType, resolveCatalogImport, type CatalogModelEntry, } from '../src/catalog'; +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 @@ -583,4 +594,45 @@ describe('catalogProviderModels', () => { 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(); + }); }); diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index c2ee1b1f4a..46b645c590 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -2,6 +2,7 @@ import type { KimiConfig, ModelAlias } from '@moonshot-ai/agent-core'; import { catalogBaseUrl, catalogProviderModels, + inferWireType, resolveCatalogImport, type Catalog, type CatalogImportInvalidReason, @@ -12,7 +13,7 @@ import { type ProviderType, } from '@moonshot-ai/kosong'; -export { catalogBaseUrl, catalogProviderModels, resolveCatalogImport }; +export { catalogBaseUrl, catalogProviderModels, inferWireType, resolveCatalogImport }; export type { CatalogImportInvalidReason, CatalogImportResolution }; export type { Catalog, CatalogModel, CatalogProviderEntry }; diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 144fb305ad..cab37997d5 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -24,6 +24,7 @@ export { CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, + inferWireType, loadBuiltInCatalog, resolveCatalogImport, } from '#/catalog'; From b660fc16d5dfd9bc7ec850400b9e3f670de11134 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 22 Jul 2026 01:02:55 +0800 Subject: [PATCH 11/13] fix: split total window from input cap and close override/endpoint gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - max_context_tokens once again means the total context window (used by completion budgeting); a model's declared input limit is tracked as max_input_tokens, which compaction, context-splice and usage-ratio checks prefer — fixing the over-clamping introduced when the input cap was stored as the context budget. - A catalog endpoint declared only as an env placeholder now always produces needs-base-url (official SDK included), so credentials are never sent to the public vendor host by default. - api-only per-model overrides are honored as same-wire endpoint changes; overrides targeting another known but inexpressible wire (e.g. google-genai on an OpenAI gateway) are skipped; same-wire models whose declared endpoint is an unusable placeholder are skipped instead of silently rerouted. --- .../context-input-cap-and-override-rules.md | 9 ++ .../fullCompaction/fullCompactionService.ts | 3 +- .../src/agent/fullCompaction/strategy.ts | 4 +- .../src/kosong/contract/capability.ts | 1 + .../agent-core-v2/src/kosong/model/catalog.ts | 1 + .../src/kosong/model/catalogService.ts | 4 + .../src/kosong/model/inspection.ts | 3 + .../agent-core-v2/src/kosong/model/model.ts | 1 + .../agent-core/src/agent/compaction/full.ts | 3 +- .../agent-core/src/agent/context/index.ts | 3 +- packages/agent-core/src/agent/index.ts | 3 +- packages/agent-core/src/config/schema.ts | 4 + .../src/services/session/sessionService.ts | 3 +- .../src/session/provider-manager.ts | 1 + .../test/harness/runtime-provider.test.ts | 27 +++++ packages/kosong/src/capability.ts | 8 ++ packages/kosong/src/catalog.ts | 67 +++++++----- packages/kosong/test/catalog.test.ts | 100 ++++++++++++++---- packages/node-sdk/src/catalog.ts | 1 + packages/node-sdk/test/catalog.test.ts | 2 +- 20 files changed, 198 insertions(+), 50 deletions(-) create mode 100644 .changeset/context-input-cap-and-override-rules.md diff --git a/.changeset/context-input-cap-and-override-rules.md b/.changeset/context-input-cap-and-override-rules.md new file mode 100644 index 0000000000..7b1a4e03d1 --- /dev/null +++ b/.changeset/context-input-cap-and-override-rules.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Close five catalog-handling gaps: the total context window is once again used for completion budgeting while a model's declared input limit (e.g. gpt-5's 272k) is tracked separately for compaction and other prompt-budget checks; a catalog endpoint that is declared only as an env placeholder now always requires a user-supplied base URL (official SDK or not); api-only per-model overrides are honored as same-wire endpoint changes; overrides targeting another known but inexpressible protocol (e.g. google-genai on an OpenAI gateway) are skipped; and same-wire models whose declared endpoint is an unusable placeholder are skipped instead of silently rerouted to the provider endpoint. 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 b25cd6fe75..7921d00dfb 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -398,6 +398,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { model.capabilities, explainedCapability.capability, model.maxContextSize, + model.maxInputSize, ); const providerOptions = buildProtocolProviderOptions( model, @@ -424,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, @@ -617,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 { @@ -626,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, 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 5fc320e6f8..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(), 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/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/schema.ts b/packages/agent-core/src/config/schema.ts index fc2f1f77e3..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(), 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 fb9272a72e..4bc63dccdc 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -241,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. diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 3f21e4b46f..32848e9a3b 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -319,6 +319,33 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { }); }); + 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. 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 3b75bf6bc2..2e3467f97a 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -289,14 +289,18 @@ export function adaptBaseUrlForWire(baseUrl: string, wire: ProviderType): string /** * True when a missing catalog endpoint cannot fall back to a built-in - * default: 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. + * 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'; @@ -313,13 +317,14 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel 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); sizing the context budget to the - // total window would let the prompt overflow before compaction kicks in. + // 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 maxContextTokens = + const maxInputTokens = typeof input === 'number' && Number.isInteger(input) && input > 0 ? Math.min(input, context) - : context; + : undefined; return { id: model.id, name: typeof model.name === 'string' && model.name.length > 0 ? model.name : undefined, @@ -338,7 +343,8 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel thinking: Boolean(model.reasoning) || thinking.efforts !== undefined || thinking.hasToggle, tool_use: model.tool_call ?? true, - max_context_tokens: maxContextTokens, + max_context_tokens: context, + max_input_tokens: maxInputTokens, dynamically_loaded_tools: model.dynamically_loaded_tools === true, }, }; @@ -427,34 +433,47 @@ function applyModelProviderOverride( ): CatalogModel | undefined { if (model === undefined) return undefined; const override = raw.provider; - if (override === undefined || typeof override.npm !== 'string') return model; - const npm = override.npm.toLowerCase(); - const overrideWire = npm.includes('anthropic') - ? 'anthropic' - : npm.includes('openai') - ? 'openai' - : undefined; + 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 api = override.api ?? entry.api; + const rawApi = override.api; + const api = rawApi ?? entry.api; const usableApi = typeof api === 'string' && api.length > 0 && !api.includes('${') ? api : undefined; - // Same wire as the provider: no protocol to re-route, but a distinct usable - // endpoint still applies to this model specifically. + 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, or Claude models on - // google-vertex (whose wire here is Gemini-mode Vertex, not - // Anthropic-over-Vertex). + // 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/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 34bf70b5c5..0cfb8cf2de 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -162,6 +162,19 @@ describe('resolveCatalogImport — endpoint resolution', () => { ).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'), @@ -439,25 +452,23 @@ describe('catalogModelToCapability', () => { expect(model?.capability.thinking).toBe(true); }); - it('prefers limit.input over limit.context for the context budget', () => { - // The gpt-5 shape on models.dev: 400k window but a 272k input cap. - expect( - catalogModelToCapability({ id: 'm', limit: { context: 400000, input: 272000 } })?.capability - .max_context_tokens, - ).toBe(272000); + 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. - expect( - catalogModelToCapability({ id: 'm', limit: { context: 1000, input: 5000 } })?.capability - .max_context_tokens, - ).toBe(1000); - expect( - catalogModelToCapability({ id: 'm', limit: { context: 1000, input: 0 } })?.capability - .max_context_tokens, - ).toBe(1000); - expect( - catalogModelToCapability({ id: 'm', limit: { context: 1000 } })?.capability - .max_context_tokens, - ).toBe(1000); + 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', () => { @@ -635,4 +646,57 @@ describe('catalogProviderModels', () => { 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/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 46b645c590..78cb2afd31 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -74,6 +74,7 @@ export function catalogModelToAlias(providerId: string, model: CatalogModel): Mo provider: providerId, model: model.id, maxContextSize: model.capability.max_context_tokens, + maxInputSize: model.capability.max_input_tokens, maxOutputSize: model.maxOutputSize, // A model that always reasons advertises `always_thinking` instead of // `thinking`, so the UI locks thinking on and offers no off option. diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index c25f677856..d1bc5a6ea9 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -232,7 +232,7 @@ describe('applyCatalogProvider', () => { baseUrl: 'https://gateway.example.test/api/anthropic', }); const plain = config.models?.['gateway/vendor/gpt-model']; - expect(plain).toMatchObject({ maxContextSize: 272000 }); + expect(plain).toMatchObject({ maxContextSize: 400000, maxInputSize: 272000 }); expect(plain?.protocol).toBeUndefined(); expect(plain?.baseUrl).toBeUndefined(); }); From 7296b13d3fc2839dd9f3fdf2d91719c9cd85954c Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 22 Jul 2026 01:04:43 +0800 Subject: [PATCH 12/13] style: drop a function-level comment from the v2 thinking resolver --- packages/agent-core-v2/src/kosong/model/thinking.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 1ee7a8f3b6..18b22a57bc 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -269,7 +269,6 @@ export function resolveThinkingEffortForModel( } if (effort === 'off' && model?.alwaysThinking === true) { - // A configured 'off' is treated as absent: the model default applies. effort = configured !== undefined && configured !== 'off' ? configured From 81e79bf4b47e1cf947c13eac61a9acff19aac3b8 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 22 Jul 2026 01:12:06 +0800 Subject: [PATCH 13/13] chore: consolidate the PR's changesets into two user-facing entries --- .changeset/always-thinking-clamp-and-cohere.md | 8 -------- .changeset/anthropic-fallback-thinking-efforts.md | 9 --------- .changeset/catalog-anthropic-base-url.md | 6 ------ .changeset/catalog-import-broader-and-safer.md | 7 +++++++ .changeset/catalog-import-hardening.md | 8 -------- .changeset/catalog-import-metadata-fixes.md | 8 -------- .changeset/catalog-import-resolver.md | 7 ------- .changeset/catalog-interleaved-true-no-pin.md | 6 ------ .changeset/catalog-null-tier-and-override-skips.md | 6 ------ .changeset/catalog-off-effort-and-family-aliases.md | 9 --------- .changeset/catalog-openai-fallback.md | 7 ------- .changeset/clamp-off-config-compat-override-endpoint.md | 9 --------- .changeset/context-input-cap-and-override-rules.md | 9 --------- .changeset/thinking-levels-from-declared-capabilities.md | 8 ++++++++ 14 files changed, 15 insertions(+), 92 deletions(-) delete mode 100644 .changeset/always-thinking-clamp-and-cohere.md delete mode 100644 .changeset/anthropic-fallback-thinking-efforts.md delete mode 100644 .changeset/catalog-anthropic-base-url.md create mode 100644 .changeset/catalog-import-broader-and-safer.md delete mode 100644 .changeset/catalog-import-hardening.md delete mode 100644 .changeset/catalog-import-metadata-fixes.md delete mode 100644 .changeset/catalog-import-resolver.md delete mode 100644 .changeset/catalog-interleaved-true-no-pin.md delete mode 100644 .changeset/catalog-null-tier-and-override-skips.md delete mode 100644 .changeset/catalog-off-effort-and-family-aliases.md delete mode 100644 .changeset/catalog-openai-fallback.md delete mode 100644 .changeset/clamp-off-config-compat-override-endpoint.md delete mode 100644 .changeset/context-input-cap-and-override-rules.md create mode 100644 .changeset/thinking-levels-from-declared-capabilities.md diff --git a/.changeset/always-thinking-clamp-and-cohere.md b/.changeset/always-thinking-clamp-and-cohere.md deleted file mode 100644 index 3ad7c55238..0000000000 --- a/.changeset/always-thinking-clamp-and-cohere.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Enforce always-on thinking on every wire: a model that declares `always_thinking` (e.g. a catalog-imported gpt-5) no longer resolves to a dishonest Off via `thinking.enabled = false` or an SDK/ACP off request — it clamps to the model's default effort instead of letting upstream keep reasoning while the UI reports Off. Cohere's proprietary SDK is now refused at catalog import instead of being guessed as OpenAI-compatible. diff --git a/.changeset/anthropic-fallback-thinking-efforts.md b/.changeset/anthropic-fallback-thinking-efforts.md deleted file mode 100644 index 68df8eac28..0000000000 --- a/.changeset/anthropic-fallback-thinking-efforts.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Fix the model selector offering thinking effort levels a model does not support: non-Claude models on Anthropic-compatible providers (e.g. a catalog-imported Kimi K3) no longer inherit Claude levels such as xhigh/max, and models imported from the models.dev catalog now advertise the effort levels the catalog declares. diff --git a/.changeset/catalog-anthropic-base-url.md b/.changeset/catalog-anthropic-base-url.md deleted file mode 100644 index 52d2ee9798..0000000000 --- a/.changeset/catalog-anthropic-base-url.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code": patch ---- - -Require an endpoint when importing non-official Anthropic-compatible vendors that lack one: `kimi provider catalog add` (and the TUI import flow) now asks for a base URL instead of silently falling back to the default Anthropic endpoint. `--base-url` now takes precedence over the catalog-declared endpoint, and an empty `--base-url` is rejected instead of persisting a blank endpoint. 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/catalog-import-hardening.md b/.changeset/catalog-import-hardening.md deleted file mode 100644 index 38b34d7565..0000000000 --- a/.changeset/catalog-import-hardening.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Harden catalog import edge cases: an explicit but unrecognized catalog `type` is now refused before npm/id inference (a future protocol is never silently miswired), user-supplied `--base-url` values for Anthropic-wire providers get the same trailing-`/v1` normalization as catalog endpoints (no more `/v1/v1/messages`), and the TUI import prompt rejects env-placeholder URLs like the CLI does. diff --git a/.changeset/catalog-import-metadata-fixes.md b/.changeset/catalog-import-metadata-fixes.md deleted file mode 100644 index 4c588ba19e..0000000000 --- a/.changeset/catalog-import-metadata-fixes.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Consume more of the metadata the models.dev catalog declares: deprecated models are no longer offered for import, per-model Anthropic protocol and endpoint overrides on gateway providers are honored, and a model's declared input token limit (e.g. gpt-5) now sizes the context budget instead of the larger total window. diff --git a/.changeset/catalog-import-resolver.md b/.changeset/catalog-import-resolver.md deleted file mode 100644 index 47adc52537..0000000000 --- a/.changeset/catalog-import-resolver.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/kimi-code": patch ---- - -Unify catalog import resolution into a single decision function: wire-type inference, the OpenAI-compatible fallback, proprietary-SDK refusal, endpoint adaptation, and the base-URL requirement are now produced together by one pure resolver consumed by both the CLI and the TUI, replacing a set of cooperating predicates. No behavior change. diff --git a/.changeset/catalog-interleaved-true-no-pin.md b/.changeset/catalog-interleaved-true-no-pin.md deleted file mode 100644 index e8c2e5ff5a..0000000000 --- a/.changeset/catalog-interleaved-true-no-pin.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code": patch ---- - -Stop pinning `reasoning_content` for catalog models that declare `interleaved: true`. The provider already scans `reasoning_content` / `reasoning_details` / `reasoning` inbound and writes `reasoning_content` outbound by default, so the pinned key only narrowed reasoning-content parsing for gateways that answer with a different field name (e.g. OpenRouter-style gateways). diff --git a/.changeset/catalog-null-tier-and-override-skips.md b/.changeset/catalog-null-tier-and-override-skips.md deleted file mode 100644 index 5838109bc6..0000000000 --- a/.changeset/catalog-null-tier-and-override-skips.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code": patch ---- - -Align catalog imports with the reference models.dev consumer: a JSON `null` entry in declared effort values is now read as the `none` off-encoding (previously such models were wrongly treated as always-thinking), alpha-status models are filtered out alongside deprecated ones, and models whose per-model provider override targets a protocol that cannot be expressed (e.g. Claude models on google-vertex, gpt models on an Anthropic provider) are skipped instead of being imported under the silently wrong wire. diff --git a/.changeset/catalog-off-effort-and-family-aliases.md b/.changeset/catalog-off-effort-and-family-aliases.md deleted file mode 100644 index d80261d6a0..0000000000 --- a/.changeset/catalog-off-effort-and-family-aliases.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Honor the thinking-disable semantics declared by the models.dev catalog: selecting Off on a model whose effort levels include `none` (e.g. xai grok) now sends `none` on the wire instead of omitting the effort field, so reasoning actually turns off; models with effort levels but no way to disable thinking are treated as always-thinking and no longer offer an Off option. Bare Claude family aliases such as `sonnet-latest` on Anthropic-compatible providers also get their inferred effort levels back. diff --git a/.changeset/catalog-openai-fallback.md b/.changeset/catalog-openai-fallback.md deleted file mode 100644 index 6e7b7f5971..0000000000 --- a/.changeset/catalog-openai-fallback.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/kimi-code": patch ---- - -Import previously unsupported vendors from the models.dev catalog: providers whose SDK is not explicitly recognized now fall back to the OpenAI-compatible wire (with a visible "guessed" note) instead of being refused, and imports that lack a usable endpoint ask for one — `--base-url` on the CLI, a prompt in the TUI. Truly proprietary SDKs (Amazon Bedrock) and env-placeholder URLs are still refused with a clear reason. diff --git a/.changeset/clamp-off-config-compat-override-endpoint.md b/.changeset/clamp-off-config-compat-override-endpoint.md deleted file mode 100644 index 19cd11c82b..0000000000 --- a/.changeset/clamp-off-config-compat-override-endpoint.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Close three follow-up gaps: a configured `thinking.effort = "off"` no longer bypasses the always-on clamp (it is treated as absent and the model default applies, on both engines); the previously public `inferWireType` is kept as a deprecated compatibility wrapper over the new catalog import resolver; and catalog model overrides that stay on the provider's wire but declare their own endpoint now persist that endpoint on the alias instead of being silently routed to the provider's base URL. diff --git a/.changeset/context-input-cap-and-override-rules.md b/.changeset/context-input-cap-and-override-rules.md deleted file mode 100644 index 7b1a4e03d1..0000000000 --- a/.changeset/context-input-cap-and-override-rules.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Close five catalog-handling gaps: the total context window is once again used for completion budgeting while a model's declared input limit (e.g. gpt-5's 272k) is tracked separately for compaction and other prompt-budget checks; a catalog endpoint that is declared only as an env placeholder now always requires a user-supplied base URL (official SDK or not); api-only per-model overrides are honored as same-wire endpoint changes; overrides targeting another known but inexpressible protocol (e.g. google-genai on an OpenAI gateway) are skipped; and same-wire models whose declared endpoint is an unusable placeholder are skipped instead of silently rerouted to the provider endpoint. 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.