feat(ai): upgrade thinking level control with default/auto options and broader model coverage

- Add 'default' option (no reasoning params sent, model uses native behavior)
- Add 'auto' option for models that natively support it (deepseek_hybrid, kimi, doubao)
- Expand model recognition with regex coverage for 15+ model families
- Add Gemini 2.5+ thinking level support via native Google provider
- Simplify deepseek_hybrid options to ['default', 'off', 'auto'] aligned with Cherry Studio
- Use primary theme color for thinking selector instead of hardcoded blue
- Update docs with thinking control architecture section

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-31 10:28:29 +08:00
1 parent a31c672474
commit 783d61f742
10 files changed
+391 -173

No files matched your search

+168 -31
View File
@@ -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')
})
})
+138 -84
View File
@@ -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<ThinkingType, ThinkingLevel[]> = {
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<ThinkingType, ThinkingLevel[]> = {
* `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<Record<ThinkingType, Partial<Record<ThinkingLevel, string | null>>>> = {
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<Record<ThinkingLevel, string | null>>
}
@@ -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]
@@ -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<string, unknown> | undefined
// thinkingLevelMap is on the model top-level (for clampThinkingLevel)
const map = model.thinkingLevelMap as Record<string, unknown> | undefined
assert.equal(map?.high, 'high')
assert.equal(map?.xhigh, 'max')
assert.equal(map?.minimal, null)
+35 -6
View File
@@ -87,15 +87,44 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
return { usage: totalUsage, finalMessages: [], toolsUsed: [], toolRounds: 0 }
}
// Resolve thinkingLevel for pi-agent-core:
// - 'default'/undefined → strip reasoning from piModel so pi-ai sends a plain request
// with NO reasoning params at all (model uses its native default behavior).
// - 'off' → pi-ai sends disable signals (thinking:{type:'disabled'} / enable_thinking:false)
// - 'auto' → for thinkingFormat models, use 'high' to enable; others no params
// - Other levels → clamp to what pi-agent-core accepts.
const isDefault = !options.thinkingLevel || options.thinkingLevel === 'default'
const effectiveModel = isDefault
? { ...piModel, reasoning: false, compat: undefined, thinkingLevelMap: undefined }
: piModel
const resolvedThinkingLevel = (() => {
if (isDefault) return 'off'
const level = options.thinkingLevel!
if (level === 'auto') {
if (!piModel.reasoning) return 'off'
const compat = piModel.compat as Record<string, unknown> | undefined
if (compat?.thinkingFormat) return 'high'
return undefined
}
return clampThinkingLevel(piModel, level as Exclude<typeof level, 'default' | 'auto'>)
})()
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),
},
+20 -16
View File
@@ -79,26 +79,29 @@ const BUILTIN_PROVIDER_API: Record<string, PiApi> = {
* 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<PiApi>['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<PiApi>['compat']
thinkingLevelMap?: PiModel<PiApi>['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<PiApi>['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,
+11 -18
View File
@@ -233,11 +233,11 @@ const supportedThinkingLevels = computed<ThinkingLevel[]>(() => {
/** 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<ThinkingLevel | undefined>(() => {
/** The currently remembered level for this model slot (undefined → 'default'). */
const currentThinkingLevel = computed<ThinkingLevel>(() => {
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}`)
})
</script>
@@ -332,9 +329,9 @@ const thinkingLevelLabel = computed(() => {
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
:class="
!currentThinkingLevel || currentThinkingLevel === 'off'
currentThinkingLevel === 'default' || currentThinkingLevel === 'off'
? 'text-gray-400 dark:text-gray-500'
: 'text-blue-500 dark:text-blue-400'
: 'text-primary-500 dark:text-primary-400'
"
:title="t('ai.chat.statusBar.thinking.tooltip')"
>
@@ -351,20 +348,16 @@ const thinkingLevelLabel = computed(() => {
:key="level"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
:class="
(currentThinkingLevel ?? 'medium') === level
? 'text-blue-600 dark:text-blue-400'
currentThinkingLevel === level
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-700 dark:text-gray-300'
"
@click="selectThinkingLevel(level)"
>
<UIcon
:name="
(currentThinkingLevel ?? 'medium') === level
? 'i-heroicons-check-circle-solid'
: 'i-heroicons-light-bulb'
"
:name="currentThinkingLevel === level ? 'i-heroicons-check-circle-solid' : 'i-heroicons-light-bulb'"
class="h-4 w-4 shrink-0"
:class="(currentThinkingLevel ?? 'medium') === level ? 'text-blue-500' : 'text-gray-400'"
:class="currentThinkingLevel === level ? 'text-primary-500' : 'text-gray-400'"
/>
<span>{{ t(`ai.chat.statusBar.thinking.level.${level}`) }}</span>
</button>
+4 -4
View File
@@ -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."
}
}
}
}
+4 -4
View File
@@ -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 パラメータを小さくしてください。"
}
}
}
}
+4 -4
View File
@@ -181,15 +181,15 @@
"thinking": {
"title": "思考强度",
"tooltip": "设置模型的思考强度",
"auto": "自动",
"off": "关闭思考",
"level": {
"off": "关闭思考",
"default": "默认",
"off": "关闭",
"minimal": "极低",
"low": "低",
"medium": "中",
"high": "高",
"xhigh": "最高"
"xhigh": "最高",
"auto": "自动"
}
},
"tokenUsageTitle": "累计 Token 使用量",
+5 -5
View File
@@ -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 參數以獲取完整資料。"
}
}
}
}