Merge master into app attribution RFC
This commit is contained in:
@@ -25,7 +25,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
### Content-block vocabulary (`types.ts`)
|
||||
|
||||
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ declare module 'cordis' {
|
||||
* Waterfall around every streaming model call (retry, caching, routing).
|
||||
* Bound to the {@link LlmService}; call `next()` to reach the resolved
|
||||
* adapter's stream, or yield your own chunks to short-circuit.
|
||||
* @param options - the full request; listeners may rewrite it before delegating.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
|
||||
@@ -85,6 +86,9 @@ 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).
|
||||
* Disposed with the fiber.
|
||||
* @param models - every model name this adapter should serve.
|
||||
* @param adapter - the adapter that streams calls for those models.
|
||||
* @returns the disposer that unregisters all of them.
|
||||
*/
|
||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
@@ -103,7 +107,10 @@ export class LlmService extends Service {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** Model names with a registered adapter. */
|
||||
/**
|
||||
* Model names with a registered adapter.
|
||||
* @returns the registered names, in registration order.
|
||||
*/
|
||||
models(): string[] {
|
||||
return [...this.adapters.keys()]
|
||||
}
|
||||
@@ -118,6 +125,8 @@ export class LlmService extends Service {
|
||||
* 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.
|
||||
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
|
||||
*/
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return this.ctx.waterfall(this, 'llm/stream', options, () => {
|
||||
|
||||
@@ -22,14 +22,10 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CallId } from './brand.ts'
|
||||
|
||||
/** Cache hint attached to a content block (provider-interpreted). */
|
||||
export type CacheHint = 'ephemeral'
|
||||
|
||||
/** Plain text visible to the end user. */
|
||||
export interface TextBlock {
|
||||
type: 'text'
|
||||
text: string
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/** Reasoning / thinking content, distinct from visible text. */
|
||||
@@ -54,27 +50,24 @@ export interface ToolResultBlock {
|
||||
toolCallId: CallId
|
||||
content: ContentBlock[]
|
||||
isError?: boolean
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/** An image, by URL or data URL. */
|
||||
export interface ImageBlock {
|
||||
type: 'image'
|
||||
url: string
|
||||
mimeType?: string
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/**
|
||||
* All known content block shapes, keyed by their `type` tag.
|
||||
* Merge-extensible: plugins add new block types via declaration merging.
|
||||
*
|
||||
* The core set is deliberately limited to blocks every shipping path honors.
|
||||
* Multimodal content (images, audio, …) has no core block type: a feature
|
||||
* that needs one adds it via declaration merging in the same coordinated
|
||||
* change that maps it in the adapters, surfaces it in the UI bridges, and
|
||||
* prices it in compaction — a producer never lands without its consumers
|
||||
* (see docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md).
|
||||
*/
|
||||
export interface ContentBlockMap {
|
||||
'text': TextBlock
|
||||
'reasoning': ReasoningBlock
|
||||
'tool-call': ToolCallBlock
|
||||
'tool-result': ToolResultBlock
|
||||
'image': ImageBlock
|
||||
}
|
||||
|
||||
export type ContentBlockType = keyof ContentBlockMap
|
||||
@@ -93,7 +86,6 @@ export interface Message {
|
||||
export interface MessageSourceMap {
|
||||
user: { kind: 'user' }
|
||||
plugin: { kind: 'plugin'; plugin: string }
|
||||
agent: { kind: 'agent'; agentId: string }
|
||||
}
|
||||
|
||||
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
||||
@@ -171,7 +163,6 @@ export interface ToolSchema {
|
||||
description: string
|
||||
/** JSON Schema object for the arguments. */
|
||||
parameters: Record<string, unknown>
|
||||
strict?: boolean
|
||||
}
|
||||
|
||||
/** A single model request, fully assembled. */
|
||||
@@ -182,8 +173,6 @@ export interface GenerateOptions {
|
||||
system?: string
|
||||
/** Tool schemas (adapters map to the provider's `tools` field). */
|
||||
tools?: ToolSchema[]
|
||||
/** Assistant prefix continuation (prefill). */
|
||||
prefill?: ContentBlock[]
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
/**
|
||||
|
||||
@@ -63,12 +63,12 @@ describe('BlockAssembler', () => {
|
||||
|
||||
it('throws from assemble() when a partial has an unhandled blockType', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
// Directly push a block-end for an image block whose block-start never
|
||||
// called ensure — but the image block-type flows through normally.
|
||||
// What we really need is a partial whose blockType is not text/reasoning/tool-call.
|
||||
// We can achieve this via a block-start for 'image' followed by blocks().
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'image' } as unknown as StreamChunk)
|
||||
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "image"')
|
||||
// A partial whose blockType is not text/reasoning/tool-call cannot be
|
||||
// assembled without its block-end. A plugin-added block type (here
|
||||
// 'video', via the merge-extensible ContentBlockMap) opened by a
|
||||
// block-start with no closing block-end exercises that throw.
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk)
|
||||
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"')
|
||||
})
|
||||
|
||||
it('mustGet throws when an index is missing from the partials map (invariant violation)', () => {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('BlockAssembler properties', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const blocks = feed(chunks).blocks()
|
||||
for (const block of blocks) {
|
||||
expect(['text', 'reasoning', 'tool-call', 'tool-result', 'image']).toContain(block.type)
|
||||
expect(['text', 'reasoning', 'tool-call', 'tool-result']).toContain(block.type)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user