Merge remote-tracking branch 'origin/master' into split-cordis-catalog

# Conflicts:
#	docs/cordis-catalog/events.md
This commit is contained in:
Tianyi Cui
2026-07-05 01:22:05 +08:00
16 changed files with 266 additions and 16 deletions

View File

@@ -23,6 +23,10 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds
`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.
## App attribution
Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode.
## Wire-format notes (verified live + against the official docs)
- 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`.

View File

@@ -5,7 +5,7 @@
* @module dsh-llm-deepseek/adapter
*/
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { serializeRequest } from './serialize.ts'
import type { RequestDefaults } from './serialize.ts'
@@ -21,13 +21,6 @@ export interface DeepSeekAdapterOptions {
defaults?: RequestDefaults
}
/**
* Attribution header sent on every request so the provider can identify the
* client. Bump in lockstep with this package's version (no build-time version
* injection is wired in this repo yet).
*/
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Map an HTTP status to a stable LlmError code. */
export function httpErrorCode(status: number): string {
if (status === 401 || status === 403) return 'AUTH'
@@ -67,7 +60,7 @@ export class DeepSeekAdapter extends LlmAdapter {
'authorization': `Bearer ${this.options.apiKey}`,
'content-type': 'application/json',
'accept': 'text/event-stream',
'user-agent': USER_AGENT,
...attributionHeaders(),
},
body: JSON.stringify(body),
...options.signal ? { signal: options.signal } : {},

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, { LlmError } from '@deepseek-ai/dsh-llm'
import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts'
@@ -109,8 +109,12 @@ describe('DeepSeekAdapter against a mock server', () => {
stream: true,
stream_options: { include_usage: true },
})
// Attribution header identifies the harness to the provider.
expect(server.headers[0]?.['user-agent']).toMatch(/^deepseek-harness\//)
// Attribution reaches the wire: the exact 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')
expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories')
})
it('streams raw chunks through ctx.llm.stream', async () => {

View File

@@ -25,6 +25,10 @@ Same shape as llm-deepseek (one-line swap in cordis.yml), with pi-ai's thinking-
reasoning: high # off | high | xhigh (xhigh → wire 'max')
```
## App attribution
Every request carries the shared attribution header from dsh-llm's `attributionHeaders()`, passed through pi-ai's `headers` stream option (pi-ai merges caller headers last, so it always reaches the wire - the unit suite asserts arrival on the mock server, same as llm-deepseek). OpenRouter-specific app attribution headers are intentionally not sent by this adapter contract; they are deferred to a future explicit OpenRouter adapter or mode. See [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts).
## Dependency weight
pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time dependencies. They are lazy-loaded — only the openai SDK actually loads for this adapter — but they do land in `node_modules`. Accepted for a package whose purpose is design verification.

View File

@@ -13,7 +13,7 @@
import { stream as piStream } from '@earendil-works/pi-ai'
import type { Model } from '@earendil-works/pi-ai'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { attributionHeaders, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { toPiContext, toStreamChunks } from './convert.ts'
@@ -157,6 +157,9 @@ export class PiAiAdapter extends LlmAdapter {
try {
const events = piStream(model, toPiContext(options), {
apiKey: this.options.apiKey,
// pi-ai merges caller headers last over its provider defaults, so the
// harness attribution always reaches the wire.
headers: attributionHeaders(),
...options.temperature !== undefined ? { temperature: options.temperature } : {},
...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {},
signal: controller.signal,

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 } 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'
@@ -11,6 +11,8 @@ import { assemble } from './assemble.ts'
interface MockServer {
url: string
requests: unknown[]
/** Header bags of received requests, in order (parallel to `requests`). */
headers: IncomingMessage['headers'][]
close(): Promise<void>
}
@@ -22,11 +24,13 @@ afterEach(async () => {
async function mockServer(script: { status?: number; events?: string[]; body?: string }[]): Promise<MockServer> {
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
if (behavior.status !== undefined && behavior.status !== 200) {
response.writeHead(behavior.status, { 'content-type': 'application/json' })
@@ -45,6 +49,7 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s
return {
url: `http://127.0.0.1:${address.port}`,
requests,
headers,
close: () => new Promise(resolve => server.close(() => { resolve() })),
}
}
@@ -91,6 +96,14 @@ describe('PiAiAdapter against a mock server', () => {
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(result.finish).toEqual({ kind: 'stop' })
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 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')
expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories')
})
it('streams tool calls with re-stringified arguments', async () => {

View File

@@ -29,6 +29,10 @@ Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `
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.
### App attribution (`attribution.ts`)
Every product adapter must identify the application on every provider HTTP request - attribution is part of the adapter contract, not an adapter-local nicety. `attributionHeaders(identity?)` builds the standard `User-Agent` header (`product/version (+url)`, from `userAgent()`) for every request. The default `APP_IDENTITY` carries only static public product facts (its version is read from this package's manifest); a white-label deployment passes its own `AppIdentity`, and omission falls back to the default - nothing can suppress attribution. OpenRouter-specific app attribution headers are intentionally not supported by this contract. An adapter proves compliance with a wire-level test: a mock server asserting the received header (or, for a library-backed adapter, that the library's header hook delivers the same value). Policy and rationale: [Mandatory `User-Agent` attribution](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
### Classes
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.

View File

@@ -0,0 +1,71 @@
/**
* App-attribution vocabulary for provider requests.
*
* Every product LLM adapter must identify the application on every provider
* HTTP request (see the adapter contract on {@link ../index.ts LlmAdapter}):
* a static, non-secret product identity, sent as the standard `User-Agent`.
* Adapters obtain the headers from {@link attributionHeaders} instead of
* hand-copying constants, so the identity cannot drift between
* implementations. The policy and its rationale are pinned in
* docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md.
*
* @module @deepseek-ai/dsh-llm/attribution
*/
import { createRequire } from 'node:module'
// The package's own manifest is the single source of the version so the
// User-Agent cannot drift from what is published (`./package.json` is an
// export of this package; the relative path resolves from both `src/` and
// the bundled `lib/`).
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
/**
* Static public application identity sent to LLM providers.
*
* Every field is a public product fact, safe on every request: no secrets,
* local paths, session ids, prompt text, or per-user identifiers belong here,
* and nothing per-request may influence the values.
*/
export interface AppIdentity {
/** `User-Agent` product token (lowercase, hyphenated). */
product: string
/** Product version; sourced from package metadata, never hand-copied. */
version: string
/** Public home URL of the app, used as the `User-Agent` comment. */
url: string
}
/**
* The harness's own identity: the default every adapter sends. Deployments
* that need a white-label identity pass their own {@link AppIdentity} to
* {@link attributionHeaders} — omission falls back to this default; nothing
* can suppress attribution entirely.
*/
export const APP_IDENTITY: AppIdentity = {
product: 'deepseek-harness',
version,
// FIXME: create the public deepseek-ai/deepseek-harness-sdk repository this
// URL promises before the first release ships attribution pointing at it.
url: 'https://github.com/deepseek-ai/deepseek-harness-sdk',
}
/**
* The standard `User-Agent` value: `product/version (+url)`. The
* parenthesized `+url` comment is the conventional self-identification form
* (RFC 9110 §10.1.5 product + comment syntax).
*/
export function userAgent(identity: AppIdentity = APP_IDENTITY): string {
return `${identity.product}/${identity.version} (+${identity.url})`
}
/**
* Build the attribution headers an adapter must send on every provider
* request. Header names are lowercase (HTTP field names are case-insensitive
* on the wire).
*/
export function attributionHeaders(
identity: AppIdentity = APP_IDENTITY,
): Record<string, string> {
return { 'user-agent': userAgent(identity) }
}

View File

@@ -10,6 +10,7 @@ import { Context, Service } from 'cordis'
import type { GenerateOptions, StreamChunk } from './types.ts'
import { HarnessError } from './error.ts'
export * from './attribution.ts'
export * from './brand.ts'
export * from './never.ts'
export * from './error.ts'
@@ -57,6 +58,13 @@ export class LlmError extends HarnessError {
* fetch/SSE) and `@deepseek-ai/dsh-llm-pi-ai` (pi-ai-backed) — two
* deliberately different internals over the same contract; see the
* adapter contract documented on `StreamChunk` in `./types.ts`.
*
* App attribution is part of the adapter contract: every HTTP request to a
* provider carries the headers from `attributionHeaders()` (`./attribution.ts`)
* — the standard `User-Agent` baseline everywhere. An adapter proves it with
* a wire-level test (a mock server asserting the received header), or, for a
* library-backed adapter, by asserting the library's header hook delivers the
* same value to the wire.
*/
export abstract class LlmAdapter {
/** Stream one model call as raw chunks. The only required method. */

View File

@@ -0,0 +1,51 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
import { APP_IDENTITY, attributionHeaders, userAgent } from '@deepseek-ai/dsh-llm'
import type { AppIdentity } from '@deepseek-ai/dsh-llm'
const manifest = createRequire(import.meta.url)('../package.json') as { version: string }
/** A white-label identity exercising every override seam. */
const forkIdentity: AppIdentity = {
product: 'fork-agent',
version: '9.9.9',
url: 'https://example.com/fork-agent',
}
describe('APP_IDENTITY', () => {
it('sources the version from the package manifest, never a hand-copied constant', () => {
expect(APP_IDENTITY.version).toBe(manifest.version)
})
it('carries only static public product facts', () => {
expect(APP_IDENTITY).toEqual({
product: 'deepseek-harness',
version: manifest.version,
url: 'https://github.com/deepseek-ai/deepseek-harness-sdk',
})
})
})
describe('userAgent', () => {
it('renders product/version with the +url comment', () => {
expect(userAgent()).toBe(
`deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness-sdk)`,
)
})
it('renders a custom identity', () => {
expect(userAgent(forkIdentity)).toBe('fork-agent/9.9.9 (+https://example.com/fork-agent)')
})
})
describe('attributionHeaders', () => {
it('defaults to the provider-neutral baseline: User-Agent and nothing else', () => {
expect(attributionHeaders()).toEqual({ 'user-agent': userAgent() })
})
it('maps a custom identity onto the User-Agent header only', () => {
expect(attributionHeaders(forkIdentity)).toEqual({
'user-agent': 'fork-agent/9.9.9 (+https://example.com/fork-agent)',
})
})
})