feat(llm): route adapters by provider
This commit is contained in:
@@ -8,8 +8,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber.
|
||||
- `ctx.llm.models(): string[]` — model names with a registered adapter.
|
||||
- `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.providers(): string[]` — provider routes with a registered 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`.
|
||||
|
||||
### Events
|
||||
@@ -20,18 +20,18 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
### Extension points
|
||||
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider.
|
||||
- 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.
|
||||
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
|
||||
|
||||
### Content-block vocabulary (`types.ts`)
|
||||
|
||||
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
||||
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages produced by the loop also carry provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
`LlmCallConfig` is the model + sampling scalars of one conversation's requests (`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).
|
||||
`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).
|
||||
|
||||
### App attribution (`attribution.ts`)
|
||||
|
||||
@@ -46,4 +46,4 @@ Every product adapter must identify the application on every provider HTTP reque
|
||||
|
||||
### Real adapters
|
||||
|
||||
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).
|
||||
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) is a hand-rolled DeepSeek fetch/SSE adapter, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves any configured provider/model in pi-ai's installed catalog. The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).
|
||||
|
||||
@@ -36,6 +36,7 @@ export class BlockAssembler {
|
||||
private order: number[] = []
|
||||
private _usage: TokenUsage | undefined
|
||||
private _finish: FinishReason | undefined
|
||||
private _replayState: unknown
|
||||
|
||||
/**
|
||||
* Feed one chunk. Returns the completed block when the chunk closes one
|
||||
@@ -87,6 +88,7 @@ export class BlockAssembler {
|
||||
}
|
||||
case 'finish': {
|
||||
this._finish = chunk.reason
|
||||
this._replayState = chunk.replayState
|
||||
return
|
||||
}
|
||||
default: return assertNever(chunk, 'BlockAssembler.push')
|
||||
@@ -144,6 +146,11 @@ export class BlockAssembler {
|
||||
return this._finish ?? { kind: 'stop' }
|
||||
}
|
||||
|
||||
/** Adapter-private replay state from the terminal finish chunk, if any. */
|
||||
get replayState(): unknown {
|
||||
return this._replayState
|
||||
}
|
||||
|
||||
/**
|
||||
* The assembled assistant message.
|
||||
* @returns an assistant-role message over `blocks()` (same open-block assembly rules).
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* The call configuration of a conversation and its comparison/freeze
|
||||
* utilities. `LlmCallConfig` is the non-content third of the request header
|
||||
* (see `EpochHeader` in dsh-session): everything about a request besides its
|
||||
* message content that can undermine provider KV-cache reuse — `model`
|
||||
* selects the cache namespace outright, and the sampling scalars are treated
|
||||
* the same way out of caution. It is per-conversation state recorded in the
|
||||
* message content that can undermine provider KV-cache reuse — `provider` and
|
||||
* `model` select the adapter and cache namespace outright, and the sampling
|
||||
* scalars are treated the same way out of caution. It is per-conversation state recorded in the
|
||||
* session log (the reconstructability RFC), never a silently-drifting
|
||||
* per-call knob: the `agent/request` waterfall proposes a replacement, and
|
||||
* the loop logs a real change as a `request/header-delta` event.
|
||||
@@ -13,11 +13,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Model + sampling scalars of one conversation's requests. Every field maps
|
||||
* 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.
|
||||
*/
|
||||
export interface LlmCallConfig {
|
||||
provider: string
|
||||
model: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
@@ -33,7 +34,7 @@ export interface LlmCallConfig {
|
||||
* @returns whether every field (including the `stop` list, element-wise) matches.
|
||||
*/
|
||||
export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean {
|
||||
if (a.model !== b.model || a.temperature !== b.temperature || a.maxTokens !== b.maxTokens) return false
|
||||
if (a.provider !== b.provider || a.model !== b.model || 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])
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { GenerateOptions, StreamChunk } from './types.ts'
|
||||
import type { GenerateOptions, Message, StreamChunk } from './types.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
import { deepFreeze } from './call-config.ts'
|
||||
|
||||
export * from './attribution.ts'
|
||||
export * from './brand.ts'
|
||||
@@ -58,7 +59,7 @@ export class LlmError extends HarnessError {
|
||||
*
|
||||
* An adapter translates between the harness vocabulary (Message/ContentBlock/
|
||||
* StreamChunk) and one provider's wire format. Adapters register themselves
|
||||
* via `ctx.llm.registerAdapter(models, adapter)`.
|
||||
* via `ctx.llm.registerAdapter(providers, adapter)`.
|
||||
*
|
||||
* Real implementations: `@deepseek-ai/dsh-llm-deepseek` (hand-rolled
|
||||
* fetch/SSE) and `@deepseek-ai/dsh-llm-pi-ai` (pi-ai-backed) — two
|
||||
@@ -93,23 +94,27 @@ export class LlmService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an adapter for the given model names. Throws `LlmError` with code
|
||||
* `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing).
|
||||
* Register an adapter for the given provider routes. Throws `LlmError` with code
|
||||
* `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).
|
||||
* Disposed with the fiber.
|
||||
* @param models - every model name this adapter should serve.
|
||||
* @param adapter - the adapter that streams calls for those models.
|
||||
* @param providers - every provider route this adapter should serve.
|
||||
* @param adapter - the adapter that streams calls for those providers.
|
||||
* @returns the disposer that unregisters all of them.
|
||||
*/
|
||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
||||
registerAdapter(providers: string[], adapter: LlmAdapter): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
for (const model of models) {
|
||||
if (this.adapters.has(model)) {
|
||||
throw new LlmError(`an adapter for model "${model}" is already registered`, 'DUPLICATE_ADAPTER')
|
||||
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
|
||||
const unique = new Set<string>()
|
||||
for (const provider of providers) {
|
||||
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
|
||||
if (unique.has(provider) || this.adapters.has(provider)) {
|
||||
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
|
||||
}
|
||||
unique.add(provider)
|
||||
}
|
||||
for (const model of models) this.adapters.set(model, adapter)
|
||||
for (const provider of providers) this.adapters.set(provider, adapter)
|
||||
yield () => {
|
||||
for (const model of models) this.adapters.delete(model)
|
||||
for (const provider of providers) this.adapters.delete(provider)
|
||||
}
|
||||
}.bind(this), 'llm.registerAdapter()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
@@ -118,29 +123,48 @@ export class LlmService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Model names with a registered adapter.
|
||||
* @returns the registered names, in registration order.
|
||||
* Provider routes with a registered adapter.
|
||||
* @returns the registered provider names, in registration order.
|
||||
*/
|
||||
models(): string[] {
|
||||
providers(): string[] {
|
||||
return [...this.adapters.keys()]
|
||||
}
|
||||
|
||||
private adapter(model: string): LlmAdapter {
|
||||
const adapter = this.adapters.get(model)
|
||||
if (!adapter) throw new LlmError(`no adapter registered for model "${model}"`, 'NO_ADAPTER')
|
||||
private adapter(provider: string): LlmAdapter {
|
||||
const adapter = this.adapters.get(provider)
|
||||
if (!adapter) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER')
|
||||
return adapter
|
||||
}
|
||||
|
||||
/** Remove replay state whose historical route is owned by another adapter. */
|
||||
private forAdapter(options: GenerateOptions, adapter: LlmAdapter): GenerateOptions {
|
||||
const messages: Message[] = options.messages.map((message) => {
|
||||
const provenance = message.provenance
|
||||
if (message.role !== 'assistant' || provenance?.replayState === undefined) return message
|
||||
if (this.adapters.get(provenance.provider) === adapter) return message
|
||||
return {
|
||||
...message,
|
||||
provenance: { provider: provenance.provider, model: provenance.model },
|
||||
}
|
||||
})
|
||||
if (messages.every((message, index) => message === options.messages[index])) return options
|
||||
const filtered = { ...options, messages }
|
||||
return Object.isFrozen(options) ? deepFreeze(filtered) : filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one model call as raw chunks (token-level deltas). Throws
|
||||
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
|
||||
* `options.model`. Dispatches through the `llm/stream` waterfall.
|
||||
* @param options - the full request; `options.model` selects the adapter.
|
||||
* `options.provider`. Replay state is retained only when the same adapter
|
||||
* instance owns its historical provider and the target provider. Dispatches
|
||||
* through the `llm/stream` waterfall.
|
||||
* @param options - the full request; `options.provider` selects the adapter.
|
||||
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
|
||||
*/
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return this.ctx.waterfall(this, 'llm/stream', options, () => {
|
||||
return this.adapter(options.model).stream(options)
|
||||
const adapter = this.adapter(options.provider)
|
||||
return adapter.stream(this.forAdapter(options, adapter))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +75,29 @@ export type ContentBlockType = keyof ContentBlockMap
|
||||
/** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */
|
||||
export type ContentBlock = ContentBlockMap[ContentBlockType]
|
||||
|
||||
/** A single message in a conversation history. */
|
||||
/** Provider ownership and adapter-private replay data for an assistant message. */
|
||||
export interface AssistantProvenance {
|
||||
/** Provider route that produced the message. */
|
||||
provider: string
|
||||
/** Provider model id that produced the message. */
|
||||
model: string
|
||||
/**
|
||||
* Lossless-JSON adapter state needed to replay the provider response.
|
||||
* `LlmService` exposes it to a target adapter only when that adapter instance
|
||||
* currently owns both this historical provider and the target provider.
|
||||
*/
|
||||
replayState?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A single message in a conversation history. Loop-derived assistant messages
|
||||
* always carry provenance; callers may omit it on hand-built foreign history.
|
||||
*/
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: ContentBlock[]
|
||||
/** Present only on assistant messages produced by a routed adapter. */
|
||||
provenance?: AssistantProvenance
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +172,12 @@ export type StreamChunk =
|
||||
| { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string }
|
||||
| { type: 'block-end'; index: number; block: ContentBlock }
|
||||
| { type: 'usage'; usage: TokenUsage }
|
||||
| { type: 'finish'; reason: FinishReason }
|
||||
| {
|
||||
type: 'finish'
|
||||
reason: FinishReason
|
||||
/** Adapter-private lossless-JSON state for replaying a successful response. */
|
||||
replayState?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-schema description of a tool, as sent to the model.
|
||||
@@ -171,6 +195,8 @@ export interface ToolSchema {
|
||||
|
||||
/** A single model request, fully assembled. */
|
||||
export interface GenerateOptions {
|
||||
/** Registered provider route selecting the adapter instance. */
|
||||
provider: string
|
||||
model: string
|
||||
/**
|
||||
* Ordered conversation messages, exactly as the provider sees them (after
|
||||
|
||||
@@ -9,14 +9,16 @@ import { callConfigEquals, deepFreeze } from '../src/call-config.ts'
|
||||
|
||||
describe('callConfigEquals', () => {
|
||||
it('compares every field, including the stop list element-wise', () => {
|
||||
expect(callConfigEquals({ model: 'm' }, { model: 'm' })).toBe(true)
|
||||
expect(callConfigEquals({ model: 'm' }, { model: 'x' })).toBe(false)
|
||||
expect(callConfigEquals({ model: 'm', temperature: 0.5 }, { model: 'm' })).toBe(false)
|
||||
expect(callConfigEquals({ model: 'm', maxTokens: 1 }, { model: 'm', maxTokens: 2 })).toBe(false)
|
||||
expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm' })).toBe(false)
|
||||
expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm', stop: ['a', 'b'] })).toBe(false)
|
||||
expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm', stop: ['b'] })).toBe(false)
|
||||
expect(callConfigEquals({ model: 'm', stop: ['a', 'b'] }, { model: 'm', stop: ['a', 'b'] })).toBe(true)
|
||||
const base = { provider: 'p', model: 'm' }
|
||||
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, temperature: 0.5 }, base)).toBe(false)
|
||||
expect(callConfigEquals({ ...base, maxTokens: 1 }, { ...base, maxTokens: 2 })).toBe(false)
|
||||
expect(callConfigEquals({ ...base, stop: ['a'] }, base)).toBe(false)
|
||||
expect(callConfigEquals({ ...base, stop: ['a'] }, { ...base, stop: ['a', 'b'] })).toBe(false)
|
||||
expect(callConfigEquals({ ...base, stop: ['a'] }, { ...base, stop: ['b'] })).toBe(false)
|
||||
expect(callConfigEquals({ ...base, stop: ['a', 'b'] }, { ...base, stop: ['a', 'b'] })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ class ScriptedAdapter extends LlmAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingAdapter extends ScriptedAdapter {
|
||||
lastOptions: GenerateOptions | undefined
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.lastOptions = options
|
||||
yield * super.stream(options)
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'hi' },
|
||||
@@ -22,18 +31,18 @@ describe('LlmService', () => {
|
||||
it('routes stream() to the registered adapter', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
|
||||
ctx.llm.registerAdapter(['test-provider'], new ScriptedAdapter(SCRIPT))
|
||||
|
||||
const chunks: StreamChunk[] = []
|
||||
for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk)
|
||||
for await (const chunk of ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })) chunks.push(chunk)
|
||||
expect(chunks).toEqual(SCRIPT)
|
||||
})
|
||||
|
||||
it('throws NO_ADAPTER for unregistered models', async () => {
|
||||
it('throws NO_ADAPTER for unregistered providers', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect((async () => {
|
||||
for await (const _ of ctx.llm.stream({ model: 'nope', messages: [] })) { /* drain */ }
|
||||
for await (const _ of ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] })) { /* drain */ }
|
||||
})()).rejects.toThrow('no adapter registered')
|
||||
})
|
||||
|
||||
@@ -44,10 +53,10 @@ describe('LlmService', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.llm.registerAdapter(['scoped-model'], new ScriptedAdapter(SCRIPT))
|
||||
}, { inject: ['llm'] }))
|
||||
expect(ctx.llm.models()).toEqual(['scoped-model'])
|
||||
expect(ctx.llm.providers()).toEqual(['scoped-model'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
})
|
||||
|
||||
it('lets llm/stream waterfall listeners wrap the underlying stream', async () => {
|
||||
@@ -64,11 +73,90 @@ describe('LlmService', () => {
|
||||
})
|
||||
|
||||
const chunks: StreamChunk[] = []
|
||||
for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk)
|
||||
for await (const chunk of ctx.llm.stream({ provider: 'test-model', model: 'dynamic-model', messages: [] })) chunks.push(chunk)
|
||||
expect(chunks).toHaveLength(4)
|
||||
expect(chunks[0]).toMatchObject({ index: 99 })
|
||||
})
|
||||
|
||||
it('resolves the provider after llm/stream listeners have had a chance to route it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new RecordingAdapter(SCRIPT)
|
||||
ctx.llm.registerAdapter(['routed'], adapter)
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
options.provider = 'routed'
|
||||
return next()
|
||||
})
|
||||
|
||||
for await (const _chunk of ctx.llm.stream({ provider: 'initial', model: 'm', messages: [] })) { /* drain */ }
|
||||
expect(adapter.lastOptions?.provider).toBe('routed')
|
||||
})
|
||||
|
||||
it('keeps replay state when historical and target providers belong to the same adapter instance', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new RecordingAdapter(SCRIPT)
|
||||
ctx.llm.registerAdapter(['historical', 'target'], adapter)
|
||||
const replayState = { private: 'state' }
|
||||
|
||||
for await (const _chunk of ctx.llm.stream({
|
||||
provider: 'target',
|
||||
model: 'new-model',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'old response' }],
|
||||
provenance: { provider: 'historical', model: 'old-model', replayState },
|
||||
}],
|
||||
})) { /* drain */ }
|
||||
|
||||
expect(adapter.lastOptions?.messages[0]?.provenance).toEqual({
|
||||
provider: 'historical', model: 'old-model', replayState,
|
||||
})
|
||||
})
|
||||
|
||||
it('strips replay state but preserves provenance when the target uses a different adapter instance', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['historical'], new RecordingAdapter(SCRIPT))
|
||||
const target = new RecordingAdapter(SCRIPT)
|
||||
ctx.llm.registerAdapter(['target'], target)
|
||||
|
||||
for await (const _chunk of ctx.llm.stream({
|
||||
provider: 'target',
|
||||
model: 'new-model',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'old response' }],
|
||||
provenance: { provider: 'historical', model: 'old-model', replayState: { private: 'state' } },
|
||||
}],
|
||||
})) { /* drain */ }
|
||||
|
||||
expect(target.lastOptions?.messages[0]?.provenance).toEqual({ provider: 'historical', model: 'old-model' })
|
||||
})
|
||||
|
||||
it('preserves immutability while stripping replay state from frozen requests', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['historical'], new RecordingAdapter(SCRIPT))
|
||||
const target = new RecordingAdapter(SCRIPT)
|
||||
ctx.llm.registerAdapter(['target'], target)
|
||||
const options = Object.freeze({
|
||||
provider: 'target',
|
||||
model: 'new-model',
|
||||
messages: [{
|
||||
role: 'assistant' as const,
|
||||
content: [{ type: 'text' as const, text: 'old response' }],
|
||||
provenance: { provider: 'historical', model: 'old-model', replayState: { private: 'state' } },
|
||||
}],
|
||||
})
|
||||
|
||||
for await (const _chunk of ctx.llm.stream(options)) { /* drain */ }
|
||||
|
||||
expect(target.lastOptions).not.toBe(options)
|
||||
expect(Object.isFrozen(target.lastOptions)).toBe(true)
|
||||
expect(target.lastOptions?.messages[0]?.provenance).toEqual({ provider: 'historical', model: 'old-model' })
|
||||
})
|
||||
|
||||
it('creates LlmError with a code for programmatic handling', () => {
|
||||
const err = new LlmError('something went wrong', 'CUSTOM_CODE')
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
@@ -104,9 +192,9 @@ describe('LlmService', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
expect(ctx.llm.providers()).toEqual(['m1'])
|
||||
dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate adapter registration with DUPLICATE_ADAPTER code', async () => {
|
||||
@@ -123,19 +211,30 @@ describe('LlmService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty and internally duplicated provider registrations atomically', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new ScriptedAdapter(SCRIPT)
|
||||
|
||||
expect(() => ctx.llm.registerAdapter([], adapter)).toThrow(expect.objectContaining({ code: 'INVALID_ADAPTER' }))
|
||||
expect(() => ctx.llm.registerAdapter([''], adapter)).toThrow(expect.objectContaining({ code: 'INVALID_ADAPTER' }))
|
||||
expect(() => ctx.llm.registerAdapter(['first', 'first'], adapter)).toThrow(expect.objectContaining({ code: 'DUPLICATE_ADAPTER' }))
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
})
|
||||
|
||||
it('re-registers a model after its prior registration is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
expect(ctx.llm.providers()).toEqual(['m1'])
|
||||
dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
|
||||
// The duplicate check is not wedged: the same model registers cleanly again.
|
||||
const disposeAgain = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
expect(ctx.llm.providers()).toEqual(['m1'])
|
||||
disposeAgain()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user