Merge master into codex/session-title
This commit is contained in:
@@ -11,12 +11,15 @@ 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.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`.
|
||||
|
||||
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`.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
@@ -25,7 +28,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re
|
||||
|
||||
### 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; their defaults use the route id as its name and advertise no models.
|
||||
- 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.
|
||||
- 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`)
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,10 +28,12 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { GenerateOptions, LlmFailure, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmFailure,
|
||||
LlmModelContext,
|
||||
LlmModelInfo,
|
||||
LlmProviderInfo,
|
||||
Message,
|
||||
StreamChunk,
|
||||
} from './types.ts'
|
||||
import type { ProviderRequestId } from './brand.ts'
|
||||
import { deepFreeze } from './call-config.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
@@ -122,6 +130,20 @@ export abstract class LlmAdapter {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
resolveModelContext(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
): Promise<LlmModelContext | 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`.
|
||||
@@ -217,6 +239,29 @@ 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.
|
||||
* @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.
|
||||
*/
|
||||
async resolveModelContext(
|
||||
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) {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid context metadata for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_CONTEXT',
|
||||
)
|
||||
}
|
||||
return { contextWindow: context.contextWindow }
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
95
packages/llm/llm/src/invariant.ts
Normal file
95
packages/llm/llm/src/invariant.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Package-owned LLM stream-protocol invariants. @module @deepseek-ai/dsh-llm/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { ContentBlockType, StreamChunk } from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'llm-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Require one chunk index to be a non-negative safe integer. */
|
||||
function validateIndex(index: number, fail: InvariantFailure): void {
|
||||
if (!Number.isSafeInteger(index) || index < 0) {
|
||||
fail(`LLM stream block index must be a non-negative safe integer, got ${index}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Require a delta to address an open block of its matching type. */
|
||||
function validateDelta(
|
||||
open: ReadonlyMap<number, ContentBlockType>,
|
||||
index: number,
|
||||
expected: ContentBlockType,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
validateIndex(index, fail)
|
||||
const actual = open.get(index)
|
||||
if (actual !== expected) {
|
||||
fail(`${expected} delta at index ${index} requires an open ${expected} block, got ${String(actual)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap one provider stream and enforce its grammar as chunks are consumed. */
|
||||
async function* validateStream(
|
||||
source: AsyncIterable<StreamChunk>,
|
||||
fail: InvariantFailure,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const open = new Map<number, ContentBlockType>()
|
||||
let usageSeen = false
|
||||
let finished = false
|
||||
for await (const chunk of source) {
|
||||
if (finished) fail(`LLM stream emitted ${chunk.type} after terminal finish`)
|
||||
switch (chunk.type) {
|
||||
case 'block-start':
|
||||
validateIndex(chunk.index, fail)
|
||||
if (open.has(chunk.index)) fail(`LLM stream repeated block-start index ${chunk.index}`)
|
||||
open.set(chunk.index, chunk.blockType)
|
||||
break
|
||||
case 'text-delta':
|
||||
validateDelta(open, chunk.index, 'text', fail)
|
||||
break
|
||||
case 'reasoning-delta':
|
||||
validateDelta(open, chunk.index, 'reasoning', fail)
|
||||
break
|
||||
case 'tool-call-delta':
|
||||
validateDelta(open, chunk.index, 'tool-call', fail)
|
||||
break
|
||||
case 'block-end': {
|
||||
validateIndex(chunk.index, fail)
|
||||
const blockType = open.get(chunk.index)
|
||||
if (blockType === undefined) fail(`LLM stream block-end index ${chunk.index} has no open block`)
|
||||
if (chunk.block.type !== blockType) {
|
||||
fail(`LLM stream block-end index ${chunk.index} closes ${chunk.block.type}, expected ${blockType}`)
|
||||
}
|
||||
open.delete(chunk.index)
|
||||
break
|
||||
}
|
||||
case 'usage':
|
||||
if (usageSeen) fail('LLM stream emitted usage more than once')
|
||||
usageSeen = true
|
||||
break
|
||||
case 'finish':
|
||||
if (open.size > 0) fail(`LLM stream finished with ${open.size} open block(s)`)
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
yield chunk
|
||||
}
|
||||
if (!finished) fail('LLM stream ended without a terminal finish chunk')
|
||||
}
|
||||
|
||||
/** Install validation around every provider stream. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the LLM invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -155,6 +155,12 @@ export interface LlmModelInfo {
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Provider-owned context capacity for one exact provider/model route. */
|
||||
export interface LlmModelContext {
|
||||
/** Maximum combined request and response context in tokens. */
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
* Block indexes correlate interleaved deltas, and `block-end` carries the
|
||||
|
||||
86
packages/llm/llm/tests/invariant.spec.ts
Normal file
86
packages/llm/llm/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmInvariant from '@deepseek-ai/dsh-llm/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(LlmInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const options: GenerateOptions = { provider: 'mock', model: 'mock', messages: [] }
|
||||
|
||||
async function* source(chunks: readonly StreamChunk[]): AsyncIterable<StreamChunk> {
|
||||
yield* chunks
|
||||
}
|
||||
|
||||
async function consume(ctx: Context, chunks: readonly StreamChunk[]): Promise<StreamChunk[]> {
|
||||
const stream = ctx.waterfall(ctx as never, 'llm/stream', options, () => source(chunks))
|
||||
const consumed: StreamChunk[] = []
|
||||
for await (const chunk of stream) consumed.push(chunk)
|
||||
return consumed
|
||||
}
|
||||
|
||||
const finish: StreamChunk = { type: 'finish', reason: { kind: 'stop' } }
|
||||
|
||||
describe('LLM stream invariants', () => {
|
||||
it('accepts a complete interleaved stream grammar', async () => {
|
||||
const ctx = await setup()
|
||||
const chunks: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'a' },
|
||||
{ type: 'block-start', index: 1, blockType: 'reasoning' },
|
||||
{ type: 'reasoning-delta', index: 1, text: 'b' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'reasoning', text: 'b' } },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'a' } },
|
||||
{ type: 'block-start', index: 2, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 2, id: CallId('c1'), name: 'echo', argumentsDelta: '{}' },
|
||||
{ type: 'block-end', index: 2, block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' } },
|
||||
{ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
finish,
|
||||
]
|
||||
await expect(consume(ctx, chunks)).resolves.toEqual(chunks)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[[{ type: 'block-start', index: -1, blockType: 'text' }, finish], /non-negative safe integer/],
|
||||
[[
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
], /repeated block-start/],
|
||||
[[{ type: 'text-delta', index: 0, text: 'x' }], /requires an open text block/],
|
||||
[[
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
{ type: 'text-delta', index: 0, text: 'x' },
|
||||
], /got reasoning/],
|
||||
[[{ type: 'block-end', index: 0, block: { type: 'text', text: '' } }], /has no open block/],
|
||||
[[
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: '' } },
|
||||
], /closes reasoning, expected text/],
|
||||
[[
|
||||
{ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
{ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
], /usage more than once/],
|
||||
[[{ type: 'block-start', index: 0, blockType: 'text' }, finish], /finished with 1 open block/],
|
||||
[[finish, { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }], /usage after terminal finish/],
|
||||
[[], /ended without a terminal finish/],
|
||||
] as Array<[StreamChunk[], RegExp]>)('rejects malformed stream %#', async (chunks, message) => {
|
||||
const ctx = await setup()
|
||||
await expect(consume(ctx, chunks)).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('preserves provider exceptions without inventing a missing-finish failure', async () => {
|
||||
const ctx = await setup()
|
||||
const stream = ctx.waterfall(ctx as never, 'llm/stream', options, async function* () {
|
||||
throw new Error('provider failed')
|
||||
})
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* consume */ }
|
||||
})()).rejects.toThrow('provider failed')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import LlmService, {
|
||||
ProviderRequestId,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
constructor(private script: StreamChunk[]) {
|
||||
@@ -48,6 +48,7 @@ class CatalogAdapter extends ScriptedAdapter {
|
||||
constructor(
|
||||
private readonly provider: LlmProviderInfo,
|
||||
private readonly models: readonly LlmModelInfo[],
|
||||
private readonly contexts: Readonly<Record<string, LlmModelContext>> = {},
|
||||
) {
|
||||
super(SCRIPT)
|
||||
}
|
||||
@@ -59,11 +60,19 @@ class CatalogAdapter extends ScriptedAdapter {
|
||||
override listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.models)
|
||||
}
|
||||
|
||||
override resolveModelContext(
|
||||
_provider: string,
|
||||
model: string,
|
||||
): Promise<LlmModelContext | undefined> {
|
||||
return Promise.resolve(this.contexts[model])
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'hi' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
@@ -644,8 +653,42 @@ 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' })
|
||||
})
|
||||
|
||||
it('resolves detached model context independently of advisory catalog membership', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const source = { contextWindow: 32_000 }
|
||||
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
|
||||
{ id: 'route', name: 'Route' },
|
||||
[],
|
||||
{ unlisted: source },
|
||||
))
|
||||
|
||||
const resolved = await ctx.llm.resolveModelContext('route', 'unlisted')
|
||||
expect(resolved).toEqual({ contextWindow: 32_000 })
|
||||
source.contextWindow = 64_000
|
||||
expect(resolved).toEqual({ contextWindow: 32_000 })
|
||||
await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN])(
|
||||
'rejects invalid adapter model context %s',
|
||||
async (contextWindow) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
|
||||
{ id: 'route', name: 'Route' },
|
||||
[],
|
||||
{ model: { contextWindow } },
|
||||
))
|
||||
await expect(ctx.llm.resolveModelContext('route', 'model'))
|
||||
.rejects.toMatchObject({ code: 'INVALID_MODEL_CONTEXT' })
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
[{ id: 1, name: 'Name' }, 'non-string id'],
|
||||
[{ id: 'other', name: 'Name' }, 'mismatched id'],
|
||||
@@ -694,13 +737,14 @@ describe('LlmService', () => {
|
||||
const inner = next()
|
||||
return (async function * () {
|
||||
yield { type: 'block-start', index: 99, blockType: 'text' } satisfies StreamChunk
|
||||
yield { type: 'block-end', index: 99, block: { type: 'text', text: '' } } satisfies StreamChunk
|
||||
yield * inner
|
||||
})()
|
||||
})
|
||||
|
||||
const chunks: StreamChunk[] = []
|
||||
for await (const chunk of ctx.llm.stream({ provider: 'test-model', model: 'dynamic-model', messages: [] })) chunks.push(chunk)
|
||||
expect(chunks).toHaveLength(4)
|
||||
expect(chunks).toHaveLength(6)
|
||||
expect(chunks[0]).toMatchObject({ index: 99 })
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user