diff --git a/.changeset/shaggy-pandas-think.md b/.changeset/shaggy-pandas-think.md new file mode 100644 index 0000000000..d68e3d235b --- /dev/null +++ b/.changeset/shaggy-pandas-think.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Recognize the support_efforts and default_effort fields when importing a custom registry, so thinking effort levels are available for those models. diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index b1cb18b761..b0faf2eeae 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -39,6 +39,8 @@ export interface CustomRegistryModelEntry { input?: readonly string[]; output?: readonly string[]; }; + readonly support_efforts?: readonly string[]; + readonly default_effort?: string; } export interface CustomRegistryProviderEntry { @@ -102,6 +104,8 @@ function toModelEntry(value: unknown): CustomRegistryModelEntry | undefined { tool_call?: boolean; reasoning?: boolean; modalities?: { input?: readonly string[]; output?: readonly string[] }; + support_efforts?: readonly string[]; + default_effort?: string; } = { id }; const name = value['name']; @@ -126,6 +130,13 @@ function toModelEntry(value: unknown): CustomRegistryModelEntry | undefined { if (typeof value['tool_call'] === 'boolean') entry.tool_call = value['tool_call']; if (typeof value['reasoning'] === 'boolean') entry.reasoning = value['reasoning']; + const supportEfforts = toStringArrayOrUndefined(value['support_efforts']); + if (supportEfforts !== undefined) entry.support_efforts = supportEfforts; + const defaultEffort = value['default_effort']; + if (typeof defaultEffort === 'string' && defaultEffort.length > 0) { + entry.default_effort = defaultEffort; + } + const modalities = value['modalities']; if (isRecord(modalities)) { const input = toStringArrayOrUndefined(modalities['input']); @@ -238,7 +249,11 @@ export async function fetchCustomRegistry( export function capabilitiesFromCustomEntry(model: CustomRegistryModelEntry): string[] { const caps = new Set(); if (model.tool_call === true) caps.add('tool_use'); - if (model.reasoning === true) caps.add('thinking'); + // Declaring concrete effort levels implies thinking support even when the + // legacy `reasoning` boolean is absent. + if (model.reasoning === true || (model.support_efforts?.length ?? 0) > 0) { + caps.add('thinking'); + } if (model.modalities?.input?.includes('image') === true) caps.add('image_in'); if (model.modalities?.input?.includes('video') === true) caps.add('video_in'); if (model.modalities?.output?.includes('image') === true) caps.add('image_out'); @@ -250,7 +265,8 @@ function hasRichCapabilityHints(model: CustomRegistryModelEntry): boolean { return ( typeof model.tool_call === 'boolean' || typeof model.reasoning === 'boolean' || - model.modalities !== undefined + model.modalities !== undefined || + model.support_efforts !== undefined ); } @@ -323,6 +339,8 @@ export function applyCustomRegistryProvider( maxContextSize, capabilities, displayName, + ...(model.support_efforts !== undefined ? { supportEfforts: model.support_efforts } : {}), + ...(model.default_effort !== undefined ? { defaultEffort: model.default_effort } : {}), }; existingModels[aliasKey] = mergeRefreshedModelAlias( existing, diff --git a/packages/oauth/src/model-alias-merge.ts b/packages/oauth/src/model-alias-merge.ts index 67f7646b70..45c71265ff 100644 --- a/packages/oauth/src/model-alias-merge.ts +++ b/packages/oauth/src/model-alias-merge.ts @@ -20,6 +20,8 @@ export const CUSTOM_REGISTRY_MODEL_FIELDS: ReadonlySet = new Set([ 'maxContextSize', 'capabilities', 'displayName', + 'supportEfforts', + 'defaultEffort', ]); function cloneOverrides( diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index df10415e54..a8a0d4441d 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -89,6 +89,29 @@ describe('fetchCustomRegistry', () => { ); }); + it('parses support_efforts and default_effort from model entries', async () => { + const body = makeKokubResponseBody(); + body['registry_chat-completions']!.models['gpt-5.5'] = { + id: 'gpt-5.5', + name: 'GPT 5.5', + support_efforts: ['low', 'high', 'max'], + default_effort: 'high', + }; + const fetchMock = vi.fn(async () => makeJsonResponse(body)); + + const result = await fetchCustomRegistry( + KOKUB_SOURCE, + fetchMock as unknown as typeof fetch, + ); + + expect(result['registry_chat-completions']?.models['gpt-5.5']).toEqual({ + id: 'gpt-5.5', + name: 'GPT 5.5', + support_efforts: ['low', 'high', 'max'], + default_effort: 'high', + }); + }); + it('omits the Authorization header when the apiKey is empty', async () => { const fetchMock = vi.fn(async () => makeJsonResponse(makeKokubResponseBody())); @@ -328,8 +351,7 @@ describe('applyCustomRegistryProvider', () => { provider: 'registry_chat-completions', model: 'gpt-5.5', maxContextSize: 131072, - supportEfforts: ['low', 'high', 'max'], - defaultEffort: 'high', + betaApi: true, } as Record, }, }; @@ -349,11 +371,100 @@ describe('applyCustomRegistryProvider', () => { ); const alias = config.models?.['registry_chat-completions/gpt-5.5']; - expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']); - expect(alias?.['defaultEffort']).toBe('high'); + expect(alias?.['betaApi']).toBe(true); // Upstream-owned fields are still refreshed. expect(alias?.['displayName']).toBe('GPT 5.5'); }); + + it('maps support_efforts / default_effort onto the model alias', () => { + const config: ManagedKimiConfigShape = { providers: {} }; + const entry: CustomRegistryProviderEntry = { + id: 'rich', + name: 'Rich Provider', + api: 'https://rich.example/v1', + type: 'openai', + models: { + 'rich-thinker': { + id: 'rich-thinker', + name: 'Rich Thinker', + reasoning: true, + support_efforts: ['low', 'high', 'max'], + default_effort: 'high', + }, + }, + }; + + applyCustomRegistryProvider(config, entry, { + kind: 'apiJson', + url: 'https://rich.example/api.json', + apiKey: 'sk-rich', + }); + + const alias = config.models?.['rich/rich-thinker'] as Record; + expect(alias['supportEfforts']).toEqual(['low', 'high', 'max']); + expect(alias['defaultEffort']).toBe('high'); + }); + + it('treats support_efforts as a thinking capability hint without reasoning: true', () => { + const config: ManagedKimiConfigShape = { providers: {} }; + const entry: CustomRegistryProviderEntry = { + id: 'rich', + name: 'Rich Provider', + api: 'https://rich.example/v1', + type: 'openai', + models: { + 'rich-effort-only': { + id: 'rich-effort-only', + name: 'Rich Effort Only', + support_efforts: ['low', 'high', 'max'], + default_effort: 'high', + }, + }, + }; + + applyCustomRegistryProvider(config, entry, { + kind: 'apiJson', + url: 'https://rich.example/api.json', + apiKey: 'sk-rich', + }); + + const alias = config.models?.['rich/rich-effort-only'] as Record; + expect(alias['capabilities']).toContain('thinking'); + expect(alias['supportEfforts']).toEqual(['low', 'high', 'max']); + }); + + it('drops stale effort fields when a refresh no longer declares them', () => { + const config: ManagedKimiConfigShape = { + providers: {}, + models: { + 'registry_chat-completions/gpt-5.5': { + provider: 'registry_chat-completions', + model: 'gpt-5.5', + maxContextSize: 131072, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + } as Record, + }, + }; + + applyCustomRegistryProvider( + config, + { + id: 'registry_chat-completions', + name: 'Sample Registry (chat completions)', + api: 'https://registry.example.test/v1', + type: 'openai', + models: { + 'gpt-5.5': { id: 'gpt-5.5', name: 'GPT 5.5' }, + }, + }, + KOKUB_SOURCE, + ); + + const alias = config.models?.['registry_chat-completions/gpt-5.5']; + expect(alias?.['supportEfforts']).toBeUndefined(); + expect(alias?.['defaultEffort']).toBeUndefined(); + }); }); describe('removeCustomRegistryProvider', () => { diff --git a/packages/oauth/test/model-alias-merge.test.ts b/packages/oauth/test/model-alias-merge.test.ts index faf5b9d969..6b8c01d26c 100644 --- a/packages/oauth/test/model-alias-merge.test.ts +++ b/packages/oauth/test/model-alias-merge.test.ts @@ -48,7 +48,7 @@ describe('mergeRefreshedModelAlias', () => { expect(merged.supportEfforts).toBeUndefined(); }); - it('keeps custom-registry supportEfforts as user data', () => { + it('refreshes custom-registry supportEfforts from upstream', () => { const merged = mergeRefreshedModelAlias( { provider: 'registry', @@ -56,6 +56,29 @@ describe('mergeRefreshedModelAlias', () => { maxContextSize: 131072, supportEfforts: ['low', 'high'], }, + { + provider: 'registry', + model: 'gpt-5.5', + maxContextSize: 131072, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }, + CUSTOM_REGISTRY_MODEL_FIELDS, + ); + + expect(merged.supportEfforts).toEqual(['low', 'high', 'max']); + expect(merged.defaultEffort).toBe('high'); + }); + + it('drops custom-registry effort fields when upstream stops declaring them', () => { + const merged = mergeRefreshedModelAlias( + { + provider: 'registry', + model: 'gpt-5.5', + maxContextSize: 131072, + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, { provider: 'registry', model: 'gpt-5.5', @@ -64,6 +87,7 @@ describe('mergeRefreshedModelAlias', () => { CUSTOM_REGISTRY_MODEL_FIELDS, ); - expect(merged.supportEfforts).toEqual(['low', 'high']); + expect(merged.supportEfforts).toBeUndefined(); + expect(merged.defaultEffort).toBeUndefined(); }); });