diff --git a/packages/core/src/ai/__tests__/thinking.test.ts b/packages/core/src/ai/__tests__/thinking.test.ts index 50d5c7d4..33eab472 100644 --- a/packages/core/src/ai/__tests__/thinking.test.ts +++ b/packages/core/src/ai/__tests__/thinking.test.ts @@ -3,12 +3,19 @@ import { describe, it } from 'node:test' import { getSupportedThinkingLevels, getThinkingCompat, isReasoningModel } from '../thinking' describe('isReasoningModel', () => { - it('returns false for anthropic provider (out of scope v1)', () => { + it('returns false for anthropic provider (out of scope)', () => { assert.equal(isReasoningModel('anthropic', 'claude-opus-4-6'), false) }) - it('returns false for gemini provider (out of scope v1)', () => { - assert.equal(isReasoningModel('gemini', 'gemini-2.5-pro'), false) + it('returns true for gemini 2.5+ reasoning models', () => { + assert.equal(isReasoningModel('gemini', 'gemini-2.5-pro'), true) + assert.equal(isReasoningModel('gemini', 'gemini-2.5-flash-preview-05-20'), true) + assert.equal(isReasoningModel('gemini', 'gemini-3-flash'), true) + }) + + it('returns false for older gemini models', () => { + assert.equal(isReasoningModel('gemini', 'gemini-2.0-flash'), false) + assert.equal(isReasoningModel('gemini', 'gemini-1.5-pro'), false) }) it('returns true for qwen provider', () => { @@ -19,6 +26,11 @@ describe('isReasoningModel', () => { assert.equal(isReasoningModel('deepseek', 'deepseek-v4-pro'), true) }) + it('returns true for deepseek hybrid (v3.x / chat)', () => { + assert.equal(isReasoningModel('deepseek', 'deepseek-chat'), true) + assert.equal(isReasoningModel('deepseek', 'deepseek-v3.1'), true) + }) + it('returns true for openai o-series', () => { assert.equal(isReasoningModel('openai', 'o3'), true) }) @@ -34,6 +46,38 @@ describe('isReasoningModel', () => { it('returns false for non-reasoning model (gpt-4o)', () => { assert.equal(isReasoningModel('openai', 'gpt-4o'), false) }) + + it('returns false for models with -non-reasoning suffix', () => { + assert.equal(isReasoningModel('xai', 'grok-4-fast-non-reasoning'), false) + }) + + it('returns true for hunyuan reasoning models', () => { + assert.equal(isReasoningModel('hunyuan', 'hunyuan-t1-latest'), true) + assert.equal(isReasoningModel('tencent', 'hunyuan-a13b'), true) + }) + + it('returns true for known reasoning model families', () => { + assert.equal(isReasoningModel('minimax', 'minimax-m2'), true) + assert.equal(isReasoningModel('baichuan', 'baichuan-m3'), true) + assert.equal(isReasoningModel('mistral', 'mistral-small-2603'), true) + assert.equal(isReasoningModel('step', 'step-3'), true) + assert.equal(isReasoningModel('google', 'gemma-4-27b'), true) + assert.equal(isReasoningModel('perplexity', 'sonar-deep-research'), true) + assert.equal(isReasoningModel('xiaomi', 'mimo-v2-flash'), true) + assert.equal(isReasoningModel('bytedance', 'seed-oss-36b'), true) + }) + + it('returns true for kimi k2.5+ models', () => { + assert.equal(isReasoningModel('kimi', 'kimi-k2.5'), true) + assert.equal(isReasoningModel('kimi', 'kimi-k3'), true) + }) + + it('returns true for grok reasoning variants', () => { + assert.equal(isReasoningModel('xai', 'grok-3-mini'), true) + assert.equal(isReasoningModel('xai', 'grok-4'), true) + assert.equal(isReasoningModel('xai', 'grok-4-fast'), true) + assert.equal(isReasoningModel('xai', 'grok-build'), true) + }) }) describe('getSupportedThinkingLevels', () => { @@ -41,51 +85,110 @@ describe('getSupportedThinkingLevels', () => { assert.deepEqual(getSupportedThinkingLevels('openai', 'gpt-4o'), []) }) - it('returns [] for anthropic (out of scope v1)', () => { + it('returns [] for anthropic (out of scope)', () => { assert.deepEqual(getSupportedThinkingLevels('anthropic', 'claude-opus-4-6'), []) }) - it('returns off+high for qwen (on/off only)', () => { - assert.deepEqual(getSupportedThinkingLevels('qwen', 'qwen3-max'), ['off', 'high']) + it('always includes default as first option for reasoning models', () => { + const levels = getSupportedThinkingLevels('qwen', 'qwen3-max') + assert.equal(levels[0], 'default') }) - it('returns off+high for glm (zai, same as qwen)', () => { - assert.deepEqual(getSupportedThinkingLevels('glm', 'glm-5'), ['off', 'high']) + it('returns default+off+high for qwen (on/off only)', () => { + assert.deepEqual(getSupportedThinkingLevels('qwen', 'qwen3-max'), ['default', 'off', 'high']) }) - it('returns off+high+xhigh for deepseek-v4', () => { - assert.deepEqual(getSupportedThinkingLevels('deepseek', 'deepseek-v4-pro'), ['off', 'high', 'xhigh']) + it('returns default+off+high for glm (same as qwen)', () => { + assert.deepEqual(getSupportedThinkingLevels('glm', 'glm-5'), ['default', 'off', 'high']) }) - it('returns low+medium+high for o-series (cannot disable)', () => { - assert.deepEqual(getSupportedThinkingLevels('openai', 'o3'), ['low', 'medium', 'high']) + it('returns default+off+high+xhigh for deepseek-v4', () => { + assert.deepEqual(getSupportedThinkingLevels('deepseek', 'deepseek-v4-pro'), ['default', 'off', 'high', 'xhigh']) }) - it('returns full range for gpt-5.2+', () => { - assert.deepEqual( - getSupportedThinkingLevels('openai', 'gpt-5.2'), - ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'], - ) + it('returns default+off+auto for deepseek hybrid', () => { + assert.deepEqual(getSupportedThinkingLevels('deepseek', 'deepseek-chat'), ['default', 'off', 'auto']) }) - it('returns minimal+low+medium+high for gpt-5 base (cannot disable)', () => { - assert.deepEqual(getSupportedThinkingLevels('openai', 'gpt-5'), ['minimal', 'low', 'medium', 'high']) + it('returns default+low+medium+high for o-series (cannot disable)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai', 'o3'), ['default', 'low', 'medium', 'high']) }) - it('returns off+high for self-hosted qwen3 (qwen type)', () => { - assert.deepEqual(getSupportedThinkingLevels('openai-compatible', 'qwen3:8b'), ['off', 'high']) + it('returns full range with default for gpt-5.2+', () => { + assert.deepEqual(getSupportedThinkingLevels('openai', 'gpt-5.2'), [ + 'default', + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]) }) - it('returns low+medium+high for kimi thinking models', () => { - assert.deepEqual(getSupportedThinkingLevels('kimi', 'kimi-k2-thinking'), ['low', 'medium', 'high']) + it('returns default+minimal+low+medium+high for gpt-5 base (cannot disable)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai', 'gpt-5'), ['default', 'minimal', 'low', 'medium', 'high']) }) - it('returns high only for doubao seed (no disable support)', () => { - assert.deepEqual(getSupportedThinkingLevels('doubao', 'doubao-seed-2-0-pro-260215'), ['high']) + it('returns default+off+high for self-hosted qwen3 (qwen type)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai-compatible', 'qwen3:8b'), ['default', 'off', 'high']) }) - it('returns low+high for grok (cannot disable)', () => { - assert.deepEqual(getSupportedThinkingLevels('xai', 'grok-4'), ['low', 'high']) + it('returns default+off+auto+low+medium+high for kimi thinking models', () => { + assert.deepEqual(getSupportedThinkingLevels('kimi', 'kimi-k2-thinking'), [ + 'default', + 'off', + 'auto', + 'low', + 'medium', + 'high', + ]) + }) + + it('returns default+off+auto+high for doubao seed', () => { + assert.deepEqual(getSupportedThinkingLevels('doubao', 'doubao-seed-2-0-pro-260215'), [ + 'default', + 'off', + 'auto', + 'high', + ]) + }) + + it('returns default+low+high for grok (cannot disable)', () => { + assert.deepEqual(getSupportedThinkingLevels('xai', 'grok-4'), ['default', 'low', 'high']) + }) + + it('returns default+off+high for hunyuan', () => { + assert.deepEqual(getSupportedThinkingLevels('hunyuan', 'hunyuan-t1-latest'), ['default', 'off', 'high']) + }) + + it('returns default+off+low+medium+high for gemini 2.5+', () => { + assert.deepEqual(getSupportedThinkingLevels('gemini', 'gemini-2.5-flash-preview-05-20'), [ + 'default', + 'off', + 'low', + 'medium', + 'high', + ]) + assert.deepEqual(getSupportedThinkingLevels('gemini', 'gemini-3-pro'), ['default', 'off', 'low', 'medium', 'high']) + }) + + it('returns empty for older gemini models', () => { + assert.deepEqual(getSupportedThinkingLevels('gemini', 'gemini-2.0-flash'), []) + }) + + it('returns default+off+auto for mimo (deepseek_hybrid type)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai-compatible', 'mimo-v2-flash'), ['default', 'off', 'auto']) + }) + + it('returns default+off+low+medium+high for generic reasoning (fallback)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai-compatible', 'some-reasoning-model'), [ + 'default', + 'off', + 'low', + 'medium', + 'high', + ]) }) }) @@ -98,17 +201,19 @@ describe('getThinkingCompat', () => { assert.deepEqual(getThinkingCompat('qwen', 'qwen3-max'), { thinkingFormat: 'qwen' }) }) - it('returns thinkingFormat:qwen for glm (zai)', () => { + it('returns thinkingFormat:qwen for glm', () => { assert.deepEqual(getThinkingCompat('glm', 'glm-5'), { thinkingFormat: 'qwen' }) }) + it('returns thinkingFormat:qwen for hunyuan', () => { + assert.deepEqual(getThinkingCompat('tencent', 'hunyuan-a13b'), { thinkingFormat: 'qwen' }) + }) + it('returns thinkingFormat:qwen for self-hosted qwen3', () => { assert.deepEqual(getThinkingCompat('openai-compatible', 'qwen3:8b'), { thinkingFormat: 'qwen' }) }) - it('returns thinkingFormat:deepseek + thinkingLevelMap for deepseek-v4 (KEY FIX)', () => { - // DeepSeek needs thinkingFormat:'deepseek' so pi-ai sends thinking:{type:"disabled"} - // when 'off' is selected — NOT the generic supportsReasoningEffort path. + it('returns thinkingFormat:deepseek + thinkingLevelMap for deepseek-v4', () => { const compat = getThinkingCompat('deepseek', 'deepseek-v4-pro') assert.equal(compat.thinkingFormat, 'deepseek') assert.equal(compat.supportsReasoningEffort, undefined) @@ -117,6 +222,14 @@ describe('getThinkingCompat', () => { assert.equal(compat.thinkingLevelMap?.minimal, null) }) + it('returns thinkingFormat:deepseek + auto mapping for deepseek hybrid', () => { + const compat = getThinkingCompat('deepseek', 'deepseek-chat') + assert.equal(compat.thinkingFormat, 'deepseek') + assert.equal(compat.thinkingLevelMap?.auto, 'auto') + assert.equal(compat.thinkingLevelMap?.high, 'high') + assert.equal(compat.thinkingLevelMap?.xhigh, null) + }) + it('returns supportsReasoningEffort for o-series', () => { const compat = getThinkingCompat('openai', 'o3') assert.equal(compat.supportsReasoningEffort, true) @@ -135,4 +248,28 @@ describe('getThinkingCompat', () => { assert.equal(compat.thinkingLevelMap?.off, 'none') assert.equal(compat.thinkingLevelMap?.xhigh, 'xhigh') }) + + it('returns supportsReasoningEffort + auto/off mappings for kimi', () => { + const compat = getThinkingCompat('kimi', 'kimi-k2-thinking') + assert.equal(compat.supportsReasoningEffort, true) + assert.equal(compat.thinkingLevelMap?.auto, 'auto') + assert.equal(compat.thinkingLevelMap?.off, 'none') + }) + + it('returns supportsReasoningEffort + auto mapping for doubao', () => { + const compat = getThinkingCompat('doubao', 'doubao-seed-2-0-pro-260215') + assert.equal(compat.supportsReasoningEffort, true) + assert.equal(compat.thinkingLevelMap?.auto, 'auto') + assert.equal(compat.thinkingLevelMap?.high, 'high') + }) + + it('returns thinkingFormat:deepseek for mimo/minimax/seed-oss (thinking:{type} format)', () => { + const compat = getThinkingCompat('openai-compatible', 'mimo-v2-flash') + assert.equal(compat.thinkingFormat, 'deepseek') + assert.equal(compat.thinkingLevelMap?.auto, 'auto') + assert.equal(compat.thinkingLevelMap?.high, 'high') + + const compat2 = getThinkingCompat('minimax', 'minimax-m2') + assert.equal(compat2.thinkingFormat, 'deepseek') + }) }) diff --git a/packages/core/src/ai/thinking.ts b/packages/core/src/ai/thinking.ts index e9d9d296..d6057cac 100644 --- a/packages/core/src/ai/thinking.ts +++ b/packages/core/src/ai/thinking.ts @@ -6,52 +6,56 @@ * - which models support thinking and at what granularity * - what `compat` fields pi-ai needs to actually inject the right request params * - * Scope (v1): openai-completions API path only. + * Scope: openai-completions API path only. * anthropic-messages / google-generative-ai are out of scope for this version. */ // ── Types ───────────────────────────────────────────────────────────────────── -/** Ordered from off (weakest) to xhigh (strongest), mirrors pi-ai's enum. */ -export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' +/** + * ThinkingLevel values the UI selector can display. + * - 'default': no reasoning params sent — rely on model's native default behavior + * - 'off': explicitly disable reasoning + * - 'auto': let the model flexibly decide reasoning intensity + * - others: specific intensity levels + */ +export type ThinkingLevel = 'default' | 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'auto' /** Used internally to categorise a model into a thinking behaviour group. */ type ThinkingType = - | 'none' // model is not a reasoning model - | 'qwen' // Qwen-family: boolean enable_thinking (off/on) - | 'deepseek_v4' // DeepSeek V4+: thinkingFormat:'deepseek', off/high/xhigh - | 'o_series' // OpenAI o1/o3: can't disable thinking; low/medium/high only - | 'gpt5' // OpenAI gpt-5 base: can't disable; minimal/low/medium/high - | 'gpt5_1' // OpenAI gpt-5.1: off→none supported; off/low/medium/high - | 'gpt5_2plus' // OpenAI gpt-5.2+: off→none + xhigh; full range - | 'grok' // xAI grok: can't disable; low/high only - | 'kimi' // Kimi thinking: low/medium/high - | 'doubao' // Doubao seed reasoning: high only (no disable support) - | 'default' // generic reasoning: off / low / medium / high + | 'none' // model is not a reasoning model + | 'qwen' // Qwen/GLM-family: boolean enable_thinking (off/on) + | 'deepseek_v4' // DeepSeek V4+: thinkingFormat:'deepseek', off/high/xhigh + | 'deepseek_hybrid' // DeepSeek V3.x hybrid inference: supports auto + | 'o_series' // OpenAI o1/o3: can't disable thinking; low/medium/high only + | 'gpt5' // OpenAI gpt-5 base: can't disable; minimal/low/medium/high + | 'gpt5_1' // OpenAI gpt-5.1: off→none supported; off/low/medium/high + | 'gpt5_2plus' // OpenAI gpt-5.2+: off→none + xhigh; full range + | 'grok' // xAI grok: can't disable; low/high only + | 'kimi' // Kimi thinking: off/auto/low/medium/high + | 'doubao' // Doubao seed reasoning: off/auto/high + | 'hunyuan' // Hunyuan: enable_thinking boolean, similar to qwen + | 'gemini' // Gemini 2.5+: thinking level via Google API (off/low/medium/high) + | 'default' // generic reasoning: off / low / medium / high // ── Internal: per-type level tables ────────────────────────────────────────── /** UI options for each type — what the selector will show. */ const TYPE_LEVELS: Record = { - none: [], - // Qwen/GLM: boolean enable_thinking; 'off' sends false, 'high' sends true - qwen: ['off', 'high'], - // DeepSeek V4+: thinkingFormat:'deepseek', 'off' → thinking:{type:"disabled"} - deepseek_v4: ['off', 'high', 'xhigh'], - // o-series: always thinks, no API param to disable; don't show 'off' - o_series: ['low', 'medium', 'high'], - // gpt-5 base: API doesn't support reasoning_effort:'none'; no 'off' - gpt5: ['minimal', 'low', 'medium', 'high'], - // gpt-5.1+: supports reasoning_effort:'none' to disable thinking - gpt5_1: ['off', 'low', 'medium', 'high'], - gpt5_2plus: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'], - // Grok: no disable support; only low/high - grok: ['low', 'high'], - // Kimi: low/medium/high (no confirmed disable API) - kimi: ['low', 'medium', 'high'], - // Doubao: only 'high' confirmed; no disable - doubao: ['high'], - default: ['off', 'low', 'medium', 'high'], + none: [], + qwen: ['default', 'off', 'high'], + deepseek_v4: ['default', 'off', 'high', 'xhigh'], + deepseek_hybrid: ['default', 'off', 'auto'], + o_series: ['default', 'low', 'medium', 'high'], + gpt5: ['default', 'minimal', 'low', 'medium', 'high'], + gpt5_1: ['default', 'off', 'low', 'medium', 'high'], + gpt5_2plus: ['default', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh'], + grok: ['default', 'low', 'high'], + kimi: ['default', 'off', 'auto', 'low', 'medium', 'high'], + doubao: ['default', 'off', 'auto', 'high'], + hunyuan: ['default', 'off', 'high'], + gemini: ['default', 'off', 'low', 'medium', 'high'], + default: ['default', 'off', 'low', 'medium', 'high'], } /** @@ -60,69 +64,94 @@ const TYPE_LEVELS: Record = { * `undefined` (key absent) means pass the level string verbatim. * Matches pi-ai's convention in models.generated.js. */ -// thinkingLevelMap is only used by models on the supportsReasoningEffort path. -// DeepSeek uses thinkingFormat:'deepseek' (its own pi-ai branch) and has no entry here. const TYPE_LEVEL_MAP: Partial>>> = { gpt5: { minimal: 'minimal', - low: 'low', - medium: 'medium', - high: 'high', - }, - // 'off' maps to reasoning_effort:'none' — API confirmed to disable thinking - gpt5_1: { - off: 'none', - low: 'low', + low: 'low', medium: 'medium', - high: 'high', - xhigh: null, + high: 'high', + }, + gpt5_1: { + off: 'none', + low: 'low', + medium: 'medium', + high: 'high', + xhigh: null, }, gpt5_2plus: { - off: 'none', + off: 'none', minimal: 'minimal', - low: 'low', - medium: 'medium', - high: 'high', - xhigh: 'xhigh', + low: 'low', + medium: 'medium', + high: 'high', + xhigh: 'xhigh', }, grok: { minimal: null, - low: 'low', - medium: null, - high: 'high', - xhigh: null, + low: 'low', + medium: null, + high: 'high', + xhigh: null, }, kimi: { + off: 'none', + auto: 'auto', minimal: null, - low: 'low', - medium: 'medium', - high: 'high', - xhigh: null, + low: 'low', + medium: 'medium', + high: 'high', + xhigh: null, }, doubao: { + off: null, + auto: 'auto', + high: 'high', + }, + deepseek_hybrid: { + off: null, + auto: 'auto', high: 'high', }, } // ── Internal: model → ThinkingType classification ───────────────────────────── +/** + * Broad regex matching common reasoning model naming patterns. + * Used as a fallback when no specific provider/model rule matches. + * Excludes explicit '-non-reasoning' variants. + */ +const REASONING_FALLBACK_REGEX = + /^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking|think)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bgrok-(?:3-mini|4|4-fast|build)(?:-[\w-]+)?\b.*)$/i + /** * Classify a model into a ThinkingType based on provider + model id. - * Kept intentionally simple: providers/ids are stable enough for direct matching. */ function classifyThinkingType(provider: string, modelId: string): ThinkingType { const id = modelId.toLowerCase() const prov = provider.toLowerCase() - // ── Non-reasoning paths handled by other API formats (out of scope v1) ── - if (prov === 'anthropic' || prov === 'gemini') return 'none' + // ── Explicit non-reasoning variants ── + if (id.includes('-non-reasoning')) return 'none' + + // ── Anthropic uses different API format (out of scope) ── + if (prov === 'anthropic') return 'none' + + // ── Gemini 2.5+ reasoning models (via Google Generative AI API) ── + if (prov === 'gemini') { + if (/gemini-(?:2\.5|3(?:\.\d+)?-(?:flash|pro)|flash-latest|pro-latest)/i.test(id)) return 'gemini' + return 'none' + } // ── Qwen (official DashScope + self-hosted models containing qwen/qwq) ── - if (prov === 'qwen' || /qwen|qwq/i.test(id)) return 'qwen' + if (prov === 'qwen' || /\bqwen|qwq/i.test(id)) return 'qwen' // ── DeepSeek V4+ ── if (/deepseek[_-]v([4-9]|\d{2,})/i.test(id) || /deepseek-ai\/deepseek-r1/i.test(id)) return 'deepseek_v4' + // ── DeepSeek V3.x hybrid inference (deepseek-chat, deepseek-v3.x) ── + if (/deepseek[_-]chat|deepseek[_-]v3/i.test(id)) return 'deepseek_hybrid' + // ── OpenAI gpt-5.x family ── if (prov === 'openai' || prov === 'openai-compatible') { if (/gpt-5\.[2-9]|gpt-5\.[1-9]\d/.test(id)) return 'gpt5_2plus' @@ -133,35 +162,57 @@ function classifyThinkingType(provider: string, modelId: string): ThinkingType { // o-series on other providers (e.g., Azure, OpenRouter) if (/^o\d/.test(id)) return 'o_series' - // ── xAI Grok reasoning models ── - if (prov === 'xai' && /grok-[34]/i.test(id) && /mini|4\b/i.test(id)) return 'grok' + // ── xAI Grok reasoning models (grok-3-mini, grok-4, grok-4-fast, grok-build) ── + if (/\bgrok-(?:3-mini|4|4-fast|build)\b/i.test(id)) return 'grok' - // ── Kimi thinking models ── + // ── Kimi thinking models (k2-thinking, k2.5+, k3+) ── + if (/\bkimi-k(?:2-thinking|2\.[5-9]|[3-9])/i.test(id)) return 'kimi' if (prov === 'kimi' && /thinking/i.test(id)) return 'kimi' // ── Doubao seed reasoning ── - if (prov === 'doubao' && /seed/i.test(id)) return 'doubao' + if (/\bdoubao-.*seed/i.test(id) || (prov === 'doubao' && /seed/i.test(id))) return 'doubao' - // ── GLM (ZAI) — only boolean enable_thinking, same as Qwen ── - if (prov === 'glm' || /glm[-_]/i.test(id)) return 'qwen' + // ── Hunyuan reasoning models ── + if (/\bhunyuan-(?:t1|a13b)/i.test(id)) return 'hunyuan' + + // ── GLM (ZAI) — glm-5, glm-4.5/4.6/4.7, glm-z1, glm-zero-preview ── + if (prov === 'glm' || /\bglm-?(?:5|4\.[5-7]|z1|zero)/i.test(id)) return 'qwen' // ── SiliconFlow hosted reasoning models (DeepSeek R1 family) ── if (prov === 'siliconflow' && /r[1-9]/i.test(id)) return 'deepseek_v4' - // ── OpenRouter: wrap by inner model id if known ── + // ── OpenRouter: classify by inner model id ── if (prov === 'openrouter') { if (/deepseek.*(v[4-9]|r\d)/i.test(id)) return 'deepseek_v4' - return 'default' + if (/deepseek[_-]?(chat|v3)/i.test(id)) return 'deepseek_hybrid' + if (/\bgrok-(?:3-mini|4|4-fast|build)\b/i.test(id)) return 'grok' + if (/\bkimi-k(?:2-thinking|2\.[5-9]|[3-9])/i.test(id)) return 'kimi' + if (REASONING_FALLBACK_REGEX.test(id)) return 'default' + return 'none' } + // ── Models using thinking:{type:'disabled'/'enabled'} format (same as DeepSeek) ── + if (/\bmimo-v2/i.test(id)) return 'deepseek_hybrid' + if (/\bminimax-m[123]/i.test(id)) return 'deepseek_hybrid' + if (/\bseed-oss/i.test(id)) return 'deepseek_hybrid' + + // ── Known reasoning model families → 'default' bucket (supportsReasoningEffort) ── + if (/\bbaichuan-m[23]/i.test(id)) return 'default' + if (/\bmagistral/i.test(id)) return 'default' + if (/\bmistral-small-2603/i.test(id)) return 'default' + if (/\bstep-(?:3|r1-v-mini)/i.test(id)) return 'default' + if (/\bgemma-?4/i.test(id)) return 'default' + if (/\bpangu-pro-moe/i.test(id)) return 'default' + if (/\bsonar-deep-research/i.test(id)) return 'default' + if (/\bring-(?:1t|mini|flash)/i.test(id)) return 'default' + // ── Self-hosted / unknown — heuristic from model name ── - if (/qwen|qwq/i.test(id)) return 'qwen' + if (/\bqwen|qwq/i.test(id)) return 'qwen' if (/deepseek[_-]?r\d|deepseek[_-]?v[4-9]/i.test(id)) return 'deepseek_v4' if (/^o\d/i.test(id)) return 'o_series' - // Only assign 'default' (reasoning bucket) when the model name itself - // suggests thinking capability. Otherwise return 'none'. - if (/reasoning|thinking|r1\b|r2\b|qwq|think/i.test(id)) return 'default' + // Broad fallback: match common reasoning naming patterns + if (REASONING_FALLBACK_REGEX.test(id)) return 'default' return 'none' } @@ -184,7 +235,6 @@ export function getSupportedThinkingLevels(provider: string, modelId: string): T * on the openai-completions path. (anthropic/gemini always return false here.) * * Used by: buildPiModel to set `PiModel.reasoning`. - * Replaces the ad-hoc inferReasoning logic in llm-builder.ts. */ export function isReasoningModel(provider: string, modelId: string): boolean { return classifyThinkingType(provider, modelId) !== 'none' @@ -197,8 +247,8 @@ export function isReasoningModel(provider: string, modelId: string): boolean { * Used by: buildPiModel (openai-completions branch). */ export interface ThinkingCompat { - thinkingFormat?: 'qwen' | 'deepseek' // provider-specific injection format - supportsReasoningEffort?: true // OpenAI-style reasoning_effort param + thinkingFormat?: 'qwen' | 'deepseek' + supportsReasoningEffort?: true thinkingLevelMap?: Partial> } @@ -207,23 +257,27 @@ export function getThinkingCompat(provider: string, modelId: string): ThinkingCo if (type === 'none') return {} - if (type === 'qwen') { - // Qwen/GLM: pi-ai injects enable_thinking boolean. - // 'off' → reasoningEffort=undefined → enable_thinking:false - // 'high' → reasoningEffort='high' → enable_thinking:true + // Gemini uses pi-ai's native Google provider; no compat fields needed. + if (type === 'gemini') return {} + + if (type === 'qwen' || type === 'hunyuan') { return { thinkingFormat: 'qwen' } } if (type === 'deepseek_v4') { - // DeepSeek requires thinkingFormat:'deepseek' so pi-ai sends: - // thinking:{type:"disabled"} when reasoningEffort=undefined (off) - // thinking:{type:"enabled"} + reasoning_effort when set return { thinkingFormat: 'deepseek', thinkingLevelMap: { high: 'high', xhigh: 'max', minimal: null, low: null, medium: null }, } } + if (type === 'deepseek_hybrid') { + return { + thinkingFormat: 'deepseek', + thinkingLevelMap: { auto: 'auto', high: 'high', minimal: null, low: null, medium: null, xhigh: null }, + } + } + // All remaining types use OpenAI-style reasoning_effort. const compat: ThinkingCompat = { supportsReasoningEffort: true } const levelMap = TYPE_LEVEL_MAP[type] diff --git a/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts index f5eb5061..a1b91427 100644 --- a/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts +++ b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts @@ -153,7 +153,8 @@ describe('buildPiModel', () => { // Must use thinkingFormat:'deepseek' so that 'off' sends thinking:{type:"disabled"} assert.equal(compat?.thinkingFormat, 'deepseek') assert.equal(compat?.supportsReasoningEffort, undefined) - const map = compat?.thinkingLevelMap as Record | undefined + // thinkingLevelMap is on the model top-level (for clampThinkingLevel) + const map = model.thinkingLevelMap as Record | undefined assert.equal(map?.high, 'high') assert.equal(map?.xhigh, 'max') assert.equal(map?.minimal, null) diff --git a/packages/node-runtime/src/ai/agent/core.ts b/packages/node-runtime/src/ai/agent/core.ts index 64ba74db..80a8ed4a 100644 --- a/packages/node-runtime/src/ai/agent/core.ts +++ b/packages/node-runtime/src/ai/agent/core.ts @@ -87,15 +87,44 @@ export async function runAgentCore(options: AgentCoreOptions): Promise { + if (isDefault) return 'off' + const level = options.thinkingLevel! + if (level === 'auto') { + if (!piModel.reasoning) return 'off' + const compat = piModel.compat as Record | undefined + if (compat?.thinkingFormat) return 'high' + return undefined + } + return clampThinkingLevel(piModel, level as Exclude) + })() + + const finalThinkingLevel = resolvedThinkingLevel ?? (piModel.reasoning ? undefined : 'off') + console.debug('[agent-core] thinkingLevel resolution:', { + input: options.thinkingLevel, + modelId: piModel.id, + modelReasoning: effectiveModel.reasoning, + isDefault, + resolved: resolvedThinkingLevel, + final: finalThinkingLevel, + }) + const coreAgent = new PiAgentCore({ initialState: { systemPrompt, - model: piModel, - thinkingLevel: options.thinkingLevel - ? clampThinkingLevel(piModel, options.thinkingLevel) - : piModel.reasoning - ? 'medium' - : 'off', + model: effectiveModel, + thinkingLevel: finalThinkingLevel, tools: maxToolRounds > 0 ? tools : [], messages: toPiHistoryMessages(history), }, diff --git a/packages/node-runtime/src/ai/llm-builder.ts b/packages/node-runtime/src/ai/llm-builder.ts index 5d27c93a..4136692b 100644 --- a/packages/node-runtime/src/ai/llm-builder.ts +++ b/packages/node-runtime/src/ai/llm-builder.ts @@ -79,26 +79,29 @@ const BUILTIN_PROVIDER_API: Record = { * carries supportsReasoningEffort + thinkingLevelMap so pi-ai can inject the * correct request-body param for each provider family. */ -function inferReasoning( - provider: string, - modelId: string, - modelDef: ModelDefinition | null, -): { reasoning: boolean; compat: PiModel['compat'] } { - // Catalog capabilities are authoritative; fall back to name-heuristic for - // unlisted / self-hosted models. - const reasoning = modelDef - ? modelDef.capabilities.includes('reasoning') - : isReasoningModel(provider, modelId) +interface InferReasoningResult { + reasoning: boolean + compat: PiModel['compat'] + thinkingLevelMap?: PiModel['thinkingLevelMap'] +} + +function inferReasoning(provider: string, modelId: string, modelDef: ModelDefinition | null): InferReasoningResult { + const reasoning = modelDef ? modelDef.capabilities.includes('reasoning') : isReasoningModel(provider, modelId) if (!reasoning) return { reasoning: false, compat: undefined } const thinkingCompat = getThinkingCompat(provider, modelId) - // thinkingCompat may be an empty object {} for unknown reasoning models — - // that's fine: pi-ai will still receive reasoning:true and default to - // passing the effort level verbatim. + if (Object.keys(thinkingCompat).length === 0) { + return { reasoning: true, compat: undefined } + } + + // thinkingLevelMap belongs on the Model top-level (for clampThinkingLevel), + // while thinkingFormat/supportsReasoningEffort belong in compat (for the provider). + const { thinkingLevelMap, ...compatFields } = thinkingCompat return { reasoning: true, - compat: Object.keys(thinkingCompat).length > 0 ? thinkingCompat : undefined, + compat: Object.keys(compatFields).length > 0 ? compatFields : undefined, + thinkingLevelMap: thinkingLevelMap as PiModel['thinkingLevelMap'], } } @@ -120,7 +123,7 @@ export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOption api: 'google-generative-ai', provider: 'google', baseUrl, - reasoning: false, + reasoning: isReasoningModel(config.provider, modelId), input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow, @@ -148,7 +151,7 @@ export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOption ? normalizeOpenAICompatibleBaseUrl(baseUrl) : baseUrl - const { reasoning, compat } = inferReasoning(config.provider, modelId, modelDef) + const { reasoning, compat, thinkingLevelMap } = inferReasoning(config.provider, modelId, modelDef) return { id: modelId, @@ -158,6 +161,7 @@ export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOption baseUrl: resolvedBaseUrl, headers: options?.headers, reasoning, + thinkingLevelMap, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow, diff --git a/src/components/AIChat/chat/ChatStatusBar.vue b/src/components/AIChat/chat/ChatStatusBar.vue index 21e2a8a7..a32119e9 100644 --- a/src/components/AIChat/chat/ChatStatusBar.vue +++ b/src/components/AIChat/chat/ChatStatusBar.vue @@ -233,11 +233,11 @@ const supportedThinkingLevels = computed(() => { /** Whether to show the selector at all. */ const showThinkingSelector = computed(() => supportedThinkingLevels.value.length > 0) -/** The currently remembered level for this model slot (undefined → default). */ -const currentThinkingLevel = computed(() => { +/** The currently remembered level for this model slot (undefined → 'default'). */ +const currentThinkingLevel = computed(() => { const cfg = llmStore.defaultAssistant - if (!cfg?.configId || !cfg?.modelId) return undefined - return promptStore.getThinkingLevel(cfg.configId, cfg.modelId) + if (!cfg?.configId || !cfg?.modelId) return 'default' + return promptStore.getThinkingLevel(cfg.configId, cfg.modelId) ?? 'default' }) function selectThinkingLevel(level: ThinkingLevel) { @@ -249,10 +249,7 @@ function selectThinkingLevel(level: ThinkingLevel) { /** Label shown on the trigger button. */ const thinkingLevelLabel = computed(() => { - const level = currentThinkingLevel.value - // undefined means no explicit preference set — core defaults to 'medium' - if (level === undefined) return t('ai.chat.statusBar.thinking.auto') - return t(`ai.chat.statusBar.thinking.level.${level}`) + return t(`ai.chat.statusBar.thinking.level.${currentThinkingLevel.value}`) }) @@ -332,9 +329,9 @@ const thinkingLevelLabel = computed(() => { diff --git a/src/i18n/locales/en-US/ai.json b/src/i18n/locales/en-US/ai.json index b94ddb2b..d08c2b38 100644 --- a/src/i18n/locales/en-US/ai.json +++ b/src/i18n/locales/en-US/ai.json @@ -181,15 +181,15 @@ "thinking": { "title": "Thinking level", "tooltip": "Set the thinking intensity for this model", - "auto": "Auto", - "off": "Thinking off", "level": { + "default": "Default", "off": "Off", "minimal": "Minimal", "low": "Low", "medium": "Medium", "high": "High", - "xhigh": "Max" + "xhigh": "Max", + "auto": "Auto" } }, "tokenUsageTitle": "Total token usage", @@ -517,4 +517,4 @@ "truncated": "Result too large, truncated. Try reducing the limit parameter for complete data." } } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja-JP/ai.json b/src/i18n/locales/ja-JP/ai.json index 11b77ec2..ade4ae01 100644 --- a/src/i18n/locales/ja-JP/ai.json +++ b/src/i18n/locales/ja-JP/ai.json @@ -181,15 +181,15 @@ "thinking": { "title": "思考強度", "tooltip": "モデルの思考強度を設定", - "auto": "自動", - "off": "思考オフ", "level": { + "default": "デフォルト", "off": "オフ", "minimal": "極小", "low": "低", "medium": "中", "high": "高", - "xhigh": "最大" + "xhigh": "最大", + "auto": "自動" } }, "tokenUsageTitle": "累計 Token 使用量", @@ -481,4 +481,4 @@ "truncated": "結果が大きすぎるため、切り捨てて表示しています。limit パラメータを小さくしてください。" } } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json index d0b42144..cd3d578c 100644 --- a/src/i18n/locales/zh-CN/ai.json +++ b/src/i18n/locales/zh-CN/ai.json @@ -181,15 +181,15 @@ "thinking": { "title": "思考强度", "tooltip": "设置模型的思考强度", - "auto": "自动", - "off": "关闭思考", "level": { - "off": "关闭思考", + "default": "默认", + "off": "关闭", "minimal": "极低", "low": "低", "medium": "中", "high": "高", - "xhigh": "最高" + "xhigh": "最高", + "auto": "自动" } }, "tokenUsageTitle": "累计 Token 使用量", diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json index 7f7a195a..eb0315fc 100644 --- a/src/i18n/locales/zh-TW/ai.json +++ b/src/i18n/locales/zh-TW/ai.json @@ -181,15 +181,15 @@ "thinking": { "title": "思考強度", "tooltip": "設定模型的思考強度", - "auto": "自動", - "off": "關閉思考", "level": { - "off": "關閉思考", + "default": "預設", + "off": "關閉", "minimal": "極低", "low": "低", "medium": "中", "high": "高", - "xhigh": "最高" + "xhigh": "最高", + "auto": "自動" } }, "tokenUsageTitle": "累計 Token 用量", @@ -481,4 +481,4 @@ "truncated": "結果過大,已截斷顯示。建議減小 limit 參數以獲取完整資料。" } } -} \ No newline at end of file +}