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:
@@ -15,6 +15,7 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds
|
||||
models: [deepseek-v4-flash, deepseek-v4-pro] # one adapter, registered for each name
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
|
||||
attributionTarget: openrouter # optional; generic | openrouter — omitted ⇒ generic
|
||||
```
|
||||
|
||||
`models` lists every model name this one adapter instance serves: the adapter registers itself for each (the harness model name IS the wire `model` string), so a `generate`/`stream` call routes to it whenever `options.model` is any of them. Registering a second adapter for a name already taken throws `LlmError('DUPLICATE_ADAPTER')` (the LLM service enforces one adapter per model, all-or-nothing).
|
||||
@@ -23,6 +24,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 headers 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 get no provider-specific headers. Set `attributionTarget: openrouter` **only** when `baseURL` points at OpenRouter: it adds OpenRouter's documented app-attribution set (`HTTP-Referer`, `X-OpenRouter-Title`, `X-OpenRouter-Categories`). The target is explicit config by design — the adapter never infers it from the URL.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { AttributionTarget, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeRequest } from './serialize.ts'
|
||||
import type { RequestDefaults } from './serialize.ts'
|
||||
import { parseSse } from './sse.ts'
|
||||
@@ -19,15 +19,15 @@ export interface DeepSeekAdapterOptions {
|
||||
baseURL: string
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults?: RequestDefaults
|
||||
/**
|
||||
* Provider-specific attribution mapping on top of the mandatory
|
||||
* `User-Agent` baseline (dsh-llm's `attributionHeaders`). Set to
|
||||
* `'openrouter'` when `baseURL` points at OpenRouter; never inferred
|
||||
* from the URL.
|
||||
*/
|
||||
attributionTarget?: AttributionTarget | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +67,7 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'text/event-stream',
|
||||
'user-agent': USER_AGENT,
|
||||
...attributionHeaders(this.options.attributionTarget),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface Config {
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Thinking effort (only meaningful with thinking enabled). */
|
||||
reasoningEffort?: 'high' | 'max'
|
||||
/**
|
||||
* Provider-specific attribution set to send alongside the mandatory
|
||||
* `User-Agent`: `'openrouter'` when `baseURL` points at OpenRouter.
|
||||
* Omitted = the provider-neutral baseline.
|
||||
*/
|
||||
attributionTarget?: 'generic' | 'openrouter'
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -53,6 +59,7 @@ export const Config: z<Config> = z.object({
|
||||
models: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v4-pro']),
|
||||
thinking: z.union(['enabled', 'disabled']),
|
||||
reasoningEffort: z.union(['high', 'max']),
|
||||
attributionTarget: z.union(['generic', 'openrouter']),
|
||||
})
|
||||
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
@@ -74,5 +81,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
thinking: config.thinking,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
},
|
||||
attributionTarget: config.attributionTarget,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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, { APP_IDENTITY, 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,28 @@ 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 without an explicitly configured target.
|
||||
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('sends the OpenRouter attribution set when the target is configured', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url, { attributionTarget: 'openrouter' })
|
||||
|
||||
await assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})
|
||||
expect(server.headers[0]).toMatchObject({
|
||||
'user-agent': userAgent(),
|
||||
'http-referer': APP_IDENTITY.url,
|
||||
'x-openrouter-title': APP_IDENTITY.title,
|
||||
'x-openrouter-categories': APP_IDENTITY.categories.join(','),
|
||||
})
|
||||
})
|
||||
|
||||
it('streams raw chunks through ctx.llm.stream', async () => {
|
||||
|
||||
@@ -23,8 +23,13 @@ Same shape as llm-deepseek (one-line swap in cordis.yml), with pi-ai's thinking-
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
models: [deepseek-v4-flash, deepseek-v4-pro]
|
||||
reasoning: high # off | high | xhigh (xhigh → wire 'max')
|
||||
attributionTarget: openrouter # optional; generic | openrouter — omitted ⇒ generic
|
||||
```
|
||||
|
||||
## App attribution
|
||||
|
||||
Every request carries the shared attribution headers from dsh-llm's `attributionHeaders()`, passed through pi-ai's `headers` stream option (pi-ai merges caller headers last, so they always reach the wire — the unit suite asserts arrival on the mock server, same as llm-deepseek). `attributionTarget: openrouter` adds OpenRouter's documented set (`HTTP-Referer`, `X-OpenRouter-Title`, `X-OpenRouter-Categories`) and is explicit config only — never inferred from `baseURL`. 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.
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
import { stream as piStream } from '@earendil-works/pi-ai'
|
||||
import type { Model } from '@earendil-works/pi-ai'
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { AttributionTarget, GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { toPiContext, toStreamChunks } from './convert.ts'
|
||||
|
||||
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
|
||||
@@ -26,6 +26,12 @@ export interface PiAiAdapterOptions {
|
||||
baseURL: string
|
||||
/** Thinking level applied to every request ('off' disables thinking). */
|
||||
reasoning?: PiAiReasoning | undefined
|
||||
/**
|
||||
* Provider-specific attribution set on top of the mandatory `User-Agent`
|
||||
* baseline (dsh-llm's `attributionHeaders`). Set to `'openrouter'` when
|
||||
* `baseURL` points at OpenRouter; never inferred from the URL.
|
||||
*/
|
||||
attributionTarget?: AttributionTarget | undefined
|
||||
}
|
||||
|
||||
/** Build the inline pi-ai model descriptor for one DeepSeek model name. */
|
||||
@@ -171,6 +177,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(this.options.attributionTarget),
|
||||
...options.temperature !== undefined ? { temperature: options.temperature } : {},
|
||||
...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {},
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface Config {
|
||||
* (thinking enabled), matching llm-deepseek's omission semantics.
|
||||
*/
|
||||
reasoning?: PiAiReasoning
|
||||
/**
|
||||
* Provider-specific attribution set to send alongside the mandatory
|
||||
* `User-Agent`: `'openrouter'` when `baseURL` points at OpenRouter.
|
||||
* Omitted = the provider-neutral baseline.
|
||||
*/
|
||||
attributionTarget?: 'generic' | 'openrouter'
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -49,6 +55,7 @@ export const Config: z<Config> = z.object({
|
||||
baseURL: z.string(),
|
||||
models: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v4-pro']),
|
||||
reasoning: z.union(['off', 'high', 'xhigh']),
|
||||
attributionTarget: z.union(['generic', 'openrouter']),
|
||||
})
|
||||
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
@@ -67,5 +74,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
apiKey,
|
||||
baseURL,
|
||||
reasoning: config.reasoning,
|
||||
attributionTarget: config.attributionTarget,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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 } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { APP_IDENTITY, CallId, LlmError, 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,30 @@ 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 without an
|
||||
// explicitly configured target.
|
||||
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('sends the OpenRouter attribution set when the target is configured', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url, { attributionTarget: 'openrouter' })
|
||||
|
||||
await assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})
|
||||
expect(server.headers[0]).toMatchObject({
|
||||
'user-agent': userAgent(),
|
||||
'http-referer': APP_IDENTITY.url,
|
||||
'x-openrouter-title': APP_IDENTITY.title,
|
||||
'x-openrouter-categories': APP_IDENTITY.categories.join(','),
|
||||
})
|
||||
})
|
||||
|
||||
it('streams tool calls with re-stringified arguments', async () => {
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
113
packages/llm/llm/src/attribution.ts
Normal file
113
packages/llm/llm/src/attribution.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
75
packages/llm/llm/tests/attribution.spec.ts
Normal file
75
packages/llm/llm/tests/attribution.spec.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user