Merge master into app attribution RFC

This commit is contained in:
Tianyi Cui
2026-07-05 00:45:39 +08:00
197 changed files with 2876 additions and 2047 deletions

View File

@@ -32,13 +32,10 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
- Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
- The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
- `strict` on tool schemas passes through (officially Beta; the public API wants the `/beta` base URL for it, the internal endpoint accepts it directly).
- Cache accounting: `cacheReadTokens``prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
## Limitations (MVP, documented deliberately)
- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work.
- `image` blocks are skipped (no vision support on these models).
- `tool_choice` is not mapped (not part of the core vocabulary).
## Errors

View File

@@ -11,12 +11,10 @@
* rule for thinking mode — required there, ignored elsewhere, so we save
* the tokens elsewhere); `tool-call` → `tool_calls[]`
* - `tool-result` → its own `{role: 'tool'}` message (text flattened)
* - `image` → skipped (MVP limitation, documented in the README)
*
* @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'
@@ -99,19 +97,8 @@ export function serializeMessages(messages: Message[]): WireMessage[] {
return wire
}
/**
* Build the full wire request. Throws `LlmError('UNSUPPORTED')` for
* `prefill` (DeepSeek's chat-prefix completion is a Beta feature on a
* different base URL — see README).
*/
/** Build the full wire request. */
export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest {
if (options.prefill !== undefined) {
throw new LlmError(
'prefill is not supported by the DeepSeek adapter (Beta chat-prefix completion is future work)',
'UNSUPPORTED',
)
}
const messages: WireMessage[] = []
if (options.system !== undefined) {
messages.push({ role: 'system', content: options.system })
@@ -124,8 +111,6 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
name: tool.name,
description: tool.description,
parameters: tool.parameters,
// strict is officially supported (Beta); pass the tool author's choice.
...tool.strict !== undefined ? { strict: tool.strict } : {},
},
}))

View File

@@ -78,8 +78,6 @@ export interface WireTool {
name: string
description: string
parameters: Record<string, unknown>
/** Beta: strict schema adherence (official: requires the /beta base URL). */
strict?: boolean
}
}

View File

@@ -110,7 +110,7 @@ describe('DeepSeekAdapter against a mock server', () => {
stream_options: { include_usage: true },
})
// Attribution reaches the wire: the exact shared User-Agent, and no
// provider-specific headers without an explicitly configured target.
// provider-specific headers under the User-Agent-only contract.
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
expect(server.headers[0]).not.toHaveProperty('http-referer')
expect(server.headers[0]).not.toHaveProperty('x-openrouter-title')

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
@@ -110,11 +110,17 @@ describe('serializeMessages', () => {
])
})
it('skips image blocks (documented MVP limitation)', () => {
it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => {
const wire = serializeMessages([
{ role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] },
{
role: 'user',
content: [
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
{ type: 'text', text: 'see chart' },
],
},
])
expect(wire).toEqual([{ role: 'user', content: 'see image' }])
expect(wire).toEqual([{ role: 'user', content: 'see chart' }])
})
it('emits an empty user message rather than dropping block-less messages', () => {
@@ -149,17 +155,17 @@ describe('serializeRequest', () => {
expect(wire.stop).toEqual(['END'])
})
it('maps tools with strict passthrough', () => {
it('maps tools to the wire function shape', () => {
const wire = serializeRequest(request({
messages: history,
tools: [
{ name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
{ name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true },
{ name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } },
],
}))
expect(wire.tools).toEqual([
{ type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } },
{ type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true } },
{ type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } },
])
})
@@ -179,17 +185,6 @@ describe('serializeRequest', () => {
expect(wire.thinking).toBeUndefined()
expect(wire.reasoning_effort).toBeUndefined()
})
it('rejects prefill with an UNSUPPORTED LlmError', () => {
expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] })))
.toThrow(LlmError)
try {
serializeRequest(request({ prefill: [] }))
expect.unreachable()
} catch (error) {
expect((error as LlmError).code).toBe('UNSUPPORTED')
}
})
})
describe('review fixes: assistant content shapes', () => {

View File

@@ -9,7 +9,7 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](ht
- pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`.
- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses).
- pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map.
- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments).
- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, scrubbing pi-ai's own per-tool `strict` default — the hand-rolled twin sends no such field — omitted reasoning effort, raw replayed tool arguments).
## Config
@@ -35,7 +35,7 @@ pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time depe
## Limitations
Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images are not representable, `tool_choice` is not mapped.
Same MVP contract as llm-deepseek: `tool_choice` is not mapped.
## Testing

View File

@@ -13,9 +13,9 @@
import { stream as piStream } from '@earendil-works/pi-ai'
import type { Model } from '@earendil-works/pi-ai'
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import { attributionHeaders, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { toPiContext, toStreamChunks } from './convert.ts'
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
@@ -61,7 +61,7 @@ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model<
}
type Payload = {
tools?: { function?: { name?: unknown; strict?: unknown } }[]
tools?: { function?: { strict?: unknown } }[]
messages?: {
role?: unknown
tool_calls?: { id?: unknown; function?: { arguments?: unknown } }[]
@@ -81,10 +81,6 @@ function rawToolArguments(options: GenerateOptions): Map<CallId, string> {
return raw
}
function strictByToolName(tools: ToolSchema[] | undefined): Map<string, boolean | undefined> {
return new Map((tools ?? []).map(tool => [tool.name, tool.strict]))
}
function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiAiReasoning | undefined): unknown {
/* v8 ignore next -- pi-ai onPayload always receives an object; tolerate unusual future hooks defensively */
if (typeof payload !== 'object' || payload === null) return payload
@@ -97,16 +93,13 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA
body.stop = options.stop
}
const strictByName = strictByToolName(options.tools)
// pi-ai stamps its own `strict` default on every serialized tool; the
// harness tool contract has no strict field and the hand-rolled twin sends
// none, so scrub it for wire parity.
for (const tool of body.tools ?? []) {
/* v8 ignore next -- malformed pi-ai payload guard: real tool entries always carry function */
if (tool.function === undefined) continue
const name = tool.function.name
/* v8 ignore next -- malformed pi-ai payload guard: real function entries always carry a string name */
if (typeof name !== 'string') continue
const strict = strictByName.get(name)
if (strict === undefined) delete tool.function.strict
else tool.function.strict = strict
delete tool.function.strict
}
const rawById = rawToolArguments(options)
@@ -131,9 +124,9 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA
*
* Implementation notes:
* - `onPayload` patches provider payload details pi-ai cannot express directly:
* stop sequences, per-tool strict, omitted reasoning effort, and raw replayed
* tool-call arguments.
* - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek).
* stop sequences, scrubbing pi-ai's own per-tool `strict` default (the
* hand-rolled twin sends no such field), omitted reasoning effort, and raw
* replayed tool-call arguments.
* - pi-ai reports request failures as in-stream error events; convert.ts
* maps them to `finish {kind:'error'|'aborted'}` chunks rather than
* throwing — both are sanctioned StreamChunk error paths.
@@ -144,13 +137,6 @@ export class PiAiAdapter extends LlmAdapter {
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (options.prefill !== undefined) {
throw new LlmError(
'prefill is not supported by the pi-ai adapter',
'UNSUPPORTED',
)
}
const model = buildModel(options.model, this.options)
// Undefined config means "provider default" (DeepSeek: thinking ENABLED),
// matching llm-deepseek's omission semantics. pi-ai derives the wire

View File

@@ -93,7 +93,7 @@ export function toPiContext(options: GenerateOptions): PiContext {
})
break
default:
// image / plugin-added block types: not representable here.
// plugin-added block types: not representable here.
break
}
}

View File

@@ -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, { CallId, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { assemble } from './assemble.ts'
@@ -98,8 +98,8 @@ describe('PiAiAdapter against a mock server', () => {
expect(result.usage).toMatchObject({ inputTokens: 3, outputTokens: 1 })
// Attribution reaches the wire through pi-ai's headers hook: the exact
// shared User-Agent, and no provider-specific headers without an
// explicitly configured target.
// shared User-Agent, and no provider-specific headers under the
// User-Agent-only contract.
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
expect(server.headers[0]).not.toHaveProperty('http-referer')
expect(server.headers[0]).not.toHaveProperty('x-openrouter-title')
@@ -162,26 +162,26 @@ describe('PiAiAdapter against a mock server', () => {
expect(server.requests[0]).toMatchObject({ stop: ['END'] })
})
it('preserves per-tool strict exactly through onPayload', async () => {
it('scrubs pi-ai\'s own per-tool strict default through onPayload', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [],
tools: [
{ name: 'strict_true', description: 'true', parameters: {}, strict: true },
{ name: 'strict_false', description: 'false', parameters: {}, strict: false },
{ name: 'strict_omitted', description: 'omitted', parameters: {} },
{ name: 'alpha', description: 'a', parameters: {} },
{ name: 'beta', description: 'b', parameters: {} },
],
})
// pi-ai stamps `strict` on every serialized tool function; the harness
// contract has none and the hand-rolled twin sends no such field, so the
// payload fixup must have deleted it from every tool.
const request = server.requests[0] as { tools: { function: { name: string; strict?: boolean } }[] }
expect(request.tools.map(tool => [tool.function.name, tool.function.strict])).toEqual([
['strict_true', true],
['strict_false', false],
['strict_omitted', undefined],
])
expect('strict' in request.tools[2]!.function).toBe(false)
expect(request.tools.map(tool => tool.function.name)).toEqual(['alpha', 'beta'])
for (const tool of request.tools) {
expect('strict' in tool.function).toBe(false)
}
})
it('preserves raw replayed tool-call arguments in the provider payload', async () => {
@@ -222,15 +222,6 @@ describe('PiAiAdapter against a mock server', () => {
expect(result.finish).toMatchObject({ kind: 'error', code })
})
it('rejects prefill with UNSUPPORTED', async () => {
const ctx = await harness('http://127.0.0.1:1')
await expect(assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [],
prefill: [{ type: 'text', text: 'Sure' }],
})).rejects.toThrow(LlmError)
})
it('registers/unregisters models on the llm service (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai'
@@ -171,13 +171,13 @@ describe('toPiContext', () => {
expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult'])
})
it('skips image and unknown blocks in assistant content', () => {
it('skips plugin-added (unknown) blocks in assistant content', () => {
const context = toPiContext({
model: 'm',
messages: [{
role: 'assistant',
content: [
{ type: 'image', url: 'data:,x' },
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
{ type: 'text', text: 'visible' },
],
}],

View File

@@ -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.

View File

@@ -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, () => {

View File

@@ -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
/**

View File

@@ -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)', () => {

View File

@@ -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)
}
}))
})