Repository navigation
llm-pi-ai: per-model default thinking level, for catalogs where only some models think #4627
Replies: 2 comments
|
Confirmed on a real commercial endpoint too (Volcengine Ark, 1. The trap is wider than the table — an explicit non-reasoning declaration doesn't escape either
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
afterEach(async () => { vi.unstubAllEnvs(); await closeMockServers() })
const providers = (models: LlmPiAi.PiAiModelProfile[]): Record<string, LlmPiAi.PiAiProviderProfile> => ({
doubao: {
apiKeyEnv: 'PI_TEST_KEY', api: 'openai-completions', reasoning: 'max',
models,
},
})
describe('route reasoning default vs non-reasoning models', () => {
const thinking = { id: 'thinker', contextWindow: 1000, maxTokens: 1000,
reasoningEfforts: { off: null, low: 'low', high: 'high', max: 'max' } }
it('fails every request on a model with no reasoningEfforts', async () => {
const server = await mockServer([{ events: textEvents }])
vi.stubEnv('PI_TEST_KEY', 'test-key')
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmPiAi, { providers: providers([thinking, { id: 'flash', contextWindow: 1000, maxTokens: 1000 }]) })
const result = await assemble(ctx, { provider: 'doubao', model: 'flash',
messages: [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'plugin', plugin: 'test' } })] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNSUPPORTED_REASONING_EFFORT' } })
expect(server.requests).toHaveLength(0)
})
it('fails identically even with an explicit reasoningEfforts: false', async () => {
const server = await mockServer([{ events: textEvents }])
vi.stubEnv('PI_TEST_KEY', 'test-key')
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmPiAi, { providers: providers([{ id: 'flash', contextWindow: 1000, maxTokens: 1000, reasoningEfforts: false }]) })
const result = await assemble(ctx, { provider: 'doubao', model: 'flash',
messages: [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'plugin', plugin: 'test' } })] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNSUPPORTED_REASONING_EFFORT' } })
})
})2. A smaller complementary fix: capability-gate the route default itself
// adapter.ts, streamWithSnapshot
const reasoning = options.reasoningEffort === undefined && !model.reasoning
? undefined
: resolveReasoningLevel(model, options.reasoningEffort ?? profile.reasoning)Semantics: the route default binds only models that can reason (a non-reasoning model omits the reasoning option — the wire request is exactly what describing the model already advertises); an explicit Verified locally: regression spec 4/4, 3. Provider intel: Volcengine Ark serves glm-5.3-flash thinking-always, with a working effort dialMeasured against
So on Ark the model is thinking-always with a live top-level |
|
Confirmed from a third deployment, plus one undocumented escape hatch that may be worth folding into the proposal. The deploymentA single local multi-vendor gateway (
Same trap, same throw site. Two notes from living with it: The OP's third row is the one that bites silently. With Splitting the route works but is the wrong shape. One gateway becomes N routes differing only in An undocumented escape hatch
gw:
reasoning: max # route default
models:
- id: qwen3.8-max
reasoningEfforts:
max: xhigh # DSH "Max" -> wire "xhigh"
- id: deepseek-v4.1-flash
reasoningEfforts:
max: maxNow The cost is the label: every model's top level reads +1 on the proposal
|
Uh oh!
There was an error while loading. Please reload this page.
Summary
llm-pi-aican't express "think by default, but only where thinking exists". A route's default thinking level is route-wide (profile.reasoning) and there's no per-model equivalent, so a catalog whose models disagree about thinking has no correct configuration.This bites any provider serving a mixed set of local models — some with a thinking branch in their chat template, some without. Concretely: an oMLX server with 12 models, 6 of which think.
The trap
Two functions close it:
An unsupported level is refused, not ignored. So on one route:
profile.reasoning'high'UNSUPPORTED_REASONING_EFFORT'off'The third row is the surprising one: with no default,
resolveReasoningLevelreturnsundefined, and the dispatch turns that intothinking: { type: 'disabled' }(anthropic) orenable_thinking: false(qwen-chat-template). A reasoning model picked from the composer silently does not reason until the user opens the effort menu and chooses a level.It's protocol-independent —
resolveReasoningLevelruns instreamWithSnapshotbefore dispatch, soopenai-completionsandanthropic-messagesbehave identically here.There's no narrower scope to retreat to:
modelFieldsisname,contextWindow,maxTokens,input,reasoningEfforts,compat, andmodelOverridesisz.object(modelFields).Workaround, and why it isn't good
Declare
reasoningEffortson every model so a route-levelhighbecomes legal everywhere. It works, and it's what I'm shipping today — but it puts an effort menu on six models that ignore it entirely. A control that does nothing is its own bug.Proposal
A per-model
defaultReasoning, consulted only when a request names no level:It follows the existing
configuredMaxTokensidiom exactly — a deployment's choice about requests, kept beside the materialized model rather than on pi-ai'sModel, for the same reason that one is:catalog.ts—defaultReasoning?: ModelThinkingLevelonPiAiModelProfile; aconfiguredReasoningmap built alongsideconfiguredMaxTokens; validated at load so a level a model doesn't offer names the model instead of failing the user's next turnconfig.ts— schema field, carried onto the profileadapter.ts— the one-line resolution change aboveDeliberately not surfaced through
describableReasoningLevel. Leaving the route's advertised default undefined keeps the picker's "Default" row, and selecting it resolves through the new field — so "Default" comes to mean this model's own default rather than no thinking, which is what a user reading the word expects.Purely additive: a config that names no
defaultReasoningbehaves exactly as it does now.Result
Same route, same picker entry:
Verification
Applied against
masterand run locally:packages/llm/llm-pi-ai— 266 tests pass (11 files), including two added tocatalog.spec.ts's "per-model reasoning efforts" block covering the recorded default and the load-time refusaldsh-llm-pi-aiin an installed DSH Desktop and pointing it at a live oMLX server: MiniMax and Qwen2.5-Coder answer with no effort control, Qwen3.8 thinks on Default, Off disablesDiff (
+80 / -3) is below.CONTRIBUTING.mdsays external PRs aren't being accepted right now, so I'm posting it here rather than opening one — happy to move it wherever is most useful, or to just leave the analysis if the shape isn't what you'd want.Diff
All reactions