feat(llm): add model-specific reasoning effort controls
This commit is contained in:
@@ -344,6 +344,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
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, ): 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 * @returns detached reasoning metadata, or `undefined` when unsupported.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveCallConfig(config: LlmCallConfig): Promise<LlmCallConfig>',
|
||||
jsDoc: '/**\n * Validate a conversation call config against its exact model capability and\n * materialize an adapter-configured default. Unsupported explicit efforts\n * reject before provider I/O; no clamping or aliasing is performed.\n * @param config - provider/model route and optional request controls.\n * @returns a detached config only when a default must be materialized.\n */',
|
||||
},
|
||||
{
|
||||
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, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * 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 */',
|
||||
@@ -1455,7 +1463,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'GenerateOptions',
|
||||
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
|
||||
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
|
||||
},
|
||||
{
|
||||
name: 'GenericCallView',
|
||||
@@ -1527,7 +1535,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'LlmCallConfig',
|
||||
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
|
||||
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmFailure',
|
||||
@@ -1541,10 +1549,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'LlmModelInfo',
|
||||
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmModelReasoningInfo',
|
||||
declaration: 'export interface LlmModelReasoningInfo {\n efforts: readonly LlmReasoningEffortInfo[];\n defaultEffort?: ReasoningEffortId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmProviderInfo',
|
||||
declaration: 'export interface LlmProviderInfo {\n id: string;\n name: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmReasoningEffortInfo',
|
||||
declaration: 'export interface LlmReasoningEffortInfo {\n id: ReasoningEffortId;\n name: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'Message',
|
||||
declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n}',
|
||||
@@ -1689,6 +1705,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ReasoningBlock',
|
||||
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ReasoningEffortId',
|
||||
declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'ResumeAgentOptions',
|
||||
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
|
||||
@@ -60,6 +60,8 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
|
||||
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
||||
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.resolveCallConfig()` to validate any adapter-owned reasoning effort and materialize its configured default. The effective config is logged in the full `request/header` before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
|
||||
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
|
||||
|
||||
@@ -621,19 +621,43 @@ async function runStep(
|
||||
|
||||
// Seed the first request from agent options and later requests from the logged header;
|
||||
// detach and freeze so listeners must return an attributable replacement.
|
||||
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
|
||||
? session.requestHeader()!.config
|
||||
: { provider: options.provider ?? '', model: options.model ?? '' }))
|
||||
const loggedConfig = session.requestHeader()?.config
|
||||
const initialProvider = options.provider ?? ''
|
||||
const initialModel = options.model ?? ''
|
||||
const initialConfig: LlmCallConfig = {
|
||||
provider: initialProvider,
|
||||
model: initialModel,
|
||||
...loggedConfig?.provider === initialProvider
|
||||
&& loggedConfig.model === initialModel
|
||||
&& loggedConfig.reasoningEffort !== undefined
|
||||
? { reasoningEffort: loggedConfig.reasoningEffort }
|
||||
: {},
|
||||
}
|
||||
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(
|
||||
transmission.loggedHeader
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
|
||||
? session.requestHeader()!.config
|
||||
: initialConfig,
|
||||
))
|
||||
|
||||
// Listener replacements are recorded in the request header before dispatch.
|
||||
const config = await events.waterfall(
|
||||
const proposedConfig = await events.waterfall(
|
||||
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
if (!config.provider || !config.model) {
|
||||
if (!proposedConfig.provider || !proposedConfig.model) {
|
||||
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
|
||||
}
|
||||
let config: LlmCallConfig
|
||||
try {
|
||||
config = await ctx.llm.resolveCallConfig(proposedConfig)
|
||||
} catch (error: unknown) {
|
||||
// A waterfall listener may own and short-circuit a route with no adapter.
|
||||
// Terminal dispatch still raises NO_ADAPTER when no listener handles it.
|
||||
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
|
||||
config = proposedConfig
|
||||
}
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
|
||||
const sessionPrefix = transmission.sessionPrefix!
|
||||
@@ -651,6 +675,9 @@ async function runStep(
|
||||
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
|
||||
provider: header.config.provider,
|
||||
model: header.config.model,
|
||||
...header.config.reasoningEffort !== undefined
|
||||
? { reasoningEffort: header.config.reasoningEffort }
|
||||
: {},
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
...header.system !== undefined ? { system: header.system } : {},
|
||||
...header.tools !== undefined ? { tools: header.tools } : {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Helpers to write scripted responses tersely. */
|
||||
@@ -64,10 +64,20 @@ export function toolCallResponse(rawCallId: string, name: string, args: object,
|
||||
export class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[]) {
|
||||
constructor(
|
||||
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[],
|
||||
private readonly reasoning?: LlmModelReasoningInfo,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
return Promise.resolve(this.reasoning)
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -104,6 +104,81 @@ describe('request stability across the loop', () => {
|
||||
expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
|
||||
})
|
||||
|
||||
it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => {
|
||||
const reasoning = {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
{ id: ReasoningEffortId('max'), name: 'Max' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
}
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning)
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request', async (_agent, turn, _step, _config, _signal, next) => {
|
||||
const config = await next()
|
||||
return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests.map(request => request.reasoningEffort)).toEqual([
|
||||
ReasoningEffortId('high'),
|
||||
ReasoningEffortId('max'),
|
||||
])
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([
|
||||
ReasoningEffortId('high'),
|
||||
ReasoningEffortId('max'),
|
||||
])
|
||||
expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change'])
|
||||
|
||||
const resumedAdapter = new MockAdapter([textResponse('three')], reasoning)
|
||||
const resumedCtx = await harness(resumedAdapter)
|
||||
const resumedHandle = await resumedCtx.agents.create({
|
||||
sessionId: SessionId('effort-resumed'),
|
||||
seed: structuredClone(agent.session.events),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
send(resumedHandle.agent, 'third')
|
||||
await waitForIdle(resumedCtx, resumedHandle.agent)
|
||||
|
||||
expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(ReasoningEffortId('max'))
|
||||
const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('max'))
|
||||
expect(resumedHeaders.at(-1)?.data.reason).toBe('resume')
|
||||
})
|
||||
|
||||
it.each(['plain error', 'LLM error'] as const)(
|
||||
'does not swallow a %s from reasoning 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> {
|
||||
return Promise.reject(failure)
|
||||
}
|
||||
}([])
|
||||
const ctx = await harness(adapter)
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors).toContain(failure)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('a compaction replace rewrites the resend, and the log explains it', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -301,6 +376,7 @@ describe('request stability across the loop', () => {
|
||||
const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
|
||||
const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
|
||||
expect(request.model).toBe(header.config.model)
|
||||
expect(request.reasoningEffort).toBe(header.config.reasoningEffort)
|
||||
expect(request.system).toEqual(header.system)
|
||||
expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? []))
|
||||
expect(request.temperature).toBe(header.config.temperature)
|
||||
|
||||
@@ -181,6 +181,11 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
|
||||
const header = record['header']
|
||||
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
|
||||
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
|
||||
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
|
||||
if (reasoningEffort !== undefined
|
||||
&& (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
|
||||
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
|
||||
}
|
||||
}
|
||||
if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) {
|
||||
throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`)
|
||||
|
||||
@@ -156,7 +156,7 @@ export interface TodoItem {
|
||||
* canonical empty optional fields are absent.
|
||||
*/
|
||||
export interface EpochHeader {
|
||||
/** The conversation's call configuration (provider, model, and sampling scalars). */
|
||||
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
|
||||
config: LlmCallConfig
|
||||
/** Rendered system prompt text; absent for a system-less request. */
|
||||
system?: string
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
const CONFIG = { provider: 'mock', model: 'm' }
|
||||
|
||||
@@ -29,6 +30,10 @@ describe('headerEquals', () => {
|
||||
it('compares every canonical field and preserves tool order', () => {
|
||||
expect(headerEquals(base, structuredClone(base))).toBe(true)
|
||||
expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false)
|
||||
expect(headerEquals(base, {
|
||||
...base,
|
||||
config: { ...base.config, reasoningEffort: ReasoningEffortId('high') },
|
||||
})).toBe(false)
|
||||
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
|
||||
expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false)
|
||||
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, {
|
||||
displayPromptContent,
|
||||
findLastMessageTurnEnd,
|
||||
@@ -118,6 +118,11 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('renders context and steering messages as plain user content', () => {
|
||||
expect(displayPromptContent({
|
||||
content: [{ type: 'text', text: 'plain prompt' }],
|
||||
source: { kind: 'user' },
|
||||
})).toEqual([{ type: 'text', text: 'plain prompt' }])
|
||||
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
@@ -228,6 +233,35 @@ describe('Session', () => {
|
||||
.toEqual([unrelatedPrimitiveData])
|
||||
})
|
||||
|
||||
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
|
||||
const valid = {
|
||||
type: 'request/header',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: {
|
||||
header: {
|
||||
config: {
|
||||
provider: 'mock',
|
||||
model: 'model',
|
||||
reasoningEffort: ReasoningEffortId('adapter-owned'),
|
||||
},
|
||||
},
|
||||
reason: 'initial',
|
||||
},
|
||||
} as const
|
||||
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
|
||||
.toEqual(valid)
|
||||
|
||||
for (const reasoningEffort of ['', 1]) {
|
||||
const invalid = structuredClone(valid) as unknown as SessionEvent
|
||||
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
|
||||
const config = invalid.data.header.config as unknown as Record<string, unknown>
|
||||
config.reasoningEffort = reasoningEffort
|
||||
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
|
||||
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates the log from mutation through a derived message (append-only contract)', () => {
|
||||
const session = new Session(SessionId('s4'))
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
|
||||
@@ -15,7 +15,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
|
||||
reasoningEffort: high # optional; high | max — omitted ⇒ high
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
|
||||
models: # optional; defaults to V4 Flash and V4 Pro
|
||||
@@ -30,9 +30,9 @@ The plugin registers the single provider route `deepseek`. A request selects it
|
||||
|
||||
`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')`.
|
||||
|
||||
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
|
||||
`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.
|
||||
|
||||
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults.
|
||||
`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.
|
||||
|
||||
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy.
|
||||
|
||||
@@ -79,7 +79,7 @@ Reasoning, text, and raw-string tool arguments are translated into harness chunk
|
||||
|
||||
#### Token effect
|
||||
|
||||
Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input.
|
||||
Generated tokens follow the request's logged reasoning effort and `maxTokens`; only loop-retained blocks affect later input.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
|
||||
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
||||
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,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
@@ -51,6 +52,12 @@ export interface DeepSeekAdapterOptions {
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
||||
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
|
||||
const HIGH_REASONING_EFFORT = ReasoningEffortId('high')
|
||||
const MAX_REASONING_EFFORT = ReasoningEffortId('max')
|
||||
const REASONING_EFFORTS = [
|
||||
{ id: HIGH_REASONING_EFFORT, name: 'High' },
|
||||
{ id: MAX_REASONING_EFFORT, name: 'Max' },
|
||||
] as const
|
||||
|
||||
function providerRetryAfterMs(value: string | null): number | undefined {
|
||||
if (value === null) return undefined
|
||||
@@ -98,6 +105,9 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
|
||||
constructor(private readonly options: DeepSeekAdapterOptions) {
|
||||
super()
|
||||
if (options.defaults?.thinking === 'disabled' && options.defaults.reasoningEffort !== undefined) {
|
||||
throw new Error('llm-deepseek: reasoningEffort cannot be configured when thinking is disabled')
|
||||
}
|
||||
if (options.defaultContextWindow !== undefined
|
||||
&& (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) {
|
||||
throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')
|
||||
@@ -134,6 +144,19 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
|
||||
}
|
||||
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
if (this.options.defaults?.thinking === 'disabled') return Promise.resolve(undefined)
|
||||
return Promise.resolve({
|
||||
efforts: REASONING_EFFORTS,
|
||||
defaultEffort: this.options.defaults?.reasoningEffort === 'max'
|
||||
? MAX_REASONING_EFFORT
|
||||
: HIGH_REASONING_EFFORT,
|
||||
})
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const consumer = new AbortController()
|
||||
const upstream = options.signal === undefined
|
||||
|
||||
@@ -28,8 +28,9 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema. Every field
|
||||
* is optional in yml: credentials/endpoint fall back to the environment (a
|
||||
* missing API key fails plugin load, not the first call), and omitted
|
||||
* thinking fields send nothing on the wire, so the provider default applies.
|
||||
* missing API key fails plugin load, not the first call), omitted thinking
|
||||
* mode uses the provider default, and omitted reasoning effort resolves to
|
||||
* `high`.
|
||||
*/
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
@@ -38,7 +39,7 @@ export interface Config {
|
||||
baseURL?: string
|
||||
/** Thinking-mode default for every request (provider default: enabled). */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Thinking effort (only meaningful with thinking enabled). */
|
||||
/** Default thinking effort when thinking is enabled (default `high`). */
|
||||
reasoningEffort?: 'high' | 'max'
|
||||
/** Positive context capacity used when the selected model has no exact value. */
|
||||
defaultContextWindow?: number
|
||||
@@ -94,6 +95,9 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
if (config.thinking === 'disabled' && config.reasoningEffort !== undefined) {
|
||||
throw new Error('llm-deepseek: reasoningEffort cannot be configured when thinking is disabled')
|
||||
}
|
||||
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* @module dsh-llm-deepseek/serialize
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { WireMessage, WireRequest, WireTool } from './types.ts'
|
||||
|
||||
@@ -15,6 +16,17 @@ export interface RequestDefaults {
|
||||
reasoningEffort?: 'high' | 'max' | undefined
|
||||
}
|
||||
|
||||
/** Validate the adapter-owned effort before assigning its narrower wire type. */
|
||||
function reasoningEffort(options: GenerateOptions): 'high' | 'max' | undefined {
|
||||
const effort = options.reasoningEffort
|
||||
if (effort === undefined) return undefined
|
||||
if (effort === 'high' || effort === 'max') return effort as 'high' | 'max'
|
||||
throw new LlmError(
|
||||
`DeepSeek does not support reasoning effort "${effort}"`,
|
||||
'UNSUPPORTED_REASONING_EFFORT',
|
||||
)
|
||||
}
|
||||
|
||||
/** Join the text blocks of a message (used for user/tool-result content). */
|
||||
function flattenText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
@@ -121,7 +133,9 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
|
||||
// A short title budget must produce visible text; conversation and
|
||||
// compaction calls continue to inherit the adapter's thinking defaults.
|
||||
const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking
|
||||
const reasoningEffort = options.purpose === 'session-title' ? undefined : defaults.reasoningEffort
|
||||
const resolvedReasoningEffort = options.purpose === 'session-title'
|
||||
? undefined
|
||||
: reasoningEffort(options)
|
||||
|
||||
return {
|
||||
model: options.model,
|
||||
@@ -129,7 +143,7 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...thinking !== undefined ? { thinking: { type: thinking } } : {},
|
||||
...reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {},
|
||||
...resolvedReasoningEffort !== undefined ? { reasoning_effort: resolvedReasoningEffort } : {},
|
||||
...tools !== undefined && tools.length > 0 ? { tools } : {},
|
||||
...options.temperature !== undefined ? { temperature: options.temperature } : {},
|
||||
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -80,11 +80,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
|
||||
it.each(['high', 'max'] as const)(
|
||||
'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback',
|
||||
async (effort) => {
|
||||
const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort })
|
||||
const ctx = await harness(PRO, { thinking: 'enabled' })
|
||||
|
||||
// Turn 1: the model must call the tool (and think before it).
|
||||
const first = await assemble(ctx,{
|
||||
model: PRO,
|
||||
reasoningEffort: ReasoningEffortId(effort),
|
||||
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
|
||||
tools: [weatherTool],
|
||||
maxTokens: 2000,
|
||||
@@ -99,6 +100,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
|
||||
// block in history (the official thinking+tools passback rule).
|
||||
const second = await assemble(ctx,{
|
||||
model: PRO,
|
||||
reasoningEffort: ReasoningEffortId(effort),
|
||||
messages: [
|
||||
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
|
||||
{ role: 'assistant', content: first.message.content },
|
||||
|
||||
@@ -8,6 +8,7 @@ import LlmService, {
|
||||
LlmError,
|
||||
ProviderRequestId,
|
||||
QUOTA_EXCEEDED_CODE,
|
||||
ReasoningEffortId,
|
||||
userAgent,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
@@ -120,6 +121,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
// The wire request carried the auth header contents we configured.
|
||||
expect(server.requests[0]).toMatchObject({
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoning_effort: 'high',
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
@@ -173,9 +175,35 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1')
|
||||
})
|
||||
|
||||
it('forwards thinking config onto the wire', async () => {
|
||||
it('forwards the configured reasoning default and a dynamic request override', async () => {
|
||||
const server = await mockServer([
|
||||
{ kind: 'sse', events: textEvents },
|
||||
{ kind: 'sse', events: textEvents },
|
||||
])
|
||||
const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'max' })
|
||||
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }],
|
||||
})
|
||||
expect(server.requests[0]).toMatchObject({
|
||||
thinking: { type: 'enabled' },
|
||||
reasoning_effort: 'max',
|
||||
})
|
||||
expect(server.requests[1]).toMatchObject({
|
||||
thinking: { type: 'enabled' },
|
||||
reasoning_effort: 'high',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits reasoning capability and effort when thinking is disabled', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
|
||||
const ctx = await harness(server.url, { thinking: 'disabled' })
|
||||
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
@@ -183,8 +211,22 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
})
|
||||
expect(server.requests[0]).toMatchObject({
|
||||
thinking: { type: 'disabled' },
|
||||
reasoning_effort: 'high',
|
||||
})
|
||||
expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
|
||||
await expect(ctx.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a per-request effort before I/O when thinking is disabled', async () => {
|
||||
const server = await mockServer([])
|
||||
const ctx = await harness(server.url, { thinking: 'disabled' })
|
||||
|
||||
await expect(assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
|
||||
expect(server.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -533,6 +575,34 @@ describe('plugin registration and config', () => {
|
||||
])
|
||||
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'),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a configured reasoning effort when thinking is disabled', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
thinking: 'disabled',
|
||||
reasoningEffort: 'high',
|
||||
})).rejects.toThrow(/reasoningEffort cannot be configured/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a disabled-thinking effort at the direct constructor boundary', () => {
|
||||
expect(() => new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaults: { thinking: 'disabled', reasoningEffort: 'high' },
|
||||
})).toThrow(/reasoningEffort cannot be configured/)
|
||||
})
|
||||
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeMessages, serializeRequest } from '../src/serialize.ts'
|
||||
|
||||
@@ -174,15 +174,22 @@ describe('serializeRequest', () => {
|
||||
expect(wire.tools).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies adapter defaults for thinking and effort', () => {
|
||||
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled', reasoningEffort: 'max' })
|
||||
it('maps adapter-default thinking and the request reasoning effort', () => {
|
||||
const wire = serializeRequest(
|
||||
request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
|
||||
{ thinking: 'enabled', reasoningEffort: 'high' },
|
||||
)
|
||||
expect(wire.thinking).toEqual({ type: 'enabled' })
|
||||
expect(wire.reasoning_effort).toBe('max')
|
||||
})
|
||||
|
||||
it('disables thinking for session-title requests without changing adapter defaults', () => {
|
||||
const wire = serializeRequest(
|
||||
request({ messages: history, purpose: 'session-title' }),
|
||||
request({
|
||||
messages: history,
|
||||
purpose: 'session-title',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
}),
|
||||
{ thinking: 'enabled', reasoningEffort: 'max' },
|
||||
)
|
||||
expect(wire.thinking).toEqual({ type: 'disabled' })
|
||||
@@ -194,6 +201,13 @@ describe('serializeRequest', () => {
|
||||
expect(wire.thinking).toBeUndefined()
|
||||
expect(wire.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an effort outside the DeepSeek capability', () => {
|
||||
expect(() => serializeRequest(request({
|
||||
messages: history,
|
||||
reasoningEffort: ReasoningEffortId('medium'),
|
||||
}))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: assistant content shapes', () => {
|
||||
|
||||
@@ -30,6 +30,8 @@ Each provider name must exist in pi-ai's installed catalog and may appear only o
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
|
||||
@@ -47,6 +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`.
|
||||
- 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.
|
||||
- `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers.
|
||||
|
||||
## App attribution
|
||||
|
||||
@@ -7,13 +7,27 @@
|
||||
import { streamSimple } from '@earendil-works/pi-ai/compat'
|
||||
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
|
||||
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai'
|
||||
import type {
|
||||
Api,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
ThinkingLevel,
|
||||
} from '@earendil-works/pi-ai'
|
||||
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
attributionHeaders,
|
||||
LlmAdapter,
|
||||
LlmError,
|
||||
ReasoningEffortId,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmModelContext,
|
||||
LlmModelInfo,
|
||||
LlmModelReasoningInfo,
|
||||
ReasoningEffortId as ReasoningEffortIdType,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolveProfiles } from './config.ts'
|
||||
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
|
||||
@@ -39,10 +53,13 @@ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model<Api>
|
||||
}
|
||||
|
||||
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
|
||||
function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions {
|
||||
function profileOptions(
|
||||
profile: PiAiProviderProfile,
|
||||
reasoning: ThinkingLevel | undefined,
|
||||
): SimpleStreamOptions {
|
||||
return {
|
||||
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
|
||||
...profile.reasoning === undefined ? {} : { reasoning: profile.reasoning },
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets },
|
||||
...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention },
|
||||
...profile.transport === undefined ? {} : { transport: profile.transport },
|
||||
@@ -53,6 +70,25 @@ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (effort === undefined) return undefined
|
||||
const supported = supportedReasoningLevels(model)
|
||||
if (supported.some(level => level === effort)) return effort as ThinkingLevel
|
||||
throw new LlmError(
|
||||
`pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`,
|
||||
'UNSUPPORTED_REASONING_EFFORT',
|
||||
)
|
||||
}
|
||||
|
||||
/** Merge deployment headers while removing case-insensitive attribution collisions. */
|
||||
function requestHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
|
||||
const attribution = attributionHeaders()
|
||||
@@ -103,6 +139,37 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
}))
|
||||
}
|
||||
|
||||
override resolveModelReasoning(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<LlmModelReasoningInfo | 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(() => {
|
||||
const resolvedModel = resolveModel(profile, model)
|
||||
const levels = supportedReasoningLevels(resolvedModel)
|
||||
if (levels.length === 0) {
|
||||
resolveReasoningLevel(resolvedModel, profile.reasoning)
|
||||
return undefined
|
||||
}
|
||||
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) },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.stop !== undefined) {
|
||||
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
|
||||
@@ -112,6 +179,10 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
|
||||
}
|
||||
const model = resolveModel(profile, options.model)
|
||||
const reasoning = resolveReasoningLevel(
|
||||
model,
|
||||
options.reasoningEffort ?? profile.reasoning,
|
||||
)
|
||||
|
||||
const consumer = new AbortController()
|
||||
const upstream = options.signal === undefined
|
||||
@@ -122,7 +193,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
|
||||
try {
|
||||
const events = streamSimple(model, toPiContext(options), {
|
||||
...profileOptions(profile),
|
||||
...profileOptions(profile, reasoning),
|
||||
...options.temperature === undefined ? {} : { temperature: options.temperature },
|
||||
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
|
||||
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
@@ -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/xhigh reasoning. Mirrors the native
|
||||
* defaults and representative high/max reasoning. Mirrors the native
|
||||
* adapter's StreamChunk contract and exercises a replayed tool follow-up.
|
||||
* Key-gated.
|
||||
*/
|
||||
@@ -75,9 +75,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
|
||||
})
|
||||
|
||||
it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => {
|
||||
const ctx = await harness(model, { reasoning: 'high' })
|
||||
const ctx = await harness(model)
|
||||
const result = await assemble(ctx,{
|
||||
model,
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
|
||||
maxTokens: 2000,
|
||||
})
|
||||
@@ -86,11 +87,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
|
||||
expect(textOf(result)).toContain('9.8')
|
||||
})
|
||||
|
||||
it('pro + reasoning xhigh (wire max): tool-call round trip', async () => {
|
||||
const ctx = await harness(PRO, { reasoning: 'xhigh' })
|
||||
it('pro + reasoning max: tool-call round trip', async () => {
|
||||
const ctx = await harness(PRO)
|
||||
|
||||
const first = await assemble(ctx,{
|
||||
model: PRO,
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
|
||||
tools: [weatherTool],
|
||||
maxTokens: 2000,
|
||||
@@ -103,6 +105,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
|
||||
|
||||
const second = await assemble(ctx,{
|
||||
model: PRO,
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
messages: [
|
||||
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
|
||||
first.message,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
@@ -124,7 +124,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
it('forwards common stream options and profile reasoning', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url, {
|
||||
reasoning: 'xhigh',
|
||||
reasoning: 'max',
|
||||
cacheRetention: 'none',
|
||||
transport: 'sse',
|
||||
timeoutMs: 5000,
|
||||
@@ -148,6 +148,25 @@ 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 ctx = await harness(server.url, { reasoning: 'max' })
|
||||
|
||||
await assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
messages: [],
|
||||
})
|
||||
expect(server.requests[0]).toMatchObject({ reasoning_effort: 'high' })
|
||||
|
||||
await expect(assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: ReasoningEffortId('xhigh'),
|
||||
messages: [],
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
|
||||
expect(server.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves omitted profile options when constructing the adapter directly', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = new Context()
|
||||
@@ -327,6 +346,51 @@ describe('provider profile lifecycle', () => {
|
||||
expect(typeof context?.contextWindow).toBe('number')
|
||||
})
|
||||
|
||||
it('exposes model-specific reasoning levels without off or an invented 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' },
|
||||
],
|
||||
})
|
||||
const extended = await ctx.llm.resolveModelReasoning('openai', 'gpt-5.6-sol')
|
||||
expect(extended?.efforts.map(effort => effort.id)).toEqual([
|
||||
ReasoningEffortId('minimal'),
|
||||
ReasoningEffortId('low'),
|
||||
ReasoningEffortId('medium'),
|
||||
ReasoningEffortId('high'),
|
||||
ReasoningEffortId('xhigh'),
|
||||
ReasoningEffortId('max'),
|
||||
])
|
||||
await expect(ctx.llm.resolveModelReasoning('openai', 'gpt-4.1'))
|
||||
.resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => {
|
||||
const supported = new Context()
|
||||
await supported.plugin(LlmService)
|
||||
await supported.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'deepseek', reasoning: 'max' }],
|
||||
})
|
||||
await expect(supported.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toMatchObject({ 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'))
|
||||
.rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
|
||||
})
|
||||
|
||||
it('accepts absent credentials for pi-ai ambient authentication', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
@@ -378,6 +442,8 @@ describe('provider profile lifecycle', () => {
|
||||
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
await expect(adapter.resolveModelContext('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'))
|
||||
.rejects.toMatchObject({ code: 'UNKNOWN_MODEL' })
|
||||
await expect((async () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
- `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): Promise<LlmModelReasoningInfo | undefined>` Resolve ordered adapter-owned reasoning efforts and an optional deployment default for one exact route.
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig): Promise<LlmCallConfig>` Validate an explicit effort and materialize an adapter-configured default without clamping.
|
||||
- `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`.
|
||||
|
||||
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
|
||||
@@ -20,6 +22,8 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re
|
||||
|
||||
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`.
|
||||
|
||||
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. Invalid capability metadata fails with `INVALID_MODEL_REASONING`; an unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
@@ -28,7 +32,7 @@ Context capacity is a separate correctness query, not a catalog decoration or gl
|
||||
|
||||
### 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, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity.
|
||||
- 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; the defaults use the route id as its name, advertise no models, and return neither capacity nor 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`)
|
||||
@@ -39,7 +43,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
|
||||
`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `resolveCallConfig()` validates and defaults it, and the loop logs the effective value before dispatch. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
|
||||
|
||||
### App attribution (`attribution.ts`)
|
||||
|
||||
@@ -61,7 +65,7 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message.
|
||||
None, as the service adds no model-bound text, schema, or message; it only materializes and logs an adapter-configured reasoning effort.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -38,3 +38,15 @@ export type ProviderRequestId = Branded<'ProviderRequestId'>
|
||||
export function ProviderRequestId(id: string): ProviderRequestId {
|
||||
return id as ProviderRequestId
|
||||
}
|
||||
|
||||
/** Adapter-owned identifier for one model's selectable reasoning effort. */
|
||||
export type ReasoningEffortId = Branded<'ReasoningEffortId'>
|
||||
|
||||
/**
|
||||
* Brand an adapter-owned reasoning-effort identifier.
|
||||
* @param id - the opaque identifier exposed by one model capability.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function ReasoningEffortId(id: string): ReasoningEffortId {
|
||||
return id as ReasoningEffortId
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
/**
|
||||
* Conversation call configuration and freeze utilities. Provider routing,
|
||||
* model, and sampling values are request-header state that can affect cache
|
||||
* reuse; request waterfalls replace them and the loop logs changed snapshots
|
||||
* instead of allowing silent per-call drift.
|
||||
* model, reasoning effort, and sampling values are request-header state that
|
||||
* can affect cache reuse; request waterfalls replace them and the loop logs
|
||||
* changed snapshots instead of allowing silent per-call drift.
|
||||
* @module dsh-llm/call-config
|
||||
*/
|
||||
|
||||
import type { GenerateOptions } from './types.ts'
|
||||
import type { ReasoningEffortId } from './brand.ts'
|
||||
|
||||
/** Process-local identities of request objects assembled by dsh-agent-loop. */
|
||||
const AGENT_LOOP_REQUESTS = new WeakSet<GenerateOptions>()
|
||||
|
||||
/**
|
||||
* Provider + model + sampling scalars of one conversation's requests. Every field maps
|
||||
* 1:1 onto the same-named `GenerateOptions` field; the loop builds requests
|
||||
* from the logged header rather than accepting these per call.
|
||||
* Provider, model, reasoning effort, and sampling scalars of one conversation's
|
||||
* requests. Every field maps 1:1 onto the same-named `GenerateOptions` field;
|
||||
* the loop builds requests from the logged header rather than accepting these
|
||||
* per call.
|
||||
*/
|
||||
export interface LlmCallConfig {
|
||||
provider: string
|
||||
model: string
|
||||
reasoningEffort?: ReasoningEffortId
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
stop?: string[]
|
||||
@@ -33,7 +36,13 @@ export interface LlmCallConfig {
|
||||
* @returns whether every field (including the `stop` list, element-wise) matches.
|
||||
*/
|
||||
export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean {
|
||||
if (a.provider !== b.provider || a.model !== b.model || a.temperature !== b.temperature || a.maxTokens !== b.maxTokens) return false
|
||||
if (
|
||||
a.provider !== b.provider
|
||||
|| a.model !== b.model
|
||||
|| a.reasoningEffort !== b.reasoningEffort
|
||||
|| a.temperature !== b.temperature
|
||||
|| a.maxTokens !== b.maxTokens
|
||||
) return false
|
||||
if (a.stop === undefined || b.stop === undefined) return a.stop === b.stop
|
||||
return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i])
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@ import type {
|
||||
LlmFailure,
|
||||
LlmModelContext,
|
||||
LlmModelInfo,
|
||||
LlmModelReasoningInfo,
|
||||
LlmProviderInfo,
|
||||
Message,
|
||||
StreamChunk,
|
||||
} from './types.ts'
|
||||
import type { ProviderRequestId } from './brand.ts'
|
||||
import { deepFreeze } from './call-config.ts'
|
||||
import { callConfigEquals, deepFreeze } from './call-config.ts'
|
||||
import type { LlmCallConfig } from './call-config.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts'
|
||||
import type { AdapterFailureScope } from './adapter-failure.ts'
|
||||
@@ -144,6 +146,20 @@ export abstract class LlmAdapter {
|
||||
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}.
|
||||
* @returns adapter-owned effort metadata, or `undefined` when unsupported.
|
||||
*/
|
||||
resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one model call as raw chunks. The only required method.
|
||||
* @param options - the fully-assembled request; implementations must honor `options.signal`.
|
||||
@@ -262,6 +278,90 @@ export class LlmService extends Service {
|
||||
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.
|
||||
* @returns detached reasoning metadata, or `undefined` when unsupported.
|
||||
*/
|
||||
async resolveModelReasoning(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
const reasoning = await this.registration(provider).adapter.resolveModelReasoning(provider, model)
|
||||
if (reasoning === undefined) return undefined
|
||||
if (reasoning.efforts.length === 0) {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid reasoning metadata for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_REASONING',
|
||||
)
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const efforts = reasoning.efforts.map((effort) => {
|
||||
if (
|
||||
typeof effort.id !== 'string'
|
||||
|| effort.id.length === 0
|
||||
|| typeof effort.name !== 'string'
|
||||
|| effort.name.length === 0
|
||||
|| (effort.description !== undefined && typeof effort.description !== 'string')
|
||||
|| seen.has(effort.id)
|
||||
) {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid or duplicate reasoning effort metadata for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_REASONING',
|
||||
)
|
||||
}
|
||||
seen.add(effort.id)
|
||||
return {
|
||||
id: effort.id,
|
||||
name: effort.name,
|
||||
...effort.description === undefined ? {} : { description: effort.description },
|
||||
}
|
||||
})
|
||||
if (reasoning.defaultEffort !== undefined && !seen.has(reasoning.defaultEffort)) {
|
||||
throw new LlmError(
|
||||
`adapter returned an unknown default reasoning effort for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_REASONING',
|
||||
)
|
||||
}
|
||||
return {
|
||||
efforts,
|
||||
...reasoning.defaultEffort === undefined ? {} : { defaultEffort: reasoning.defaultEffort },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a conversation call config against its exact model capability and
|
||||
* materialize an adapter-configured default. Unsupported explicit efforts
|
||||
* reject before provider I/O; no clamping or aliasing is performed.
|
||||
* @param config - provider/model route and optional request controls.
|
||||
* @returns a detached config only when a default must be materialized.
|
||||
*/
|
||||
async resolveCallConfig(config: LlmCallConfig): Promise<LlmCallConfig> {
|
||||
const reasoning = await this.resolveModelReasoning(config.provider, config.model)
|
||||
const requested = config.reasoningEffort
|
||||
if (reasoning === undefined) {
|
||||
if (requested !== undefined) {
|
||||
throw new LlmError(
|
||||
`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${requested}"`,
|
||||
'UNSUPPORTED_REASONING_EFFORT',
|
||||
)
|
||||
}
|
||||
return config
|
||||
}
|
||||
const effective = requested ?? reasoning.defaultEffort
|
||||
if (effective === undefined) return config
|
||||
if (!reasoning.efforts.some(effort => effort.id === effective)) {
|
||||
throw new LlmError(
|
||||
`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`,
|
||||
'UNSUPPORTED_REASONING_EFFORT',
|
||||
)
|
||||
}
|
||||
return requested === effective ? config : { ...config, reasoningEffort: effective }
|
||||
}
|
||||
|
||||
private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } {
|
||||
const registration = this.adapters.get(provider)
|
||||
if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER')
|
||||
@@ -298,8 +398,14 @@ export class LlmService extends Service {
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
let iterator: AsyncIterator<StreamChunk>
|
||||
try {
|
||||
const adapter = this.registration(options.provider).adapter
|
||||
const stream = adapter.stream(this.forAdapter(options, adapter))
|
||||
const resolvedConfig = await this.resolveCallConfig(options)
|
||||
const resolvedOptions = callConfigEquals(options, resolvedConfig)
|
||||
? options
|
||||
: Object.isFrozen(options)
|
||||
? deepFreeze({ ...options, ...resolvedConfig })
|
||||
: { ...options, ...resolvedConfig }
|
||||
const adapter = this.registration(resolvedOptions.provider).adapter
|
||||
const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter))
|
||||
iterator = stream[Symbol.asyncIterator]()
|
||||
} catch (error: unknown) {
|
||||
throw markLlmAdapterFailure(failures, error)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CallId, ProviderRequestId } from './brand.ts'
|
||||
import type { CallId, ProviderRequestId, ReasoningEffortId } from './brand.ts'
|
||||
|
||||
/** Serializable provider-boundary facts; policy decides whether they are retryable. */
|
||||
export interface LlmFailure {
|
||||
@@ -161,6 +161,27 @@ export interface LlmModelContext {
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
/** Display metadata for one adapter-owned reasoning effort. */
|
||||
export interface LlmReasoningEffortInfo {
|
||||
/** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */
|
||||
id: ReasoningEffortId
|
||||
/** Human-readable effort name for selectors and diagnostics. */
|
||||
name: string
|
||||
/** Optional user-facing distinction from otherwise similar efforts. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Selectable reasoning efforts for one exact provider/model route. */
|
||||
export interface LlmModelReasoningInfo {
|
||||
/** Supported efforts in adapter-preferred display order. */
|
||||
efforts: readonly LlmReasoningEffortInfo[]
|
||||
/**
|
||||
* Adapter-configured default materialized into requests when callers omit
|
||||
* an effort. Absence preserves the provider's own default.
|
||||
*/
|
||||
defaultEffort?: ReasoningEffortId
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
* Block indexes correlate interleaved deltas, and `block-end` carries the
|
||||
@@ -201,6 +222,8 @@ export interface GenerateOptions {
|
||||
/** Registered provider route selecting the adapter instance. */
|
||||
provider: string
|
||||
model: string
|
||||
/** Adapter-owned reasoning effort selected for this exact model. */
|
||||
reasoningEffort?: ReasoningEffortId
|
||||
/**
|
||||
* Ordered conversation messages, exactly as the provider sees them (after
|
||||
* the `system` slot). A loop-built request assembles them as
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from '../src/call-config.ts'
|
||||
import { ReasoningEffortId } from '../src/brand.ts'
|
||||
import type { GenerateOptions } from '../src/types.ts'
|
||||
|
||||
describe('callConfigEquals', () => {
|
||||
@@ -14,6 +15,11 @@ describe('callConfigEquals', () => {
|
||||
expect(callConfigEquals(base, base)).toBe(true)
|
||||
expect(callConfigEquals(base, { provider: 'x', model: 'm' })).toBe(false)
|
||||
expect(callConfigEquals(base, { provider: 'p', model: 'x' })).toBe(false)
|
||||
expect(callConfigEquals({ ...base, reasoningEffort: ReasoningEffortId('high') }, base)).toBe(false)
|
||||
expect(callConfigEquals(
|
||||
{ ...base, reasoningEffort: ReasoningEffortId('high') },
|
||||
{ ...base, reasoningEffort: ReasoningEffortId('high') },
|
||||
)).toBe(true)
|
||||
expect(callConfigEquals({ ...base, temperature: 0.5 }, base)).toBe(false)
|
||||
expect(callConfigEquals({ ...base, maxTokens: 1 }, { ...base, maxTokens: 2 })).toBe(false)
|
||||
expect(callConfigEquals({ ...base, stop: ['a'] }, base)).toBe(false)
|
||||
|
||||
@@ -11,9 +11,15 @@ import LlmService, {
|
||||
LlmError,
|
||||
llmFailureOf,
|
||||
ProviderRequestId,
|
||||
ReasoningEffortId,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
LlmModelContext,
|
||||
LlmModelInfo,
|
||||
LlmModelReasoningInfo,
|
||||
LlmProviderInfo,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
constructor(private script: StreamChunk[]) {
|
||||
@@ -49,6 +55,7 @@ class CatalogAdapter extends ScriptedAdapter {
|
||||
private readonly provider: LlmProviderInfo,
|
||||
private readonly models: readonly LlmModelInfo[],
|
||||
private readonly contexts: Readonly<Record<string, LlmModelContext>> = {},
|
||||
private readonly reasoning: Readonly<Record<string, LlmModelReasoningInfo>> = {},
|
||||
) {
|
||||
super(SCRIPT)
|
||||
}
|
||||
@@ -67,6 +74,13 @@ class CatalogAdapter extends ScriptedAdapter {
|
||||
): Promise<LlmModelContext | undefined> {
|
||||
return Promise.resolve(this.contexts[model])
|
||||
}
|
||||
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
model: string,
|
||||
): Promise<LlmModelReasoningInfo | undefined> {
|
||||
return Promise.resolve(this.reasoning[model])
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT: StreamChunk[] = [
|
||||
@@ -674,6 +688,117 @@ describe('LlmService', () => {
|
||||
await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves detached adapter-owned reasoning metadata and materializes its default', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const source = {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('standard'), name: 'Standard' },
|
||||
{ id: ReasoningEffortId('ultra'), name: 'Ultra', description: 'Largest budget' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('standard'),
|
||||
}
|
||||
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
|
||||
{ id: 'route', name: 'Route' },
|
||||
[],
|
||||
{},
|
||||
{ model: source },
|
||||
))
|
||||
|
||||
const resolved = await ctx.llm.resolveModelReasoning('route', 'model')
|
||||
expect(resolved).toEqual(source)
|
||||
source.efforts[0]!.name = 'mutated'
|
||||
expect(resolved?.efforts[0]?.name).toBe('Standard')
|
||||
await expect(ctx.llm.resolveCallConfig({ provider: 'route', model: 'model' })).resolves.toEqual({
|
||||
provider: 'route',
|
||||
model: 'model',
|
||||
reasoningEffort: ReasoningEffortId('standard'),
|
||||
})
|
||||
const explicit = { provider: 'route', model: 'model', reasoningEffort: ReasoningEffortId('ultra') }
|
||||
await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ efforts: [] }, 'empty effort list'],
|
||||
[{ efforts: [{ id: '', name: 'Empty' }] }, 'empty id'],
|
||||
[{ efforts: [{ id: 'valid', name: '' }] }, 'empty name'],
|
||||
[{ efforts: [{ id: 'valid', name: 'Valid', description: 1 }] }, 'non-string description'],
|
||||
[{ efforts: [{ id: 'same', name: 'One' }, { id: 'same', name: 'Two' }] }, 'duplicate id'],
|
||||
[{ efforts: [{ id: 'valid', name: 'Valid' }], defaultEffort: 'other' }, 'unknown default'],
|
||||
] as const)('rejects invalid model reasoning metadata (%s: %s)', async (metadata, _label) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
|
||||
{ id: 'route', name: 'Route' },
|
||||
[],
|
||||
{},
|
||||
{ model: metadata as unknown as LlmModelReasoningInfo },
|
||||
))
|
||||
await expect(ctx.llm.resolveModelReasoning('route', 'model'))
|
||||
.rejects.toMatchObject({ code: 'INVALID_MODEL_REASONING' })
|
||||
})
|
||||
|
||||
it('rejects unsupported reasoning efforts without clamping', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
|
||||
{ id: 'route', name: 'Route' },
|
||||
[],
|
||||
{},
|
||||
{ model: { efforts: [{ id: ReasoningEffortId('ultra'), name: 'Ultra' }] } },
|
||||
))
|
||||
|
||||
await expect(ctx.llm.resolveCallConfig({
|
||||
provider: 'route',
|
||||
model: 'model',
|
||||
reasoningEffort: ReasoningEffortId('standard'),
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
|
||||
await expect(ctx.llm.resolveCallConfig({
|
||||
provider: 'route',
|
||||
model: 'plain',
|
||||
reasoningEffort: ReasoningEffortId('standard'),
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
|
||||
})
|
||||
|
||||
it('resolves reasoning defaults at the final adapter boundary after routing middleware', async () => {
|
||||
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({
|
||||
efforts: [{ id: ReasoningEffortId('standard'), name: 'Standard' }],
|
||||
defaultEffort: ReasoningEffortId('standard'),
|
||||
})
|
||||
}
|
||||
}(SCRIPT)
|
||||
ctx.llm.registerAdapter(['routed'], adapter)
|
||||
const disposeRouting = ctx.on('llm/stream', (options, next) => {
|
||||
options.provider = 'routed'
|
||||
return next()
|
||||
})
|
||||
|
||||
for await (const _chunk of ctx.llm.stream({
|
||||
provider: 'initial',
|
||||
model: 'model',
|
||||
messages: [],
|
||||
})) { /* drain */ }
|
||||
|
||||
expect(adapter.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('standard'))
|
||||
disposeRouting()
|
||||
|
||||
const frozenRequest: GenerateOptions = Object.freeze({
|
||||
provider: 'routed',
|
||||
model: 'model',
|
||||
messages: [],
|
||||
})
|
||||
for await (const _chunk of ctx.llm.stream(frozenRequest)) { /* drain */ }
|
||||
expect(adapter.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('standard'))
|
||||
expect(Object.isFrozen(adapter.lastOptions)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN])(
|
||||
'rejects invalid adapter model context %s',
|
||||
async (contextWindow) => {
|
||||
|
||||
Reference in New Issue
Block a user