Implement mandatory app-attribution headers per the RFC

dsh-llm owns the vocabulary (attribution.ts): AppIdentity with the version
read from the package manifest, userAgent(), and attributionHeaders(target,
identity) over a closed AttributionTarget union ('generic' | 'openrouter').
Both adapters send the headers on every provider request — llm-deepseek in
its fetch headers, llm-pi-ai through pi-ai's StreamOptions.headers — behind
an explicit attributionTarget config (never inferred from baseURL), with
mock-server tests asserting exact wire arrival and the absence of the
OpenRouter set by default.

The RFC moves to implemented/ amended with the settled identity (the
deepseek-harness token, the DeepSeek Harness title, the planned
deepseek-ai/deepseek-harness-sdk URL behind a FIXME until that repo exists)
and the explicit-config OpenRouter decision.
This commit is contained in:
Tianyi Cui
2026-07-04 18:14:42 +08:00
parent 7e6edb7474
commit 0ebb86e70f
18 changed files with 403 additions and 100 deletions

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(target?, identity?)` builds the headers to send: the standard `User-Agent` baseline (`product/version (+url)`, from `userAgent()`) for every request, plus a provider-specific set only for an explicitly configured `AttributionTarget` (`'openrouter'` adds OpenRouter's documented `HTTP-Referer` / `X-OpenRouter-Title` / `X-OpenRouter-Categories`; the target is adapter config, never inferred from a base URL). 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. An adapter proves compliance with a wire-level test: a mock server asserting the received headers (or, for a library-backed adapter, that the library's header hook delivers the same values). Policy and rationale: [Mandatory app-attribution headers](../../../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,113 @@
/**
* 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`
* baseline plus provider-specific headers only where a provider documents an
* attribution mechanism (OpenRouter today). 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'
import { assertNever } from './never.ts'
// 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 display name, for providers with app pages (OpenRouter title). */
title: string
/** Public home URL of the app (OpenRouter's app identifier). */
url: string
/** Category tags for providers with app marketplaces (OpenRouter). */
categories: readonly 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,
title: 'DeepSeek Harness',
// 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',
categories: ['cli-agent'],
}
/**
* Which provider-specific attribution mapping to apply on top of the
* `User-Agent` baseline. A closed union: add a variant only when a provider
* documents an attribution mechanism — never reuse another provider's
* headers by analogy.
*
* - `'generic'` — the provider-neutral baseline; `User-Agent` only.
* - `'openrouter'` — adds OpenRouter's documented app-attribution set
* (`HTTP-Referer`, `X-OpenRouter-Title`, `X-OpenRouter-Categories`).
* Selection is always explicit adapter config; adapters must not infer it
* from base-URL fragments or model names.
*/
export type AttributionTarget = 'generic' | 'openrouter'
/**
* 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; OpenRouter documents them as `HTTP-Referer`,
* `X-OpenRouter-Title`, and `X-OpenRouter-Categories`, the latter joined
* from {@link AppIdentity.categories} with commas).
*
* `target` defaults to `'generic'` here, in the module that owns the
* vocabulary, so every adapter shares one defaulting rule instead of each
* implementation hiding its own.
*/
export function attributionHeaders(
target: AttributionTarget = 'generic',
identity: AppIdentity = APP_IDENTITY,
): Record<string, string> {
switch (target) {
case 'generic':
return { 'user-agent': userAgent(identity) }
case 'openrouter':
return {
'user-agent': userAgent(identity),
'http-referer': identity.url,
'x-openrouter-title': identity.title,
'x-openrouter-categories': identity.categories.join(','),
}
default:
return assertNever(target, 'attributionHeaders')
}
}

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'
@@ -56,6 +57,14 @@ 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, plus a provider-specific
* set only for an explicitly configured {@link AttributionTarget}. An adapter
* proves it with a wire-level test (a mock server asserting the received
* headers), or, for a library-backed adapter, by asserting the library's
* header hook delivers the same values to the wire.
*/
export abstract class LlmAdapter {
/** Stream one model call as raw chunks. The only required method. */

View File

@@ -0,0 +1,75 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
import { APP_IDENTITY, attributionHeaders, userAgent } from '@deepseek-ai/dsh-llm'
import type { AppIdentity, AttributionTarget } 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',
title: 'Fork Agent',
url: 'https://example.com/fork-agent',
categories: ['ide-extension', 'cli-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,
title: 'DeepSeek Harness',
url: 'https://github.com/deepseek-ai/deepseek-harness-sdk',
categories: ['cli-agent'],
})
})
})
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('adds exactly the OpenRouter set for the openrouter target', () => {
expect(attributionHeaders('openrouter')).toEqual({
'user-agent': userAgent(),
'http-referer': APP_IDENTITY.url,
'x-openrouter-title': APP_IDENTITY.title,
'x-openrouter-categories': 'cli-agent',
})
})
it('maps a custom identity onto both targets', () => {
expect(attributionHeaders('generic', forkIdentity)).toEqual({
'user-agent': 'fork-agent/9.9.9 (+https://example.com/fork-agent)',
})
expect(attributionHeaders('openrouter', forkIdentity)).toEqual({
'user-agent': 'fork-agent/9.9.9 (+https://example.com/fork-agent)',
'http-referer': 'https://example.com/fork-agent',
'x-openrouter-title': 'Fork Agent',
'x-openrouter-categories': 'ide-extension,cli-agent',
})
})
it('rejects targets outside the closed union at runtime', () => {
expect(() => attributionHeaders('acme' as unknown as AttributionTarget))
.toThrow('unreachable variant in attributionHeaders: "acme"')
})
})