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

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

View File

@@ -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 } : {},

View File

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

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