diff --git a/.changeset/fix-adaptive-thinking-opt-out.md b/.changeset/fix-adaptive-thinking-opt-out.md new file mode 100644 index 0000000000..76f487dd85 --- /dev/null +++ b/.changeset/fix-adaptive-thinking-opt-out.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Honor adaptive_thinking = false on Anthropic-compatible models by limiting thinking efforts to the legacy budget set and omitting the effort parameter from requests. diff --git a/.changeset/fix-anthropic-effort-profile.md b/.changeset/fix-anthropic-effort-profile.md new file mode 100644 index 0000000000..4fe9c355a9 --- /dev/null +++ b/.changeset/fix-anthropic-effort-profile.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Apply official Anthropic effort profiles and a 128k output fallback for unknown models. Preserve compatible-provider thinking history across session resumes and model switches, normalize incomplete stream events, and warn on unlisted efforts. diff --git a/.changeset/fix-anthropic-provider-effort-context.md b/.changeset/fix-anthropic-provider-effort-context.md new file mode 100644 index 0000000000..91a889a652 --- /dev/null +++ b/.changeset/fix-anthropic-provider-effort-context.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix custom-named models on Anthropic-compatible providers starting new sessions with thinking effort off instead of the model default, and not showing the thinking control in ACP clients. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 2d86fa180c..e3d6ad852c 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -45,6 +45,11 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig { }; } +function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { + const providerType = host.state.appState.availableProviders[model.provider]?.type; + return effectiveModelAlias(model, (model.protocol ?? providerType) === 'anthropic'); +} + export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; if (session === undefined) { @@ -234,7 +239,7 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): host.showError('No model selected. Run /model to select one first.'); return; } - const effective = effectiveModelAlias(model); + const effective = effectiveModelForHost(host, model); const segments = segmentsFor(effective); const arg = args.trim().toLowerCase(); if (arg.length === 0) { @@ -242,10 +247,19 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): return; } if (!segments.includes(arg)) { - host.showError( - `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + const providerType = host.state.appState.availableProviders[effective.provider]?.type; + const protocol = effective.protocol ?? providerType; + if (protocol !== 'anthropic') { + host.showError( + `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + ); + return; + } + const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared'; + host.showStatus( + `Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`, + 'warning', ); - return; } await performModelSwitch(host, alias, arg, true); } @@ -358,7 +372,13 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise } export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { - const entries = Object.entries(host.state.appState.availableModels); + const models = Object.fromEntries( + Object.entries(host.state.appState.availableModels).map(([alias, model]) => [ + alias, + effectiveModelForHost(host, model), + ]), + ); + const entries = Object.entries(models); if (entries.length === 0) { host.showNotice( 'No models configured', @@ -368,7 +388,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = } host.mountEditorReplacement( new TabbedModelSelectorComponent({ - models: host.state.appState.availableModels, + models, currentValue: host.state.appState.model, selectedValue, currentThinkingEffort: host.state.appState.thinkingEffort, diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index ba1499e04c..f40cc4093b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -338,6 +338,55 @@ describe('ModelSelectorComponent', () => { expect(out).toContain('Thinking (←→ to switch)'); }); + it('derives official Anthropic effort segments from the model name', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + opus: { + provider: 'anthropic', + model: 'claude-opus-4-6', + maxContextSize: 200000, + }, + }, + currentValue: 'opus', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Low'); + expect(out).toContain('[ High ]'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + expect(out).not.toContain('Xhigh'); + + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'opus', thinking: 'max' }); + }); + + it('derives official always-on Anthropic models without an Off segment', () => { + const picker = new ModelSelectorComponent({ + models: { + fable: { + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 200000, + }, + }, + currentValue: 'fable', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Xhigh'); + expect(out).toContain('Max'); + expect(out).not.toContain('Off'); + }); + it('cycles efforts with Left/Right and clamps at the ends', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ 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 f285be77dd..028738cac2 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 @@ -12,6 +12,7 @@ import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; +import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; import { @@ -5161,16 +5162,20 @@ describe('/model status displayName override', () => { }); describe('/effort support_efforts override', () => { - it('rejects efforts hidden by support_efforts override', async () => { + it('warns and applies efforts hidden by an Anthropic support_efforts override', async () => { const session = makeSession(); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, models: { k2: { - provider: 'managed:kimi-code', - model: 'kimi-k2', + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', maxContextSize: 100, - displayName: 'Kimi K2', + displayName: 'Compatible Model', capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'], overrides: { supportEfforts: ['low', 'high'] }, @@ -5184,8 +5189,73 @@ describe('/effort support_efforts override', () => { driver.handleUserInput('/effort max'); await vi.waitFor(() => { - expect(renderTranscript(driver)).toContain('Unsupported thinking effort "max" for k2. Available: off, low, high'); + expect(session.setThinking).toHaveBeenCalledWith('max'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to max.'); + }); + const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' '); + expect(transcript).toContain( + 'Thinking effort "max" is not listed for k2 (known: low, high). Sending "max" unchanged; the configured provider will validate it.', + ); + expect(transcript).toContain('Thinking set to max.'); + }); + + it('offers the latest Opus efforts for an unknown Anthropic-compatible model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + 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')).toContain('Max'); + }); + + it('keeps rejecting efforts hidden by a Kimi support_efforts override', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + kimi: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'kimi', + model: 'kimi-model', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain( + 'Unsupported thinking effort "max" for k2. Available: off, low, high', + ); }); - expect(renderTranscript(driver)).not.toContain('Switched to Kimi K2 with thinking max.'); + expect(session.setThinking).not.toHaveBeenCalled(); }); }); diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts index 5ce4256d9b..05e5a4e21e 100644 --- a/packages/acp-adapter/src/model-catalog.ts +++ b/packages/acp-adapter/src/model-catalog.ts @@ -15,11 +15,20 @@ * `for model_key, model in models.items()`. * * `thinkingSupported` is true if any of: - * 1. the alias's declared `capabilities` array contains `'thinking'`, or + * 1. the alias's declared `capabilities` array contains `'thinking'` + * (including the capability inferred from the Anthropic wire protocol — + * see the `anthropicCompatible` context below), or * 2. the underlying model name matches `/thinking|reason/i` * (always-thinking variants), or * 3. the underlying model name is on the {@link TOGGLEABLE_THINKING_MODELS} * allow-list (mirrors `kimi-cli/src/kimi_cli/llm.py:derive_model_capabilities`). + * + * The runtime resolves a model's wire protocol from + * `alias.protocol ?? provider.type` (see + * `ProviderManager.resolveProviderConfig`). The derive helpers below take the + * provider-derived `anthropicCompatible` flag as an optional second argument + * so the catalog agrees with the runtime about Anthropic profiles even when + * the alias itself does not declare `protocol`. */ import { effectiveModelAlias } from '@moonshot-ai/agent-core'; @@ -55,8 +64,8 @@ export interface AcpModelEntry { */ const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']); -export function deriveThinkingSupported(alias: ModelAlias): boolean { - const effective = effectiveModelAlias(alias); +export function deriveThinkingSupported(alias: ModelAlias, anthropicCompatible = false): boolean { + const effective = effectiveModelAlias(alias, anthropicCompatible); const declared = effective.capabilities ?? []; if (declared.includes('thinking') || declared.includes('always_thinking')) return true; const lower = effective.model.toLowerCase(); @@ -72,8 +81,10 @@ export function deriveThinkingSupported(alias: ModelAlias): boolean { * `thinkingSupported`, but only an explicit (server-derived) declaration * may remove the off option from the client. */ -export function deriveAlwaysThinking(alias: ModelAlias): boolean { - return (effectiveModelAlias(alias).capabilities ?? []).includes('always_thinking'); +export function deriveAlwaysThinking(alias: ModelAlias, anthropicCompatible = false): boolean { + return (effectiveModelAlias(alias, anthropicCompatible).capabilities ?? []).includes( + 'always_thinking', + ); } /** @@ -81,8 +92,11 @@ export function deriveAlwaysThinking(alias: ModelAlias): boolean { * `default_effort`, else the middle `support_efforts` entry, else `'on'` for * boolean models (no `support_efforts`). */ -export function deriveDefaultThinkingEffort(alias: ModelAlias): string { - const effective = effectiveModelAlias(alias); +export function deriveDefaultThinkingEffort( + alias: ModelAlias, + anthropicCompatible = false, +): string { + const effective = effectiveModelAlias(alias, anthropicCompatible); const efforts = effective.supportEfforts; if (efforts !== undefined && efforts.length > 0) { return effective.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; @@ -102,24 +116,45 @@ export async function listModelsFromHarness( harness: KimiHarness, ): Promise { if (typeof harness.getConfig !== 'function') return []; - let models: Record | undefined; + let config: Awaited>; try { - const config = await harness.getConfig(); - models = config.models; + config = await harness.getConfig(); } catch { return []; } + const models = config.models; if (models === undefined) return []; const out: AcpModelEntry[] = []; for (const [id, alias] of Object.entries(models)) { - const effective = effectiveModelAlias(alias); + const anthropicCompatible = usesAnthropicProvider(alias, config); + const effective = effectiveModelAlias(alias, anthropicCompatible); out.push({ id, name: effective.displayName ?? effective.model ?? id, - thinkingSupported: deriveThinkingSupported(alias), - alwaysThinking: deriveAlwaysThinking(alias), - defaultThinkingEffort: deriveDefaultThinkingEffort(alias), + thinkingSupported: deriveThinkingSupported(alias, anthropicCompatible), + alwaysThinking: deriveAlwaysThinking(alias, anthropicCompatible), + defaultThinkingEffort: deriveDefaultThinkingEffort(alias, anthropicCompatible), }); } return out; } + +/** + * Provider-level Anthropic context for an alias, mirroring how + * `ProviderManager.resolveProviderConfig` resolves the wire protocol: the + * alias's provider (falling back to the configured default provider) decides + * when the alias itself does not declare `protocol`. Without this the catalog + * would mark a custom-named model on an `type = "anthropic"` provider as not + * thinking-capable while the runtime infers the latest Anthropic profile. + */ +function usesAnthropicProvider( + alias: ModelAlias, + config: { + providers?: Record; + defaultProvider?: string | undefined; + }, +): boolean { + const providerName = alias.provider ?? config.defaultProvider; + if (providerName === undefined) return false; + return config.providers?.[providerName]?.type === 'anthropic'; +} diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index a43fe5ebbc..d0286d18db 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -115,6 +115,12 @@ async function harnessIsAuthed(harness: KimiHarness): Promise { return status.providers.some((entry) => entry.hasToken === true); } +function thinkingEnabledFromEffort(effort: unknown): boolean | undefined { + if (typeof effort !== 'string') return undefined; + const normalized = effort.trim().toLowerCase(); + return normalized.length > 0 && normalized !== 'off'; +} + /** * Agent-side ACP handler. Routes `initialize` + `session/new` + `session/cancel` * into {@link KimiHarness}; refuses methods that are not yet wired with a @@ -295,7 +301,7 @@ export class AcpServer implements Agent { mcpServers, }); const currentModelId = await this.resolveCurrentModelId(); - const currentThinkingEnabled = await this.resolveCurrentThinkingEnabled(); + const currentThinkingEnabled = await this.resolveCurrentThinkingEnabled(session); const acpSession = new AcpSession( this.conn, session, @@ -492,14 +498,13 @@ export class AcpServer implements Agent { // Phase 15 reads the resumed thinking effort off the main-agent // config and projects it onto the binary toggle: any non-`'off'` // effort reads as "thinking on" because the ACP surface only - // exposes the boolean axis. Falls back to the harness-level default - // when the resume state lacks the field. + // exposes the boolean axis. Falls back to the live session status, then + // the harness-level default, when the resume state lacks the field. const resumedThinkingEffort = resumeState?.agents?.['main']?.config?.thinkingEffort; - const currentThinkingEnabled = - typeof resumedThinkingEffort === 'string' - ? resumedThinkingEffort.trim().toLowerCase() !== 'off' && - resumedThinkingEffort.trim().length > 0 - : await this.resolveCurrentThinkingEnabled(); + const currentThinkingEnabled = await this.resolveCurrentThinkingEnabled( + session, + resumedThinkingEffort, + ); const acpSession = new AcpSession( this.conn, session, @@ -824,31 +829,43 @@ export class AcpServer implements Agent { } /** - * Compute the initial value for the `thinking` toggle when - * a session is created (or loaded with no persisted thinking state). - * Reads the harness's `getConfig().thinking.enabled` flag if exposed — - * the same source `Session.createSession` would consult for new - * sessions. Returns `false` when the harness has no opinion, so the - * toggle starts off. + * Compute the initial value for the `thinking` toggle from the session's + * effective effort. A persisted resume-state effort wins; otherwise the + * live session status is authoritative. The harness config remains a + * best-effort fallback for partial SDK stubs and status-read failures. * - * Tolerant to partial-stub harnesses for the same reason - * {@link resolveCurrentModelId} is — adapter-level unit tests - * routinely omit `getConfig`. The swallow-and-fallback path keeps - * the test ergonomics symmetric. + * Tolerant to partial SDK/session stubs for the same reason + * {@link resolveCurrentModelId} is — adapter-level unit tests routinely + * omit `getStatus` or `getConfig`. The swallow-and-fallback path keeps the + * test ergonomics symmetric. */ - private async resolveCurrentThinkingEnabled(): Promise { + private async resolveCurrentThinkingEnabled( + session: Session, + resumedThinkingEffort?: unknown, + ): Promise { + const resumed = thinkingEnabledFromEffort(resumedThinkingEffort); + if (resumed !== undefined) return resumed; + + if (typeof session.getStatus === 'function') { + try { + const current = thinkingEnabledFromEffort((await session.getStatus()).thinkingEffort); + if (current !== undefined) return current; + } catch (error) { + log.warn('acp: session.getStatus threw during thinking toggle resolution; falling back', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + if (typeof this.harness.getConfig !== 'function') return false; try { const config = await this.harness.getConfig(); const thinking = (config as { thinking?: { enabled?: unknown; effort?: unknown } }) .thinking; - if (typeof thinking?.enabled === 'boolean') return thinking.enabled; - // A non-empty effort with no explicit enabled flag still means thinking - // is on — agent-core's resolveThinkingEffort treats config.effort as - // enabled unless enabled === false, so mirror that here to keep the - // toggle consistent with the runtime. - if (typeof thinking?.effort === 'string' && thinking.effort.length > 0) return true; - return false; + if (thinking?.enabled === false) return false; + const configured = thinkingEnabledFromEffort(thinking?.effort); + if (configured !== undefined) return configured; + return thinking?.enabled === true; } catch (err) { log.warn('acp: harness.getConfig threw during thinking toggle resolution; defaulting to off', { error: err instanceof Error ? err.message : String(err), diff --git a/packages/acp-adapter/test/config-options.test.ts b/packages/acp-adapter/test/config-options.test.ts index acd0110d63..c65d67e745 100644 --- a/packages/acp-adapter/test/config-options.test.ts +++ b/packages/acp-adapter/test/config-options.test.ts @@ -11,18 +11,30 @@ import { import type { AcpModelEntry } from '../src/model-catalog'; function makeHarnessWithModels( - entries: ReadonlyArray<{ id: string; model?: string; displayName?: string; capabilities?: readonly string[] }>, + entries: ReadonlyArray<{ + id: string; + model?: string; + displayName?: string; + capabilities?: readonly string[]; + protocol?: 'anthropic'; + }>, ): { harness: KimiHarness; getConfig: ReturnType } { // Mirror the `listAvailableModels` derivation: `id` is the config map // key, `model` defaults to id, `displayName` to model. The test fixtures // below pick names that exercise the three thinkingSupported triggers // (name regex, capabilities array, toggleable allow-list). - const models: Record = {}; + const models: Record = {}; for (const entry of entries) { models[entry.id] = { model: entry.model ?? entry.id, ...(entry.displayName !== undefined ? { displayName: entry.displayName } : {}), ...(entry.capabilities !== undefined ? { capabilities: entry.capabilities } : {}), + protocol: entry.protocol, }; } const getConfig = vi.fn(async () => ({ models })); @@ -157,6 +169,20 @@ describe('buildSessionConfigOptions', () => { } }); + it('shows the thinking control for an unknown model using the Anthropic protocol', async () => { + const { harness } = makeHarnessWithModels([ + { + id: 'custom', + model: 'custom-anthropic-model', + protocol: 'anthropic', + }, + ]); + + const result = await buildSessionConfigOptions(harness, 'custom', false, 'default'); + + expect(result.map((option) => option.id)).toEqual(['model', 'thinking', 'mode']); + }); + it('omits the thinking toggle when current model is non-thinking-supported', async () => { const { harness } = makeHarnessWithModels([ { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, diff --git a/packages/acp-adapter/test/model-catalog.test.ts b/packages/acp-adapter/test/model-catalog.test.ts index e637a95bd5..a99c40c0aa 100644 --- a/packages/acp-adapter/test/model-catalog.test.ts +++ b/packages/acp-adapter/test/model-catalog.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest'; -import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiHarness, ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { deriveAlwaysThinking, deriveDefaultThinkingEffort, deriveThinkingSupported, + listModelsFromHarness, } from '../src/model-catalog'; function alias(model: string, capabilities?: readonly string[]): ModelAlias { @@ -52,3 +53,62 @@ describe('deriveDefaultThinkingEffort', () => { ).toBe('high'); }); }); + +describe('listModelsFromHarness', () => { + it('advertises thinking with a high default for an unknown model using the Anthropic protocol', async () => { + const harness = { + getConfig: async () => ({ + models: { + custom: { + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + }, + }, + }), + } as unknown as KimiHarness; + + await expect(listModelsFromHarness(harness)).resolves.toEqual([ + { + id: 'custom', + name: 'custom-anthropic-model', + thinkingSupported: true, + alwaysThinking: false, + defaultThinkingEffort: 'high', + }, + ]); + }); + + 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). + const harness = { + getConfig: async () => ({ + defaultProvider: 'compat', + providers: { + compat: { type: 'anthropic', apiKey: 'test-key', baseUrl: 'https://api.example.test' }, + }, + models: { + custom: { + provider: 'compat', + model: 'joint-model-0714-vibe', + maxContextSize: 200000, + }, + }, + }), + } as unknown as KimiHarness; + + await expect(listModelsFromHarness(harness)).resolves.toEqual([ + { + id: 'custom', + name: 'joint-model-0714-vibe', + thinkingSupported: true, + alwaysThinking: false, + defaultThinkingEffort: 'high', + }, + ]); + }); +}); diff --git a/packages/acp-adapter/test/session-load.test.ts b/packages/acp-adapter/test/session-load.test.ts index fb3e62807f..e7f7b989d4 100644 --- a/packages/acp-adapter/test/session-load.test.ts +++ b/packages/acp-adapter/test/session-load.test.ts @@ -63,6 +63,7 @@ function makeInMemoryStreamPair(): { function makeSessionWithHistory( sessionId: string, history: ReadonlyArray, + statusThinkingEffort?: string, ): Session { return { id: sessionId, @@ -77,6 +78,10 @@ function makeSessionWithHistory( }, }, }), + getStatus: + statusThinkingEffort === undefined + ? undefined + : async () => ({ thinkingEffort: statusThinkingEffort }), } as unknown as Session; } @@ -319,4 +324,24 @@ describe('AcpServer session/load replay', () => { // Phase 15: model dropdown holds N rows (no `,thinking` variants). expect(modelOpt!.options).toHaveLength(2); }); + + it('advertises thinking on when resume state omits effort and live status is high', async () => { + const sessionId = 'sess-status-thinking-high'; + const session = makeSessionWithHistory(sessionId, [], 'high'); + const harness = makeHarness({ hasUsableToken: true, session }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + void new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + const response = await clientConn.loadSession({ + sessionId, + cwd: '/tmp/x', + mcpServers: [], + }); + + const thinking = response.configOptions?.find((option) => option.id === 'thinking'); + if (thinking?.type !== 'select') throw new Error('thinking option must be a select'); + expect(thinking.currentValue).toBe('on'); + }); }); diff --git a/packages/acp-adapter/test/session-new.test.ts b/packages/acp-adapter/test/session-new.test.ts index ece351999d..61220b462b 100644 --- a/packages/acp-adapter/test/session-new.test.ts +++ b/packages/acp-adapter/test/session-new.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { AgentSideConnection, @@ -49,7 +49,12 @@ interface CapturedCall { options: { id?: string; workDir: string; mcpServers?: Record }; } -function makeHarness(sessionId: string, captured: CapturedCall[]): { +function makeHarness( + sessionId: string, + captured: CapturedCall[], + statusThinkingEffort?: string | Error, + fallbackThinking?: { enabled?: boolean; effort?: string }, +): { harness: KimiHarness; fakeSession: Session; } { @@ -58,6 +63,13 @@ function makeHarness(sessionId: string, captured: CapturedCall[]): { prompt: async () => undefined, cancel: async () => undefined, onEvent: () => () => undefined, + getStatus: + statusThinkingEffort === undefined + ? undefined + : vi.fn(async () => { + if (statusThinkingEffort instanceof Error) throw statusThinkingEffort; + return { thinkingEffort: statusThinkingEffort }; + }), } as unknown as Session; const harness = { auth: { status: async () => AUTHED_STATUS }, @@ -73,6 +85,7 @@ function makeHarness(sessionId: string, captured: CapturedCall[]): { { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: true }, { id: 'kimi-plain', name: 'Kimi Plain', thinkingSupported: false }, ]), + thinking: fallbackThinking, }), } as unknown as KimiHarness; return { harness, fakeSession }; @@ -207,4 +220,56 @@ describe('AcpServer session/new', () => { const modelValues = modelOpt!.options.map((o) => 'value' in o ? o.value : ''); expect(modelValues).toEqual(['kimi-coder', 'kimi-plain']); }); + + it('advertises thinking on when the created session status has a high effort', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-thinking-high', captured, 'high'); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + void new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + const thinking = response.configOptions?.find((option) => option.id === 'thinking'); + if (thinking?.type !== 'select') throw new Error('thinking option must be a select'); + expect(thinking.currentValue).toBe('on'); + }); + + it.each([ + { name: 'explicit high effort', config: { effort: 'high' }, expected: 'on' }, + { name: 'explicit off effort', config: { effort: 'off' }, expected: 'off' }, + { + name: 'disabled with a high effort', + config: { enabled: false, effort: 'high' }, + expected: 'off', + }, + { + name: 'enabled with an off effort', + config: { enabled: true, effort: 'off' }, + expected: 'off', + }, + ])( + 'falls back to $name when the created session status cannot be read', + async ({ config, expected }) => { + const captured: CapturedCall[] = []; + const { harness, fakeSession } = makeHarness( + 'sess-thinking-status-error', + captured, + new Error('status unavailable'), + config, + ); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + void new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + expect(fakeSession.getStatus).toHaveBeenCalledOnce(); + const thinking = response.configOptions?.find((option) => option.id === 'thinking'); + if (thinking?.type !== 'select') throw new Error('thinking option must be a select'); + expect(thinking.currentValue).toBe(expected); + }, + ); }); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 7a27849d62..08c2029c7b 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -13,7 +13,8 @@ * handler, records `usage` through `IAgentUsageService`, resolves to an * `LLMRequestFinish` on the `finish` event, logs the request lifecycle * (config deduplicated by content, request/response/failure lines, plus - * per-request fields) through `log`, records durable request-trace Ops + * per-request fields) through `log`, publishes advisory model-capability + * warnings through `eventBus`, records durable request-trace Ops * through `wire`, and reports provider failures through `telemetry`. Bound * at Agent scope. */ @@ -36,6 +37,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentUsageService } from '#/agent/usage/usage'; import { IConfigService } from '#/app/config/config'; +import { IEventBus } from '#/app/event/eventBus'; import { APIConnectionError, APIContextOverflowError, @@ -130,6 +132,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { private readonly turnConfigs = new Map(); private readonly mediaDegradedTurns = new Set(); private readonly mediaStrippedTurns = new Map(); + private readonly emittedThinkingEffortWarnings = new Set(); constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -144,6 +147,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { @ITelemetryService private readonly telemetry: ITelemetryService, @IWireService private readonly wire: IWireService, @IFaultInjectionService private readonly faultInjection: IFaultInjectionService, + @IEventBus private readonly eventBus: IEventBus, ) {} async request( @@ -240,6 +244,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { projection === 'normal' ? request.logFields : { ...request.logFields, projection }; + this.warnAboutAnthropicThinkingEffort(request); const logInput: LLMRequestLogInput = { protocol: request.model.protocol, modelName: request.model.name, @@ -372,6 +377,45 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { } } + private warnAboutAnthropicThinkingEffort(request: ResolvedLLMRequest): void { + if (request.model.protocol !== 'anthropic') return; + const effort = request.thinkingEffort; + if (effort === 'on') return; + + let code: string; + let message: string; + let knownEfforts: string | undefined; + if (effort === 'off') { + if (!request.model.alwaysThinking) return; + code = 'anthropic-thinking-cannot-disable'; + message = `Model "${request.model.name}" declares always-on thinking. The configured effort "off" will be sent unchanged to the Anthropic-compatible backend.`; + } else { + const supportEfforts = request.model.supportEfforts?.filter((value) => value.length > 0); + if (supportEfforts === undefined || supportEfforts.length === 0) return; + if (supportEfforts.includes(effort)) return; + code = 'anthropic-thinking-effort-not-listed'; + knownEfforts = supportEfforts.join(','); + message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; + } + + const key = [code, request.modelAlias, request.model.name, effort, knownEfforts].join('\u0000'); + if (this.emittedThinkingEffortWarnings.has(key)) return; + this.emittedThinkingEffortWarnings.add(key); + try { + this.log.warn(message, { + modelAlias: request.modelAlias, + model: request.model.name, + effort, + knownEfforts, + }); + } catch { + } + try { + this.eventBus.publish({ type: 'warning', code, message }); + } catch { + } + } + private isRecoveryTurn(set: ReadonlySet, source: LLMRequestSource | undefined): boolean { if (source?.type !== 'turn') return false; return set.has(source.turnId); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 98fd708486..bbb2e5e365 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -98,6 +98,7 @@ export class AgentProfileService implements IAgentProfileService { private optionsValue: ProfileServiceOptions = {}; private activeToolNamesOverlay: readonly string[] | undefined; private agentsMdWarning: string | undefined; + private readonly emittedThinkingEffortWarnings = new Set(); private get activeToolNames(): ActiveToolsState { return ( @@ -424,11 +425,44 @@ export class AgentProfileService implements IAgentProfileService { const protocol = this.tryResolveRawModel()?.protocol; this.telemetryContext.set({ provider_type: protocol, protocol }); } + if (changed.modelAlias !== undefined || changed.thinkingLevel !== undefined) { + this.warnAboutAnthropicThinkingEffort(); + } this.emitStatusUpdated( changed.modelAlias !== undefined || changed.thinkingLevel !== undefined, ); } + private warnAboutAnthropicThinkingEffort(): void { + try { + const model = this.tryResolveRawModel(); + if (model?.protocol !== 'anthropic') return; + const effort = this.getEffectiveThinkingLevel(); + if (effort === 'on') return; + + let code: string; + let message: string; + let knownEfforts = ''; + if (effort === 'off') { + if (!model.alwaysThinking) return; + code = 'anthropic-thinking-cannot-disable'; + message = `Model "${model.name}" declares always-on thinking. The configured effort "off" will be sent unchanged to the Anthropic-compatible backend.`; + } else { + const efforts = model.supportEfforts?.filter((value) => value.length > 0); + if (efforts === undefined || efforts.length === 0 || efforts.includes(effort)) return; + knownEfforts = efforts.join(','); + code = 'anthropic-thinking-effort-not-listed'; + message = `Thinking effort "${effort}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; + } + + const key = [code, model.id, model.name, effort, knownEfforts].join('\u0000'); + if (this.emittedThinkingEffortWarnings.has(key)) return; + this.emittedThinkingEffortWarnings.add(key); + this.eventBus.publish({ type: 'warning', code, message }); + } catch { + } + } + private setActiveTools(names: readonly string[]): void { this.activeToolNamesOverlay = undefined; this.wire.dispatch(setActiveTools({ names: [...names] })); diff --git a/packages/agent-core-v2/src/agent/profile/thinking.ts b/packages/agent-core-v2/src/agent/profile/thinking.ts index 0cf9066b49..4cb39e5064 100644 --- a/packages/agent-core-v2/src/agent/profile/thinking.ts +++ b/packages/agent-core-v2/src/agent/profile/thinking.ts @@ -15,21 +15,28 @@ import { import type { ThinkingConfig } from './configSection'; -type ThinkingModel = ModelThinkingMetadata & { readonly providerType?: string }; +type ThinkingModel = ModelThinkingMetadata & { + readonly protocol?: string; + readonly providerType?: string; +}; + +function usesKimiThinkingSemantics(model: ThinkingModel | undefined): boolean { + return model?.protocol === 'kimi'; +} export function resolveThinkingEffort( requested: string | undefined, defaults: ThinkingConfig | undefined, model?: ThinkingModel, ): ThinkingEffort { - return resolveThinkingEffortForModel(requested, defaults, model, model?.providerType === 'kimi'); + return resolveThinkingEffortForModel(requested, defaults, model, usesKimiThinkingSemantics(model)); } export function supportsThinkingEffort( effort: ThinkingEffort, model: ThinkingModel | undefined, ): boolean { - return modelSupportsThinkingEffort(effort, model, model?.providerType === 'kimi'); + return modelSupportsThinkingEffort(effort, model, usesKimiThinkingSemantics(model)); } const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic-profile.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic-profile.ts new file mode 100644 index 0000000000..8ac9623735 --- /dev/null +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic-profile.ts @@ -0,0 +1,153 @@ +/** + * `llmProtocol` domain (L0) — Anthropic model capability profiles and name matching. + * Matrix source: https://platform.claude.com/docs/en/build-with-claude/effort + * and https://platform.claude.com/docs/en/build-with-claude/extended-thinking. + */ + +export type AnthropicThinkingMode = 'budget' | 'adaptive'; + +export interface AnthropicModelProfile { + readonly mode: AnthropicThinkingMode; + readonly efforts: readonly string[]; + readonly supportsEffortParam: boolean; + readonly canDisableThinking: boolean; +} + +export type AnthropicModelFamily = 'opus' | 'sonnet' | 'haiku' | 'fable' | 'mythos'; + +export interface AnthropicModelVersion { + readonly family: AnthropicModelFamily; + readonly major: number; + readonly minor: number | null; +} + +export const BUDGET_THINKING_EFFORTS = ['low', 'medium', 'high'] as const; +const ADAPTIVE_MAX_EFFORTS = ['low', 'medium', 'high', 'max'] as const; +export const LATEST_OPUS_THINKING_EFFORTS = [ + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +] as const; + +const BUDGET_PROFILE: AnthropicModelProfile = { + mode: 'budget', + efforts: BUDGET_THINKING_EFFORTS, + supportsEffortParam: false, + canDisableThinking: true, +}; + +const OPUS_45_PROFILE: AnthropicModelProfile = { + ...BUDGET_PROFILE, + supportsEffortParam: true, +}; + +const ADAPTIVE_MAX_PROFILE: AnthropicModelProfile = { + mode: 'adaptive', + efforts: ADAPTIVE_MAX_EFFORTS, + supportsEffortParam: true, + canDisableThinking: true, +}; + +export const LATEST_OPUS_PROFILE: AnthropicModelProfile = { + mode: 'adaptive', + efforts: LATEST_OPUS_THINKING_EFFORTS, + supportsEffortParam: true, + canDisableThinking: true, +}; + +const ALWAYS_ADAPTIVE_PROFILE: AnthropicModelProfile = { + ...LATEST_OPUS_PROFILE, + canDisableThinking: false, +}; + +const ALWAYS_ADAPTIVE_MAX_PROFILE: AnthropicModelProfile = { + ...ADAPTIVE_MAX_PROFILE, + canDisableThinking: false, +}; + +const FAMILY_FIRST_RE = + /(opus|sonnet|haiku|fable|mythos)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; +const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; +const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; + +export function parseAnthropicModelVersion( + model: string, + requireClaudeMarker = false, +): AnthropicModelVersion | null { + const normalized = model.toLowerCase(); + if (requireClaudeMarker && !normalized.includes('claude')) return null; + + const familyFirst = FAMILY_FIRST_RE.exec(normalized); + if (familyFirst !== null) { + return { + family: familyFirst[1] as AnthropicModelFamily, + major: Number.parseInt(familyFirst[2]!, 10), + minor: familyFirst[3] !== undefined ? Number.parseInt(familyFirst[3], 10) : null, + }; + } + + const versionFirst = VERSION_FIRST_RE.exec(normalized); + if (versionFirst !== null) { + return { + major: Number.parseInt(versionFirst[1]!, 10), + minor: Number.parseInt(versionFirst[2]!, 10), + family: versionFirst[3] as AnthropicModelFamily, + }; + } + + const bare = BARE_FAMILY_RE.exec(normalized); + if (bare !== null) { + return { + major: Number.parseInt(bare[1]!, 10), + minor: null, + family: bare[2] as AnthropicModelFamily, + }; + } + + return null; +} + +export function matchKnownAnthropicModelProfile( + model: string, +): AnthropicModelProfile | undefined { + const normalized = model.toLowerCase(); + if (/mythos[-._]preview/.test(normalized)) return ALWAYS_ADAPTIVE_MAX_PROFILE; + + const version = parseAnthropicModelVersion(model); + if (version === null) return undefined; + + switch (version.family) { + case 'opus': + if (version.major === 4 && (version.minor === 7 || version.minor === 8)) { + return LATEST_OPUS_PROFILE; + } + if (version.major === 4 && version.minor === 6) return ADAPTIVE_MAX_PROFILE; + if (version.major === 4 && version.minor === 5) return OPUS_45_PROFILE; + if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) < 5)) { + return BUDGET_PROFILE; + } + return undefined; + case 'sonnet': + if (version.major === 5) return LATEST_OPUS_PROFILE; + if (version.major === 4 && version.minor === 6) return ADAPTIVE_MAX_PROFILE; + if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) <= 5)) { + return BUDGET_PROFILE; + } + return undefined; + case 'haiku': + if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) <= 5)) { + return BUDGET_PROFILE; + } + return undefined; + case 'fable': + return version.major === 5 ? ALWAYS_ADAPTIVE_PROFILE : undefined; + case 'mythos': + return version.major === 5 ? ALWAYS_ADAPTIVE_PROFILE : undefined; + } +} + +export function inferAnthropicModelProfile(model: string): AnthropicModelProfile { + return matchKnownAnthropicModelProfile(model) ?? LATEST_OPUS_PROFILE; +} diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index c9a6f297c3..1de66bfa1a 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -1,3 +1,7 @@ +/** + * `llmProtocol` domain (L0) — Anthropic-compatible chat request and response adapter. + */ + import { APIConnectionError, APITimeoutError, @@ -41,6 +45,14 @@ import type { ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/messages/messages.js'; +import { + BUDGET_THINKING_EFFORTS, + inferAnthropicModelProfile, + matchKnownAnthropicModelProfile, + parseAnthropicModelVersion, + type AnthropicModelProfile, + type AnthropicModelVersion, +} from './anthropic-profile'; import { mergeConsecutiveUserMessages } from './merge-user-messages'; import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth'; import { @@ -82,6 +94,7 @@ export interface AnthropicOptions { metadata?: Record | undefined; stream?: boolean | undefined; adaptiveThinking?: boolean | undefined; + supportEfforts?: readonly string[] | undefined; kimiThinking?: boolean | undefined; betaApi?: boolean | undefined; clientFactory?: (auth: ProviderRequestAuth) => Anthropic; @@ -102,13 +115,9 @@ interface AnthropicContextManagement { edits: Array<{ type: string; keep?: unknown }>; } -type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; - const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; -const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/; -const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const; const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), maxLength: 64, @@ -137,6 +146,7 @@ function applyResponseFormat( const CEILING_BY_FAMILY_VERSION: Readonly> = { 'fable-5': 128000, + 'mythos-5': 128000, 'opus-4-8': 128000, 'opus-4-7': 128000, 'opus-4-6': 128000, @@ -144,7 +154,8 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = { 'opus-4-1': 32000, 'opus-4-0': 32000, 'opus-4': 32000, - 'sonnet-4-6': 64000, + 'sonnet-5': 128000, + 'sonnet-4-6': 128000, 'sonnet-4-5': 64000, 'sonnet-4-0': 64000, 'sonnet-4': 64000, @@ -159,61 +170,9 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = { 'haiku-3': 4096, }; -const FALLBACK_MAX_TOKENS = 32000; - -type ClaudeFamily = 'opus' | 'sonnet' | 'haiku' | 'fable'; - -interface ClaudeVersion { - family: ClaudeFamily; - major: number; - minor: number | null; -} - -const FAMILY_FIRST_RE = - /(opus|sonnet|haiku|fable)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; -const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; -const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; - -function parseClaudeVersion(model: string): ClaudeVersion | null { - return parseClaudeFamilyVersion(model, true); -} - -function parseClaudeAliasVersion(model: string): ClaudeVersion | null { - return parseClaudeFamilyVersion(model, false); -} - -function parseClaudeFamilyVersion(model: string, requireClaudeMarker: boolean): ClaudeVersion | null { - const normalized = model.toLowerCase(); - if (requireClaudeMarker && !normalized.includes('claude')) return null; - - const familyFirst = FAMILY_FIRST_RE.exec(normalized); - if (familyFirst !== null) { - return { - family: familyFirst[1] as ClaudeFamily, - major: Number.parseInt(familyFirst[2]!, 10), - minor: familyFirst[3] !== undefined ? Number.parseInt(familyFirst[3], 10) : null, - }; - } - const versionFirst = VERSION_FIRST_RE.exec(normalized); - if (versionFirst !== null) { - return { - major: Number.parseInt(versionFirst[1]!, 10), - minor: Number.parseInt(versionFirst[2]!, 10), - family: versionFirst[3] as ClaudeFamily, - }; - } - const bare = BARE_FAMILY_RE.exec(normalized); - if (bare !== null) { - return { - major: Number.parseInt(bare[1]!, 10), - minor: null, - family: bare[2] as ClaudeFamily, - }; - } - return null; -} +const FALLBACK_MAX_TOKENS = 128000; -function lookupClaudeCeiling(version: ClaudeVersion): number | undefined { +function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined { const { family, major, minor } = version; if (minor !== null) { for (let candidate = minor; candidate >= 0; candidate--) { @@ -225,7 +184,7 @@ function lookupClaudeCeiling(version: ClaudeVersion): number | undefined { } export function resolveDefaultMaxTokens(model: string, override?: number): number { - const parsed = parseClaudeVersion(model); + const parsed = parseAnthropicModelVersion(model, true); const ceiling = parsed === null ? undefined : lookupClaudeCeiling(parsed); if (ceiling === undefined) { return override ?? FALLBACK_MAX_TOKENS; @@ -233,93 +192,53 @@ export function resolveDefaultMaxTokens(model: string, override?: number): numbe return override === undefined ? ceiling : Math.min(override, ceiling); } -function parseVersion(match: RegExpExecArray): { major: number; minor: number } { - const majorRaw = match[1]; - const minorRaw = match[2]; - if (majorRaw === undefined || minorRaw === undefined) { - throw new Error('Model version regex did not capture major and minor versions.'); - } - return { major: Number.parseInt(majorRaw, 10), minor: Number.parseInt(minorRaw, 10) }; -} - -function versionAtLeast( - version: { major: number; minor: number }, - minimum: { major: number; minor: number }, -): boolean { - return ( - version.major > minimum.major || - (version.major === minimum.major && version.minor >= minimum.minor) +function requiresAdaptiveThinking(efforts: readonly string[]): boolean { + return efforts.some( + (effort) => effort !== 'low' && effort !== 'medium' && effort !== 'high', ); } -function supportsAdaptiveThinking(model: string): boolean { - const version = parseClaudeAliasVersion(model); - if (version === null) { - return false; +function resolveThinkingProfile( + model: string, + supportEfforts: readonly string[] | undefined, + adaptiveThinking: boolean | undefined, +): AnthropicModelProfile { + const inferred = inferAnthropicModelProfile(model); + if (adaptiveThinking === false) { + return { + ...inferred, + mode: 'budget', + efforts: supportEfforts ?? BUDGET_THINKING_EFFORTS, + supportsEffortParam: false, + }; } - return versionAtLeast( - { major: version.major, minor: version.minor ?? 0 }, - ADAPTIVE_MIN_VERSION, - ); -} -function isOpus47(model: string): boolean { - const match = OPUS_VERSION_RE.exec(model.toLowerCase()); - if (match === null) { - return false; - } - const version = parseVersion(match); - return version.major === 4 && version.minor === 7; -} - -function isFableModel(model: string): boolean { - return parseClaudeAliasVersion(model)?.family === 'fable'; -} - -function supportsEffortParam(model: string, adaptive: boolean): boolean { - if (adaptive) { - return true; + if (adaptiveThinking === true) { + return { + ...inferred, + mode: 'adaptive', + efforts: supportEfforts ?? inferred.efforts, + supportsEffortParam: true, + }; } - const normalized = model.toLowerCase(); - return normalized.includes('opus-4-5') || normalized.includes('opus-4.5'); -} -function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): ThinkingEffort { - if (effort === 'off') { - return effort; - } - if (effort === 'xhigh' && !isOpus47(model) && !isFableModel(model)) { - return 'high'; + if (supportEfforts === undefined) { + return inferred; } - if (effort === 'max' && !adaptive) { - return 'high'; - } - if ( - effort !== 'low' && - effort !== 'medium' && - effort !== 'high' && - effort !== 'xhigh' && - effort !== 'max' - ) { - return 'high'; - } - return effort; + return { + ...inferred, + mode: requiresAdaptiveThinking(supportEfforts) ? 'adaptive' : inferred.mode, + efforts: supportEfforts, + supportsEffortParam: + requiresAdaptiveThinking(supportEfforts) || inferred.supportsEffortParam, + }; } -function budgetTokensForEffort(effort: ThinkingEffort): number { - switch (effort) { - case 'low': - return 1024; - case 'medium': - return 4096; - case 'high': - return 32_000; - case 'off': - case 'xhigh': - case 'max': - throw new Error(`Unsupported budget-based thinking effort: ${effort}`); - } - throw new Error(`Unknown thinking effort: ${String(effort)}`); +function budgetTokensForEffort(effort: ThinkingEffort): number | undefined { + if (effort === 'low') return 1024; + if (effort === 'medium') return 4096; + if (effort === 'on' || effort === 'high') return 32_000; + return undefined; } const CACHE_CONTROL = { type: 'ephemeral' as const }; @@ -327,7 +246,24 @@ const CACHE_CONTROL = { type: 'ephemeral' as const }; type CacheableBlock = ContentBlockParam & { cache_control?: { type: 'ephemeral' } }; function shouldPreserveUnsignedThinking(model: string): boolean { - return parseClaudeAliasVersion(model) === null; + return ( + parseAnthropicModelVersion(model) === null && + matchKnownAnthropicModelProfile(model) === undefined + ); +} + +function shouldBackfillPreservedThinking( + model: string, + thinking: MessageCreateParams['thinking'] | undefined, + contextManagement: AnthropicContextManagement | undefined, +): boolean { + return ( + shouldPreserveUnsignedThinking(model) && + thinking?.type !== 'disabled' && + contextManagement?.edits.some( + (edit) => edit.type === CLEAR_THINKING_EDIT && edit.keep === 'all', + ) === true + ); } const CACHEABLE_TYPES = new Set([ @@ -475,7 +411,11 @@ function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResu content: blocks, } as ToolResultBlockParam; } -function convertMessage(message: Message, model: string): MessageParam { +function convertMessage( + message: Message, + model: string, + backfillPreservedThinking: boolean, +): MessageParam { const role = message.role; if (role === 'system') { @@ -498,19 +438,26 @@ function convertMessage(message: Message, model: string): MessageParam { } const blocks: ContentBlockParam[] = []; + let hasThinkingPart = false; + let lastUnsignedThinkingBlockIndex: number | undefined; + let hasNonEmptyEmittedThinking = false; for (const part of message.content) { if (part.type === 'text') { blocks.push({ type: 'text', text: part.text } satisfies TextBlockParam); } else if (part.type === 'image_url') { blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); } else if (part.type === 'think') { + hasThinkingPart = true; if (part.encrypted !== undefined) { + hasNonEmptyEmittedThinking ||= part.think.length > 0; blocks.push({ type: 'thinking', thinking: part.think, signature: part.encrypted, } satisfies ThinkingBlockParam); } else if (shouldPreserveUnsignedThinking(model)) { + lastUnsignedThinkingBlockIndex = blocks.length; + hasNonEmptyEmittedThinking ||= part.think.length > 0; blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); } } else if (part.type === 'video_url') { @@ -524,6 +471,20 @@ function convertMessage(message: Message, model: string): MessageParam { } } + if (role === 'assistant' && backfillPreservedThinking) { + if (!hasThinkingPart) { + blocks.unshift({ type: 'thinking', thinking: ' ' } as unknown as ThinkingBlockParam); + } else if ( + lastUnsignedThinkingBlockIndex !== undefined && + !hasNonEmptyEmittedThinking + ) { + blocks[lastUnsignedThinkingBlockIndex] = { + type: 'thinking', + thinking: ' ', + } as unknown as ThinkingBlockParam; + } + } + if (message.toolCalls.length > 0) { for (const tc of message.toolCalls) { let toolInput: Record = {}; @@ -551,6 +512,11 @@ function convertMessage(message: Message, model: string): MessageParam { return { role: role, content: blocks }; } + +function shouldKeepConvertedMessage(message: MessageParam): boolean { + return message.role !== 'assistant' || message.content.length > 0; +} + export function convertAnthropicError(error: unknown): ChatProviderError { if (error instanceof AnthropicTimeoutError) { return new APITimeoutError(error.message); @@ -740,7 +706,7 @@ class AnthropicStreamedMessage implements StreamedMessage { yield { type: 'text', text: block.text }; break; case 'thinking': - yield { type: 'think', think: block.thinking }; + yield { type: 'think', think: block.thinking ?? '' }; break; case 'redacted_thinking': yield { @@ -770,7 +736,7 @@ class AnthropicStreamedMessage implements StreamedMessage { yield { type: 'text', text: delta.text }; break; case 'thinking_delta': - yield { type: 'think', think: delta.thinking }; + yield { type: 'think', think: delta.thinking ?? '' }; break; case 'input_json_delta': yield { @@ -830,6 +796,7 @@ export class AnthropicChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; + private readonly _supportEfforts: readonly string[] | undefined; private readonly _kimiThinking: boolean; private _betaApi: boolean; private _explicitMaxTokens: boolean; @@ -839,6 +806,7 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; + this._supportEfforts = options.supportEfforts; this._kimiThinking = options.kimiThinking ?? false; this._betaApi = options.betaApi ?? false; this._apiKey = @@ -913,13 +881,19 @@ export class AnthropicChatProvider implements ChatProvider { ] : undefined; + const backfillPreservedThinking = shouldBackfillPreservedThinking( + this._model, + this._generationKwargs.thinking, + this._generationKwargs.contextManagement, + ); + const messages = mergeConsecutiveUserMessages( normalizeToolCallIdsForProvider( history.filter((msg) => !isToolDeclarationOnlyMessage(msg)), ANTHROPIC_TOOL_CALL_ID_POLICY, - ).map((msg) => - convertMessage(msg, this._model), - ), + ) + .map((msg) => convertMessage(msg, this._model, backfillPreservedThinking)) + .filter(shouldKeepConvertedMessage), { isUser: (message) => message.role === 'user', isToolResultOnly, @@ -949,7 +923,7 @@ export class AnthropicChatProvider implements ChatProvider { kwargs['top_p'] = this._generationKwargs.top_p; } const thinking = this._generationKwargs.thinking; - if (thinking !== undefined && !(thinking.type === 'disabled' && isFableModel(this._model))) { + if (thinking !== undefined) { kwargs['thinking'] = thinking; } if (this._generationKwargs.output_config !== undefined) { @@ -1096,11 +1070,15 @@ export class AnthropicChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): AnthropicChatProvider { - const adaptive = this._adaptiveThinking ?? supportsAdaptiveThinking(this._model); + const profile = resolveThinkingProfile( + this._model, + this._supportEfforts, + this._kimiThinking ? true : this._adaptiveThinking, + ); if (effort === 'off') { let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; - if (adaptive) { + if (profile.mode === 'adaptive') { newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); } const clone = this._withGenerationKwargs({ @@ -1112,7 +1090,7 @@ export class AnthropicChatProvider implements ChatProvider { } let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; - if (adaptive) { + if (profile.mode === 'adaptive') { newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); } if (this._kimiThinking) { @@ -1130,31 +1108,32 @@ export class AnthropicChatProvider implements ChatProvider { return clone; } - const clamped = clampEffort(effort, this._model, adaptive); - if (clamped === 'off') { - throw new Error('Non-off thinking effort unexpectedly clamped to off.'); - } - const effectiveEffort = clamped as AnthropicEffort; - - if (adaptive) { + if (profile.mode === 'adaptive') { return this._withGenerationKwargs({ thinking: { type: 'adaptive', display: 'summarized' }, - output_config: { effort: effectiveEffort }, + output_config: + effort === 'on' + ? undefined + : ({ effort } as MessageCreateParams['output_config']), betaFeatures: newBetas, }); } + const budgetTokens = budgetTokensForEffort(effort); const kwargs: Partial = { - thinking: { type: 'enabled', budget_tokens: budgetTokensForEffort(effectiveEffort) }, + thinking: + budgetTokens === undefined + ? ({ type: 'enabled' } as MessageCreateParams['thinking']) + : { type: 'enabled', budget_tokens: budgetTokens }, betaFeatures: newBetas, }; - if (supportsEffortParam(this._model, adaptive)) { - kwargs.output_config = { effort: effectiveEffort }; + if ((profile.supportsEffortParam || budgetTokens === undefined) && effort !== 'on') { + kwargs.output_config = { effort } as MessageCreateParams['output_config']; } else { kwargs.output_config = undefined; } const clone = this._withGenerationKwargs(kwargs); - if (!supportsEffortParam(this._model, adaptive)) { + if (!profile.supportsEffortParam && budgetTokens !== undefined) { delete clone._generationKwargs.output_config; } return clone; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index c77e6fc106..5078ba0514 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -156,7 +156,10 @@ function convertMessage(message: Message, preservedThinkingEnabled: boolean): Op } if (hasReasoningPart || (preservedThinkingEnabled && message.role === 'assistant')) { - result.reasoning_content = reasoningContent; + result.reasoning_content = + preservedThinkingEnabled && message.role === 'assistant' && reasoningContent.length === 0 + ? ' ' + : reasoningContent; } if (message.tools !== undefined && message.tools.length > 0) { diff --git a/packages/agent-core-v2/src/app/model/modelAuth.ts b/packages/agent-core-v2/src/app/model/modelAuth.ts index fad5e2587f..6f768a9f80 100644 --- a/packages/agent-core-v2/src/app/model/modelAuth.ts +++ b/packages/agent-core-v2/src/app/model/modelAuth.ts @@ -7,6 +7,11 @@ */ import { ErrorCodes, Error2 } from '#/errors'; +import { + BUDGET_THINKING_EFFORTS, + inferAnthropicModelProfile, + matchKnownAnthropicModelProfile, +} from '#/app/llmProtocol/providers/anthropic-profile'; import { type PlatformConfig, UNKNOWN_PLATFORM_KEY } from '#/app/platform/platform'; import type { OAuthRef, ProviderConfig } from '#/app/provider/provider'; import type { Protocol } from '#/app/protocol/protocol'; @@ -70,19 +75,50 @@ export function resolveModelAuthMaterial(args: { return {}; } -export function effectiveModelConfig(model: ModelConfig): ModelConfig { +export function effectiveModelConfig( + model: ModelConfig, + anthropicCompatible = false, +): ModelConfig { const { overrides, ...base } = model; - if (overrides === undefined) return model; - const effective: ModelConfig = { ...base, ...overrides }; + const effective: ModelConfig = overrides === undefined ? model : { ...base, ...overrides }; if ( - overrides.supportEfforts !== undefined && + overrides?.supportEfforts !== undefined && overrides.defaultEffort === undefined && effective.defaultEffort !== undefined && !overrides.supportEfforts.includes(effective.defaultEffort) ) { delete effective.defaultEffort; } - return effective; + return withAnthropicProfile( + effective, + anthropicCompatible || effective.protocol === 'anthropic', + ); +} + +function withAnthropicProfile(model: ModelConfig, anthropicCompatible: boolean): ModelConfig { + const wireName = model.name ?? model.model; + const profile = + wireName === undefined + ? undefined + : anthropicCompatible + ? inferAnthropicModelProfile(wireName) + : matchKnownAnthropicModelProfile(wireName); + if (profile === undefined) return model; + const capability = profile.canDisableThinking ? 'thinking' : 'always_thinking'; + const capabilities = model.capabilities ?? []; + const hasCapability = capabilities.some( + (candidate) => candidate.trim().toLowerCase() === capability, + ); + const supportEfforts = + model.supportEfforts ?? + (model.adaptiveThinking === false ? [...BUDGET_THINKING_EFFORTS] : [...profile.efforts]); + return { + ...model, + capabilities: hasCapability ? capabilities : [...capabilities, capability], + supportEfforts, + defaultEffort: + model.defaultEffort ?? (supportEfforts.includes('high') ? 'high' : undefined), + }; } export function deriveProviderId(baseUrl: string): string { diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 175ce24943..f7019ae062 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -84,9 +84,11 @@ export class ModelResolverService extends Disposable implements IModelResolver { `Model "${id}" is not configured in config.toml.`, ); } - const model = effectiveModelConfig(configuredModel); - - const { providerConfig, providerName, resolvedBaseUrl: rawBaseUrl } = this.resolveProviderContext(id, model); + const routingModel = effectiveModelConfig(configuredModel); + const { providerConfig, providerName, resolvedBaseUrl: rawBaseUrl } = + this.resolveProviderContext(id, routingModel); + const protocol = this.resolveProtocol(id, routingModel, providerConfig); + const model = effectiveModelConfig(configuredModel, protocol === 'anthropic'); const auth = resolveModelAuthMaterial({ modelId: id, model, @@ -96,7 +98,6 @@ export class ModelResolverService extends Disposable implements IModelResolver { }); const authProvider = this.buildAuthProvider(providerName, auth); - const protocol = this.resolveProtocol(id, model, providerConfig); const providerType = providerConfig?.type ?? protocol; const resolvedBaseUrl = model.protocol === 'anthropic' && rawBaseUrl !== undefined @@ -160,14 +161,16 @@ export class ModelResolverService extends Disposable implements IModelResolver { const effort = this.resolveDefaultThinking( model, alwaysThinking, + protocol === 'kimi', providerType === 'kimi', ); - return effort === 'off' ? impl : impl.withThinking(effort); + return effort === 'off' && protocol !== 'anthropic' ? impl : impl.withThinking(effort); } private resolveDefaultThinking( model: ModelConfig, alwaysThinking: boolean, + kimiProtocol: boolean, kimiProvider: boolean, ): ThinkingEffort { const thinking = this.config.get('thinking'); @@ -178,7 +181,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { effort: thinking?.effort, }, { ...model, alwaysThinking }, - kimiProvider, + kimiProtocol, ); return ( resolveKimiThinkingEffortOverride(thinking?.forcedEffort, effort, kimiProvider) ?? effort @@ -336,6 +339,7 @@ function buildProtocolProviderOptions( switch (protocol) { case 'anthropic': if (model.maxOutputSize !== undefined) options.defaultMaxTokens = model.maxOutputSize; + if (model.supportEfforts !== undefined) options.supportEfforts = model.supportEfforts; if (model.adaptiveThinking !== undefined) options.adaptiveThinking = model.adaptiveThinking; if (provider?.type === 'kimi') options.kimiThinking = true; if (model.betaApi !== undefined) options.betaApi = model.betaApi; diff --git a/packages/agent-core-v2/src/app/model/thinking.ts b/packages/agent-core-v2/src/app/model/thinking.ts index b9fac6f3a4..a46054b218 100644 --- a/packages/agent-core-v2/src/app/model/thinking.ts +++ b/packages/agent-core-v2/src/app/model/thinking.ts @@ -102,9 +102,9 @@ export function defaultThinkingEffortForModel( export function modelSupportsThinkingEffort( effort: ThinkingEffort, model: ModelThinkingMetadata | undefined, - kimiProvider: boolean, + kimiProtocol: boolean, ): boolean { - if (!kimiProvider || effort === 'off') return true; + if (!kimiProtocol || effort === 'off') return true; if (!modelSupportsThinking(model)) return false; const efforts = effortsFor(model); return efforts.length === 0 || effort === 'on' || efforts.includes(effort); @@ -113,11 +113,11 @@ export function modelSupportsThinkingEffort( function normalizeThinkingEffortForModel( effort: ThinkingEffort, model: ModelThinkingMetadata | undefined, - kimiProvider: boolean, + kimiProtocol: boolean, ): ThinkingEffort { if (effort === 'off' && model?.alwaysThinking !== true) return 'off'; const efforts = effortsFor(model); - if (!kimiProvider) { + if (!kimiProtocol) { return effort === 'on' && efforts.length > 0 ? defaultThinkingEffortForModel(model) : effort; @@ -134,7 +134,7 @@ export function resolveThinkingEffortForModel( requested: string | undefined, defaults: ThinkingDefaults | undefined, model: ModelThinkingMetadata | undefined, - kimiProvider = false, + kimiProtocol = false, ): ThinkingEffort { const configured = nonEmpty(defaults?.effort) as ThinkingEffort | undefined; const normalized = normalizeRequestedThinkingEffort(requested); @@ -147,8 +147,8 @@ export function resolveThinkingEffortForModel( effort = configured ?? defaultThinkingEffortForModel(model); } - if (effort === 'off' && model?.alwaysThinking === true) { + if (kimiProtocol && effort === 'off' && model?.alwaysThinking === true) { effort = configured ?? defaultThinkingEffortForModel(model); } - return normalizeThinkingEffortForModel(effort, model, kimiProvider); + return normalizeThinkingEffortForModel(effort, model, kimiProtocol); } diff --git a/packages/agent-core-v2/src/app/modelCatalog/modelCatalog.ts b/packages/agent-core-v2/src/app/modelCatalog/modelCatalog.ts index 8789f8b649..d8e58310a9 100644 --- a/packages/agent-core-v2/src/app/modelCatalog/modelCatalog.ts +++ b/packages/agent-core-v2/src/app/modelCatalog/modelCatalog.ts @@ -104,8 +104,12 @@ export interface ProviderCredentialState { readonly hasOAuthToken: boolean; } -export function toProtocolModel(modelId: string, alias: ModelAlias): ModelCatalogItem { - const effective = effectiveModelConfig(alias); +export function toProtocolModel( + modelId: string, + alias: ModelAlias, + anthropicCompatible = false, +): ModelCatalogItem { + const effective = effectiveModelConfig(alias, anthropicCompatible); return { provider: effective.provider ?? '', model: modelId, diff --git a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts index ae33a1ac6e..101baa0b10 100644 --- a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts @@ -67,7 +67,9 @@ export class ModelCatalogService implements IModelCatalogService { async listModels(): Promise { const models = this.modelService.list(); - return Object.entries(models).map(([modelId, alias]) => toProtocolModel(modelId, alias)); + return Object.entries(models).map(([modelId, alias]) => + toProtocolModel(modelId, alias, this.usesAnthropicProtocol(alias)), + ); } async listProviders(): Promise { @@ -100,10 +102,16 @@ export class ModelCatalogService implements IModelCatalogService { const updatedAlias = this.modelService.get(modelId) ?? alias; return { default_model: modelId, - model: toProtocolModel(modelId, updatedAlias), + model: toProtocolModel(modelId, updatedAlias, this.usesAnthropicProtocol(updatedAlias)), }; } + private usesAnthropicProtocol(alias: ModelAlias): boolean { + const providerId = + alias.providerId ?? alias.provider ?? this.config.get('defaultProvider'); + return (alias.protocol ?? this.providerService.get(providerId ?? '')?.type) === 'anthropic'; + } + refreshProviderModels( options: RefreshProviderModelsOptions = {}, ): Promise { diff --git a/packages/agent-core-v2/src/app/protocol/protocol.ts b/packages/agent-core-v2/src/app/protocol/protocol.ts index 67575c69ec..13d7f9a409 100644 --- a/packages/agent-core-v2/src/app/protocol/protocol.ts +++ b/packages/agent-core-v2/src/app/protocol/protocol.ts @@ -33,6 +33,7 @@ export type Protocol = z.infer; export interface ProtocolProviderOptions { readonly reasoningKey?: string; readonly defaultMaxTokens?: number; + readonly supportEfforts?: readonly string[]; readonly adaptiveThinking?: boolean; readonly kimiThinking?: boolean; readonly betaApi?: boolean; diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index 4b5626b8bc..d8bf823a19 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -12,7 +12,7 @@ */ import { SyncDescriptor } from '#/_base/di/descriptors'; -import { DisposableStore } from '#/_base/di/lifecycle'; +import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; @@ -31,10 +31,12 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentUsageService } from '#/agent/usage/usage'; import { IConfigService } from '#/app/config/config'; +import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { IFlagService } from '#/app/flag/flag'; import { APIRequestTooLargeError, APIStatusError } from '#/app/llmProtocol/errors'; import { emptyUsage } from '#/app/llmProtocol/usage'; import type { Message } from '#/app/llmProtocol/message'; +import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; import type { ModelCapability } from '#/app/llmProtocol/capability'; import type { LLMEvent, LLMRequestInput, Model } from '#/app/model/modelInstance'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -127,16 +129,20 @@ function createService( > >) | undefined, - options: { readonly flagEnabled?: boolean } = {}, + options: { + readonly flagEnabled?: boolean; + readonly thinkingLevel?: ThinkingEffort; + } = {}, ) { const ix = disposables.add(new TestInstantiationService()); + const thinkingLevel = options.thinkingLevel ?? 'off'; const profile: Partial = { resolveModelContext: () => ({ modelAlias: 'm', modelCapabilities: capabilities, maxOutputSize: undefined, alwaysThinking: undefined, - thinkingLevel: 'off', + thinkingLevel, reservedContextSize: undefined, compactionTriggerRatio: undefined, }), @@ -146,7 +152,7 @@ function createService( cwd: '', modelAlias: 'm', modelCapabilities: capabilities, - thinkingLevel: 'off', + thinkingLevel, systemPrompt: 'system', }), isToolActive: () => true, @@ -170,6 +176,12 @@ function createService( }; const flagEnabled = options.flagEnabled ?? true; const testSnapshot = Object.freeze({}) as MediaStripSnapshot; + const events: DomainEvent[] = []; + const eventBus: IEventBus = { + _serviceBrand: undefined, + publish: (event) => events.push(event), + subscribe: () => toDisposable(() => {}), + }; ix.stub(IAgentContextMemoryService, context); ix.stub(IAgentToolSelectService, toolSelect); @@ -195,7 +207,10 @@ function createService( ix.stub(ILogService, log); ix.stub(ITelemetryService, telemetry); const records: WireRecord[] = []; - registerTestAgentWire(ix, 'wire/llm-requester', { log: recordingWireLog(records) }); + registerTestAgentWire(ix, 'wire/llm-requester', { + log: recordingWireLog(records), + eventBus, + }); ix.set(IFaultInjectionService, new SyncDescriptor(FaultInjectionService)); ix.set(IAgentLLMRequesterService, new SyncDescriptor(AgentLLMRequesterService)); @@ -204,9 +219,33 @@ function createService( faultInjection: ix.get(IFaultInjectionService), wire: ix.get(IWireService), records, + events, }; } +describe('AgentLLMRequesterService Anthropic effort diagnostics', () => { + it('warns and sends when the effort is not listed by the model', async () => { + const calls = { value: 0 }; + const model = createModel(calls, null); + Object.defineProperty(model, 'supportEfforts', { value: ['max'] }); + Object.defineProperty(model, 'withMaxCompletionTokens', { value: () => model }); + const { service, events } = createService(model, undefined, { thinkingLevel: 'high' }); + + const result = await service.request(); + + expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(calls.value).toBe(1); + expect(events.filter((event) => event.type === 'warning')).toEqual([ + { + type: 'warning', + code: 'anthropic-thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "wire-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + }, + ]); + }); +}); + describe('AgentLLMRequesterService strict resend', () => { it('resends once with strict projection after a recoverable structural 400', async () => { const calls = { value: 0 }; 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 9dae5e8760..719bd06435 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 @@ -275,6 +275,15 @@ describe('ConfigState thinking clamp for always-thinking models', () => { supportEfforts: ['low', 'high', 'ultra'], defaultEffort: 'ultra', }, + 'kimi-code/compatible': { + provider: 'kimi', + protocol: 'anthropic', + model: 'compatible-model', + maxContextSize: 128_000, + capabilities: ['thinking', 'always_thinking'], + supportEfforts: ['max'], + defaultEffort: 'max', + } as TestProtocolModelConfig, }, }; capturedProvider = undefined; @@ -384,6 +393,38 @@ 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', () => { + profile.update({ modelAlias: 'kimi-code/compatible', thinkingLevel: 'max' }); + + expect(() => { + profile.setThinking('high'); + }).not.toThrow(); + expect(profile.data().thinkingLevel).toBe('high'); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: { + code: 'anthropic-thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + }, + }); + + 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.', + }, + }); + }); }); describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => { 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 750edbcaf9..a301c37441 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -16,10 +16,12 @@ const effortModelWithDefault = { const alwaysThinkingModel = { capabilities: ['thinking', 'always_thinking'], alwaysThinking: true, + protocol: 'kimi', }; const alwaysThinkingEffortModel = { capabilities: ['thinking', 'always_thinking'], alwaysThinking: true, + protocol: 'kimi', supportEfforts: ['low', 'high', 'max'], defaultEffort: 'high', }; @@ -117,6 +119,16 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort(undefined, { enabled: false }, booleanModel)).toBe('off'); }); + it('preserves off for Kimi-managed always-thinking models using Anthropic protocol', () => { + expect( + resolveThinkingEffort('off', undefined, { + ...alwaysThinkingEffortModel, + protocol: 'anthropic', + providerType: 'kimi', + }), + ).toBe('off'); + }); + it('carries custom requested efforts through', () => { expect(resolveThinkingEffort('xhigh', undefined)).toBe('xhigh'); expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('bogus'); @@ -131,6 +143,7 @@ describe('resolveThinkingEffort', () => { expect( resolveThinkingEffort('ultra', undefined, { ...effortModel, + protocol: 'kimi', providerType: 'kimi', }), ).toBe('medium'); @@ -140,6 +153,7 @@ describe('resolveThinkingEffort', () => { expect( resolveThinkingEffort('ultra', undefined, { ...booleanModel, + protocol: 'kimi', providerType: 'kimi', }), ).toBe('on'); @@ -147,7 +161,11 @@ describe('resolveThinkingEffort', () => { it('reports unsupported concrete efforts only for Kimi effort models', () => { expect( - supportsThinkingEffort('ultra', { ...effortModel, providerType: 'kimi' }), + supportsThinkingEffort('ultra', { + ...effortModel, + protocol: 'kimi', + providerType: 'kimi', + }), ).toBe(false); expect( supportsThinkingEffort('ultra', { ...effortModel, providerType: 'openai' }), diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts index e75b7b0f2c..3f9c499346 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts @@ -1,12 +1,18 @@ +/** + * Scenario: Anthropic context-management requests replay native and compatible-model history. + * Responsibilities: emit preserved-thinking controls and normalize required compatible history only. + * Wiring: real v2 Anthropic adapter with only the remote SDK client boundary replaced by mocks. + * Run: pnpm exec vitest run packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts + */ import type { Message } from '#/app/llmProtocol/message'; import { AnthropicChatProvider } from '#/app/llmProtocol/providers/anthropic'; import { describe, expect, it, vi } from 'vitest'; const HISTORY: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }]; -function createProvider(): AnthropicChatProvider { +function createProvider(model = 'kimi-for-coding'): AnthropicChatProvider { return new AnthropicChatProvider({ - model: 'kimi-for-coding', + model, apiKey: 'test-key', defaultMaxTokens: 1024, stream: false, @@ -25,7 +31,10 @@ function makeAnthropicResponse() { }; } -async function captureBetaRequestBody(provider: AnthropicChatProvider): Promise> { +async function captureBetaRequestBody( + provider: AnthropicChatProvider, + history: Message[] = HISTORY, +): Promise> { let capturedParams: Record | undefined; const standardCreate = vi.fn(); @@ -36,7 +45,7 @@ async function captureBetaRequestBody(provider: AnthropicChatProvider): Promise< }); (provider as unknown as { _client: { messages: { create: unknown } } })._client.messages.create = standardCreate; - const stream = await provider.generate('', [], HISTORY); + const stream = await provider.generate('', [], history); for await (const part of stream) void part; if (capturedParams === undefined) { @@ -87,4 +96,159 @@ describe('Anthropic withThinkingKeep context_management parity', () => { { type: 'clear_thinking_20251015', keep: 'all' }, ]); }); + + it('backfills non-empty thinking when compatible text history is replayed with keep all', async () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + }, + { role: 'user', content: [{ type: 'text', text: 'Continue' }], toolCalls: [] }, + ]; + const provider = createProvider('compatible-preserved-thinking-model') + .withThinking('max') + .withThinkingKeep('all'); + + const body = await captureBetaRequestBody(provider, history); + const messages = body['messages'] as Array<{ role: string; content: unknown[] }>; + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'Hello' }, + ], + }); + }); + + it('backfills non-empty thinking before a compatible assistant tool call with keep all', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + const provider = createProvider('compatible-preserved-thinking-model') + .withThinking('max') + .withThinkingKeep('all'); + + const body = await captureBetaRequestBody(provider, history); + const messages = body['messages'] as Array<{ role: string; content: unknown[] }>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: ' ' }, + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: { q: 'test' }, + cache_control: { type: 'ephemeral' }, + }, + ], + }); + }); + + it('replaces an existing empty thinking block when compatible history uses keep all', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '' }, + { type: 'text', text: 'Hello' }, + ], + toolCalls: [], + }, + ]; + const provider = createProvider('compatible-preserved-thinking-model') + .withThinking('max') + .withThinkingKeep('all'); + + const body = await captureBetaRequestBody(provider, history); + const messages = body['messages'] as Array<{ role: string; content: unknown[] }>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, + ], + }); + }); + + it('leaves missing compatible thinking absent when keep all is not enabled', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + }, + ]; + const provider = createProvider('compatible-preserved-thinking-model').withThinking('max'); + + let captured: Record | undefined; + (provider as unknown as { _client: { messages: { create: unknown } } })._client.messages.create = + vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve(makeAnthropicResponse()); + }); + + const stream = await provider.generate('', [], history); + for await (const part of stream) void part; + const messages = captured?.['messages'] as Array<{ role: string; content: unknown[] }>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }], + }); + }); + + it('leaves missing compatible thinking absent when thinking is disabled', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + }, + ]; + const provider = createProvider('compatible-preserved-thinking-model') + .withThinking('off') + .withThinkingKeep('all'); + + const body = await captureBetaRequestBody(provider, history); + const messages = body['messages'] as Array<{ role: string; content: unknown[] }>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }], + }); + }); + + it.each(['claude-opus-4-8', 'claude-opus-4-9', 'claude-mythos-preview'])( + 'does not synthesize unsigned thinking for Claude model %s with keep all', + async (model) => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + }, + ]; + const provider = createProvider(model).withThinking('max').withThinkingKeep('all'); + + const body = await captureBetaRequestBody(provider, history); + const messages = body['messages'] as Array<{ role: string; content: unknown[] }>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }], + }); + }, + ); }); diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-max-tokens.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-max-tokens.test.ts index 1b01f46a3e..ffde69f1cc 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-max-tokens.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-max-tokens.test.ts @@ -1,3 +1,7 @@ +/** + * `llmProtocol` domain (L0) — verifies Anthropic request limits and thinking profiles. + */ + import { describe, expect, it, vi } from 'vitest'; import type { Message } from '#/app/llmProtocol/message'; @@ -5,6 +9,7 @@ import { AnthropicChatProvider, resolveDefaultMaxTokens, } from '#/app/llmProtocol/providers/anthropic'; +import { matchKnownAnthropicModelProfile } from '#/app/llmProtocol/providers/anthropic-profile'; const HISTORY: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, @@ -55,6 +60,33 @@ async function maxTokensFor( return (await captureRequestBody(provider))['max_tokens'] as number; } +describe('Anthropic model profile matching', () => { + it.each([ + ['claude-opus-4-5', 'budget', ['low', 'medium', 'high'], true, true], + ['anthropic.claude-opus-4-6-v1:0', 'adaptive', ['low', 'medium', 'high', 'max'], true, true], + ['claude-opus-4-7', 'adaptive', ['low', 'medium', 'high', 'xhigh', 'max'], true, true], + ['claude-sonnet-4-6', 'adaptive', ['low', 'medium', 'high', 'max'], true, true], + ['claude-sonnet-5', 'adaptive', ['low', 'medium', 'high', 'xhigh', 'max'], true, true], + ['claude-fable-5', 'adaptive', ['low', 'medium', 'high', 'xhigh', 'max'], true, false], + ['claude-mythos-5', 'adaptive', ['low', 'medium', 'high', 'xhigh', 'max'], true, false], + ['claude-mythos-preview', 'adaptive', ['low', 'medium', 'high', 'max'], true, false], + ] as const)( + 'matches %s to the built-in official profile', + (model, mode, efforts, supportsEffortParam, canDisableThinking) => { + expect(matchKnownAnthropicModelProfile(model)).toEqual({ + mode, + efforts, + supportsEffortParam, + canDisableThinking, + }); + }, + ); + + it('does not claim an official profile for an unrecognized compatible model', () => { + expect(matchKnownAnthropicModelProfile('Example Compatible Model')).toBeUndefined(); + }); +}); + describe('resolveDefaultMaxTokens', () => { it('returns per-version Messages-API caps for known Claude 4 models', () => { expect(resolveDefaultMaxTokens('claude-fable-5')).toBe(128000); @@ -62,20 +94,21 @@ describe('resolveDefaultMaxTokens', () => { expect(resolveDefaultMaxTokens('claude-opus-4-7')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-6')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-5-20251101')).toBe(64000); - expect(resolveDefaultMaxTokens('claude-sonnet-4-6')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-sonnet-5')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-sonnet-4-6')).toBe(128000); expect(resolveDefaultMaxTokens('claude-haiku-4-5')).toBe(64000); }); it('matches dotted version separators', () => { expect(resolveDefaultMaxTokens('claude-opus-4.8')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4.7')).toBe(128000); - expect(resolveDefaultMaxTokens('claude-sonnet-4.6')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-sonnet-4.6')).toBe(128000); }); it('falls back to the nearest lower catalogued minor for unknown minors', () => { expect(resolveDefaultMaxTokens('claude-opus-4-9')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-10')).toBe(128000); - expect(resolveDefaultMaxTokens('claude-sonnet-4-9')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-sonnet-4-9')).toBe(128000); expect(resolveDefaultMaxTokens('claude-haiku-4-9')).toBe(64000); expect(resolveDefaultMaxTokens('claude-opus-4-3')).toBe(32000); }); @@ -85,9 +118,9 @@ describe('resolveDefaultMaxTokens', () => { expect(resolveDefaultMaxTokens('claude-opus-4-7', 999999)).toBe(128000); }); - it('honors the override for unknown models and falls back to 32000', () => { + it('honors the override for unknown models and otherwise falls back to 128k', () => { expect(resolveDefaultMaxTokens('unknown-model', 12345)).toBe(12345); - expect(resolveDefaultMaxTokens('totally-unknown-model')).toBe(32000); + expect(resolveDefaultMaxTokens('totally-unknown-model')).toBe(128000); }); }); @@ -95,13 +128,17 @@ describe('AnthropicChatProvider constructor max_tokens', () => { it('uses per-version Messages-API caps for known Claude models', async () => { expect(await maxTokensFor('claude-opus-4-8')).toBe(128000); expect(await maxTokensFor('claude-opus-4-7')).toBe(128000); - expect(await maxTokensFor('claude-sonnet-4-6')).toBe(64000); + expect(await maxTokensFor('claude-sonnet-4-6')).toBe(128000); }); it('honors defaultMaxTokens for unknown models', async () => { expect(await maxTokensFor('unknown-model', { defaultMaxTokens: 4321 })).toBe(4321); }); + it('uses the 128k fallback for unknown models without an override', async () => { + expect(await maxTokensFor('unknown-model')).toBe(128000); + }); + it('honors a lower defaultMaxTokens on known models', async () => { expect(await maxTokensFor('claude-opus-4-7', { defaultMaxTokens: 200 })).toBe(200); }); @@ -133,3 +170,139 @@ describe('AnthropicChatProvider constructor max_tokens', () => { expect(body['max_tokens']).toBe(128000); }); }); + +describe('AnthropicChatProvider thinking profiles', () => { + it('uses the latest Opus profile for an unrecognized model name', async () => { + const provider = new AnthropicChatProvider({ + model: 'compatible-model', + apiKey: 'test-key', + stream: false, + }).withThinking('max'); + + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('lets declared supportEfforts override a legacy model-name profile', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-5', + apiKey: 'test-key', + stream: false, + supportEfforts: ['low', 'high', 'max'], + }).withThinking('max'); + + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('passes an effort outside declared supportEfforts through unchanged', async () => { + const provider = new AnthropicChatProvider({ + model: 'compatible-model', + apiKey: 'test-key', + stream: false, + supportEfforts: ['low', 'high'], + }).withThinking('max'); + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('keeps a concrete effort when adaptiveThinking is false', async () => { + const provider = new AnthropicChatProvider({ + model: 'compatible-model', + apiKey: 'test-key', + stream: false, + adaptiveThinking: false, + supportEfforts: ['low', 'high', 'max'], + }).withThinking('max'); + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'enabled' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('adaptiveThinking=false omits the effort param for an unversioned model name', async () => { + const provider = new AnthropicChatProvider({ + model: 'compatible-model', + apiKey: 'test-key', + stream: false, + adaptiveThinking: false, + }).withThinking('high'); + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); + expect(body['output_config']).toBeUndefined(); + }); + + it('infers the budget profile for a pre-4.6 Claude model', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-5', + apiKey: 'test-key', + stream: false, + }).withThinking('high'); + + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); + expect(body['output_config']).toEqual({ effort: 'high' }); + }); + + it('passes max through without converting it for a pre-4.6 Claude model', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-5', + apiKey: 'test-key', + stream: false, + }).withThinking('max'); + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'enabled' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('passes xhigh through for 4.6 without affecting max', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-sonnet-4-6', + apiKey: 'test-key', + stream: false, + adaptiveThinking: true, + }); + + const xhighBody = await captureRequestBody(provider.withThinking('xhigh')); + const body = await captureRequestBody(provider.withThinking('max')); + expect(xhighBody['output_config']).toEqual({ effort: 'xhigh' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it.each(['claude-fable-5', 'claude-mythos-5', 'claude-mythos-preview'])( + '%s: passes off through for the backend to validate', + async (model) => { + const provider = new AnthropicChatProvider({ + model, + apiKey: 'test-key', + stream: false, + }).withThinking('off'); + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'disabled' }); + expect(body['output_config']).toBeUndefined(); + }, + ); + + it('represents boolean on with the legacy high token budget', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + stream: false, + }).withThinking('on'); + + const body = await captureRequestBody(provider); + + expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); + expect(body['output_config']).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts index 184934d804..2c11497044 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts @@ -5,6 +5,7 @@ * Run: pnpm exec vitest run packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts */ import type { Message, StreamedMessagePart } from '#/app/llmProtocol/message'; +import { generate } from '#/app/llmProtocol/generate'; import { AnthropicChatProvider } from '#/app/llmProtocol/providers/anthropic'; import { GoogleGenAIChatProvider, @@ -28,6 +29,16 @@ const EMPTY_THINKING_TOOL_HISTORY: Message[] = [ }, ]; +const UNSIGNED_THINKING_ONLY_HISTORY: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Start' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'think', think: 'Partial reasoning' }], + toolCalls: [], + }, + { role: 'user', content: [{ type: 'text', text: 'Continue' }], toolCalls: [] }, +]; + function chatCompletionResponse(message: Record) { return { id: 'chatcmpl-test', @@ -75,6 +86,64 @@ async function captureKimiMessages( return captured['messages'] as Array>; } +async function captureAnthropicMessages( + model: string, + history: Message[], + configure?: (provider: AnthropicChatProvider) => AnthropicChatProvider, +): Promise> { + let captured: Record | undefined; + const create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve({ + id: 'msg_test', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }); + let provider = new AnthropicChatProvider({ + model, + apiKey: '', + defaultMaxTokens: 1024, + stream: false, + clientFactory: () => ({ + messages: { create }, + beta: { messages: { create } }, + }) as never, + }); + if (configure !== undefined) { + provider = configure(provider); + } + + const response = await provider.generate('', [], history); + await collectParts(response); + + if (captured === undefined) { + throw new Error('Expected Anthropic provider to send a request.'); + } + return captured['messages'] as Array<{ role: string; content: unknown[] }>; +} + +function createStreamingAnthropicProvider( + events: readonly Record[], +) { + async function* responseStream() { + yield* events; + } + const create = vi.fn().mockResolvedValue(responseStream()); + return new AnthropicChatProvider({ + model: 'compatible-model', + apiKey: '', + defaultMaxTokens: 1024, + clientFactory: () => ({ messages: { create } }) as never, + }); +} + +async function collectAnthropicStreamParts( + events: readonly Record[], +): Promise { + return collectParts(await createStreamingAnthropicProvider(events).generate('', [], [])); +} + describe('empty thinking round-trip', () => { it('Kimi sends an explicitly empty ThinkPart back as reasoning_content', async () => { const messages = await captureKimiMessages(EMPTY_THINKING_TOOL_HISTORY); @@ -96,7 +165,7 @@ describe('empty thinking round-trip', () => { provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ''); + expect(messages[0]).toHaveProperty('reasoning_content', ' '); }); it('Kimi backfills a text assistant message when keep=all omits thinking.type', async () => { @@ -112,30 +181,36 @@ describe('empty thinking round-trip', () => { provider.withExtraBody({ thinking: { keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ''); + expect(messages[0]).toHaveProperty('reasoning_content', ' '); }); - it.each([ - ['empty', ''], - ['non-empty', 'reasoning text'], - ])( - 'Kimi sends an existing %s ThinkPart verbatim when preserved thinking is active', - async (_kind, think) => { - const history: Message[] = [ - { - role: 'assistant', - content: [{ type: 'think', think }], - toolCalls: [], - }, - ]; + it('Kimi replaces an existing empty ThinkPart when preserved thinking is active', async () => { + const messages = await captureKimiMessages(EMPTY_THINKING_TOOL_HISTORY, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); - const messages = await captureKimiMessages(history, (provider) => - provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), - ); + expect(messages[0]).toHaveProperty('reasoning_content', ' '); + }); - expect(messages[0]).toHaveProperty('reasoning_content', think); - }, - ); + it('Kimi sends existing non-empty thinking verbatim when preserved thinking is active', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '' }, + { type: 'think', think: 'reasoning text' }, + { type: 'think', think: '' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', 'reasoning text'); + }); it.each([ ['missing', undefined], @@ -181,6 +256,14 @@ describe('empty thinking round-trip', () => { expect(messages[0]).not.toHaveProperty('reasoning_content'); }); + it('Kimi leaves explicit empty thinking unchanged when thinking is disabled', async () => { + const messages = await captureKimiMessages(EMPTY_THINKING_TOOL_HISTORY, (provider) => + provider.withExtraBody({ thinking: { type: 'disabled', keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', ''); + }); + it('Kimi does not backfill reasoning_content on non-assistant messages', async () => { const history: Message[] = [ { role: 'system', content: [{ type: 'text', text: 'System.' }], toolCalls: [] }, @@ -317,28 +400,269 @@ describe('empty thinking round-trip', () => { }); it('Anthropic-compatible providers send unsigned empty thinking blocks back', async () => { - let captured: Record | undefined; - const create = vi.fn().mockImplementation((params: unknown) => { - captured = params as Record; - return Promise.resolve({ - id: 'msg_test', - content: [{ type: 'text', text: 'done' }], - usage: { input_tokens: 1, output_tokens: 1 }, - }); - }); - const provider = new AnthropicChatProvider({ - model: 'compatible-model', - apiKey: '', - defaultMaxTokens: 1024, - stream: false, - clientFactory: () => ({ messages: { create } }) as never, + const messages = await captureAnthropicMessages( + 'compatible-model', + EMPTY_THINKING_TOOL_HISTORY, + ); + expect(messages[0]!.content[0]).toEqual({ type: 'thinking', thinking: '' }); + }); + + it('Anthropic-compatible providers preserve an unsigned-only assistant message', async () => { + const messages = await captureAnthropicMessages( + 'compatible-model', + UNSIGNED_THINKING_ONLY_HISTORY, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'thinking', thinking: 'Partial reasoning' }], }); + }); - const response = await provider.generate('', [], EMPTY_THINKING_TOOL_HISTORY); - await collectParts(response); + it('Anthropic-compatible providers replace only the last empty unsigned thinking block', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '' }, + { type: 'think', think: '' }, + { type: 'text', text: 'Done.' }, + ], + toolCalls: [], + }, + { + role: 'user', + content: [{ type: 'text', text: 'Continue.' }], + toolCalls: [], + }, + ]; - const messages = captured?.['messages'] as Array<{ content: unknown[] }>; - expect(messages[0]!.content[0]).toEqual({ type: 'thinking', thinking: '' }); + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + history, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: '' }, + { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'Done.' }, + ]); + }); + + it('Anthropic-compatible providers preserve unsigned blocks when any thinking is non-empty', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: 'reasoning text' }, + { type: 'think', think: '' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + history, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: 'reasoning text' }, + { type: 'thinking', thinking: '' }, + ]); + }); + + it('Anthropic-compatible providers preserve empty signed thinking byte-for-byte', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '', encrypted: 'signed-thinking' }], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + history, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: '', signature: 'signed-thinking' }, + ]); + }); + + it('Anthropic-compatible providers replace the last empty unsigned block after empty signed thinking', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '', encrypted: 'signed-thinking' }, + { type: 'think', think: '' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + history, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: '', signature: 'signed-thinking' }, + { type: 'thinking', thinking: ' ' }, + ]); + }); + + it('Anthropic-compatible providers do not replace empty unsigned thinking after non-empty signed thinking', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: 'signed reasoning', encrypted: 'signed-thinking' }, + { type: 'think', think: '' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + history, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { + type: 'thinking', + thinking: 'signed reasoning', + signature: 'signed-thinking', + }, + { type: 'thinking', thinking: '' }, + ]); + }); + + it('Anthropic normalizes a thinking delta with no thinking field to an empty ThinkPart', async () => { + const parts = await collectAnthropicStreamParts([ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta' }, + }, + ]); + + expect(parts).toEqual([{ type: 'think', think: '' }]); + }); + + it('Anthropic normalizes a thinking block start with no thinking field to an empty ThinkPart', async () => { + const parts = await collectAnthropicStreamParts([ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking' }, + }, + ]); + + expect(parts).toEqual([{ type: 'think', think: '' }]); + }); + + it('Anthropic omits a missing thinking delta from the reduced assistant history', async () => { + const result = await generate( + createStreamingAnthropicProvider([ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: 'Simple request.' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta' }, + }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: 'Done' }, + }, + ]), + '', + [], + [], + ); + + expect(result.message.content).toEqual([ + { type: 'think', think: 'Simple request.' }, + { type: 'text', text: 'Done' }, + ]); + }); + + it('Anthropic preserves a complete thinking delta', async () => { + const parts = await collectAnthropicStreamParts([ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'reasoning' }, + }, + ]); + + expect(parts).toEqual([{ type: 'think', think: 'reasoning' }]); + }); + + it('Anthropic preserves the signature delta immediately following a thinking delta', async () => { + const parts = await collectAnthropicStreamParts([ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'reasoning' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'signature' }, + }, + ]); + + expect(parts).toEqual([ + { type: 'think', think: 'reasoning' }, + { type: 'think', think: '', encrypted: 'signature' }, + ]); + }); + + it.each(['claude-opus-4-9', 'opus-4-9', 'claude-mythos-preview'])( + 'Claude model %s drops unsigned thinking blocks', + async (model) => { + const messages = await captureAnthropicMessages(model, EMPTY_THINKING_TOOL_HISTORY); + expect(messages[0]!.content).toEqual([ + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: { q: 'test' }, + cache_control: { type: 'ephemeral' }, + }, + ]); + }, + ); + + it('Claude drops an unsigned-only assistant without leaving an empty wire message', async () => { + const messages = await captureAnthropicMessages( + 'claude-opus-4-9', + UNSIGNED_THINKING_ONLY_HISTORY, + ); + + expect(messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Start' }, + { type: 'text', text: 'Continue', cache_control: { type: 'ephemeral' } }, + ], + }, + ]); }); it('OpenAI Responses sends an explicitly empty ThinkPart as a reasoning item', async () => { 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 858b73ab2f..49ab54742d 100644 --- a/packages/agent-core-v2/test/app/model/model.test.ts +++ b/packages/agent-core-v2/test/app/model/model.test.ts @@ -20,6 +20,80 @@ import { import { modelsFromToml, modelsToToml } from '#/app/model/configSection'; import { ModelService } from '#/app/model/modelService'; import { ENV_MODEL_PROVIDER_KEY } from '#/app/provider/provider'; +import { effectiveModelConfig } from '#/app/model/modelAuth'; + +describe('effectiveModelConfig', () => { + it('derives the official effort metadata from a Claude model name', () => { + expect( + effectiveModelConfig({ + provider: 'anthropic', + model: 'claude-opus-4-6', + maxContextSize: 200000, + }), + ).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high', 'max'], + defaultEffort: 'high', + }); + }); + + it('infers Anthropic effort metadata for an unknown model with an explicit Anthropic protocol', () => { + expect( + effectiveModelConfig({ + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + }), + ).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'high', + }); + }); + + it('limits an adaptive_thinking=false model to budget efforts', () => { + expect( + effectiveModelConfig({ + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + adaptiveThinking: false, + }), + ).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + defaultEffort: 'high', + }); + }); + + it('does not infer Anthropic effort metadata for an unknown model without an Anthropic protocol', () => { + const model = { + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + }; + + expect(effectiveModelConfig(model)).toEqual(model); + }); + + it('marks official always-on models while preserving explicit effort metadata', () => { + expect( + effectiveModelConfig({ + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 200000, + supportEfforts: ['high', 'max'], + defaultEffort: 'max', + }), + ).toMatchObject({ + capabilities: ['always_thinking'], + supportEfforts: ['high', 'max'], + defaultEffort: 'max', + }); + }); +}); describe('ModelService', () => { let disposables: DisposableStore; diff --git a/packages/agent-core-v2/test/app/model/modelResolver.test.ts b/packages/agent-core-v2/test/app/model/modelResolver.test.ts index db9f76b103..0c169a245c 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver.test.ts @@ -619,11 +619,11 @@ describe('ModelResolverService', () => { expect(model.supportEfforts).toEqual(['low', 'high']); }); - it('does not pass supportEfforts through for non-Kimi providers', async () => { + it('passes Anthropic supportEfforts through to the protocol adapter', async () => { providers['p'] = { type: 'anthropic', baseUrl: 'https://example.test', apiKey: 'sk' }; models['m'] = { provider: 'p', - model: 'kimi-for-coding', + model: 'compatible-model', maxContextSize: 1000, supportEfforts: ['low', 'high', 'max'], }; @@ -632,11 +632,8 @@ describe('ModelResolverService', () => { expect(config).toMatchObject({ protocol: 'anthropic', + providerOptions: { supportEfforts: ['low', 'high', 'max'] }, }); - const providerOptions = config?.['providerOptions'] as - | { readonly supportEfforts?: readonly string[] } - | undefined; - expect(providerOptions?.supportEfforts).toBeUndefined(); }); it('marks the Anthropic adapter when it transports a Kimi provider', async () => { @@ -657,6 +654,21 @@ describe('ModelResolverService', () => { }); }); + it('infers latest Opus metadata for an unknown Kimi-managed Anthropic model', () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + protocol: 'anthropic', + model: 'compatible-model', + maxContextSize: 1000, + }; + + const model = ix.get(IModelResolver).resolve('m'); + + expect(model.supportEfforts).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); + expect(model.defaultEffort).toBe('high'); + }); + it('passes Vertex service-account options and derives location from the baseUrl', async () => { providers['p'] = { type: 'vertexai', @@ -803,6 +815,25 @@ describe('ModelResolverService', () => { expect(resolveEffort()).toBeNull(); }); + it('applies explicit off to Anthropic providers like v1', async () => { + configValues['thinking'] = { enabled: false }; + providers['p'] = { type: 'anthropic', baseUrl: 'https://example.test', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'compatible-model', + maxContextSize: 1000, + capabilities: ['thinking'], + }; + + const model = ix.get(IModelResolver).resolve('m'); + for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) { + void _event; + } + + expect(model.thinkingEffort).toBe('off'); + expect(appliedThinkingEfforts).toEqual(['off']); + }); + it('uses the configured thinking.effort', () => { configValues['thinking'] = { effort: 'medium' }; expect(resolveEffort(['thinking'], ['low', 'medium', 'high'])).toBe('medium'); diff --git a/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts b/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts index a5ecc2e4de..5f0848edaa 100644 --- a/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts +++ b/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts @@ -173,6 +173,40 @@ describe('ModelCatalogService', () => { }); }); + it('projects official Anthropic effort metadata inferred from the model name', async () => { + backing.providers['anthropic'] = { type: 'anthropic' }; + backing.models['opus'] = { + provider: 'anthropic', + model: 'claude-opus-4-6', + maxContextSize: 200000, + }; + + const opus = (await catalog().listModels()).find((model) => model.model === 'opus'); + expect(opus).toMatchObject({ + capabilities: ['thinking'], + support_efforts: ['low', 'medium', 'high', 'max'], + default_effort: 'high', + }); + }); + + it('projects latest Opus efforts for unknown Kimi-managed Anthropic models', async () => { + backing.models['compatible'] = { + provider: 'kimi', + protocol: 'anthropic', + model: 'compatible-model', + maxContextSize: 128000, + }; + + const compatible = (await catalog().listModels()).find( + (model) => model.model === 'compatible', + ); + expect(compatible).toMatchObject({ + capabilities: ['thinking'], + support_efforts: ['low', 'medium', 'high', 'xhigh', 'max'], + default_effort: 'high', + }); + }); + it('projects effort fields from overrides when present', async () => { backing.models['k2'] = { ...backing.models['k2'], diff --git a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts index 7b3be26a23..64ba732382 100644 --- a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts +++ b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts @@ -41,6 +41,7 @@ describe('ProtocolAdapterRegistry', () => { providerOptions: { defaultMaxTokens: 12345, adaptiveThinking: false, + supportEfforts: ['low', 'high'], kimiThinking: true, betaApi: true, metadata: { user_id: 'session-test' }, @@ -49,6 +50,7 @@ describe('ProtocolAdapterRegistry', () => { expect(Reflect.get(provider, '_generationKwargs')).toMatchObject({ max_tokens: 12345 }); expect(Reflect.get(provider, '_adaptiveThinking')).toBe(false); + expect(Reflect.get(provider, '_supportEfforts')).toEqual(['low', 'high']); expect(Reflect.get(provider, '_kimiThinking')).toBe(true); expect(Reflect.get(provider, '_betaApi')).toBe(true); expect(Reflect.get(provider, '_metadata')).toEqual({ user_id: 'session-test' }); diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index c8e3678ceb..29d9cf255d 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -31,7 +31,10 @@ export class ConfigState { private _cwd: string; private _modelAlias: string | undefined; private _profileName: string | undefined; - private _unforcedThinkingEffort: ThinkingEffort = 'off'; + // `undefined` until an effort has actually been resolved: a bare modelAlias + // update must then fall through to the model's own default instead of + // treating the never-chosen initial "off" as an explicit user choice. + private _unforcedThinkingEffort: ThinkingEffort | undefined; private _thinkingEffort: ThinkingEffort = 'off'; private _systemPrompt: string = ''; @@ -46,6 +49,7 @@ export class ConfigState { const targetAlias = changed.modelAlias ?? this._modelAlias; const targetProvider = this.tryResolvedProviderConfigFor(targetAlias); const targetModel = this.modelForThinking(targetAlias, targetProvider); + const kimiProtocol = targetProvider?.provider.type === 'kimi'; const kimiProvider = targetProvider?.type === 'kimi'; let unforcedThinkingEffort: ThinkingEffort | undefined; let thinkingEffort: ThinkingEffort | undefined; @@ -54,14 +58,19 @@ export class ConfigState { changed.thinkingEffort, this.agent.kimiConfig?.thinking, targetModel, - kimiProvider, + kimiProtocol, ); } else if (changed.modelAlias !== undefined) { + // A bare model switch carries the previously resolved effort over to the + // new model. Before any effort was resolved (fresh session bootstrap) + // `undefined` lets resolveThinkingEffort fall through to the model + // default — computed from the resolved provider, whose capabilities and + // efforts include the provider-level protocol inference. unforcedThinkingEffort = resolveThinkingEffort( - this._modelAlias === undefined ? undefined : this._unforcedThinkingEffort, + this._unforcedThinkingEffort, this.agent.kimiConfig?.thinking, targetModel, - kimiProvider, + kimiProtocol, ); } if (unforcedThinkingEffort !== undefined) { @@ -100,13 +109,16 @@ export class ConfigState { if (this.hasProvider && (changed.cwd !== undefined || changed.modelAlias)) { this.agent.tools.initializeBuiltinTools(); } + if (thinkingEffort !== undefined || changed.modelAlias !== undefined) { + this.agent.warnAboutCurrentAnthropicThinkingEffort(); + } this.agent.emitStatusUpdated(thinkingEffort !== undefined); } setThinkingEffort(effort: ThinkingEffort): void { const model = this.currentModel; - const kimiProvider = this.tryResolvedProviderConfig()?.type === 'kimi'; - if (!supportsThinkingEffort(effort, model, kimiProvider)) { + const kimiProtocol = this.tryResolvedProviderConfig()?.provider.type === 'kimi'; + if (!supportsThinkingEffort(effort, model, kimiProtocol)) { const efforts = model?.supportEfforts ?? []; const supported = efforts.length === 0 ? 'off' : ['off', ...efforts].join(', '); throw new KimiError( diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 9b5e64bb71..727063b6dc 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -50,9 +50,9 @@ export function defaultThinkingEffortFor(model: ModelAlias | undefined): Thinkin export function supportsThinkingEffort( effort: ThinkingEffort, model: ModelAlias | undefined, - kimiProvider: boolean, + kimiProtocol: boolean, ): boolean { - if (!kimiProvider || effort === 'off') return true; + if (!kimiProtocol || effort === 'off') return true; const effective = model === undefined ? undefined : effectiveModelAlias(model); if (!supportsThinking(effective)) return false; const efforts = effortsFor(effective); @@ -62,7 +62,7 @@ export function supportsThinkingEffort( function normalizeThinkingEffortForModel( effort: ThinkingEffort, model: ModelAlias | undefined, - kimiProvider: boolean, + kimiProtocol: boolean, ): ThinkingEffort { const effective = model === undefined ? undefined : effectiveModelAlias(model); if (effort === 'off' && effective?.capabilities?.includes('always_thinking') !== true) { @@ -70,7 +70,7 @@ function normalizeThinkingEffortForModel( } const efforts = effortsFor(effective); - if (!kimiProvider) { + if (!kimiProtocol) { return effort === 'on' && efforts.length > 0 ? defaultThinkingEffortFor(effective) : effort; @@ -91,15 +91,15 @@ 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 here and only here: when a - * model declares `always_thinking`, an `'off'` result is clamped back to the - * model's default effort so thinking can never be disabled for it. + * 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. */ export function resolveThinkingEffort( requested: ThinkingEffort | undefined, config: ThinkingConfig | undefined, model: ModelAlias | undefined, - kimiProvider = false, + kimiProtocol = false, ): ThinkingEffort { const effectiveModel = model === undefined ? undefined : effectiveModelAlias(model); let effort: ThinkingEffort; @@ -111,7 +111,11 @@ export function resolveThinkingEffort( effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel); } - if (effort === 'off' && effectiveModel?.capabilities?.includes('always_thinking') === true) { + if ( + kimiProtocol && + 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 @@ -119,5 +123,5 @@ export function resolveThinkingEffort( effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel); } - return normalizeThinkingEffortForModel(effort, effectiveModel, kimiProvider); + return normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol); } diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 49da2182c0..9afcd89015 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -6,7 +6,7 @@ import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; import { log } from '#/logging/logger'; import type { Logger } from '#/logging/types'; import type { AgentAPI, AgentEvent, KimiConfig, SDKAgentRPC, UsageStatus } from '#/rpc'; -import { generate } from '@moonshot-ai/kosong'; +import { generate, type ChatProvider } from '@moonshot-ai/kosong'; import type { EnabledPluginSessionStart, PluginCommandDef } from '#/plugin'; import { expandCommandArguments } from '../plugin/commands'; @@ -162,6 +162,15 @@ export class Agent { private additionalDirs: readonly string[]; private activeProfile?: ResolvedAgentProfile; private brandHome?: string; + private readonly emittedThinkingEffortWarnings = new Set(); + private readonly pendingThinkingEffortWarnings: Array<{ + readonly code: string; + readonly message: string; + readonly modelAlias: string | undefined; + readonly model: string; + readonly effort: string; + readonly knownEfforts: string | undefined; + }> = []; private readonly systemPromptContextProvider?: (() => Promise) | undefined; constructor(options: AgentOptions) { @@ -269,6 +278,7 @@ export class Agent { // before dispatching), so it must not leave a request trace or a // diagnostic log line claiming a request was sent. if (requestOptions?.signal?.aborted !== true) { + this.warnAboutAnthropicThinkingEffort(provider, modelAlias); this.llmRequestLogger.logRequest({ provider, modelAlias, @@ -303,6 +313,107 @@ export class Agent { }; } + private warnAboutAnthropicThinkingEffort( + provider: ChatProvider, + modelAlias: string | undefined, + ): void { + if (provider.name !== 'anthropic') return; + const effort = provider.thinkingEffort; + if (effort === null || effort === 'on') return; + + let warning: + | { readonly code: string; readonly message: string; readonly knownEfforts?: string } + | undefined; + try { + const resolved = + modelAlias === undefined + ? undefined + : this.modelProvider?.resolveProviderConfig(modelAlias); + if (resolved === undefined) return; + + if (effort === 'off') { + if (resolved.alwaysThinking !== true) return; + warning = { + code: 'anthropic-thinking-cannot-disable', + message: `Model "${provider.modelName}" declares always-on thinking. The configured effort "off" will be sent unchanged to the Anthropic-compatible backend.`, + }; + } else { + const supportEfforts = resolved.supportEfforts?.filter((value) => value.length > 0); + if (supportEfforts === undefined || supportEfforts.length === 0) return; + if (supportEfforts.includes(effort)) return; + warning = { + code: 'anthropic-thinking-effort-not-listed', + message: `Thinking effort "${effort}" is not listed for model "${provider.modelName}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`, + knownEfforts: supportEfforts.join(','), + }; + } + } catch { + // Capability diagnostics must never turn an otherwise sendable request + // into a client-side failure. + return; + } + + if (warning === undefined) return; + const key = [warning.code, modelAlias, provider.modelName, effort, warning.knownEfforts].join( + '\u0000', + ); + if (this.emittedThinkingEffortWarnings.has(key)) return; + this.emittedThinkingEffortWarnings.add(key); + const pending = { + code: warning.code, + message: warning.message, + modelAlias, + model: provider.modelName, + effort, + knownEfforts: warning.knownEfforts, + }; + if (this.records.restoring) { + this.pendingThinkingEffortWarnings.push(pending); + return; + } + this.publishAnthropicThinkingEffortWarning(pending); + } + + private publishAnthropicThinkingEffortWarning( + warning: (typeof this.pendingThinkingEffortWarnings)[number], + ): void { + try { + this.log.warn(warning.message, { + modelAlias: warning.modelAlias, + model: warning.model, + effort: warning.effort, + knownEfforts: warning.knownEfforts, + }); + } catch { + // Diagnostics must never block resume or request dispatch. + } + try { + const delivery = this.rpc?.emitEvent?.({ + type: 'warning', + code: warning.code, + message: warning.message, + }); + void delivery?.catch(() => {}); + } catch { + // Diagnostics must never block resume or request dispatch. + } + } + + private flushPendingAnthropicThinkingEffortWarnings(): void { + for (const warning of this.pendingThinkingEffortWarnings.splice(0)) { + this.publishAnthropicThinkingEffortWarning(warning); + } + } + + warnAboutCurrentAnthropicThinkingEffort(): void { + try { + if (!this.config.hasProvider) return; + this.warnAboutAnthropicThinkingEffort(this.config.provider, this.config.modelAlias); + } catch { + // A capability warning must never make config replay or session resume fail. + } + } + get llm(): KosongLLM { // All provider-level request config (thinking, sampling params, thinking.keep) // is applied in ConfigState.provider so compaction shares it. See get provider(). @@ -370,6 +481,7 @@ export class Agent { async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { const result = await this.records.replay(options); + this.flushPendingAnthropicThinkingEffortWarnings(); try { this.replayBuilder.postRestoring = true; this.goal.normalizeAfterReplay(); diff --git a/packages/agent-core/src/config/model.ts b/packages/agent-core/src/config/model.ts index c31ea35c7a..3411965691 100644 --- a/packages/agent-core/src/config/model.ts +++ b/packages/agent-core/src/config/model.ts @@ -1,16 +1,20 @@ +import { + BUDGET_THINKING_EFFORTS, + inferAnthropicModelProfile, + matchKnownAnthropicModelProfile, +} from '@moonshot-ai/kosong/providers/anthropic-profile'; + import type { ModelAlias } from './schema'; -export function effectiveModelAlias(alias: ModelAlias): ModelAlias { +export function effectiveModelAlias( + alias: ModelAlias, + anthropicCompatible = false, +): ModelAlias { const { overrides, ...base } = alias; - if (overrides === undefined) return alias; - - const effective: ModelAlias = { - ...base, - ...overrides, - }; + const effective: ModelAlias = overrides === undefined ? alias : { ...base, ...overrides }; if ( - overrides.supportEfforts !== undefined && + overrides?.supportEfforts !== undefined && overrides.defaultEffort === undefined && effective.defaultEffort !== undefined && !overrides.supportEfforts.includes(effective.defaultEffort) @@ -18,7 +22,37 @@ export function effectiveModelAlias(alias: ModelAlias): ModelAlias { delete effective.defaultEffort; } - return effective; + return withAnthropicProfile( + effective, + anthropicCompatible || effective.protocol === 'anthropic', + ); +} + +function withAnthropicProfile(model: ModelAlias, anthropicCompatible: boolean): ModelAlias { + const profile = anthropicCompatible + ? inferAnthropicModelProfile(model.model) + : matchKnownAnthropicModelProfile(model.model); + if (profile === undefined) return model; + + const capability = profile.canDisableThinking ? 'thinking' : 'always_thinking'; + const capabilities = model.capabilities ?? []; + const hasCapability = capabilities.some( + (candidate) => candidate.trim().toLowerCase() === capability, + ); + // `adaptive_thinking = false` opts the endpoint out of the adaptive API, so + // the catalog must not advertise adaptive-only efforts (xhigh/max) — this + // mirrors the budget branch of kosong's resolveThinkingProfile. + const supportEfforts = + model.supportEfforts ?? + (model.adaptiveThinking === false ? [...BUDGET_THINKING_EFFORTS] : [...profile.efforts]); + + return { + ...model, + capabilities: hasCapability ? capabilities : [...capabilities, capability], + supportEfforts, + defaultEffort: + model.defaultEffort ?? (supportEfforts.includes('high') ? 'high' : undefined), + }; } export function effectiveModelAliases( diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 2fdc740cbf..33318ee552 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -250,7 +250,15 @@ export class KimiCore implements PromisableMethods { const id = options.id ?? createSessionId(); const modelAlias = options.model ?? config.defaultModel; const model = modelAlias !== undefined ? config.models?.[modelAlias] : undefined; - const thinkingEffort = resolveThinkingEffort(options.thinking, config.thinking, model); + // Forward only an explicitly requested effort. With no explicit value the + // initial effort is left to ConfigState.update(), which resolves it from + // the resolved provider — that carries the provider-level protocol context + // a raw model alias lacks (e.g. provider type "anthropic" with a custom + // model name must default to the inferred profile effort, not "off"). + const thinkingEffort = + options.thinking === undefined + ? undefined + : resolveThinkingEffort(options.thinking, config.thinking, model); const permissionMode = options.permission ?? config.defaultPermissionMode; const baseMcpConfig = await resolveSessionMcpConfig({ cwd: workDir, diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts index 46b83fb07a..3944cdd80d 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts @@ -57,8 +57,9 @@ export class ModelNotFoundError extends Error { export function toProtocolModel( modelId: string, alias: ModelAlias, + anthropicCompatible = false, ): ModelCatalogItem { - const effective = effectiveModelAlias(alias); + const effective = effectiveModelAlias(alias, anthropicCompatible); return { provider: effective.provider, model: modelId, diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index 69418ca6a8..1b5f8460b8 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -1,5 +1,5 @@ import { Disposable, InstantiationType, registerSingleton } from '../../di'; -import type { KimiConfig, ProviderConfig } from '../../config'; +import type { KimiConfig, ModelAlias, ProviderConfig } from '../../config'; import type { ModelCatalogItem, ProviderCatalogItem, @@ -61,7 +61,7 @@ export class ModelCatalogService async listModels(): Promise { const config = await this._readConfig(); return Object.entries(config.models ?? {}).map(([modelId, alias]) => - toProtocolModel(modelId, alias), + toProtocolModel(modelId, alias, this._usesAnthropicProtocol(config, alias)), ); } @@ -94,10 +94,19 @@ export class ModelCatalogService const updatedAlias = updated.models?.[modelId] ?? alias; return { default_model: modelId, - model: toProtocolModel(modelId, updatedAlias), + model: toProtocolModel( + modelId, + updatedAlias, + this._usesAnthropicProtocol(updated, updatedAlias), + ), }; } + private _usesAnthropicProtocol(config: KimiConfig, alias: ModelAlias): boolean { + const providerId = alias.provider ?? config.defaultProvider; + return (alias.protocol ?? config.providers[providerId ?? '']?.type) === 'anthropic'; + } + async refreshOAuthProviderModels(): Promise { return this.refreshProviderModels({ scope: 'oauth' }); } diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 5a84fdeaa9..14d2b5ba18 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -97,7 +97,6 @@ export class ProviderManager implements ModelProvider { ); } - const effectiveAlias = effectiveModelAlias(alias); const providerName = alias.provider ?? this.config.defaultProvider; if (providerName === undefined) { throw new KimiError( @@ -114,6 +113,9 @@ export class ProviderManager implements ModelProvider { ); } + const protocol = alias.protocol ?? providerConfig.type; + const effectiveAlias = effectiveModelAlias(alias, protocol === 'anthropic'); + if (!Number.isInteger(effectiveAlias.maxContextSize) || effectiveAlias.maxContextSize <= 0) { throw new KimiError( ErrorCodes.CONFIG_INVALID, @@ -129,6 +131,7 @@ export class ProviderManager implements ModelProvider { effectiveAlias.maxOutputSize, effectiveAlias.reasoningKey, this.options.promptCacheKey, + effectiveAlias.supportEfforts, effectiveAlias.adaptiveThinking, alias.betaApi, ); @@ -254,6 +257,7 @@ function toKosongProviderConfig( maxOutputSize: number | undefined, reasoningKey: string | undefined, promptCacheKey: string | undefined, + supportEfforts: readonly string[] | undefined, adaptiveThinking: boolean | undefined, betaApi: boolean | undefined, ): KosongProviderConfig { @@ -271,6 +275,7 @@ function toKosongProviderConfig( : baseUrl, apiKey: providerApiKey(provider), ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), + supportEfforts, ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), ...(provider.type === 'kimi' ? { kimiThinking: true } : {}), ...(betaApi !== undefined ? { betaApi } : {}), diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 0947978d76..c7b4c896b6 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -152,6 +152,66 @@ describe('ConfigState model capabilities', () => { expect(requestMaxTokens).toBe(131072); }); + it('warns and sends when an Anthropic effort is not listed by the model', async () => { + let requests = 0; + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['max'], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + ctx.agent.config.setThinkingEffort('high'); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: { + code: 'anthropic-thinking-effort-not-listed', + message: + '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('uses session id as a provider prompt cache hint without storing it on Agent', () => { const ctx = testAgent({ providerManager: new ProviderManager({ diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index 503cb20a5f..aa642d8e4b 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -104,8 +104,10 @@ describe('resolveThinkingEffort', () => { }); it('forces always-thinking models back on when the resolved effort is off', () => { - expect(resolveThinkingEffort('off', undefined, alwaysThinkingModel)).toBe('on'); - expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingModel)).toBe('on'); + expect(resolveThinkingEffort('off', undefined, alwaysThinkingModel, true)).toBe('on'); + expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingModel, true)).toBe( + 'on', + ); }); it('honors a configured effort when clamping always-thinking models back on', () => { @@ -113,12 +115,21 @@ describe('resolveThinkingEffort', () => { // an explicitly configured effort is preserved instead of falling back to // the model default. expect( - resolveThinkingEffort(undefined, { enabled: false, effort: 'max' }, alwaysThinkingEffortModel), + resolveThinkingEffort( + undefined, + { enabled: false, effort: 'max' }, + alwaysThinkingEffortModel, + true, + ), ).toBe('max'); // without an explicit effort, fall back to the model's default effort. - expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingEffortModel)).toBe( - 'high', - ); + expect( + resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingEffortModel, true), + ).toBe('high'); + }); + + it('preserves off for always-thinking models on compatible protocols', () => { + expect(resolveThinkingEffort('off', undefined, alwaysThinkingEffortModel, false)).toBe('off'); }); it('does not force on for models that are not always-thinking', () => { @@ -165,6 +176,7 @@ describe('resolveThinkingEffort overrides', () => { capabilities: ['thinking'], overrides: { capabilities: ['thinking', 'always_thinking'] }, }), + true, ), ).toBe('on'); }); diff --git a/packages/agent-core/test/config/model-overrides.test.ts b/packages/agent-core/test/config/model-overrides.test.ts index d1caf9a52c..40ab1186e9 100644 --- a/packages/agent-core/test/config/model-overrides.test.ts +++ b/packages/agent-core/test/config/model-overrides.test.ts @@ -45,4 +45,105 @@ describe('effectiveModelAlias', () => { expect(effectiveModelAlias(model).defaultEffort).toBe('high'); }); + + it('derives the official effort list and thinking capability from a Claude model name', () => { + const model: ModelAlias = { + provider: 'anthropic', + model: 'claude-opus-4-6', + maxContextSize: 200000, + }; + + expect(effectiveModelAlias(model)).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high', 'max'], + defaultEffort: 'high', + }); + }); + + it('infers Anthropic effort metadata for an unknown model with an explicit Anthropic protocol', () => { + const model: ModelAlias = { + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + }; + + expect(effectiveModelAlias(model)).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'high', + }); + }); + + it('limits an adaptive_thinking=false model to budget efforts', () => { + const model: ModelAlias = { + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + adaptiveThinking: false, + }; + + expect(effectiveModelAlias(model)).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + defaultEffort: 'high', + }); + }); + + it('keeps a declared supportEfforts list authoritative when adaptive_thinking=false', () => { + const model: ModelAlias = { + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + protocol: 'anthropic', + adaptiveThinking: false, + supportEfforts: ['low', 'high'], + }; + + expect(effectiveModelAlias(model)).toMatchObject({ + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }); + }); + + it('does not infer Anthropic effort metadata for an unknown model without an Anthropic protocol', () => { + const model: ModelAlias = { + provider: 'custom', + model: 'custom-anthropic-model', + maxContextSize: 200000, + }; + + expect(effectiveModelAlias(model)).toEqual(model); + }); + + it('marks official always-on models and does not surface off', () => { + const model: ModelAlias = { + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 200000, + }; + + expect(effectiveModelAlias(model)).toMatchObject({ + capabilities: ['always_thinking'], + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'high', + }); + }); + + it('keeps an explicit supportEfforts list authoritative over the official profile', () => { + const model: ModelAlias = { + provider: 'anthropic', + model: 'claude-opus-4-7', + maxContextSize: 200000, + supportEfforts: ['low', 'max'], + defaultEffort: 'max', + }; + + expect(effectiveModelAlias(model)).toMatchObject({ + supportEfforts: ['low', 'max'], + defaultEffort: 'max', + }); + }); }); 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 6d9b66388a..18f0d327fb 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -58,6 +58,34 @@ describe('HarnessAPI session model aliases', () => { await rm(tmp, { recursive: true, force: true }); }); + const compatibleConfig = (supportEfforts: string, defaultEffort: string) => ` +default_model = "compatible/model" + +[providers.compatible] +type = "kimi" +api_key = "test-key" +base_url = "https://api.example.test" + +[models."compatible/model"] +provider = "compatible" +model = "compatible-model" +protocol = "anthropic" +max_context_size = 128000 +capabilities = ["thinking"] +support_efforts = [${supportEfforts}] +default_effort = "${defaultEffort}" +`; + + async function createEffortReplaySession(): Promise { + await writeFile(configPath, compatibleConfig('"high", "max"', 'high')); + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir, model: 'compatible/model' }); + await rpc.setThinking({ sessionId: created.id, agentId: 'main', effort: 'max' }); + await rpc.closeSession({ sessionId: created.id }); + await writeFile(configPath, compatibleConfig('"max"', 'max')); + return created.id; + } + it('keeps the configured alias separate from the provider model across create, setModel, and resume', async () => { const rpc = await createTestRpc(); const created = await rpc.createSession({ @@ -88,6 +116,99 @@ describe('HarnessAPI session model aliases', () => { ); }); + 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". + await writeFile( + configPath, + ` +default_model = "compat/custom" + +[providers.compat] +type = "anthropic" +api_key = "test-key" +base_url = "https://api.example.test" + +[models."compat/custom"] +provider = "compat" +model = "joint-model-0714-vibe" +max_context_size = 200000 +`, + ); + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir }); + + const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' }); + expect(config.thinkingEffort).toBe('high'); + + // The recorded bootstrap effort must survive resume unchanged. + await rpc.closeSession({ sessionId: created.id }); + const freshRpc = await createTestRpc(); + await freshRpc.resumeSession({ sessionId: created.id }); + const restored = await freshRpc.getConfig({ sessionId: created.id, agentId: 'main' }); + expect(restored.thinkingEffort).toBe('high'); + }); + + it('honors an explicit session effort for an Anthropic-typed provider', async () => { + await writeFile( + configPath, + ` +default_model = "compat/custom" + +[providers.compat] +type = "anthropic" +api_key = "test-key" +base_url = "https://api.example.test" + +[models."compat/custom"] +provider = "compat" +model = "joint-model-0714-vibe" +max_context_size = 200000 +`, + ); + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir, thinking: 'low' }); + + const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' }); + expect(config.thinkingEffort).toBe('low'); + }); + + it('restores the final effort after replaying an earlier unlisted Anthropic effort', async () => { + const sessionId = await createEffortReplaySession(); + + // The current catalog no longer lists the earlier `high` state. Replay + // must continue to the following `max` record instead of validating and + // aborting at the transient state. + const events: Array[0]> = []; + const freshRpc = await createTestRpc({ emitEvent: (event) => events.push(event) }); + + await expect(freshRpc.resumeSession({ sessionId })).resolves.toBeDefined(); + expect(events).toContainEqual({ + sessionId, + agentId: 'main', + type: 'warning', + code: 'anthropic-thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + }); + const restored = await freshRpc.getConfig({ sessionId, agentId: 'main' }); + expect(restored.modelAlias).toBe('compatible/model'); + expect(restored.thinkingEffort).toBe('max'); + }); + + it('does not block resume when the warning sink fails', async () => { + const sessionId = await createEffortReplaySession(); + + const throwingRpc = await createTestRpc({ + emitEvent: () => { + throw new Error('warning sink failed'); + }, + }); + await expect(throwingRpc.resumeSession({ sessionId })).resolves.toBeDefined(); + }); + it('re-bootstraps profile and model when resuming a session whose wire has no config.update', async () => { // A migrated session ships a wire.jsonl with only `metadata` and message // records — none of the `config.update` / `tools.set_active_tools` @@ -464,6 +585,7 @@ max_context_size = 1000000 async function createTestRpc( options: { readonly appVersion?: string; + readonly emitEvent?: (event: Parameters[0]) => void; readonly telemetry?: TelemetryClient; } = {}, ) { @@ -475,7 +597,7 @@ max_context_size = 1000000 telemetry: options.telemetry, }); return sdkRpc({ - emitEvent: vi.fn(), + emitEvent: options.emitEvent ?? vi.fn(), requestApproval: vi.fn(async () => ({ decision: 'rejected' as const })), requestQuestion: vi.fn(async () => null), toolCall: vi.fn(async () => ({ output: '' })), diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index f79a0d7b8a..b16547a807 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -338,6 +338,34 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { }); }); + it('forwards alias.supportEfforts to the anthropic provider config', () => { + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' }, + }, + models: { + ...BASE_CONFIG.models!, + 'compatible-alias': { + provider: 'anthropic', + model: 'compatible-model', + maxContextSize: 200000, + supportEfforts: ['low', 'high', 'max'], + }, + }, + }, + model: 'compatible-alias', + }); + + expect(resolved.provider).toMatchObject({ + type: 'anthropic', + model: 'compatible-model', + supportEfforts: ['low', 'high', 'max'], + }); + }); + it('forwards alias.betaApi to the anthropic provider config', () => { const resolved = resolveRuntimeProvider({ config: { @@ -840,8 +868,10 @@ describe('resolveThinkingEffort', () => { }); it('forces always-thinking models back on even when off is requested', () => { - expect(resolveThinkingEffort('off', { enabled: false }, alwaysThinkingModel)).toBe('on'); - expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingModel)).toBe('on'); + expect(resolveThinkingEffort('off', { enabled: false }, alwaysThinkingModel, true)).toBe('on'); + expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingModel, true)).toBe( + 'on', + ); }); }); 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 a6d6440c2b..e50736ad16 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -165,6 +165,20 @@ describe('model catalog adapters', () => { expect(toProtocolModel('turbo', alias).display_name).toBe('kimi-turbo'); }); + it('projects official Anthropic effort metadata inferred from the model name', () => { + expect( + toProtocolModel('opus', { + provider: 'anthropic', + model: 'claude-opus-4-6', + maxContextSize: 200000, + }), + ).toMatchObject({ + capabilities: ['thinking'], + support_efforts: ['low', 'medium', 'high', 'max'], + default_effort: 'high', + }); + }); + it('maps provider model ids and global default', () => { const config = catalogConfig(); expect( @@ -195,6 +209,25 @@ describe('ModelCatalogService', () => { expect(getCalls).toEqual([{ reload: true }, { reload: true }]); }); + it('projects latest Opus efforts for unknown Anthropic-compatible models', async () => { + const configRef = { current: catalogConfig() }; + configRef.current.models!['compatible'] = { + provider: 'kimi', + protocol: 'anthropic', + 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).toMatchObject({ + capabilities: ['thinking'], + support_efforts: ['low', 'medium', 'high', 'xhigh', 'max'], + default_effort: 'high', + }); + }); + it('gets one provider or throws ProviderNotFoundError', async () => { const configRef = { current: catalogConfig() }; const { core } = makeCore(configRef); diff --git a/packages/kosong/src/providers/anthropic-profile.ts b/packages/kosong/src/providers/anthropic-profile.ts new file mode 100644 index 0000000000..0650bd8fb7 --- /dev/null +++ b/packages/kosong/src/providers/anthropic-profile.ts @@ -0,0 +1,154 @@ +/** + * Anthropic effort and thinking profiles, matched from model identifiers. + * Keep this matrix aligned with: + * https://platform.claude.com/docs/en/build-with-claude/effort + * https://platform.claude.com/docs/en/build-with-claude/extended-thinking + */ + +export type AnthropicThinkingMode = 'budget' | 'adaptive'; + +export interface AnthropicModelProfile { + readonly mode: AnthropicThinkingMode; + readonly efforts: readonly string[]; + readonly supportsEffortParam: boolean; + readonly canDisableThinking: boolean; +} + +export type AnthropicModelFamily = 'opus' | 'sonnet' | 'haiku' | 'fable' | 'mythos'; + +export interface AnthropicModelVersion { + readonly family: AnthropicModelFamily; + readonly major: number; + readonly minor: number | null; +} + +export const BUDGET_THINKING_EFFORTS = ['low', 'medium', 'high'] as const; +const ADAPTIVE_MAX_EFFORTS = ['low', 'medium', 'high', 'max'] as const; +export const LATEST_OPUS_THINKING_EFFORTS = [ + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +] as const; + +const BUDGET_PROFILE: AnthropicModelProfile = { + mode: 'budget', + efforts: BUDGET_THINKING_EFFORTS, + supportsEffortParam: false, + canDisableThinking: true, +}; + +const OPUS_45_PROFILE: AnthropicModelProfile = { + ...BUDGET_PROFILE, + supportsEffortParam: true, +}; + +const ADAPTIVE_MAX_PROFILE: AnthropicModelProfile = { + mode: 'adaptive', + efforts: ADAPTIVE_MAX_EFFORTS, + supportsEffortParam: true, + canDisableThinking: true, +}; + +export const LATEST_OPUS_PROFILE: AnthropicModelProfile = { + mode: 'adaptive', + efforts: LATEST_OPUS_THINKING_EFFORTS, + supportsEffortParam: true, + canDisableThinking: true, +}; + +const ALWAYS_ADAPTIVE_PROFILE: AnthropicModelProfile = { + ...LATEST_OPUS_PROFILE, + canDisableThinking: false, +}; + +const ALWAYS_ADAPTIVE_MAX_PROFILE: AnthropicModelProfile = { + ...ADAPTIVE_MAX_PROFILE, + canDisableThinking: false, +}; + +const FAMILY_FIRST_RE = + /(opus|sonnet|haiku|fable|mythos)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; +const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; +const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; + +export function parseAnthropicModelVersion( + model: string, + requireClaudeMarker = false, +): AnthropicModelVersion | null { + const normalized = model.toLowerCase(); + if (requireClaudeMarker && !normalized.includes('claude')) return null; + + const familyFirst = FAMILY_FIRST_RE.exec(normalized); + if (familyFirst !== null) { + return { + family: familyFirst[1] as AnthropicModelFamily, + major: Number.parseInt(familyFirst[2]!, 10), + minor: familyFirst[3] !== undefined ? Number.parseInt(familyFirst[3], 10) : null, + }; + } + + const versionFirst = VERSION_FIRST_RE.exec(normalized); + if (versionFirst !== null) { + return { + major: Number.parseInt(versionFirst[1]!, 10), + minor: Number.parseInt(versionFirst[2]!, 10), + family: versionFirst[3] as AnthropicModelFamily, + }; + } + + const bare = BARE_FAMILY_RE.exec(normalized); + if (bare !== null) { + return { + major: Number.parseInt(bare[1]!, 10), + minor: null, + family: bare[2] as AnthropicModelFamily, + }; + } + + return null; +} + +export function matchKnownAnthropicModelProfile( + model: string, +): AnthropicModelProfile | undefined { + const normalized = model.toLowerCase(); + if (/mythos[-._]preview/.test(normalized)) return ALWAYS_ADAPTIVE_MAX_PROFILE; + + const version = parseAnthropicModelVersion(model); + if (version === null) return undefined; + + switch (version.family) { + case 'opus': + if (version.major === 4 && (version.minor === 7 || version.minor === 8)) { + return LATEST_OPUS_PROFILE; + } + if (version.major === 4 && version.minor === 6) return ADAPTIVE_MAX_PROFILE; + if (version.major === 4 && version.minor === 5) return OPUS_45_PROFILE; + if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) < 5)) { + return BUDGET_PROFILE; + } + return undefined; + case 'sonnet': + if (version.major === 5) return LATEST_OPUS_PROFILE; + if (version.major === 4 && version.minor === 6) return ADAPTIVE_MAX_PROFILE; + if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) <= 5)) { + return BUDGET_PROFILE; + } + return undefined; + case 'haiku': + if (version.major < 4 || (version.major === 4 && (version.minor ?? 0) <= 5)) { + return BUDGET_PROFILE; + } + return undefined; + case 'fable': + return version.major === 5 ? ALWAYS_ADAPTIVE_PROFILE : undefined; + case 'mythos': + return version.major === 5 ? ALWAYS_ADAPTIVE_PROFILE : undefined; + } +} + +export function inferAnthropicModelProfile(model: string): AnthropicModelProfile { + return matchKnownAnthropicModelProfile(model) ?? LATEST_OPUS_PROFILE; +} diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index d538d11ac5..2f6c00faee 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -41,6 +41,14 @@ import type { ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/messages/messages.js'; +import { + BUDGET_THINKING_EFFORTS, + inferAnthropicModelProfile, + matchKnownAnthropicModelProfile, + parseAnthropicModelVersion, + type AnthropicModelProfile, + type AnthropicModelVersion, +} from './anthropic-profile'; import { mergeConsecutiveUserMessages } from './merge-user-messages'; import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth'; import { @@ -96,6 +104,12 @@ export interface AnthropicOptions { * encode a parseable Claude version. Leave undefined to infer from the name. */ adaptiveThinking?: boolean | undefined; + /** + * Concrete thinking efforts declared by the model catalog. When omitted, + * the provider infers a Claude profile from the model name and falls back to + * the latest Opus profile for unrecognized Anthropic-compatible models. + */ + supportEfforts?: readonly string[] | undefined; kimiThinking?: boolean | undefined; /** * Use the Anthropic **beta** Messages API (`client.beta.messages.create`, @@ -133,9 +147,6 @@ interface AnthropicContextManagement { const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; -const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const; -const THINKING_EFFORT_CONFIG_DOCS_URL = - 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), maxLength: 64, @@ -178,6 +189,7 @@ function applyResponseFormat( const CEILING_BY_FAMILY_VERSION: Readonly> = { // Claude Fable 5 documents a 128k output ceiling. 'fable-5': 128000, + 'mythos-5': 128000, // Claude Opus per minor version. 4.6 through 4.8 document a 128k cap; // 4.5 ships at 64k; 4.1 and the dated 4.0 release stay at 32k. 'opus-4-8': 128000, @@ -187,8 +199,9 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = { 'opus-4-1': 32000, 'opus-4-0': 32000, 'opus-4': 32000, - // Claude Sonnet 4.x: 4.0 / 4.5 / 4.6 all document a 64k ceiling. - 'sonnet-4-6': 64000, + // Claude Sonnet 5 and 4.6 document a 128k ceiling; older 4.x stays at 64k. + 'sonnet-5': 128000, + 'sonnet-4-6': 128000, 'sonnet-4-5': 64000, 'sonnet-4-0': 64000, 'sonnet-4': 64000, @@ -207,87 +220,9 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = { 'haiku-3': 4096, }; -const FALLBACK_MAX_TOKENS = 32000; - -type ClaudeFamily = 'opus' | 'sonnet' | 'haiku' | 'fable'; - -interface ClaudeVersion { - family: ClaudeFamily; - major: number; - minor: number | null; -} - -// Family-first form: "opus-4-7", "sonnet-4.6", "haiku-4-5-20251001", -// "fable-5" (single version component — Fable ids carry no minor). -// Version numbers are capped at 1–2 digits with a non-digit lookahead so -// 8-digit date suffixes (e.g. `-20251001`) don't get consumed as version -// components. -const FAMILY_FIRST_RE = - /(opus|sonnet|haiku|fable)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; -// Legacy version-first form: "3-5-sonnet", "3.7.opus" — used by older -// Anthropic model ids and Bedrock variants of Claude 3.x. -const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; -// Bare family form for base Claude 3 (no minor): "3-opus", "3.haiku". -const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; - -/** - * Extract Claude family + version from a model id. - * - * Designed to survive the naming variants we see across vendors: - * vendor prefixes (`anthropic.`, `aws/`, `openrouter/`, - * `online-`), suffixes (date stamps like `-20251001`, build tags - * like `-construct`, `-v1:0`), and `.` vs `-` separators between - * the family and version components. - * - * Returns `null` when the id contains no Claude marker or no - * recognizable family/version, in which case the resolver should fall - * back to the override or {@link FALLBACK_MAX_TOKENS}. - */ -function parseClaudeVersion(model: string): ClaudeVersion | null { - return parseClaudeFamilyVersion(model, true); -} - -function parseClaudeAliasVersion(model: string): ClaudeVersion | null { - return parseClaudeFamilyVersion(model, false); -} +const FALLBACK_MAX_TOKENS = 128000; -function parseClaudeFamilyVersion(model: string, requireClaudeMarker: boolean): ClaudeVersion | null { - const normalized = model.toLowerCase(); - // Guard against false positives on non-Claude models that happen to - // contain an `opus-4-7`-like substring (e.g. fine-tunes named after a - // checkpoint). The Anthropic provider might still be configured for - // non-Claude endpoints, so without this guard we'd quietly apply - // Claude ceilings to unrelated models. - if (requireClaudeMarker && !normalized.includes('claude')) return null; - - const familyFirst = FAMILY_FIRST_RE.exec(normalized); - if (familyFirst !== null) { - return { - family: familyFirst[1] as ClaudeFamily, - major: Number.parseInt(familyFirst[2]!, 10), - minor: familyFirst[3] !== undefined ? Number.parseInt(familyFirst[3], 10) : null, - }; - } - const versionFirst = VERSION_FIRST_RE.exec(normalized); - if (versionFirst !== null) { - return { - major: Number.parseInt(versionFirst[1]!, 10), - minor: Number.parseInt(versionFirst[2]!, 10), - family: versionFirst[3] as ClaudeFamily, - }; - } - const bare = BARE_FAMILY_RE.exec(normalized); - if (bare !== null) { - return { - major: Number.parseInt(bare[1]!, 10), - minor: null, - family: bare[2] as ClaudeFamily, - }; - } - return null; -} - -function lookupClaudeCeiling(version: ClaudeVersion): number | undefined { +function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined { const { family, major, minor } = version; if (minor !== null) { // Exact minor first, then walk down to the nearest catalogued minor: @@ -319,7 +254,7 @@ function lookupClaudeCeiling(version: ClaudeVersion): number | undefined { * {@link FALLBACK_MAX_TOKENS}. */ export function resolveDefaultMaxTokens(model: string, override?: number): number { - const parsed = parseClaudeVersion(model); + const parsed = parseAnthropicModelVersion(model, true); const ceiling = parsed === null ? undefined : lookupClaudeCeiling(parsed); if (ceiling === undefined) { return override ?? FALLBACK_MAX_TOKENS; @@ -327,55 +262,56 @@ export function resolveDefaultMaxTokens(model: string, override?: number): numbe return override === undefined ? ceiling : Math.min(override, ceiling); } -function versionAtLeast( - version: { major: number; minor: number }, - minimum: { major: number; minor: number }, -): boolean { - return ( - version.major > minimum.major || - (version.major === minimum.major && version.minor >= minimum.minor) +function requiresAdaptiveThinking(efforts: readonly string[]): boolean { + return efforts.some( + (effort) => effort !== 'low' && effort !== 'medium' && effort !== 'high', ); } -function supportsAdaptiveThinking(model: string): boolean { - const version = parseClaudeAliasVersion(model); - if (version === null) { - return false; +function resolveThinkingProfile( + model: string, + supportEfforts: readonly string[] | undefined, + adaptiveThinking: boolean | undefined, +): AnthropicModelProfile { + const inferred = inferAnthropicModelProfile(model); + if (adaptiveThinking === false) { + return { + ...inferred, + mode: 'budget', + efforts: supportEfforts ?? BUDGET_THINKING_EFFORTS, + // Opting out of adaptive also opts out of the effort param: budget + // efforts must go out as pure `budget_tokens` payloads instead of + // inheriting `supportsEffortParam` from an adaptive inferred profile. + supportsEffortParam: false, + }; } - // A missing minor is a bare family-major id: "claude-fable-5" (5.0 ≥ 4.6, - // adaptive-only) or "claude-opus-4" (4.0 < 4.6, budget-based). - return versionAtLeast( - { major: version.major, minor: version.minor ?? 0 }, - ADAPTIVE_MIN_VERSION, - ); -} -function isFableModel(model: string): boolean { - return parseClaudeAliasVersion(model)?.family === 'fable'; -} + if (adaptiveThinking === true) { + return { + ...inferred, + mode: 'adaptive', + efforts: supportEfforts ?? inferred.efforts, + supportsEffortParam: true, + }; + } -function supportsEffortParam(model: string, adaptive: boolean): boolean { - if (adaptive) { - return true; + if (supportEfforts === undefined) { + return inferred; } - const normalized = model.toLowerCase(); - return normalized.includes('opus-4-5') || normalized.includes('opus-4.5'); + return { + ...inferred, + mode: requiresAdaptiveThinking(supportEfforts) ? 'adaptive' : inferred.mode, + efforts: supportEfforts, + supportsEffortParam: + requiresAdaptiveThinking(supportEfforts) || inferred.supportsEffortParam, + }; } -function budgetTokensForEffort(effort: ThinkingEffort): number { - switch (effort) { - case 'low': - return 1024; - case 'medium': - return 4096; - case 'on': - case 'high': - return 32_000; - default: - throw new Error( - `Anthropic budget-based thinking cannot express effort "${effort}". Use low, medium, or high, or configure an adaptive / effort-param-capable model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`, - ); - } +function budgetTokensForEffort(effort: ThinkingEffort): number | undefined { + if (effort === 'low') return 1024; + if (effort === 'medium') return 4096; + if (effort === 'on' || effort === 'high') return 32_000; + return undefined; } const CACHE_CONTROL = { type: 'ephemeral' as const }; @@ -383,7 +319,24 @@ const CACHE_CONTROL = { type: 'ephemeral' as const }; type CacheableBlock = ContentBlockParam & { cache_control?: { type: 'ephemeral' } }; function shouldPreserveUnsignedThinking(model: string): boolean { - return parseClaudeAliasVersion(model) === null; + return ( + parseAnthropicModelVersion(model) === null && + matchKnownAnthropicModelProfile(model) === undefined + ); +} + +function shouldBackfillPreservedThinking( + model: string, + thinking: MessageCreateParams['thinking'] | undefined, + contextManagement: AnthropicContextManagement | undefined, +): boolean { + return ( + shouldPreserveUnsignedThinking(model) && + thinking?.type !== 'disabled' && + contextManagement?.edits.some( + (edit) => edit.type === CLEAR_THINKING_EDIT && edit.keep === 'all', + ) === true + ); } /** @@ -544,7 +497,11 @@ function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResu content: blocks, } as ToolResultBlockParam; } -function convertMessage(message: Message, model: string): MessageParam { +function convertMessage( + message: Message, + model: string, + backfillPreservedThinking: boolean, +): MessageParam { const role = message.role; // system role -> ... wrapped user message @@ -570,12 +527,16 @@ function convertMessage(message: Message, model: string): MessageParam { // user or assistant const blocks: ContentBlockParam[] = []; + let hasThinkingPart = false; + let lastUnsignedThinkingBlockIndex: number | undefined; + let hasNonEmptyEmittedThinking = false; for (const part of message.content) { if (part.type === 'text') { blocks.push({ type: 'text', text: part.text } satisfies TextBlockParam); } else if (part.type === 'image_url') { blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); } else if (part.type === 'think') { + hasThinkingPart = true; // ThinkPart -> ThinkingBlockParam. // // Signed: emit the block with its signature. api.anthropic.com requires a @@ -595,8 +556,11 @@ function convertMessage(message: Message, model: string): MessageParam { thinking: part.think, signature: part.encrypted, } satisfies ThinkingBlockParam); + hasNonEmptyEmittedThinking ||= part.think.length > 0; } else if (shouldPreserveUnsignedThinking(model)) { + lastUnsignedThinkingBlockIndex = blocks.length; blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); + hasNonEmptyEmittedThinking ||= part.think.length > 0; } } else if (part.type === 'video_url') { blocks.push(videoUrlPartToAnthropic(part.videoUrl.url) as unknown as ContentBlockParam); @@ -609,6 +573,23 @@ function convertMessage(message: Message, model: string): MessageParam { } } + if (role === 'assistant' && backfillPreservedThinking) { + // Some compatible endpoints require every replayed assistant message to + // carry non-empty thinking. Keep the placeholder wire-only, and never + // alter signed blocks because their text is covered by the signature. + if (!hasThinkingPart) { + blocks.unshift({ type: 'thinking', thinking: ' ' } as unknown as ThinkingBlockParam); + } else if ( + lastUnsignedThinkingBlockIndex !== undefined && + !hasNonEmptyEmittedThinking + ) { + blocks[lastUnsignedThinkingBlockIndex] = { + type: 'thinking', + thinking: ' ', + } as unknown as ThinkingBlockParam; + } + } + // Tool calls -> ToolUseBlockParam if (message.toolCalls.length > 0) { for (const tc of message.toolCalls) { @@ -637,6 +618,11 @@ function convertMessage(message: Message, model: string): MessageParam { return { role: role, content: blocks }; } + +function shouldKeepConvertedMessage(message: MessageParam): boolean { + return message.role !== 'assistant' || message.content.length > 0; +} + export function convertAnthropicError(error: unknown): ChatProviderError { // Check timeout before connection (APIConnectionTimeoutError extends APIConnectionError) if (error instanceof AnthropicTimeoutError) { @@ -833,7 +819,7 @@ class AnthropicStreamedMessage implements StreamedMessage { yield { type: 'text', text: block.text }; break; case 'thinking': - yield { type: 'think', think: block.thinking }; + yield { type: 'think', think: block.thinking ?? '' }; break; case 'redacted_thinking': yield { @@ -866,7 +852,7 @@ class AnthropicStreamedMessage implements StreamedMessage { yield { type: 'text', text: delta.text }; break; case 'thinking_delta': - yield { type: 'think', think: delta.thinking }; + yield { type: 'think', think: delta.thinking ?? '' }; break; case 'input_json_delta': yield { @@ -950,6 +936,7 @@ export class AnthropicChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; + private readonly _supportEfforts: readonly string[] | undefined; private readonly _kimiThinking: boolean; private _betaApi: boolean; private _explicitMaxTokens: boolean; @@ -959,6 +946,7 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; + this._supportEfforts = options.supportEfforts; this._kimiThinking = options.kimiThinking ?? false; this._betaApi = options.betaApi ?? false; this._apiKey = @@ -1018,6 +1006,12 @@ export class AnthropicChatProvider implements ChatProvider { ] : undefined; + const backfillPreservedThinking = shouldBackfillPreservedThinking( + this._model, + this._generationKwargs.thinking, + this._generationKwargs.contextManagement, + ); + // Convert messages, then merge consecutive user messages into one. Strict // Anthropic-compatible backends reject consecutive user messages with HTTP // 400 ("roles must alternate"), and api.anthropic.com concatenates them @@ -1036,9 +1030,9 @@ export class AnthropicChatProvider implements ChatProvider { // garbage `` user turn). See isToolDeclarationOnlyMessage. history.filter((msg) => !isToolDeclarationOnlyMessage(msg)), ANTHROPIC_TOOL_CALL_ID_POLICY, - ).map((msg) => - convertMessage(msg, this._model), - ), + ) + .map((msg) => convertMessage(msg, this._model, backfillPreservedThinking)) + .filter(shouldKeepConvertedMessage), { isUser: (message) => message.role === 'user', isToolResultOnly, @@ -1070,12 +1064,8 @@ export class AnthropicChatProvider implements ChatProvider { if (this._generationKwargs.top_p !== undefined) { kwargs['top_p'] = this._generationKwargs.top_p; } - // Fable rejects an explicit `disabled` thinking config (HTTP 400, unlike - // Opus 4.7/4.8 which accept it), so omit the field instead. Note thinking - // cannot actually be turned off on Fable: adaptive thinking is always on, - // and an omitted `thinking` field still runs with it. const thinking = this._generationKwargs.thinking; - if (thinking !== undefined && !(thinking.type === 'disabled' && isFableModel(this._model))) { + if (thinking !== undefined) { kwargs['thinking'] = thinking; } if (this._generationKwargs.output_config !== undefined) { @@ -1241,9 +1231,11 @@ export class AnthropicChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): AnthropicChatProvider { - // Resolve once: an explicit `adaptiveThinking` option overrides the - // model-name version inference, so custom-named endpoints can opt in/out. - const adaptive = this._adaptiveThinking ?? supportsAdaptiveThinking(this._model); + const profile = resolveThinkingProfile( + this._model, + this._supportEfforts, + this._kimiThinking ? true : this._adaptiveThinking, + ); let thinking: MessageCreateParams['thinking']; let outputConfig: MessageCreateParams['output_config'] | undefined; @@ -1253,22 +1245,26 @@ export class AnthropicChatProvider implements ChatProvider { thinking = { type: 'enabled' } as MessageCreateParams['thinking']; outputConfig = effort === 'on' ? undefined : ({ effort } as MessageCreateParams['output_config']); - } else if (adaptive) { + } else if (profile.mode === 'adaptive') { thinking = { type: 'adaptive', display: 'summarized' }; outputConfig = effort === 'on' ? undefined : ({ effort } as MessageCreateParams['output_config']); } else { - thinking = { type: 'enabled', budget_tokens: budgetTokensForEffort(effort) }; + const budgetTokens = budgetTokensForEffort(effort); + thinking = + budgetTokens === undefined + ? ({ type: 'enabled' } as MessageCreateParams['thinking']) + : { type: 'enabled', budget_tokens: budgetTokens }; outputConfig = - supportsEffortParam(this._model, adaptive) && effort !== 'on' + (profile.supportsEffortParam || budgetTokens === undefined) && effort !== 'on' ? ({ effort } as MessageCreateParams['output_config']) : undefined; } let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; - if (adaptive) { + if (profile.mode === 'adaptive') { newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); } const clone = this._withGenerationKwargs({ diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index d114201958..1847ab8183 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -167,7 +167,12 @@ function convertMessage(message: Message, preservedThinkingEnabled: boolean): Op } if (hasReasoningPart || (preservedThinkingEnabled && message.role === 'assistant')) { - result.reasoning_content = reasoningContent; + // Keep the non-empty replay placeholder on the wire; canonical history + // continues to retain the original empty reasoning value. + result.reasoning_content = + preservedThinkingEnabled && message.role === 'assistant' && reasoningContent.length === 0 + ? ' ' + : reasoningContent; } // Message-level tool declarations: a system message carrying `tools` loads diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index d4d5ab7f64..739f00ae62 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1,6 +1,13 @@ +/** + * Scenario: Anthropic request serialization and response streaming across native and compatible models. + * Responsibilities: preserve provider wire contracts, thinking semantics, tool calls, and request options. + * Wiring: real Anthropic adapter with only the remote SDK client boundary replaced by mocks. + * Run: pnpm exec vitest run packages/kosong/test/anthropic.test.ts + */ 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 type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; @@ -39,6 +46,43 @@ function createStreamProvider(model: string = 'k25'): AnthropicChatProvider { }); } +const UNSIGNED_THINKING_ONLY_HISTORY: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Start' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'think', think: 'Partial reasoning' }], + toolCalls: [], + }, + { role: 'user', content: [{ type: 'text', text: 'Continue' }], toolCalls: [] }, +]; + +describe('Anthropic model profile matching', () => { + it.each([ + ['claude-opus-4-5', 'budget', ['low', 'medium', 'high'], true, true], + ['anthropic.claude-opus-4-6-v1:0', 'adaptive', ['low', 'medium', 'high', 'max'], true, true], + ['claude-opus-4-7', 'adaptive', ['low', 'medium', 'high', 'xhigh', 'max'], true, true], + ['claude-sonnet-4-6', 'adaptive', ['low', 'medium', 'high', 'max'], true, true], + ['claude-sonnet-5', 'adaptive', ['low', 'medium', 'high', 'xhigh', 'max'], true, true], + ['claude-fable-5', 'adaptive', ['low', 'medium', 'high', 'xhigh', 'max'], true, false], + ['claude-mythos-5', 'adaptive', ['low', 'medium', 'high', 'xhigh', 'max'], true, false], + ['claude-mythos-preview', 'adaptive', ['low', 'medium', 'high', 'max'], true, false], + ] as const)( + 'matches %s to the built-in official profile', + (model, mode, efforts, supportsEffortParam, canDisableThinking) => { + expect(matchKnownAnthropicModelProfile(model)).toEqual({ + mode, + efforts, + supportsEffortParam, + canDisableThinking, + }); + }, + ); + + it('does not claim an official profile for an unrecognized compatible model', () => { + expect(matchKnownAnthropicModelProfile('Example Compatible Model')).toBeUndefined(); + }); +}); + type AnthropicGenerationState = { max_tokens?: number | undefined; temperature?: number | undefined; @@ -94,7 +138,7 @@ async function captureRequestBody( } /** Create a mock stream that yields the given events as an async iterable. */ -function mockStream(events: unknown[]) { +function mockStream(events: readonly unknown[]) { return { async *[Symbol.asyncIterator]() { for (const event of events) { @@ -115,6 +159,56 @@ async function collectParts( return parts; } +async function collectAnthropicStreamParts( + events: readonly Record[], +): Promise { + const create = vi.fn().mockResolvedValue(mockStream(events)); + const provider = new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: '', + stream: true, + clientFactory: () => ({ messages: { create } }) as never, + }); + + return collectParts( + await provider.generate( + '', + [], + [{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }], + ), + ); +} + +async function captureAnthropicMessages( + model: string, + history: Message[], + configure?: (provider: AnthropicChatProvider) => AnthropicChatProvider, +): Promise> { + let captured: Record | undefined; + const create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve(makeAnthropicResponse(model)); + }); + let provider = new AnthropicChatProvider({ + model, + apiKey: '', + defaultMaxTokens: 1024, + stream: false, + clientFactory: () => ({ messages: { create }, beta: { messages: { create } } }) as never, + }); + if (configure !== undefined) { + provider = configure(provider); + } + + const response = await provider.generate('', [], history); + await collectParts(response); + + if (captured === undefined) { + throw new Error('Expected Anthropic provider to send a request.'); + } + return captured['messages'] as Array<{ role: string; content: unknown[] }>; +} + const ADD_TOOL: Tool = { name: 'add', description: 'Add two integers.', @@ -342,6 +436,306 @@ describe('withThinkingKeep (context_management)', () => { }); expect(body['betas']).toContain('context-management-2025-06-27'); }); + + it('backfills non-empty thinking when compatible text history is replayed with keep all', async () => { + const compatibleHistory: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + }, + { role: 'user', content: [{ type: 'text', text: 'Continue' }], toolCalls: [] }, + ]; + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'Hello' }, + ], + }); + }); + + it('backfills non-empty thinking before a compatible assistant tool call with keep all', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: ' ' }, + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: { q: 'test' }, + cache_control: { type: 'ephemeral' }, + }, + ], + }); + }); + + it('makes an existing unsigned empty thinking block non-empty when keep all is active', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '' }, + { type: 'text', text: 'Hello' }, + ], + toolCalls: [], + }, + ]; + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, + ], + }); + }); + + it('makes only the last unsigned block non-empty when every unsigned block is empty', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '' }, + { type: 'think', think: '' }, + { type: 'text', text: 'Hello' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: '' }, + { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, + ]); + }); + + it('backfills each empty assistant message independently when keep all is active', async () => { + const compatibleHistory: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'First' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'First response' }], + toolCalls: [], + }, + { role: 'user', content: [{ type: 'text', text: 'Second' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Second response' }], + toolCalls: [], + }, + { role: 'user', content: [{ type: 'text', text: 'Third' }], toolCalls: [] }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect([messages[1]!.content[0], messages[3]!.content[0]]).toEqual([ + { type: 'thinking', thinking: ' ' }, + { type: 'thinking', thinking: ' ' }, + ]); + }); + + it('preserves every unsigned block when one contains non-empty thinking', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '' }, + { type: 'think', think: 'reasoning' }, + { type: 'text', text: 'Hello' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: '' }, + { type: 'thinking', thinking: 'reasoning' }, + { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, + ]); + }); + + it('preserves an empty signed thinking block when keep all is active', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '', encrypted: 'signature' }, + { type: 'text', text: 'Hello' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: '', signature: 'signature' }, + { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, + ]); + }); + + it('makes the unsigned block non-empty when signed and unsigned thinking are empty', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '', encrypted: 'signature' }, + { type: 'think', think: '' }, + { type: 'text', text: 'Hello' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: '', signature: 'signature' }, + { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, + ]); + }); + + it('leaves unsigned empty thinking unchanged when signed thinking is non-empty', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: 'signed reasoning', encrypted: 'signature' }, + { type: 'think', think: '' }, + { type: 'text', text: 'Hello' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]!.content).toEqual([ + { type: 'thinking', thinking: 'signed reasoning', signature: 'signature' }, + { type: 'thinking', thinking: '' }, + { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, + ]); + }); + + it('leaves missing compatible thinking absent when keep all is not enabled', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + }, + ]; + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('max'), + ); + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }], + }); + }); + + it('leaves missing compatible thinking absent when thinking is disabled', async () => { + const compatibleHistory: Message[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + }, + ]; + const messages = await captureAnthropicMessages( + 'compatible-preserved-thinking-model', + compatibleHistory, + (provider) => provider.withThinking('off').withThinkingKeep('all'), + ); + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }], + }); + }); + + it.each(['claude-opus-4-8', 'claude-opus-4-9', 'claude-mythos-preview'])( + 'does not synthesize unsigned thinking for Claude model %s with keep all', + async (model) => { + const claudeHistory: Message[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + }, + ]; + const messages = await captureAnthropicMessages(model, claudeHistory, (provider) => + provider.withThinking('max').withThinkingKeep('all'), + ); + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }], + }); + }, + ); }); describe('AnthropicChatProvider', () => { @@ -1341,7 +1735,25 @@ describe('AnthropicChatProvider', () => { expect(messages[0]!.content[0]).toEqual({ type: 'thinking', thinking: '' }); }); - it.each(['claude-opus-4-6', 'opus-4-6'])( + it('preserves an unsigned-only assistant message for Anthropic-compatible models', async () => { + const messages = await captureAnthropicMessages( + 'compatible-model', + UNSIGNED_THINKING_ONLY_HISTORY, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'thinking', thinking: 'Partial reasoning' }], + }); + }); + + it.each([ + 'claude-opus-4-6', + 'opus-4-6', + 'claude-opus-4-9', + 'opus-4-9', + 'claude-mythos-preview', + ])( 'drops unsigned thinking for Claude model %s before tool_use blocks', async (model) => { const provider = createProvider(model); @@ -1371,6 +1783,23 @@ describe('AnthropicChatProvider', () => { }, ); + it('drops an unsigned-only Claude assistant without leaving an empty wire message', async () => { + const messages = await captureAnthropicMessages( + 'claude-opus-4-9', + UNSIGNED_THINKING_ONLY_HISTORY, + ); + + expect(messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Start' }, + { type: 'text', text: 'Continue', cache_control: { type: 'ephemeral' } }, + ], + }, + ]); + }); + it('base64 image', async () => { const provider = createProvider(); const history: Message[] = [ @@ -1480,7 +1909,7 @@ describe('AnthropicChatProvider', () => { }); it('combines thinking and max_tokens in internal state', () => { - const provider = createProvider() + const provider = createProvider('claude-sonnet-4-5') .withThinking('high') .withGenerationKwargs({ max_tokens: 512 }); const state = getGenerationState(provider); @@ -1520,7 +1949,7 @@ describe('AnthropicChatProvider', () => { ]; it('pre-4.6 model: high -> budget_tokens=32000', async () => { - const provider = createProvider('k25').withThinking('high'); + const provider = createProvider('claude-sonnet-4-5').withThinking('high'); const body = await captureRequestBody(provider, '', [], thinkHistory); expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); @@ -1561,17 +1990,20 @@ describe('AnthropicChatProvider', () => { } }); - it('claude-fable-5 with thinking off omits the thinking field entirely', async () => { - // Fable 400s on an explicit `disabled` thinking config (unlike Opus - // 4.7/4.8); the provider must drop the field from the request while - // still reporting `off` to callers. - const provider = createProvider('claude-fable-5').withThinking('off'); - expect(provider.thinkingEffort).toBe('off'); + it.each(['claude-fable-5', 'claude-mythos-5', 'claude-mythos-preview'])( + '%s: passes thinking off through for the backend to validate', + async (model) => { + const body = await captureRequestBody( + createProvider(model).withThinking('off'), + '', + [], + thinkHistory, + ); - const body = await captureRequestBody(provider, '', [], thinkHistory); - expect('thinking' in body).toBe(false); - expect(body['output_config']).toBeUndefined(); - }); + expect(body['thinking']).toEqual({ type: 'disabled' }); + expect(body['output_config']).toBeUndefined(); + }, + ); it.each([ 'claude-sonnet-4-6', @@ -1603,7 +2035,7 @@ describe('AnthropicChatProvider', () => { it('adaptiveThinking=true forces adaptive on an unversioned model name', async () => { const provider = new AnthropicChatProvider({ - model: 'coding-model-okapi-0527-vibe', + model: 'compatible-model', apiKey: 'test-key', defaultMaxTokens: 1024, stream: false, @@ -1629,17 +2061,73 @@ describe('AnthropicChatProvider', () => { expect(body['output_config']).toEqual({ effort: 'max' }); }); - it('unversioned model name without adaptiveThinking stays budget-based', async () => { + it('adaptiveThinking=true passes an unlisted effort through unchanged', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-6', + apiKey: 'test-key', + stream: false, + adaptiveThinking: true, + }).withThinking('xhigh'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'xhigh' }); + }); + + it('unversioned model without supportEfforts preserves max through the latest Opus profile', async () => { const provider = new AnthropicChatProvider({ model: 'coding-model-okapi-0527-vibe', apiKey: 'test-key', defaultMaxTokens: 1024, stream: false, - }).withThinking('high'); + }).withThinking('max'); const body = await captureRequestBody(provider, '', [], thinkHistory); - expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); - expect(body['output_config']).toBeUndefined(); + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('declared supportEfforts override a legacy model-name profile', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-5', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + supportEfforts: ['low', 'medium', 'high', 'max'], + }).withThinking('max'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('passes efforts outside declared supportEfforts without converting them', async () => { + const provider = new AnthropicChatProvider({ + model: 'Example Compatible Model', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + supportEfforts: ['low', 'high'], + }).withThinking('max'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }); + + it('keeps a concrete effort when adaptiveThinking is false', async () => { + const provider = new AnthropicChatProvider({ + model: 'Example Compatible Model', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + adaptiveThinking: false, + supportEfforts: ['low', 'high', 'max'], + }).withThinking('max'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'enabled' }); + expect(body['output_config']).toEqual({ effort: 'max' }); }); it('Kimi thinking mode sends concrete effort without budget conversion', async () => { @@ -1714,18 +2202,48 @@ describe('AnthropicChatProvider', () => { expect(body['output_config']).toBeUndefined(); }); - it('pre-4.6 budget model rejects xhigh and max', () => { + it('adaptiveThinking=false omits the effort param for an unversioned model name', async () => { + const provider = new AnthropicChatProvider({ + model: 'coding-model-okapi-0527-vibe', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + adaptiveThinking: false, + }); + for (const [effort, budget] of [ + ['low', 1024], + ['medium', 4096], + ['high', 32_000], + ] as const) { + const body = await captureRequestBody(provider.withThinking(effort), '', [], thinkHistory); + expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: budget }); + expect(body['output_config']).toBeUndefined(); + } + }); + + it('pre-4.6 budget model passes xhigh and max through unchanged', async () => { for (const effort of ['xhigh', 'max'] as const) { - expect(() => createProvider('claude-sonnet-4-5').withThinking(effort)).toThrow( - /budget-based thinking cannot express effort/, + const body = await captureRequestBody( + createProvider('claude-sonnet-4-5').withThinking(effort), + '', + [], + thinkHistory, ); + expect(body['thinking']).toEqual({ type: 'enabled' }); + expect(body['output_config']).toEqual({ effort }); } }); - it('opus-4-5 rejects xhigh', () => { - expect(() => createProvider('claude-opus-4-5').withThinking('xhigh')).toThrow( - /budget-based thinking cannot express effort/, + it('opus-4-5 passes xhigh through unchanged', async () => { + const body = await captureRequestBody( + createProvider('claude-opus-4-5').withThinking('xhigh'), + '', + [], + thinkHistory, ); + + expect(body['thinking']).toEqual({ type: 'enabled' }); + expect(body['output_config']).toEqual({ effort: 'xhigh' }); }); it('opus-4-6 with thinking off -> disabled', async () => { @@ -1820,7 +2338,6 @@ describe('AnthropicChatProvider', () => { ['claude-opus-4-7', 'high', 'high'], ['claude-opus-4-7', 'xhigh', 'xhigh'], ['claude-opus-4-7', 'max', 'max'], - ['claude-opus-4-6', 'xhigh', 'xhigh'], ['claude-opus-4-6', 'max', 'max'], ] as const)( 'adaptive wire body: %s + %s -> output_config.effort=%s', @@ -1940,12 +2457,6 @@ describe('AnthropicChatProvider', () => { 'claude-haiku-3-5', 'claude-haiku-4-5', 'claude-haiku-4-5-20251001', - // Non-Claude models / garbage input - 'gpt-4', - 'gpt-4-turbo', - 'gemini-2.5-pro', - 'unknown-model', - 'claude', // no family word ])('non-adaptive: %s -> type=enabled budget', async (model) => { const provider = createProvider(model).withThinking('high'); const body = await captureRequestBody(provider, '', [], thinkHistory); @@ -1953,6 +2464,17 @@ describe('AnthropicChatProvider', () => { expect(body['thinking']).toMatchObject({ type: 'enabled' }); expect((body['thinking'] as { type: string }).type).not.toBe('adaptive'); }); + + it.each(['gpt-4', 'gpt-4-turbo', 'gemini-2.5-pro', 'unknown-model', 'claude'])( + 'unrecognized model %s uses the latest Opus adaptive profile', + async (model) => { + const provider = createProvider(model).withThinking('max'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'max' }); + }, + ); }); // Effort handling per model capability: adaptive-capable models pass @@ -1967,10 +2489,8 @@ describe('AnthropicChatProvider', () => { ['claude-opus-4-7', 'max', 'max'], ['claude-opus-4-7-20260301', 'xhigh', 'xhigh'], ['claude-opus-4-6', 'max', 'max'], - ['claude-opus-4-6', 'xhigh', 'xhigh'], ['claude-opus-4-6-20260205', 'max', 'max'], ['claude-sonnet-4-6', 'max', 'max'], - ['claude-sonnet-4-6', 'xhigh', 'xhigh'], ['claude-opus-4-6', 'medium', 'medium'], ['claude-fable-5', 'xhigh', 'xhigh'], ['claude-fable-5', 'max', 'max'], @@ -1991,16 +2511,22 @@ describe('AnthropicChatProvider', () => { it.each([ ['claude-opus-4-5', 'max'], ['claude-opus-4-5', 'xhigh'], + ['claude-opus-4-6', 'xhigh'], ['claude-sonnet-4-20250514', 'max'], ['claude-sonnet-4-20250514', 'xhigh'], ['claude-sonnet-4-5', 'xhigh'], + ['claude-sonnet-4-6', 'xhigh'], ['claude-haiku-4-5', 'max'], ] as const)( - 'legacy budget rejects unsupported effort: %s + %s', - (model, effort) => { - expect(() => createProvider(model).withThinking(effort)).toThrow( - /budget-based thinking cannot express effort/, + 'legacy budget passes an unlisted effort through: %s + %s', + async (model, effort) => { + const body = await captureRequestBody( + createProvider(model).withThinking(effort), + '', + [], + thinkHistory, ); + expect(body['output_config']).toEqual({ effort }); }, ); @@ -2020,6 +2546,14 @@ describe('AnthropicChatProvider', () => { } }, ); + + it('represents boolean on with the legacy high token budget', async () => { + const provider = createProvider('claude-sonnet-4-5').withThinking('on'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); + expect(body['output_config']).toBeUndefined(); + }); }); // Effort-param gating: adaptive-capable models and explicit @@ -2034,6 +2568,8 @@ describe('AnthropicChatProvider', () => { 'claude-opus-4-6-20260205', 'claude-sonnet-4-6', 'claude-opus-5-0', + 'gpt-4', + 'claude-2.1', // Opus 4.5 explicitly supports effort (legacy budget thinking + effort) 'claude-opus-4-5', 'claude-opus-4-5-20251001', @@ -2065,9 +2601,6 @@ describe('AnthropicChatProvider', () => { 'claude-3-5-haiku-20241022', // Bedrock + old format 'anthropic.claude-3-5-sonnet-20240620-v1:0', - // Non-Claude / garbage - 'gpt-4', - 'claude-2.1', ])('effort unsupported: %s -> output_config absent', async (model) => { const provider = createProvider(model).withThinking('high'); const body = await captureRequestBody(provider, '', [], thinkHistory); @@ -2125,19 +2658,19 @@ describe('AnthropicChatProvider', () => { expect(max.thinkingEffort).toBe('max'); }); - it('reports adaptive effort verbatim', () => { - const provider = createProvider('claude-sonnet-4-6').withThinking('xhigh'); - expect(provider.thinkingEffort).toBe('xhigh'); + it('reports an officially supported adaptive effort verbatim', () => { + const provider = createProvider('claude-sonnet-4-6').withThinking('max'); + expect(provider.thinkingEffort).toBe('max'); }); it('pre-4.6 budget-based efforts', () => { - const low = createProvider().withThinking('low'); + const low = createProvider('claude-sonnet-4-5').withThinking('low'); expect(low.thinkingEffort).toBe('low'); - const med = createProvider().withThinking('medium'); + const med = createProvider('claude-sonnet-4-5').withThinking('medium'); expect(med.thinkingEffort).toBe('medium'); - const high = createProvider().withThinking('high'); + const high = createProvider('claude-sonnet-4-5').withThinking('high'); expect(high.thinkingEffort).toBe('high'); }); }); @@ -2334,6 +2867,30 @@ describe('AnthropicChatProvider', () => { }); }); + it('normalizes a thinking delta with no thinking field to an empty ThinkPart', async () => { + const parts = await collectAnthropicStreamParts([ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta' }, + }, + ]); + + expect(parts).toEqual([{ type: 'think', think: '' }]); + }); + + it('normalizes a thinking block start with no thinking field to an empty ThinkPart', async () => { + const parts = await collectAnthropicStreamParts([ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking' }, + }, + ]); + + expect(parts).toEqual([{ type: 'think', think: '' }]); + }); + it('yields tool_use start and argument deltas from stream events', async () => { const provider = createStreamProvider(); const stream = mockStream([ @@ -2743,7 +3300,8 @@ describe('resolveDefaultMaxTokens', () => { expect(resolveDefaultMaxTokens('claude-opus-4-5-20251101')).toBe(64000); expect(resolveDefaultMaxTokens('claude-opus-4-1-20250805')).toBe(32000); expect(resolveDefaultMaxTokens('claude-opus-4-20250514')).toBe(32000); - expect(resolveDefaultMaxTokens('claude-sonnet-4-6')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-sonnet-5')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-sonnet-4-6')).toBe(128000); expect(resolveDefaultMaxTokens('claude-sonnet-4-5-20250929')).toBe(64000); expect(resolveDefaultMaxTokens('claude-sonnet-4-20250514')).toBe(64000); expect(resolveDefaultMaxTokens('claude-haiku-4-5-20251001')).toBe(64000); @@ -2772,7 +3330,7 @@ describe('resolveDefaultMaxTokens', () => { expect(resolveDefaultMaxTokens('claude-opus-4.8')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4.7')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4.6')).toBe(128000); - expect(resolveDefaultMaxTokens('claude-sonnet-4.6')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-sonnet-4.6')).toBe(128000); expect(resolveDefaultMaxTokens('claude-haiku-4.5')).toBe(64000); }); @@ -2797,7 +3355,7 @@ describe('resolveDefaultMaxTokens', () => { // ceiling (a newer minor inherits at least its predecessor's cap). expect(resolveDefaultMaxTokens('claude-opus-4-9')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-10')).toBe(128000); - expect(resolveDefaultMaxTokens('claude-sonnet-4-9')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-sonnet-4-9')).toBe(128000); expect(resolveDefaultMaxTokens('claude-haiku-4-9')).toBe(64000); // A gap between catalogued minors also resolves to the nearest lower one. expect(resolveDefaultMaxTokens('claude-opus-4-3')).toBe(32000); @@ -2805,7 +3363,7 @@ describe('resolveDefaultMaxTokens', () => { it('matches case-insensitively', () => { expect(resolveDefaultMaxTokens('CLAUDE-OPUS-4-7')).toBe(128000); - expect(resolveDefaultMaxTokens('Claude-Sonnet-4-6')).toBe(64000); + expect(resolveDefaultMaxTokens('Claude-Sonnet-4-6')).toBe(128000); expect(resolveDefaultMaxTokens('Anthropic.Claude-Opus-4-7-v1:0')).toBe(128000); }); @@ -2822,19 +3380,19 @@ describe('resolveDefaultMaxTokens', () => { it('clamps an override above the documented ceiling for known models', () => { expect(resolveDefaultMaxTokens('claude-opus-4-7', 999999)).toBe(128000); - expect(resolveDefaultMaxTokens('claude-sonnet-4-6', 200000)).toBe(64000); + expect(resolveDefaultMaxTokens('claude-sonnet-4-6', 200000)).toBe(128000); expect(resolveDefaultMaxTokens('claude-3-opus', 99999)).toBe(4096); }); - it('falls back to 32000 when both lookup and override miss', () => { - expect(resolveDefaultMaxTokens('totally-unknown-model')).toBe(32000); - expect(resolveDefaultMaxTokens('gpt-5')).toBe(32000); + it('falls back to the latest Opus 128k ceiling when both lookup and override miss', () => { + expect(resolveDefaultMaxTokens('totally-unknown-model')).toBe(128000); + expect(resolveDefaultMaxTokens('gpt-5')).toBe(128000); }); it('does not apply Claude ceilings to non-Claude ids that contain an opus/sonnet/haiku token', () => { // No "claude" marker → fall through to the override / fallback rather // than quietly applying a Claude ceiling to a fine-tune or unrelated model. - expect(resolveDefaultMaxTokens('vendor-opus-4-7-preview')).toBe(32000); + expect(resolveDefaultMaxTokens('vendor-opus-4-7-preview')).toBe(128000); expect(resolveDefaultMaxTokens('vendor-opus-4-7-preview', 8000)).toBe(8000); }); }); @@ -2861,7 +3419,7 @@ describe('AnthropicChatProvider constructor max_tokens', () => { expect(await maxTokensFor('claude-opus-4-7')).toBe(128000); expect(await maxTokensFor('claude-opus-4-6')).toBe(128000); expect(await maxTokensFor('claude-opus-4-5')).toBe(64000); - expect(await maxTokensFor('claude-sonnet-4-6')).toBe(64000); + expect(await maxTokensFor('claude-sonnet-4-6')).toBe(128000); expect(await maxTokensFor('claude-haiku-4-5-20251001')).toBe(64000); }); @@ -2873,6 +3431,10 @@ describe('AnthropicChatProvider constructor max_tokens', () => { expect(await maxTokensFor('unknown-model', { defaultMaxTokens: 12345 })).toBe(12345); }); + it('uses the 128k fallback for unknown models without an override', async () => { + expect(await maxTokensFor('unknown-model')).toBe(128000); + }); + it('lets defaultMaxTokens lower the budget for known models', async () => { expect(await maxTokensFor('claude-opus-4-7', { defaultMaxTokens: 200 })).toBe(200); }); diff --git a/packages/kosong/test/e2e/anthropic-adapter.test.ts b/packages/kosong/test/e2e/anthropic-adapter.test.ts index bbf020746f..f7851df721 100644 --- a/packages/kosong/test/e2e/anthropic-adapter.test.ts +++ b/packages/kosong/test/e2e/anthropic-adapter.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: exercise the Anthropic adapter over a real local HTTP connection. + * Responsibilities: verify public-provider request serialization and response parsing at the wire. + * Wiring: the provider and Anthropic SDK are real; only the remote Messages API is stubbed. + * Run: pnpm exec vitest run packages/kosong/test/e2e/anthropic-adapter.test.ts + */ import type { Message, StreamedMessagePart, ToolCall } from '#/message'; import { AnthropicChatProvider } from '#/providers/anthropic'; import type { Tool } from '#/tool'; @@ -61,6 +67,114 @@ const MUL_TOOL: Tool = { }; describe('e2e: Anthropic adapter bridge', () => { + it('replays model-switched text-only history as valid preserved-thinking wire content for a compatible endpoint', async () => { + const harness = await createFakeProviderHarness(); + + try { + harness.route('POST', '/v1/messages', async (_request, reply) => { + const stream = [ + anthropicSseFrame('message_start', { + type: 'message_start', + message: { + id: 'msg_compatible', + type: 'message', + role: 'assistant', + model: 'compatible-preserved-thinking-model', + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 12, output_tokens: 0 }, + }, + }), + anthropicSseFrame('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: 'Compatible endpoint accepted the history.' }, + }), + anthropicSseFrame('content_block_stop', { + type: 'content_block_stop', + index: 0, + }), + anthropicSseFrame('message_delta', { + type: 'message_delta', + delta: { type: 'message_delta', stop_reason: 'end_turn', stop_sequence: null }, + usage: { input_tokens: 12, output_tokens: 7 }, + }), + anthropicSseFrame('message_stop', { type: 'message_stop' }), + ].join(''); + + await reply.raw(200, stream, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }); + }); + + const provider = new AnthropicChatProvider({ + model: 'compatible-preserved-thinking-model', + apiKey: 'test-key', + baseUrl: harness.baseUrl, + stream: true, + }) + .withThinking('max') + .withThinkingKeep('all'); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello from Opus' }], + toolCalls: [], + }, + { + role: 'user', + content: [{ type: 'text', text: 'Continue with the compatible model' }], + toolCalls: [], + }, + ]; + + const response = await provider.generate('', [], history); + expect(await collectParts(response)).toEqual([ + { type: 'text', text: 'Compatible endpoint accepted the history.' }, + ]); + expect(harness.requests).toHaveLength(1); + expect(harness.requests[0]!.search).toBe('?beta=true'); + expect(harness.requests[0]!.headers['anthropic-beta']).toBe( + 'context-management-2025-06-27', + ); + expect(harness.requests[0]!.bodyJson).toMatchObject({ + model: 'compatible-preserved-thinking-model', + max_tokens: 128000, + thinking: { type: 'adaptive', display: 'summarized' }, + output_config: { effort: 'max' }, + context_management: { + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'Hello from Opus' }, + ], + }, + { + role: 'user', + content: [ + { + type: 'text', + text: 'Continue with the compatible model', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + }); + } finally { + await harness.close(); + } + }); + it('sends the adapter request body and parses streamed text, tool use, and usage', async () => { const previousAuthToken = process.env['ANTHROPIC_AUTH_TOKEN']; const previousCustomHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index e14d3e510e..23cbebb4c1 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -633,7 +633,7 @@ describe('KimiChatProvider', () => { ]); }); - it('backfills an assistant tool-call message when preserved thinking is active', async () => { + it('backfills non-empty reasoning for an assistant tool call when preserved thinking is active', async () => { const history: Message[] = [ { role: 'assistant', @@ -648,10 +648,10 @@ describe('KimiChatProvider', () => { provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ''); + expect(messages[0]).toHaveProperty('reasoning_content', ' '); }); - it('backfills a text assistant message when keep=all omits thinking.type', async () => { + it('backfills non-empty reasoning for a text assistant when keep=all omits thinking.type', async () => { const history: Message[] = [ { role: 'assistant', @@ -664,30 +664,62 @@ describe('KimiChatProvider', () => { provider.withExtraBody({ thinking: { keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ''); + expect(messages[0]).toHaveProperty('reasoning_content', ' '); }); - it.each([ - ['empty', ''], - ['non-empty', 'reasoning text'], - ])( - 'sends an existing %s ThinkPart verbatim when preserved thinking is active', - async (_kind, think) => { - const history: Message[] = [ - { - role: 'assistant', - content: [{ type: 'think', think }], - toolCalls: [], - }, - ]; + it('makes an existing empty ThinkPart non-empty when preserved thinking is active', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '' }], + toolCalls: [], + }, + ]; - const messages = await captureKimiMessages(history, (provider) => - provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), - ); + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); - expect(messages[0]).toHaveProperty('reasoning_content', think); - }, - ); + expect(messages[0]).toHaveProperty('reasoning_content', ' '); + }); + + it('makes aggregated empty ThinkParts non-empty when preserved thinking is active', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '' }, + { type: 'think', think: '' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', ' '); + }); + + it('preserves non-empty reasoning when preserved thinking is active', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'think', think: '' }, + { type: 'think', think: 'reasoning text' }, + ], + toolCalls: [], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', 'reasoning text'); + }); it.each([ ['missing', undefined], diff --git a/packages/kosong/tsdown.config.ts b/packages/kosong/tsdown.config.ts index 00783406f0..577fed3fe3 100644 --- a/packages/kosong/tsdown.config.ts +++ b/packages/kosong/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ './src/providers/kimi.ts', './src/providers/openai-legacy.ts', './src/providers/openai-responses.ts', + './src/providers/anthropic-profile.ts', './src/providers/anthropic.ts', './src/providers/google-genai.ts', './src/providers/openai-common.ts',