refactor(llm): resolve model metadata together
This commit is contained in:
@@ -272,7 +272,7 @@ export class BasicCompactService extends CompactService {
|
||||
return this.compactRegion(range.start, range.end, agent, signal)
|
||||
}
|
||||
|
||||
const context = await this.ctx.llm.resolveModelContext(target.provider, target.model)
|
||||
const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model)).context
|
||||
const targetKey = `${target.provider}/${target.model}`
|
||||
if (context === undefined) {
|
||||
throw new TargetPressureConfigError(
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
ContentBlock,
|
||||
GenerateOptions,
|
||||
LlmFailure,
|
||||
LlmModelContext,
|
||||
LlmResolvedModelInfo,
|
||||
Message,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
@@ -33,8 +33,13 @@ class ContextAdapter extends LlmAdapter {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelContext(): Promise<LlmModelContext> {
|
||||
return Promise.resolve({ contextWindow: this.contextWindow })
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
context: { contextWindow: this.contextWindow },
|
||||
})
|
||||
}
|
||||
|
||||
override async * stream(): AsyncIterable<StreamChunk> {
|
||||
@@ -47,9 +52,14 @@ class RoutedContextAdapter extends LlmAdapter {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelContext(provider: string): Promise<LlmModelContext | undefined> {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const contextWindow = this.windows[provider]
|
||||
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||
})
|
||||
}
|
||||
|
||||
override async * stream(): AsyncIterable<StreamChunk> {
|
||||
@@ -485,7 +495,11 @@ describe('pressure measurement and retention', () => {
|
||||
void new LlmService(ctx)
|
||||
void new TokenMeterService(ctx)
|
||||
ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000))
|
||||
vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined)
|
||||
vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
}))
|
||||
const compact = service(compactConfig, ctx)
|
||||
const session = conversation(4)
|
||||
session.append('request/header', {
|
||||
@@ -1337,7 +1351,11 @@ describe('automatic listener and loader composition', () => {
|
||||
const ctx = createContext()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
|
||||
vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined)
|
||||
vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
}))
|
||||
void new TestCompactService(ctx, {
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -41,8 +41,13 @@ class StepwiseToolAdapter extends LlmAdapter {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelContext(): Promise<{ contextWindow: number }> {
|
||||
return Promise.resolve({ contextWindow: 400 })
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
context: { contextWindow: 400 },
|
||||
})
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
@@ -76,8 +81,13 @@ class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelContext(): Promise<{ contextWindow: number }> {
|
||||
return Promise.resolve({ contextWindow: 128 })
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
context: { contextWindow: 128 },
|
||||
})
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
|
||||
@@ -381,12 +381,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveModelContext( provider: string, model: string, ): Promise<LlmModelContext | undefined>',
|
||||
jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveModelReasoning( provider: string, model: string, signal?: AbortSignal, ): Promise<LlmModelReasoningInfo | undefined>',
|
||||
jsDoc: '/**\n * Resolve selectable reasoning efforts from the adapter that owns one exact\n * route. Metadata is validated and detached; an absent result means an\n * effort selector is unsupported for that model.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @param signal - optional cancellation for adapter-owned asynchronous lookup.\n * @returns detached reasoning metadata, or `undefined` when unsupported.\n */',
|
||||
signature: 'async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise<LlmResolvedModelInfo>',
|
||||
jsDoc: '/**\n * Resolve and validate all metadata from the adapter that owns one exact\n * route. The result is detached from adapter-owned objects; catalog\n * membership remains advisory and does not control request routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @param signal - optional cancellation for adapter-owned asynchronous lookup.\n * @returns exact model identity plus available context and reasoning metadata.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>',
|
||||
@@ -398,7 +394,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
|
||||
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous reasoning resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
|
||||
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1706,7 +1702,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'LlmAdapter',
|
||||
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext | undefined>;\n resolveModelReasoning(_provider: string, _model: string, _signal?: AbortSignal): Promise<LlmModelReasoningInfo | undefined>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmCallConfig',
|
||||
@@ -1736,6 +1732,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'LlmReasoningEffortInfo',
|
||||
declaration: 'export interface LlmReasoningEffortInfo {\n id: ReasoningEffortId;\n name: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmResolvedModelInfo',
|
||||
declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n reasoning?: LlmModelReasoningInfo;\n}',
|
||||
},
|
||||
{
|
||||
name: 'Message',
|
||||
declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n}',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Helpers to write scripted responses tersely. */
|
||||
@@ -71,11 +71,16 @@ export class MockAdapter extends LlmAdapter {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
return Promise.resolve(this.reasoning)
|
||||
override resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
|
||||
})
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
@@ -152,7 +152,7 @@ describe('request stability across the loop', () => {
|
||||
expect(resumedHeaders.at(-1)?.data.reason).toBe('resume')
|
||||
})
|
||||
|
||||
it('keeps reasoning resolution, request logging, and dispatch on one adapter registration', async () => {
|
||||
it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -163,13 +163,18 @@ describe('request stability across the loop', () => {
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const reasoning = Promise.withResolvers<LlmModelReasoningInfo>()
|
||||
const first = new class extends MockAdapter {
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
override async resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
_signal?: AbortSignal,
|
||||
): typeof reasoning.promise {
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
started.resolve(undefined)
|
||||
return reasoning.promise
|
||||
return {
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
reasoning: await reasoning.promise,
|
||||
}
|
||||
}
|
||||
}([textResponse('first')])
|
||||
const second = new MockAdapter([textResponse('second')], {
|
||||
@@ -200,7 +205,7 @@ describe('request stability across the loop', () => {
|
||||
it('aborts a blocked reasoning lookup before quiescent disposal completes', async () => {
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
const adapter = new class extends MockAdapter {
|
||||
override resolveModelReasoning(
|
||||
override resolveModel(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -235,13 +240,13 @@ describe('request stability across the loop', () => {
|
||||
})
|
||||
|
||||
it.each(['plain error', 'LLM error'] as const)(
|
||||
'does not swallow a %s from reasoning resolution',
|
||||
'does not swallow a %s from exact-model resolution',
|
||||
async (kind) => {
|
||||
const failure = kind === 'plain error'
|
||||
? new Error('reasoning metadata failed')
|
||||
: new LlmError('unsupported effort', 'UNSUPPORTED_REASONING_EFFORT')
|
||||
const adapter = new class extends MockAdapter {
|
||||
override resolveModelReasoning(): Promise<never> {
|
||||
override resolveModel(): Promise<never> {
|
||||
return Promise.reject(failure)
|
||||
}
|
||||
}([])
|
||||
|
||||
@@ -28,9 +28,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
|
||||
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for UI selectors and deployment introspection, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
|
||||
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists, `context` is absent without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
`ctx.llm.resolveModelReasoning('deepseek', model)` returns the ordered `high` and `max` efforts for every pass-through model while thinking is enabled. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header` and serialized as the official top-level `reasoning_effort` field. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O.
|
||||
The same exact-model result exposes ordered `high` and `max` efforts under `reasoning` for every pass-through model while thinking is enabled. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header` and serialized as the official top-level `reasoning_effort` field. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O.
|
||||
|
||||
`thinking: disabled` removes the reasoning capability and omits `reasoning_effort`; combining it with a configured default fails plugin loading, and a per-request effort fails as unsupported. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults.
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmModelContext,
|
||||
LlmModelInfo,
|
||||
LlmModelReasoningInfo,
|
||||
LlmProviderInfo,
|
||||
LlmResolvedModelInfo,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
@@ -59,6 +58,15 @@ const REASONING_EFFORTS = [
|
||||
{ id: MAX_REASONING_EFFORT, name: 'Max' },
|
||||
] as const
|
||||
|
||||
function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo {
|
||||
return {
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
}
|
||||
}
|
||||
|
||||
function providerRetryAfterMs(value: string | null): number | undefined {
|
||||
if (value === null) return undefined
|
||||
if (/^\d+$/.test(value)) {
|
||||
@@ -127,34 +135,32 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve((this.options.models ?? []).map(model => ({
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
})))
|
||||
return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model)))
|
||||
}
|
||||
|
||||
override resolveModelContext(
|
||||
_provider: string,
|
||||
override resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<LlmModelContext | undefined> {
|
||||
const contextWindow = this.options.models?.find(entry => entry.id === model)?.contextWindow
|
||||
?? this.options.defaultContextWindow
|
||||
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
|
||||
}
|
||||
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
if (this.options.defaults?.thinking === 'disabled') return Promise.resolve(undefined)
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
const configured = this.options.models?.find(entry => entry.id === model)
|
||||
const contextWindow = configured?.contextWindow
|
||||
?? this.options.defaultContextWindow
|
||||
return Promise.resolve({
|
||||
efforts: REASONING_EFFORTS,
|
||||
defaultEffort: this.options.defaults?.reasoningEffort === 'max'
|
||||
? MAX_REASONING_EFFORT
|
||||
: HIGH_REASONING_EFFORT,
|
||||
...configured === undefined
|
||||
? { provider, id: model, name: model }
|
||||
: modelInfo(provider, configured),
|
||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||
...this.options.defaults?.thinking === 'disabled'
|
||||
? {}
|
||||
: {
|
||||
reasoning: {
|
||||
efforts: REASONING_EFFORTS,
|
||||
defaultEffort: this.options.defaults?.reasoningEffort === 'max'
|
||||
? MAX_REASONING_EFFORT
|
||||
: HIGH_REASONING_EFFORT,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -213,8 +213,8 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
thinking: { type: 'disabled' },
|
||||
})
|
||||
expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
|
||||
await expect(ctx.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toBeUndefined()
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.not.toHaveProperty('reasoning')
|
||||
})
|
||||
|
||||
it('rejects a per-request effort before I/O when thinking is disabled', async () => {
|
||||
@@ -573,15 +573,19 @@ describe('plugin registration and config', () => {
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
])
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toEqual({ contextWindow: 128_000 })
|
||||
await expect(ctx.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toEqual({
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
{ id: ReasoningEffortId('max'), name: 'Max' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toMatchObject({
|
||||
provider: 'deepseek',
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'deepseek-v4-flash',
|
||||
context: { contextWindow: 128_000 },
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
{ id: ReasoningEffortId('max'), name: 'Max' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -635,10 +639,15 @@ describe('plugin registration and config', () => {
|
||||
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast' },
|
||||
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
|
||||
])
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'private-fast'))
|
||||
.resolves.toEqual({ contextWindow: 32_000 })
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'arbitrary-unlisted'))
|
||||
.resolves.toBeUndefined()
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 32_000 } })
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'private-reasoner'))
|
||||
.resolves.toMatchObject({
|
||||
name: 'Private Reasoner',
|
||||
description: 'Higher reasoning budget',
|
||||
})
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'arbitrary-unlisted'))
|
||||
.resolves.not.toHaveProperty('context')
|
||||
})
|
||||
|
||||
it('uses exact model capacity before the adapter-wide default', async () => {
|
||||
@@ -654,12 +663,12 @@ describe('plugin registration and config', () => {
|
||||
],
|
||||
})
|
||||
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'inherits-default'))
|
||||
.resolves.toEqual({ contextWindow: 256_000 })
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'exact-override'))
|
||||
.resolves.toEqual({ contextWindow: 64_000 })
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'unlisted-pass-through'))
|
||||
.resolves.toEqual({ contextWindow: 256_000 })
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'inherits-default'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 256_000 } })
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'exact-override'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 64_000 } })
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 256_000 } })
|
||||
})
|
||||
|
||||
it('allows an explicit empty model catalog', async () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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,{
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -11,8 +11,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber.
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
|
||||
- `ctx.llm.resolveModelContext(provider: string, model: string): Promise<LlmModelContext | undefined>` Resolve authoritative context capacity for one exact route from its owning adapter.
|
||||
- `ctx.llm.resolveModelReasoning(provider: string, model: string, signal?: AbortSignal): Promise<LlmModelReasoningInfo | undefined>` Resolve ordered adapter-owned reasoning efforts and an optional deployment default for one exact route, with optional cancellation for asynchronous adapters.
|
||||
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize an adapter-configured default without clamping.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config and capture its current adapter registration as one cancellable, one-shot call.
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
|
||||
@@ -21,9 +20,9 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
|
||||
|
||||
Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`.
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`.
|
||||
|
||||
Reasoning effort is also an exact-route capability, but its identifiers are opaque adapter-owned strings rather than a core enum. `resolveModelReasoning()` validates and detaches the ordered display metadata; `undefined` means the model has no selectable effort. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. Invalid capability metadata fails with `INVALID_MODEL_REASONING`; an unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
|
||||
### Events
|
||||
|
||||
@@ -33,7 +32,7 @@ Reasoning effort is also an exact-route capability, but its identifiers are opaq
|
||||
|
||||
### Extension points
|
||||
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, `resolveModelContext()` when exact capacity is known, and `resolveModelReasoning()` when a model exposes selectable efforts; an asynchronous reasoning resolver must honor its optional cancellation signal. The defaults use the route id as its name, advertise no models, and return neither capacity nor reasoning metadata.
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata.
|
||||
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead.
|
||||
|
||||
### Content-block vocabulary (`types.ts`)
|
||||
|
||||
@@ -10,9 +10,8 @@ import { Context, Service } from 'cordis'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmFailure,
|
||||
LlmModelContext,
|
||||
LlmModelInfo,
|
||||
LlmModelReasoningInfo,
|
||||
LlmResolvedModelInfo,
|
||||
LlmProviderInfo,
|
||||
Message,
|
||||
StreamChunk,
|
||||
@@ -147,34 +146,20 @@ export abstract class LlmAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve context capacity for one model accepted by this adapter. Absence
|
||||
* means the adapter does not know the capacity, not that routing is invalid.
|
||||
* @param _provider - one provider route owned by this adapter.
|
||||
* @param _model - exact model id passed to {@link GenerateOptions.model}.
|
||||
* @returns provider-owned context metadata, or `undefined` when unavailable.
|
||||
* Resolve all metadata available for one exact model. This query is
|
||||
* independent of the advisory catalog and does not validate request routing.
|
||||
* @param provider - one provider route owned by this adapter.
|
||||
* @param model - exact model id passed to {@link GenerateOptions.model}.
|
||||
* @param _signal - cancellation for this exact-model lookup; asynchronous
|
||||
* implementations must settle promptly after it aborts.
|
||||
* @returns provider/model identity plus any context and reasoning metadata.
|
||||
*/
|
||||
resolveModelContext(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
): Promise<LlmModelContext | undefined> {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve selectable reasoning efforts for one exact model. Absence means
|
||||
* the model has no selectable reasoning-effort capability.
|
||||
* @param _provider - one provider route owned by this adapter.
|
||||
* @param _model - exact model id passed to {@link GenerateOptions.model}.
|
||||
* @param _signal - cancellation for this exact-model lookup; implementations
|
||||
* must settle promptly after it aborts.
|
||||
* @returns adapter-owned effort metadata, or `undefined` when unsupported.
|
||||
*/
|
||||
resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
return Promise.resolve(undefined)
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,53 +258,59 @@ export class LlmService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve context capacity from the adapter that owns one exact route.
|
||||
* This query is independent of the advisory model catalog: an unlisted model
|
||||
* may return metadata, while `undefined` never rejects later routing.
|
||||
* Resolve and validate all metadata from the adapter that owns one exact
|
||||
* route. The result is detached from adapter-owned objects; catalog
|
||||
* membership remains advisory and does not control request routing.
|
||||
* @param provider - registered provider route to inspect.
|
||||
* @param model - exact model id passed to the adapter.
|
||||
* @returns detached context metadata, or `undefined` when the adapter has none.
|
||||
* @param signal - optional cancellation for adapter-owned asynchronous lookup.
|
||||
* @returns exact model identity plus available context and reasoning metadata.
|
||||
*/
|
||||
async resolveModelContext(
|
||||
async resolveModelInfo(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<LlmModelContext | undefined> {
|
||||
const context = await this.registration(provider).adapter.resolveModelContext(provider, model)
|
||||
if (context === undefined) return undefined
|
||||
if (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0) {
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
return this.resolveModelInfoFor(this.registration(provider), model, signal)
|
||||
}
|
||||
|
||||
private async resolveModelInfoFor(
|
||||
registration: AdapterRegistration,
|
||||
model: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
const provider = registration.provider.id
|
||||
const resolved = await registration.adapter.resolveModel(provider, model, signal)
|
||||
if (
|
||||
typeof resolved.provider !== 'string'
|
||||
|| resolved.provider !== provider
|
||||
|| typeof resolved.id !== 'string'
|
||||
|| resolved.id !== model
|
||||
|| typeof resolved.name !== 'string'
|
||||
|| resolved.name.length === 0
|
||||
|| (resolved.description !== undefined && typeof resolved.description !== 'string')
|
||||
) {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid exact model metadata for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_INFO',
|
||||
)
|
||||
}
|
||||
const context = resolved.context
|
||||
if (context !== undefined && (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0)) {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid context metadata for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_CONTEXT',
|
||||
)
|
||||
}
|
||||
return { contextWindow: context.contextWindow }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve selectable reasoning efforts from the adapter that owns one exact
|
||||
* route. Metadata is validated and detached; an absent result means an
|
||||
* effort selector is unsupported for that model.
|
||||
* @param provider - registered provider route to inspect.
|
||||
* @param model - exact model id passed to the adapter.
|
||||
* @param signal - optional cancellation for adapter-owned asynchronous lookup.
|
||||
* @returns detached reasoning metadata, or `undefined` when unsupported.
|
||||
*/
|
||||
async resolveModelReasoning(
|
||||
provider: string,
|
||||
model: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
return this.resolveModelReasoningFor(this.registration(provider), model, signal)
|
||||
}
|
||||
|
||||
private async resolveModelReasoningFor(
|
||||
registration: AdapterRegistration,
|
||||
model: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
const provider = registration.provider.id
|
||||
const reasoning = await registration.adapter.resolveModelReasoning(provider, model, signal)
|
||||
if (reasoning === undefined) return undefined
|
||||
const info: LlmResolvedModelInfo = {
|
||||
provider,
|
||||
id: model,
|
||||
name: resolved.name,
|
||||
...resolved.description === undefined ? {} : { description: resolved.description },
|
||||
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
|
||||
}
|
||||
const reasoning = resolved.reasoning
|
||||
if (reasoning === undefined) return info
|
||||
if (reasoning.efforts.length === 0) {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid reasoning metadata for provider "${provider}" model "${model}"`,
|
||||
@@ -355,8 +346,11 @@ export class LlmService extends Service {
|
||||
)
|
||||
}
|
||||
return {
|
||||
efforts,
|
||||
...reasoning.defaultEffort === undefined ? {} : { defaultEffort: reasoning.defaultEffort },
|
||||
...info,
|
||||
reasoning: {
|
||||
efforts,
|
||||
...reasoning.defaultEffort === undefined ? {} : { defaultEffort: reasoning.defaultEffort },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +373,7 @@ export class LlmService extends Service {
|
||||
config: LlmCallConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlmCallConfig> {
|
||||
const reasoning = await this.resolveModelReasoningFor(registration, config.model, signal)
|
||||
const reasoning = (await this.resolveModelInfoFor(registration, config.model, signal)).reasoning
|
||||
const requested = config.reasoningEffort
|
||||
if (reasoning === undefined) {
|
||||
if (requested !== undefined) {
|
||||
@@ -520,7 +514,7 @@ export class LlmService extends Service {
|
||||
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
|
||||
* `options.provider`. Replay state is retained only when the same adapter
|
||||
* instance owns its historical provider and the target provider. Final
|
||||
* adapter selection remains fixed through asynchronous reasoning resolution
|
||||
* adapter selection remains fixed through asynchronous exact-model resolution
|
||||
* and dispatch. Selection, dispatch, and iteration failures retain their
|
||||
* original Error identity and are tagged in a call-local scope for narrow
|
||||
* agent-loop request recovery; middleware and nested-call failures remain
|
||||
|
||||
@@ -182,6 +182,14 @@ export interface LlmModelReasoningInfo {
|
||||
defaultEffort?: ReasoningEffortId
|
||||
}
|
||||
|
||||
/** Exact-route model metadata resolved by its owning adapter. */
|
||||
export interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
/** Provider-owned context capacity when known. */
|
||||
context?: LlmModelContext
|
||||
/** Adapter-owned selectable reasoning levels when exposed. */
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
* Block indexes correlate interleaved deltas, and `block-end` carries the
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
LlmModelInfo,
|
||||
LlmModelReasoningInfo,
|
||||
LlmProviderInfo,
|
||||
LlmResolvedModelInfo,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
@@ -68,18 +69,17 @@ class CatalogAdapter extends ScriptedAdapter {
|
||||
return Promise.resolve(this.models)
|
||||
}
|
||||
|
||||
override resolveModelContext(
|
||||
_provider: string,
|
||||
override resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<LlmModelContext | undefined> {
|
||||
return Promise.resolve(this.contexts[model])
|
||||
}
|
||||
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
model: string,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
return Promise.resolve(this.reasoning[model])
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...this.contexts[model] === undefined ? {} : { context: this.contexts[model] },
|
||||
...this.reasoning[model] === undefined ? {} : { reasoning: this.reasoning[model] },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,8 +667,32 @@ describe('LlmService', () => {
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'plain', name: 'plain' }])
|
||||
await expect(ctx.llm.listModels('plain')).resolves.toEqual([])
|
||||
await expect(ctx.llm.listModels('missing')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
await expect(ctx.llm.resolveModelContext('plain', 'unlisted')).resolves.toBeUndefined()
|
||||
await expect(ctx.llm.resolveModelContext('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
await expect(ctx.llm.resolveModelInfo('plain', 'unlisted')).resolves.toEqual({
|
||||
provider: 'plain', id: 'unlisted', name: 'unlisted',
|
||||
})
|
||||
await expect(ctx.llm.resolveModelInfo('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ provider: 1, id: 'model', name: 'Model' }, 'non-string provider'],
|
||||
[{ provider: 'other', id: 'model', name: 'Model' }, 'mismatched provider'],
|
||||
[{ provider: 'route', id: 1, name: 'Model' }, 'non-string id'],
|
||||
[{ provider: 'route', id: 'other', name: 'Model' }, 'mismatched id'],
|
||||
[{ provider: 'route', id: 'model', name: 1 }, 'non-string name'],
|
||||
[{ provider: 'route', id: 'model', name: '' }, 'empty name'],
|
||||
[{ provider: 'route', id: 'model', name: 'Model', description: 1 }, 'non-string description'],
|
||||
] as const)('rejects invalid exact model metadata (%s: %s)', async (metadata, _label) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new class extends ScriptedAdapter {
|
||||
override resolveModel(): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve(metadata as unknown as LlmResolvedModelInfo)
|
||||
}
|
||||
}(SCRIPT)
|
||||
ctx.llm.registerAdapter(['route'], adapter)
|
||||
|
||||
await expect(ctx.llm.resolveModelInfo('route', 'model'))
|
||||
.rejects.toMatchObject({ code: 'INVALID_MODEL_INFO' })
|
||||
})
|
||||
|
||||
it('resolves detached model context independently of advisory catalog membership', async () => {
|
||||
@@ -681,11 +705,13 @@ describe('LlmService', () => {
|
||||
{ unlisted: source },
|
||||
))
|
||||
|
||||
const resolved = await ctx.llm.resolveModelContext('route', 'unlisted')
|
||||
expect(resolved).toEqual({ contextWindow: 32_000 })
|
||||
const resolved = await ctx.llm.resolveModelInfo('route', 'unlisted')
|
||||
expect(resolved.context).toEqual({ contextWindow: 32_000 })
|
||||
source.contextWindow = 64_000
|
||||
expect(resolved).toEqual({ contextWindow: 32_000 })
|
||||
await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined()
|
||||
expect(resolved.context).toEqual({ contextWindow: 32_000 })
|
||||
await expect(ctx.llm.resolveModelInfo('route', 'other')).resolves.toEqual({
|
||||
provider: 'route', id: 'other', name: 'other',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves detached adapter-owned reasoning metadata and materializes its default', async () => {
|
||||
@@ -705,10 +731,10 @@ describe('LlmService', () => {
|
||||
{ model: source },
|
||||
))
|
||||
|
||||
const resolved = await ctx.llm.resolveModelReasoning('route', 'model')
|
||||
expect(resolved).toEqual(source)
|
||||
const resolved = await ctx.llm.resolveModelInfo('route', 'model')
|
||||
expect(resolved.reasoning).toEqual(source)
|
||||
source.efforts[0]!.name = 'mutated'
|
||||
expect(resolved?.efforts[0]?.name).toBe('Standard')
|
||||
expect(resolved.reasoning?.efforts[0]?.name).toBe('Standard')
|
||||
await expect(ctx.llm.resolveCallConfig({ provider: 'route', model: 'model' })).resolves.toEqual({
|
||||
provider: 'route',
|
||||
model: 'model',
|
||||
@@ -734,7 +760,7 @@ describe('LlmService', () => {
|
||||
{},
|
||||
{ model: metadata as unknown as LlmModelReasoningInfo },
|
||||
))
|
||||
await expect(ctx.llm.resolveModelReasoning('route', 'model'))
|
||||
await expect(ctx.llm.resolveModelInfo('route', 'model'))
|
||||
.rejects.toMatchObject({ code: 'INVALID_MODEL_REASONING' })
|
||||
})
|
||||
|
||||
@@ -764,13 +790,16 @@ describe('LlmService', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new class extends RecordingAdapter {
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
): Promise<LlmModelReasoningInfo> {
|
||||
return Promise.resolve({
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const reasoning: LlmModelReasoningInfo = {
|
||||
efforts: [{ id: ReasoningEffortId('standard'), name: 'Standard' }],
|
||||
defaultEffort: ReasoningEffortId('standard'),
|
||||
}
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
reasoning,
|
||||
})
|
||||
}
|
||||
}(SCRIPT)
|
||||
@@ -799,19 +828,24 @@ describe('LlmService', () => {
|
||||
expect(Object.isFrozen(adapter.lastOptions)).toBe(true)
|
||||
})
|
||||
|
||||
it('pins one adapter registration across asynchronous reasoning resolution and dispatch', async () => {
|
||||
it('pins one adapter registration across asynchronous exact-model resolution and dispatch', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const reasoning = Promise.withResolvers<LlmModelReasoningInfo>()
|
||||
const first = new class extends RecordingAdapter {
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
override async resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<LlmModelReasoningInfo> {
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
started.resolve(undefined)
|
||||
return reasoning.promise
|
||||
return {
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
reasoning: await reasoning.promise,
|
||||
}
|
||||
}
|
||||
}(SCRIPT)
|
||||
const disposeFirst = ctx.llm.registerAdapter(['route'], first)
|
||||
@@ -869,18 +903,18 @@ describe('LlmService', () => {
|
||||
})).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' }))
|
||||
})
|
||||
|
||||
it('passes cancellation through reasoning capability resolution', async () => {
|
||||
it('passes cancellation through exact-model resolution', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const adapter = new class extends ScriptedAdapter {
|
||||
override resolveModelReasoning(
|
||||
override resolveModel(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
started.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
return new Promise<LlmResolvedModelInfo>((_resolve, reject) => {
|
||||
if (signal === undefined) {
|
||||
reject(new Error('missing reasoning signal'))
|
||||
return
|
||||
@@ -918,7 +952,7 @@ describe('LlmService', () => {
|
||||
[],
|
||||
{ model: { contextWindow } },
|
||||
))
|
||||
await expect(ctx.llm.resolveModelContext('route', 'model'))
|
||||
await expect(ctx.llm.resolveModelInfo('route', 'model'))
|
||||
.rejects.toMatchObject({ code: 'INVALID_MODEL_CONTEXT' })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I
|
||||
|
||||
## Configuration
|
||||
|
||||
The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelContext()`.
|
||||
The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelInfo().context`.
|
||||
|
||||
## Measurement contract
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { delimiter as pathDelimiter } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { GenerateOptions, LlmModelContext, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
@@ -268,12 +268,20 @@ class ReplayAdapter extends LlmAdapter {
|
||||
})))
|
||||
}
|
||||
|
||||
override resolveModelContext(provider: string, model: string): Promise<LlmModelContext | undefined> {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const configured = this.providers.get(provider)
|
||||
/* v8 ignore next -- LlmService only asks about routes registered from this same map. */
|
||||
if (configured === undefined) return Promise.resolve(undefined)
|
||||
const contextWindow = configured.models?.find(candidate => candidate.id === model)?.contextWindow
|
||||
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
|
||||
if (configured === undefined) return Promise.resolve({ provider, id: model, name: model })
|
||||
const configuredModel = configured.models?.find(candidate => candidate.id === model)
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: configuredModel?.name ?? model,
|
||||
...configuredModel?.description === undefined ? {} : { description: configuredModel.description },
|
||||
...configuredModel?.contextWindow === undefined
|
||||
? {}
|
||||
: { context: { contextWindow: configuredModel.contextWindow } },
|
||||
})
|
||||
}
|
||||
|
||||
override stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
|
||||
@@ -258,10 +258,12 @@ describe('installLlmReplay (through the real LlmService)', () => {
|
||||
{ provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' },
|
||||
])
|
||||
await expect(ctx.llm.listModels('empty')).resolves.toEqual([])
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'flash')).resolves.toEqual({ contextWindow: 128_000 })
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'pro')).resolves.toBeUndefined()
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'unlisted')).resolves.toBeUndefined()
|
||||
await expect(ctx.llm.resolveModelContext('empty', 'unlisted')).resolves.toBeUndefined()
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({
|
||||
context: { contextWindow: 128_000 },
|
||||
})
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context')
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context')
|
||||
await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context')
|
||||
expect(await drain(ctx.llm.stream({ provider: 'deepseek', model: 'pro', messages: [] }))).toEqual(TEXT_CHUNKS)
|
||||
|
||||
dispose()
|
||||
|
||||
@@ -10,7 +10,7 @@ This package owns interactive terminal presentation and input only. It injects `
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
|
||||
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
|
||||
|
||||
@@ -22,11 +22,11 @@ When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. Models without selectable effort metadata ignore Shift+Tab; the selector does not synthesize `off`, clamp a value, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. Models without selectable effort metadata ignore Shift+Tab; the selector renders the exact advertised list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
|
||||
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
|
||||
@@ -630,7 +630,7 @@ async function readModelChoices(
|
||||
models.push({ provider: provider.id, id: current.model, name: current.model })
|
||||
}
|
||||
return Promise.all(models.map(async (model): Promise<ModelChoice> => {
|
||||
const reasoning = await ctx.llm.resolveModelReasoning(provider.id, model.id)
|
||||
const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning
|
||||
return {
|
||||
provider: provider.id,
|
||||
model: model.id,
|
||||
@@ -2101,8 +2101,8 @@ export function createTuiChat(
|
||||
contextWindow = undefined
|
||||
const resolution = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelContext(selected.provider, selected.model).then(
|
||||
context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const),
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const),
|
||||
(error: unknown) => ({ kind: 'error', error } as const),
|
||||
)
|
||||
contextResolution = resolution
|
||||
|
||||
@@ -10,10 +10,9 @@ import AgentRegistry, {
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
ContentBlock,
|
||||
LlmModelContext,
|
||||
LlmModelInfo,
|
||||
LlmModelReasoningInfo,
|
||||
LlmProviderInfo,
|
||||
LlmResolvedModelInfo,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
@@ -53,11 +52,10 @@ export interface TuiHarnessOptions {
|
||||
providers: LlmProviderInfo[]
|
||||
models: LlmModelInfo[]
|
||||
listModels?: (provider: string) => Promise<LlmModelInfo[]>
|
||||
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
|
||||
resolveModelReasoning?: (
|
||||
resolveModelInfo?: (
|
||||
provider: string,
|
||||
model: string,
|
||||
) => Promise<LlmModelReasoningInfo | undefined>
|
||||
) => Promise<Pick<LlmResolvedModelInfo, 'context' | 'reasoning'>>
|
||||
}
|
||||
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
|
||||
sessionPersistence?: {
|
||||
@@ -128,12 +126,20 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
return catalog.listModels?.(provider)
|
||||
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
|
||||
},
|
||||
resolveModelContext(provider: string, model: string) {
|
||||
return catalog.resolveModelContext?.(provider, model)
|
||||
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
|
||||
},
|
||||
resolveModelReasoning(provider: string, model: string) {
|
||||
return catalog.resolveModelReasoning?.(provider, model) ?? Promise.resolve(undefined)
|
||||
async resolveModelInfo(provider: string, model: string) {
|
||||
const advertised = catalog.models.find(candidate =>
|
||||
candidate.provider === provider && candidate.id === model)
|
||||
const capabilities = await (catalog.resolveModelInfo?.(provider, model)
|
||||
?? Promise.resolve({
|
||||
context: { contextWindow: options.contextWindow ?? 128_000 },
|
||||
}))
|
||||
return {
|
||||
provider,
|
||||
id: model,
|
||||
name: advertised?.name ?? model,
|
||||
...advertised?.description === undefined ? {} : { description: advertised.description },
|
||||
...capabilities,
|
||||
}
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ buffer
|
||||
style 8-8 fg=bright-blue
|
||||
style 38-77 fg=bright-black
|
||||
style 83-83 fg=bright-blue
|
||||
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — Max │ "
|
||||
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — Off │ "
|
||||
style 8-8 fg=bright-blue
|
||||
style 10-64 fg=bright-blue inverse
|
||||
style 83-83 fg=bright-blue
|
||||
|
||||
@@ -8,10 +8,10 @@ buffer
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-pro max • main-session"
|
||||
2| " deepseek-v4-pro off • main-session"
|
||||
style 1-36 dim
|
||||
3| <blank>
|
||||
4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: Max. New steps will use it. "
|
||||
4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: Off. New steps will use it. "
|
||||
style 1-87 fg=bright-black
|
||||
5| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
@@ -19,7 +19,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
7| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
8| "deepseek-v4-pro max /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
8| "deepseek-v4-pro off /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-45 dim
|
||||
style 65-91 dim
|
||||
9-31| <blank>
|
||||
|
||||
@@ -640,12 +640,16 @@ describe('TUI terminal-state snapshots', () => {
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
|
||||
],
|
||||
resolveModelReasoning: () => Promise.resolve({
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
{ id: ReasoningEffortId('max'), name: 'Max' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
resolveModelInfo: () => Promise.resolve({
|
||||
context: { contextWindow: 128_000 },
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('off'), name: 'Off' },
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
{ id: ReasoningEffortId('max'), name: 'Max' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}, { columns: 92, rows: 32 })
|
||||
@@ -657,6 +661,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('\x1b[B')
|
||||
harness.terminal.send('\x1b[Z')
|
||||
harness.terminal.send('\x1b[Z')
|
||||
})
|
||||
await checkpoint('model-effort-switching', harness.terminal, { includeScrollback: true })
|
||||
await renderAfter(harness, () => {
|
||||
|
||||
@@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
ReasoningEffortId,
|
||||
type LlmCallConfig,
|
||||
type LlmModelReasoningInfo,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
@@ -143,7 +147,11 @@ function provideLlmCatalog(ctx: Context): void {
|
||||
ctx.provide('llm', {
|
||||
listProviders: () => [],
|
||||
listModels: () => Promise.resolve([]),
|
||||
resolveModelContext: () => Promise.resolve(undefined),
|
||||
resolveModelInfo: (provider: string, model: string) => Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
}),
|
||||
} as never)
|
||||
}
|
||||
|
||||
@@ -1155,7 +1163,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('restored answer')
|
||||
expect(result.terminal.output).toContain('write tests')
|
||||
expect(result.terminal.output).toContain('↑1.3k ↓42')
|
||||
// Context resolution is async (resolveModelContext); settle before reading.
|
||||
// Exact model resolution is async; settle before reading.
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('42% context tools:collapsed')
|
||||
// Narrow terminals clip the right-hand context/tools segment first; the
|
||||
@@ -1741,7 +1749,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelContext: () => Promise.resolve(undefined),
|
||||
resolveModelInfo: () => Promise.resolve({}),
|
||||
},
|
||||
})
|
||||
result.terminal.send('/status')
|
||||
@@ -2288,6 +2296,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
|
||||
it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
|
||||
const initialContext = Promise.withResolvers<{ contextWindow: number }>()
|
||||
let deferInitialContext = true
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'alpha', model: 'a1' },
|
||||
contextTokens: 50,
|
||||
@@ -2299,37 +2308,41 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
||||
{ provider: 'beta', id: 'shared', name: 'Beta Shared' },
|
||||
],
|
||||
resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1'
|
||||
? initialContext.promise
|
||||
: Promise.resolve({ contextWindow: 200 }),
|
||||
resolveModelReasoning: (provider, model) => {
|
||||
async resolveModelInfo(provider, model) {
|
||||
const shouldDeferContext = provider === 'alpha' && model === 'a1' && deferInitialContext
|
||||
if (shouldDeferContext) deferInitialContext = false
|
||||
const context = shouldDeferContext
|
||||
? await initialContext.promise
|
||||
: { contextWindow: 200 }
|
||||
let reasoning: LlmModelReasoningInfo | undefined
|
||||
if (model === 'a1') {
|
||||
return Promise.resolve({
|
||||
reasoning = {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('low'), name: 'Low' },
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('low'),
|
||||
})
|
||||
}
|
||||
if (model === 'b1') {
|
||||
return Promise.resolve({
|
||||
}
|
||||
} else if (model === 'b1') {
|
||||
reasoning = {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
{ id: ReasoningEffortId('max'), name: 'Max' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
})
|
||||
}
|
||||
if (provider === 'alpha' && model === 'shared') {
|
||||
return Promise.resolve({
|
||||
}
|
||||
} else if (provider === 'alpha' && model === 'shared') {
|
||||
reasoning = {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('standard'), name: 'Standard' },
|
||||
{ id: ReasoningEffortId('ultra'), name: 'Ultra' },
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
context,
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2522,7 +2535,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||||
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
||||
resolveModelContext: () => Promise.resolve(undefined),
|
||||
resolveModelInfo: () => Promise.resolve({}),
|
||||
},
|
||||
})
|
||||
unset.terminal.send('/model')
|
||||
@@ -2554,7 +2567,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [],
|
||||
listModels: () => Promise.reject(new Error('catalog offline')),
|
||||
resolveModelContext: () => Promise.reject(new Error('capacity offline')),
|
||||
resolveModelInfo: () => Promise.reject(new Error('capacity offline')),
|
||||
},
|
||||
})
|
||||
failed.terminal.send('/model')
|
||||
@@ -2569,7 +2582,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
catalog: {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [{ provider: 'deepseek', id: 'model-1', name: 'Model One' }],
|
||||
resolveModelReasoning: () => Promise.reject(new Error('reasoning metadata offline')),
|
||||
resolveModelInfo: () => Promise.reject(new Error('reasoning metadata offline')),
|
||||
},
|
||||
})
|
||||
reasoningFailed.terminal.send('/model')
|
||||
@@ -2619,7 +2632,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
catalog: {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [],
|
||||
resolveModelContext: () => context.promise,
|
||||
resolveModelInfo: () => context.promise.then(value => ({ context: value })),
|
||||
},
|
||||
})
|
||||
await contextResult.controller.dispose()
|
||||
|
||||
Reference in New Issue
Block a user