feat(llm-pi-ai): per-model reasoningEfforts and reasoning-dispatch compat switches
A model entry's reasoningEfforts dict declares its selectable thinking levels — key = offered level, value = the wire spelling dispatch sends; only off may leave the value empty (supported, send nothing). false strips reasoning from a catalog model; every level is materialized explicitly into pi-ai's thinkingLevelMap so nobody has to know pi-ai's asymmetric absent-key defaulting. compat.thinkingFormat and compat.supportsReasoningEffort become configurable on the route and per model (model > route > catalog entry > pi-ai's URL-derived guess), openai-completions only, so a private gateway speaking the DeepSeek reasoning dialect no longer depends on its URL being recognizable. Record-typed drift gates pin both enums to pi-ai's, and an unserviceable declaration is refused at the write that produced it, naming route, model, and level.
This commit is contained in:
@@ -10,8 +10,8 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
|
||||
import { createModels } from '@earendil-works/pi-ai'
|
||||
import type { Api, Model, Provider } from '@earendil-works/pi-ai'
|
||||
import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
|
||||
import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai'
|
||||
import { resolveProfiles } from '../src/config.ts'
|
||||
import { buildProvider, supportedProtocols } from '../src/provider.ts'
|
||||
import { assemble } from './assemble.ts'
|
||||
@@ -475,6 +475,177 @@ describe('catalog routes with per-model configuration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-model reasoning efforts', () => {
|
||||
/** One hand-declared route holding exactly the given models. */
|
||||
function declared(models: LlmPiAi.PiAiModelProfile[]): Record<string, LlmPiAi.PiAiProviderProfile> {
|
||||
return { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models } }
|
||||
}
|
||||
|
||||
/** The first materialized model of one route, or throw. */
|
||||
function modelOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route = 'acme-gateway'): Model<Api> {
|
||||
const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
|
||||
if (model === undefined) throw new Error(`route "${route}" resolved no models`)
|
||||
return model
|
||||
}
|
||||
|
||||
it('declares selectable levels with their wire spellings on a hand-declared model', () => {
|
||||
const model = modelOf(declared([{
|
||||
id: 'acme-think',
|
||||
reasoningEfforts: { off: null, low: 'low', high: 'high', max: 'ultra' },
|
||||
}]))
|
||||
|
||||
expect(model.reasoning).toBe(true)
|
||||
// Undeclared levels are pinned null rather than left to pi-ai's own
|
||||
// defaulting, which is asymmetric: an absent key means "supported" for the
|
||||
// five base levels but "unsupported" for xhigh/max. A profile author
|
||||
// should not need to know that. Declared `off` with no value stays absent
|
||||
// from the map — supported, send nothing.
|
||||
expect(model.thinkingLevelMap).toEqual({
|
||||
minimal: null,
|
||||
medium: null,
|
||||
xhigh: null,
|
||||
low: 'low',
|
||||
high: 'high',
|
||||
max: 'ultra',
|
||||
})
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max'])
|
||||
})
|
||||
|
||||
it('sends a declared off value on the wire instead of omitting the parameter', () => {
|
||||
const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }]))
|
||||
expect(model.thinkingLevelMap?.off).toBe('none')
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high'])
|
||||
})
|
||||
|
||||
it('offers exactly the declared keys: leaving off out makes thinking mandatory', () => {
|
||||
const model = modelOf(declared([{ id: 'm', reasoningEfforts: { high: 'high' } }]))
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['high'])
|
||||
})
|
||||
|
||||
it('narrows a catalog model’s levels in place', () => {
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
expect(getSupportedThinkingLevels(catalogModel as Model<Api>)).toEqual(['off', 'high', 'max'])
|
||||
|
||||
const model = modelOf({
|
||||
deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: { off: null, high: 'high' } }] },
|
||||
}, 'deepseek')
|
||||
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high'])
|
||||
// Only the reasoning fields change; identity and capacities stay catalog.
|
||||
expect(model.name).toBe(catalogModel.name)
|
||||
expect(model.contextWindow).toBe(catalogModel.contextWindow)
|
||||
})
|
||||
|
||||
it('strips reasoning from a catalog model with false', () => {
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
expect(catalogModel.reasoning).toBe(true)
|
||||
|
||||
const model = modelOf({ deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: false }] } }, 'deepseek')
|
||||
|
||||
expect(model.reasoning).toBe(false)
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['off'])
|
||||
})
|
||||
|
||||
it('inherits the catalog capability when the field is absent', () => {
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
|
||||
const model = modelOf({ deepseek: { models: [{ id: catalogModel.id }] } }, 'deepseek')
|
||||
|
||||
expect(model.reasoning).toBe(catalogModel.reasoning)
|
||||
expect(model.thinkingLevelMap).toEqual(catalogModel.thinkingLevelMap)
|
||||
})
|
||||
|
||||
it('rejects a declaration that offers nothing or spells a level it cannot send', () => {
|
||||
const declare = (efforts: NonNullable<LlmPiAi.PiAiModelProfile['reasoningEfforts']>): (() => unknown) =>
|
||||
() => resolveProfiles(declared([{ id: 'm', reasoningEfforts: efforts }]))
|
||||
|
||||
expect(declare({})).toThrow(/empty reasoningEfforts/)
|
||||
// A YAML `reasoningEfforts:` left valueless arrives as null through the
|
||||
// schema union; it declares nothing and is not a spelling of "inherit".
|
||||
expect(declare(null as never)).toThrow(/empty reasoningEfforts/)
|
||||
expect(declare({ off: null })).toThrow(/offers no level beyond "off"/)
|
||||
expect(declare({ off: 'none' })).toThrow(/offers no level beyond "off"/)
|
||||
expect(declare({ high: null })).toThrow(/only "off" may leave it empty/)
|
||||
expect(declare({ high: '' })).toThrow(/must not be an empty string/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reasoning-dispatch compat switches', () => {
|
||||
/** The materialized models of one route, keyed by id. */
|
||||
function modelsOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route: string): Map<string, Model<Api>> {
|
||||
const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
|
||||
return new Map(models.map(model => [model.id, model]))
|
||||
}
|
||||
|
||||
it('applies route switches to every openai-completions model, entries winning per field', () => {
|
||||
const models = modelsOf({
|
||||
'acme-gateway': {
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test',
|
||||
compat: { thinkingFormat: 'deepseek' },
|
||||
models: [
|
||||
{ id: 'dialect-default', reasoningEfforts: { off: null, high: 'high' } },
|
||||
{ id: 'dialect-odd', compat: { thinkingFormat: 'openai', supportsReasoningEffort: false } },
|
||||
],
|
||||
},
|
||||
}, 'acme-gateway')
|
||||
|
||||
expect(models.get('dialect-default')?.compat).toEqual({ thinkingFormat: 'deepseek' })
|
||||
expect(models.get('dialect-odd')?.compat).toEqual({ thinkingFormat: 'openai', supportsReasoningEffort: false })
|
||||
})
|
||||
|
||||
it('merges the switches over the catalog entry’s own compat instead of replacing it', () => {
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
const inherited = catalogModel.compat as OpenAICompletionsCompat
|
||||
expect(inherited.requiresReasoningContentOnAssistantMessages).toBe(true)
|
||||
|
||||
const models = modelsOf({
|
||||
deepseek: { models: [{ id: catalogModel.id, compat: { thinkingFormat: 'openai' } }] },
|
||||
}, 'deepseek')
|
||||
|
||||
// The one switched field changes; the catalog's other quirks survive,
|
||||
// because configuration has no way to restate them.
|
||||
expect(models.get(catalogModel.id)?.compat).toEqual({ ...inherited, thinkingFormat: 'openai' })
|
||||
})
|
||||
|
||||
it('skips models of other protocols on a mixed route instead of failing them', () => {
|
||||
// xai ships both completions and responses models, so a route-level switch
|
||||
// must land on the former without invalidating the latter.
|
||||
const catalog = getBuiltinModels('xai') as readonly Model<Api>[]
|
||||
const completions = catalog.find(model => model.api === 'openai-completions')
|
||||
const responses = catalog.find(model => model.api === 'openai-responses')
|
||||
if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog')
|
||||
|
||||
const models = modelsOf({
|
||||
xai: {
|
||||
compat: { supportsReasoningEffort: false },
|
||||
models: [{ id: completions.id }, { id: responses.id }],
|
||||
},
|
||||
}, 'xai')
|
||||
|
||||
expect((models.get(completions.id)?.compat as OpenAICompletionsCompat).supportsReasoningEffort).toBe(false)
|
||||
expect(models.get(responses.id)?.compat).toEqual(responses.compat)
|
||||
})
|
||||
|
||||
it('rejects a model-level switch on a protocol that has no such field', () => {
|
||||
expect(() => resolveProfiles({
|
||||
anthropic: {
|
||||
models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }],
|
||||
},
|
||||
})).toThrow(/exist only on openai-completions/)
|
||||
})
|
||||
|
||||
it('rejects route switches no model on the route can take', () => {
|
||||
expect(() => resolveProfiles({
|
||||
anthropic: { compat: { thinkingFormat: 'openai' } },
|
||||
})).toThrow(/no model on the route speaks openai-completions/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolution snapshots', () => {
|
||||
it('finishes an in-flight request under the configuration it started with', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
|
||||
Reference in New Issue
Block a user