refactor(llm): resolve model metadata together

This commit is contained in:
Yichen Jiang
2026-07-26 13:07:27 +08:00
parent baea5018e5
commit 73e7e27799
55 changed files with 607 additions and 460 deletions
+3 -3
View File
@@ -28,9 +28,9 @@ Configure credentials and deployment-specific transport settings per provider. O
Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin.
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers.
`ctx.llm.resolveModelReasoning(provider, model)` uses pi-ai's `getSupportedThinkingLevels(model)` and returns that model's ordered levels after filtering the separate `off` control. The Harness exposes the canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model returns `undefined`. The profile `reasoning` value is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped.
The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`.
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
@@ -49,7 +49,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state
- pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output.
- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. A terminal `stop` whose message carries no content blocks maps to a `finish {kind:'error'}` with code `EMPTY_RESPONSE` (retried by default policy) instead of a successful empty message.
- pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map.
- pi-ai may internally support an `off` thinking level, but the Harness reasoning-effort capability deliberately excludes mode changes.
- pi-ai's `off` thinking level crosses the Harness capability seam unchanged and becomes an omitted pi-ai common `reasoning` option at dispatch.
- `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers.
## App attribution
+28 -46
View File
@@ -11,6 +11,7 @@ import { getSupportedThinkingLevels } from '@earendil-works/pi-ai'
import type {
Api,
Model,
ModelThinkingLevel,
SimpleStreamOptions,
ThinkingLevel,
} from '@earendil-works/pi-ai'
@@ -22,9 +23,8 @@ import {
} from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions,
LlmModelContext,
LlmModelInfo,
LlmModelReasoningInfo,
LlmResolvedModelInfo,
ReasoningEffortId as ReasoningEffortIdType,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
@@ -44,7 +44,7 @@ export interface PiAiAdapterOptions {
* Resolve a catalog model dynamically and apply only the configured endpoint
* override, preserving the catalog's API/capability/compatibility metadata.
*/
function resolveModel(profile: PiAiProviderProfile, modelId: string): Model<Api> {
function resolvePiModel(profile: PiAiProviderProfile, modelId: string): Model<Api> {
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
if (model === undefined) {
throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL')
@@ -55,11 +55,12 @@ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model<Api>
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
function profileOptions(
profile: PiAiProviderProfile,
reasoning: ThinkingLevel | undefined,
reasoning: ModelThinkingLevel | undefined,
): SimpleStreamOptions {
const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
return {
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
...reasoning === undefined ? {} : { reasoning },
...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning },
...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets },
...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention },
...profile.transport === undefined ? {} : { transport: profile.transport },
@@ -70,19 +71,14 @@ function profileOptions(
}
}
/** Selectable pi-ai levels exclude the separate on/off control. */
function supportedReasoningLevels(model: Model<Api>): ThinkingLevel[] {
return getSupportedThinkingLevels(model).filter((level): level is ThinkingLevel => level !== 'off')
}
/** Validate an explicit Harness/profile effort without invoking pi-ai's clamp. */
function resolveReasoningLevel(
model: Model<Api>,
effort: ReasoningEffortIdType | ThinkingLevel | undefined,
): ThinkingLevel | undefined {
effort: ReasoningEffortIdType | ModelThinkingLevel | undefined,
): ModelThinkingLevel | undefined {
if (effort === undefined) return undefined
const supported = supportedReasoningLevels(model)
if (supported.some(level => level === effort)) return effort as ThinkingLevel
const supported = getSupportedThinkingLevels(model)
if (supported.some(level => level === effort)) return effort as ModelThinkingLevel
throw new LlmError(
`pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
@@ -123,27 +119,11 @@ export class PiAiAdapter extends LlmAdapter {
})))
}
override resolveModelContext(
provider: string,
model: string,
): Promise<LlmModelContext | undefined> {
const profile = this.profiles.get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(
`pi-ai adapter does not own provider "${provider}"`,
'NO_ADAPTER',
))
}
return Promise.resolve().then(() => ({
contextWindow: resolveModel(profile, model).contextWindow,
}))
}
override resolveModelReasoning(
override resolveModel(
provider: string,
model: string,
_signal?: AbortSignal,
): Promise<LlmModelReasoningInfo | undefined> {
): Promise<LlmResolvedModelInfo> {
const profile = this.profiles.get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(
@@ -152,21 +132,23 @@ export class PiAiAdapter extends LlmAdapter {
))
}
return Promise.resolve().then(() => {
const resolvedModel = resolveModel(profile, model)
const levels = supportedReasoningLevels(resolvedModel)
if (levels.length === 0) {
resolveReasoningLevel(resolvedModel, profile.reasoning)
return undefined
}
const resolvedModel = resolvePiModel(profile, model)
const levels = getSupportedThinkingLevels(resolvedModel)
const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning)
return {
efforts: levels.map(level => ({
id: ReasoningEffortId(level),
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
})),
...defaultLevel === undefined
? {}
: { defaultEffort: ReasoningEffortId(defaultLevel) },
provider,
id: model,
name: resolvedModel.name,
context: { contextWindow: resolvedModel.contextWindow },
reasoning: {
efforts: levels.map(level => ({
id: ReasoningEffortId(level),
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
})),
...defaultLevel === undefined
? {}
: { defaultEffort: ReasoningEffortId(defaultLevel) },
},
}
})
}
@@ -179,7 +161,7 @@ export class PiAiAdapter extends LlmAdapter {
if (profile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
}
const model = resolveModel(profile, options.model)
const model = resolvePiModel(profile, options.model)
const reasoning = resolveReasoningLevel(
model,
options.reasoningEffort ?? profile.reasoning,
+3 -3
View File
@@ -5,7 +5,7 @@
*/
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai'
import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
@@ -23,7 +23,7 @@ export interface PiAiProviderProfile {
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
reasoning?: ThinkingLevel
reasoning?: ModelThinkingLevel
/** Token budgets used by reasoning providers that support them. */
thinkingBudgets?: ThinkingBudgets
/** Prompt-cache retention preference. */
@@ -62,7 +62,7 @@ const profile = z.object({
apiKey: z.string(),
baseURL: z.string(),
headers: z.dict(z.string()),
reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
thinkingBudgets,
cacheRetention: z.union(['none', 'short', 'long']),
transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']),
+14 -1
View File
@@ -9,7 +9,7 @@ import { assemble, type AssembledResult } from './assemble.ts'
/**
* Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro with provider
* defaults and representative high/max reasoning. Mirrors the native
* defaults and representative off/high/max reasoning. Mirrors the native
* adapter's StreamChunk contract and exercises a replayed tool follow-up.
* Key-gated.
*/
@@ -74,6 +74,19 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
expect(textOf(result).toLowerCase()).toContain('pong')
})
it('flash + reasoning off: plain text without reasoning blocks', async () => {
const ctx = await harness(FLASH)
const result = await assemble(ctx,{
model: FLASH,
reasoningEffort: ReasoningEffortId('off'),
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(textOf(result).toLowerCase()).toContain('pong')
})
it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => {
const ctx = await harness(model)
const result = await assemble(ctx,{
+44 -23
View File
@@ -149,7 +149,7 @@ describe('PiAiAdapter provider routing', () => {
})
it('uses a dynamic request effort and rejects unsupported efforts before network I/O', async () => {
const server = await mockServer([{ events: textEvents }])
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await harness(server.url, { reasoning: 'max' })
await assemble(ctx, {
@@ -159,12 +159,20 @@ describe('PiAiAdapter provider routing', () => {
})
expect(server.requests[0]).toMatchObject({ reasoning_effort: 'high' })
await assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('off'),
messages: [],
})
expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } })
expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
await expect(assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('xhigh'),
messages: [],
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
expect(server.requests).toHaveLength(1)
expect(server.requests).toHaveLength(2)
})
it('preserves omitted profile options when constructing the adapter directly', async () => {
@@ -341,27 +349,30 @@ describe('provider profile lifecycle', () => {
provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
})
expect(models.every(model => model.provider === 'openai')).toBe(true)
const context = await ctx.llm.resolveModelContext('openai', 'gpt-4.1')
expect(context).toBeDefined()
expect(typeof context?.contextWindow).toBe('number')
const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1')
expect(typeof info.context?.contextWindow).toBe('number')
})
it('exposes model-specific reasoning levels without off or an invented provider default', async () => {
it('exposes pi-ai model thinking levels verbatim without inventing a provider default', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek' }, { provider: 'openai' }],
})
await expect(ctx.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash'))
.resolves.toEqual({
efforts: [
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
},
})
const extended = await ctx.llm.resolveModelReasoning('openai', 'gpt-5.6-sol')
expect(extended?.efforts.map(effort => effort.id)).toEqual([
const extended = await ctx.llm.resolveModelInfo('openai', 'gpt-5.6-sol')
expect(extended.reasoning?.efforts.map(effort => effort.id)).toEqual([
ReasoningEffortId('off'),
ReasoningEffortId('minimal'),
ReasoningEffortId('low'),
ReasoningEffortId('medium'),
@@ -369,8 +380,12 @@ describe('provider profile lifecycle', () => {
ReasoningEffortId('xhigh'),
ReasoningEffortId('max'),
])
await expect(ctx.llm.resolveModelReasoning('openai', 'gpt-4.1'))
.resolves.toBeUndefined()
await expect(ctx.llm.resolveModelInfo('openai', 'gpt-4.1'))
.resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
},
})
})
it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => {
@@ -379,16 +394,24 @@ describe('provider profile lifecycle', () => {
await supported.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'max' }],
})
await expect(supported.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ defaultEffort: ReasoningEffortId('max') })
await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } })
const unsupported = new Context()
await unsupported.plugin(LlmService)
await unsupported.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'medium' }],
})
await expect(unsupported.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash'))
await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
const disabled = new Context()
await disabled.plugin(LlmService)
await disabled.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'off' }],
})
await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } })
})
it('accepts absent credentials for pi-ai ambient authentication', async () => {
@@ -440,11 +463,9 @@ describe('provider profile lifecycle', () => {
it('constructs the adapter directly and rejects routes it does not own', async () => {
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] })
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(adapter.resolveModelContext('anthropic', 'claude-sonnet-4'))
await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4'))
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(adapter.resolveModelReasoning('anthropic', 'claude-sonnet-4'))
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(adapter.resolveModelContext('openai', 'not-a-catalog-model'))
await expect(adapter.resolveModel('openai', 'not-a-catalog-model'))
.rejects.toMatchObject({ code: 'UNKNOWN_MODEL' })
await expect((async () => {
for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ }