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

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

View File

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

View File

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

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