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

@@ -211,7 +211,7 @@ Waterfall around every streaming model call (retry, caching, routing). Bound to
Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:31`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:32`](../../packages/llm/llm/src/index.ts)
### `session/*`
@@ -458,7 +458,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:78`](../../packages/llm/llm/src/index.ts)
### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)

View File

@@ -26,9 +26,24 @@ Every adapter MUST obey these, and every consumer may rely on them:
- **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering.
- **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`.
- **Two sanctioned error paths.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted'}` (provider in-band errors, for adapters that can't throw mid-stream). Consumers must handle *both*. The agent loop translates a finish-error/aborted into a turn error — it never logs a normal completed assistant message for a failed step.
- **Every provider HTTP request carries the app-attribution headers.** Adapters send `attributionHeaders()` (below) — the `User-Agent` baseline always, a provider-specific set only for an explicitly configured target — and prove it with a wire-level test (mock server asserting received headers, or the library's header hook for a library-backed adapter).
This contract is why two adapters exist as a deliberate pair: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (the same endpoint through `@earendil-works/pi-ai`). Two independent internals over one contract is what pinned the protocol down — the library-backed adapter can't throw mid-stream, so it exercises the finish-chunk error path the hand-rolled one might not.
## `AppIdentity` — app attribution
The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(target?, identity?)` maps it to wire headers per `AttributionTarget` — a **closed** union (`'generic'` = the `User-Agent` baseline only; `'openrouter'` adds OpenRouter's documented `HTTP-Referer` / `X-OpenRouter-Title` / `X-OpenRouter-Categories`), selected by explicit adapter config and never inferred from a base URL. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact — no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory app-attribution headers](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
```ts type-equiv
interface AppIdentity {
product: string
version: string
title: string
url: string
categories: readonly string[]
}
```
## `TokenUsage`
Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out.

View File

@@ -69,7 +69,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|---|---|
| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
| [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
| [Mandatory app-attribution headers for provider requests](proposed/architecture/2026-06-21-mandatory-app-attribution-headers.md) | 2026-06-21 |
### Process
@@ -143,6 +142,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
| [Mandatory app-attribution headers for provider requests](implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md) | 2026-06-21 |
| [Web capability seam — provider registry and model-facing web tools](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 |
| [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 |
| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 |

View File

@@ -0,0 +1,83 @@
# RFC: Mandatory app-attribution headers for provider requests
Status: implemented
## Problem
LLM provider requests should identify the product making them. That is useful for provider-side support, abuse investigation, compatibility debugging, traffic analytics, and public app attribution where a provider exposes it. Before this RFC the harness only partially did this: the hand-rolled DeepSeek adapter sent a hand-copied `User-Agent` constant (`packages/llm/llm-deepseek/src/adapter.ts`), while the pi-ai-backed twin sent no harness-owned headers at all (`packages/llm/llm-pi-ai/src/adapter.ts`). New adapters could therefore omit attribution silently, and a library-backed adapter could drift from the hand-rolled adapter even though [the twin-adapter RFC](2026-06-13-twin-llm-adapters.md) exists to keep the provider seam honest across both implementations.
The immediate prompt came from OpenRouter's [App Attribution](https://openrouter.ai/docs/app-attribution) docs. OpenRouter creates app pages and rankings from `HTTP-Referer` plus display/category headers. That is valuable, but it is not the HTTP standard for application identity. The risk is adopting OpenRouter's exact header set as if it were universal, then leaking provider-specific headers to direct DeepSeek requests, future OpenAI/Anthropic/Vertex adapters, test servers, or proxies that log unknown fields indefinitely.
## Investigation
- **OpenRouter's mechanism is provider-specific.** Their current docs say app attribution is tracked through `HTTP-Referer` (required), `X-OpenRouter-Title`, and `X-OpenRouter-Categories`; `X-Title` is only accepted for backward compatibility. Their API reference calls the headers optional and says they make the app discoverable on OpenRouter. This is a concrete OpenRouter contract, not an IETF or OpenAI-compatible API standard.
- **In agent tooling, `HTTP-Referer` is an OpenRouter-aware convention, not a general agent convention.** It is common enough that OpenRouter SDKs and OpenRouter examples expose it directly, and frameworks that target OpenRouter usually need a way to pass it through. But agent protocols such as ACP negotiate names, versions, and capabilities in their own initialize messages, while model-provider requests still need HTTP-level identity. "Accepted in the agent world" therefore means "recognized by OpenRouter integrations," not "portable across agent runtimes or providers."
- **Observed coding agents use product/version `User-Agent` strings, sometimes with environment context.** A non-exhaustive public-code survey found OpenAI Codex building `{originator}/{version} ({os} {os_version}; {arch}) ...` and carrying an `originator` header; Google Gemini CLI sending `GeminiCLI[-clientName]/{version}/{model} ({platform}; {arch}; {surface})` or a Cloud Code VS Code variant; Cline's Codex backend client sending `cline/{version} ({platform} {release}; {arch}) node/{nodeVersion}` plus `originator: cline`; SWE-agent setting `swe-agent/{version}` unless the user already supplied a header; Continue setting `Continue/{version}` for its ClawRouter provider plus `X-Continue-Provider`. Aider also appends `Aider/{version} +{website}` to browser-like user agents for web scraping, but that is not a model-provider request path. The pattern is not one exact format; it is product identity in `User-Agent`, with provider-specific side headers only where a provider/backend asks for them.
- **The standards-track general client identity header is `User-Agent`.** RFC 9110 section 10.1.5 defines `User-Agent` as the user-agent software identity, says it is used for interoperability reports and analytics, and says a user agent SHOULD send it on each request unless configured not to. This is the only standard header that directly matches "what product is making this HTTP request."
- **`Referer` is standard, but OpenRouter's `HTTP-Referer` is not the standard field.** RFC 9110 section 10.1.3 defines `Referer` as the URI from which the target URI was obtained and spends significant text on privacy restrictions. OpenRouter instead asks for `HTTP-Referer`, using it as an app URL identifier. That name and meaning are OpenRouter-specific even though it resembles the CGI environment variable form of the standard `Referer` header.
- **`From` is standard but not suitable as a mandatory default.** RFC 9110 section 10.1.2 defines `From` as an email address for the human responsible for a user agent. Robotic agents SHOULD send it so servers can contact an operator, but non-robotic agents should not send it without explicit user configuration because of privacy and security policy concerns. The harness can support an operator contact later, but must not invent one or require it globally.
- **Request-body `user` or `metadata` fields are not app attribution.** Some model APIs expose a stable end-user identifier, request metadata, labels, or project/account headers. Those are useful for abuse monitoring, internal billing, dashboards, or trace correlation, but they either identify the end user rather than the product, are provider-specific body schema, or are not guaranteed to be forwarded through OpenAI-compatible gateways. They are not a substitute for a static application identity header.
- **SDK telemetry headers identify the SDK, not the app.** Official and third-party SDKs often send library/version headers. Those help the SDK maintainer debug their client, but they do not identify the harness as the application unless the application explicitly supplies a product attribution layer.
- **pi-ai has a first-class header hook.** `@earendil-works/pi-ai`'s `StreamOptions.headers` merges caller headers last over provider defaults, so a library-backed adapter can satisfy the same wire contract as the hand-rolled one without wrapping or upstream work — the mock-server suites assert arrival on the wire for both adapters.
## Decision
Provider request attribution is mandatory at the LLM adapter boundary, with a provider-neutral app identity and provider-specific wire mappings. The rule: every product LLM adapter sends a static, non-secret application identity on every provider HTTP request, and every adapter has tests proving the identity reaches the wire (a mock server asserting received headers; for a library-backed adapter, the library's header hook feeding the same mock-server assertion).
For OpenRouter specifically, attribution means sending **both** the provider-neutral `User-Agent` and OpenRouter's app identifier, `HTTP-Referer`. `User-Agent` identifies the client software in the standard HTTP way; `HTTP-Referer` is the OpenRouter-specific app URL key that creates the app page and ranking entry. `X-OpenRouter-Title` and `X-OpenRouter-Categories` refine that same OpenRouter app identity.
The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attribution.ts`), not by individual adapters. `AppIdentity` contains only public product facts, and the default `APP_IDENTITY` settles the values the proposal left open:
- product token for `User-Agent`: `deepseek-harness` (continuity with the pre-RFC wire value and the repo/org identity)
- version: read from the owning package's manifest via `createRequire`, never a hand-copied constant
- app title: `DeepSeek Harness`
- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` — the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists
- category list for providers with public app marketplaces: `cli-agent`
The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(target, identity)` — the override seam is the function parameter, with no deployment config plumbing until a consumer needs it — and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields.
Wire mapping (`attributionHeaders`; header names lowercase in code — HTTP field names are case-insensitive on the wire):
| Target | Mapping |
|---|---|
| `generic` (every HTTP-based adapter's default) | `User-Agent: {product}/{version} (+{url})` — the parenthesized `+url` comment stays within RFC 9110's conservative product/comment syntax. |
| `openrouter` | `HTTP-Referer`, `X-OpenRouter-Title`, and `X-OpenRouter-Categories` (comma-joined) in addition to `User-Agent`. `X-OpenRouter-Title`, not legacy `X-Title`. |
| Direct DeepSeek endpoint | `generic`; no OpenRouter-only headers unless DeepSeek documents an equivalent contract. |
| Future providers | Add an `AttributionTarget` variant only when that provider documents an app attribution mechanism. Do not reuse `HTTP-Referer` by analogy. |
Target selection is **explicit adapter config only**: both adapters expose `attributionTarget: 'generic' | 'openrouter'` (`DeepSeekAdapterOptions` / `PiAiAdapterOptions` and the matching plugin `Config` key), defaulting to `generic` in `dsh-llm` where the vocabulary lives. The proposal's alternative arm — recognizing `https://openrouter.ai/api/v1` by exact match — was not taken: it trades a magic constant for covering only one spelling of the endpoint (proxied/regional URLs still need the option), and a user pointing `baseURL` at OpenRouter without the flag still sends the standard `User-Agent` baseline.
`AttributionTarget` is a closed union (`switch` + `assertNever`), deliberately **not** merge-extensible: an attribution mapping is a documented cross-provider contract owned by `dsh-llm`, not a plugin extension point, so a future provider mapping is a compile-visible change to the owning module.
## Acceptance criteria (all landed)
- `dsh-llm` documents the mandatory app-attribution contract for `LlmAdapter` authors (`LlmAdapter` JSDoc, package README, and the adapter-contract section of `docs/core-data-structures/llm-streaming.md`).
- A shared helper (`attributionHeaders` / `userAgent`) constructs the app identity and the standard `User-Agent` value from package metadata, so adapters do not hand-copy version constants.
- `dsh-llm-deepseek` sends the shared headers on every request; its mock-server suite asserts the exact `User-Agent`, asserts the OpenRouter set is absent by default, and asserts the exact OpenRouter headers when `attributionTarget: 'openrouter'` is configured.
- `dsh-llm-pi-ai` sends the same headers through pi-ai's `StreamOptions.headers` hook, with the same three wire-level assertions — the twin contract includes attribution.
- No app-attribution field carries secrets, local paths, session ids, prompt text, model output, user email, or per-user stable identifiers.
- The adapter READMEs state the attribution policy and the OpenRouter-specific mapping.
## Alternatives considered
**OpenRouter headers everywhere.** Rejected. It would satisfy OpenRouter rankings, but it treats a custom OpenRouter contract as a universal standard and sends fields with misleading semantics to providers that did not ask for them. It also risks using `HTTP-Referer` as a generic app URL field even though standard HTTP already has `User-Agent` for product identity and `Referer` for a different browsing-context concept.
**Only `User-Agent`.** Rejected as incomplete. It is the correct baseline and the only standard mechanism, but it cannot create OpenRouter app pages or marketplace rankings because OpenRouter requires `HTTP-Referer` for that product feature. Deferring the OpenRouter mapping until an in-repo OpenRouter deployment existed was also considered and rejected: the DeepSeek adapters already accept any OpenAI-compatible `baseURL`, so OpenRouter is reachable today via config alone, and the mapping is small enough that shipping it with wire tests costs less than re-opening the contract later.
**Only provider account/project identity.** Rejected. Organization/project headers, API keys, cloud accounts, and billing projects identify who pays or owns the request, not which application is sending traffic. They also expose no public app title/category and do not help gateways like OpenRouter build app rankings.
**End-user `user`/`metadata` fields.** Rejected for this RFC. Those are valuable for abuse monitoring and customer support but describe the human or tenant behind a request. App attribution must be static product identity and safe to send on every request.
**Config-only opt-in attribution.** Rejected. A default-off setting is exactly how adapters keep drifting. The policy is mandatory default attribution with overrideable public values, not optional attribution.
**Product-named token (`deepseek-code`).** Considered for the `User-Agent` token, since the product's name is DeepSeek Code. `deepseek-harness` won on continuity: it is the identity providers already see from this codebase, it matches the org/repo and planned SDK-repo naming, and a public rename can change the display `title` without breaking the machine-readable token history.
## Risks / what we give up
**Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`.
**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics).
**Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the headers, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract.
**OpenRouter categorization might go stale.** `cli-agent` is correct for the coding-agent demos and terminal use, but future editor-only or cloud-hosted products might deserve `ide-extension` or `cloud-agent`. Categories are overrideable via `AppIdentity` and are provider-specific presentation, not the core identity.

View File

@@ -1,81 +0,0 @@
# RFC: Mandatory app-attribution headers for provider requests
Status: proposed
## Problem
LLM provider requests should identify the product making them. That is useful for provider-side support, abuse investigation, compatibility debugging, traffic analytics, and public app attribution where a provider exposes it. The harness only partially does this today: the hand-rolled DeepSeek adapter sends `User-Agent: deepseek-harness/0.0.1` (`packages/llm/llm-deepseek/src/adapter.ts`), while the pi-ai-backed twin has no harness-owned header path visible in this repo (`packages/llm/llm-pi-ai/src/adapter.ts`). New adapters can therefore omit attribution silently, and a library-backed adapter can drift from the hand-rolled adapter even though [the twin-adapter RFC](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) exists to keep the provider seam honest across both implementations.
The immediate prompt came from OpenRouter's [App Attribution](https://openrouter.ai/docs/app-attribution) docs. OpenRouter creates app pages and rankings from `HTTP-Referer` plus display/category headers. That is valuable, but it is not the HTTP standard for application identity. The risk is adopting OpenRouter's exact header set as if it were universal, then leaking provider-specific headers to direct DeepSeek requests, future OpenAI/Anthropic/Vertex adapters, test servers, or proxies that log unknown fields indefinitely.
## Investigation
- **OpenRouter's mechanism is provider-specific.** Their current docs say app attribution is tracked through `HTTP-Referer` (required), `X-OpenRouter-Title`, and `X-OpenRouter-Categories`; `X-Title` is only accepted for backward compatibility. Their API reference calls the headers optional and says they make the app discoverable on OpenRouter. This is a concrete OpenRouter contract, not an IETF or OpenAI-compatible API standard.
- **In agent tooling, `HTTP-Referer` is an OpenRouter-aware convention, not a general agent convention.** It is common enough that OpenRouter SDKs and OpenRouter examples expose it directly, and frameworks that target OpenRouter usually need a way to pass it through. But agent protocols such as ACP negotiate names, versions, and capabilities in their own initialize messages, while model-provider requests still need HTTP-level identity. "Accepted in the agent world" therefore means "recognized by OpenRouter integrations," not "portable across agent runtimes or providers."
- **Observed coding agents use product/version `User-Agent` strings, sometimes with environment context.** A non-exhaustive public-code survey found OpenAI Codex building `{originator}/{version} ({os} {os_version}; {arch}) ...` and carrying an `originator` header; Google Gemini CLI sending `GeminiCLI[-clientName]/{version}/{model} ({platform}; {arch}; {surface})` or a Cloud Code VS Code variant; Cline's Codex backend client sending `cline/{version} ({platform} {release}; {arch}) node/{nodeVersion}` plus `originator: cline`; SWE-agent setting `swe-agent/{version}` unless the user already supplied a header; Continue setting `Continue/{version}` for its ClawRouter provider plus `X-Continue-Provider`. Aider also appends `Aider/{version} +{website}` to browser-like user agents for web scraping, but that is not a model-provider request path. The pattern is not one exact format; it is product identity in `User-Agent`, with provider-specific side headers only where a provider/backend asks for them.
- **The standards-track general client identity header is `User-Agent`.** RFC 9110 section 10.1.5 defines `User-Agent` as the user-agent software identity, says it is used for interoperability reports and analytics, and says a user agent SHOULD send it on each request unless configured not to. This is the only standard header that directly matches "what product is making this HTTP request."
- **`Referer` is standard, but OpenRouter's `HTTP-Referer` is not the standard field.** RFC 9110 section 10.1.3 defines `Referer` as the URI from which the target URI was obtained and spends significant text on privacy restrictions. OpenRouter instead asks for `HTTP-Referer`, using it as an app URL identifier. That name and meaning are OpenRouter-specific even though it resembles the CGI environment variable form of the standard `Referer` header.
- **`From` is standard but not suitable as a mandatory default.** RFC 9110 section 10.1.2 defines `From` as an email address for the human responsible for a user agent. Robotic agents SHOULD send it so servers can contact an operator, but non-robotic agents should not send it without explicit user configuration because of privacy and security policy concerns. The harness can support an operator contact later, but must not invent one or require it globally.
- **Request-body `user` or `metadata` fields are not app attribution.** Some model APIs expose a stable end-user identifier, request metadata, labels, or project/account headers. Those are useful for abuse monitoring, internal billing, dashboards, or trace correlation, but they either identify the end user rather than the product, are provider-specific body schema, or are not guaranteed to be forwarded through OpenAI-compatible gateways. They are not a substitute for a static application identity header.
- **SDK telemetry headers identify the SDK, not the app.** Official and third-party SDKs often send library/version headers. Those help the SDK maintainer debug their client, but they do not identify "DeepSeek Code" as the application unless the application explicitly supplies a product attribution layer.
## Proposal
Make provider request attribution mandatory at the LLM adapter boundary, with a provider-neutral app identity and provider-specific wire mappings. The rule is: every product LLM adapter must send a static, non-secret application identity on every provider HTTP request, and every adapter must have tests proving the identity reaches the wire or, for a library-backed adapter, proving the configured library hook emits equivalent headers.
For OpenRouter specifically, mandatory attribution means sending **both** the provider-neutral `User-Agent` and OpenRouter's required app identifier, `HTTP-Referer`. `User-Agent` identifies the client software in the standard HTTP way; `HTTP-Referer` is the OpenRouter-specific app URL key that creates the app page and ranking entry. `X-OpenRouter-Title` and `X-OpenRouter-Categories` refine that same OpenRouter app identity.
The provider-neutral identity should be owned outside individual adapters, ideally in `dsh-llm` or a tiny support package if importing package metadata from `dsh-llm` is too awkward. It should contain only public product facts:
- product token for `User-Agent`: `deepseek-code` or `deepseek-harness` (settle this when implementation chooses the public product name)
- version: the package/root version, not a manually duplicated constant
- app title: `DeepSeek Code`
- app URL: the public product or repository URL, not a local workspace path
- optional category list for providers that support public app marketplaces, initially `cli-agent`
The default is mandatory and non-empty. Deployments may override the title/URL/category values for white-label products or forks, but omission must fall back to the harness default rather than suppress attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields.
Wire mapping:
| Target | Required mapping |
|---|---|
| All HTTP-based adapters | Send `User-Agent` with the product token and version. Include the app URL as a comment only if the final value stays within the conservative syntax in RFC 9110. |
| OpenRouter endpoints | Send `HTTP-Referer`, `X-OpenRouter-Title`, and, when configured, `X-OpenRouter-Categories` in addition to `User-Agent`. Use `X-OpenRouter-Title`, not legacy `X-Title`, for new code. |
| Direct DeepSeek endpoint | Send `User-Agent`; do not send OpenRouter-only headers unless DeepSeek documents an equivalent contract. |
| Future providers | Add a small provider-specific mapper only when that provider documents an app attribution mechanism. Do not reuse `HTTP-Referer` by analogy. |
Endpoint detection should be explicit. If the adapter has an OpenRouter provider package later, that package always applies the OpenRouter mapper. If an existing OpenAI-compatible adapter can be pointed at arbitrary `baseURL` values, it may recognize `https://openrouter.ai/api/v1` exactly or expose an explicit `provider: 'openrouter'`/`attributionTarget: 'openrouter'` config. It should not infer OpenRouter from arbitrary path fragments or model names.
For the current twin adapters, this means the pi-ai-backed adapter cannot remain a silent exception. Either configure `@earendil-works/pi-ai` with request headers if the library supports that, wrap or contribute the missing hook upstream, or retire the library-backed adapter from product use until it can honor the same attribution contract. The value of the twin is comparing real implementations under one contract; attribution is now part of that contract.
## Acceptance criteria
- `dsh-llm` documents the mandatory app-attribution contract for `LlmAdapter` authors.
- A shared helper constructs the default app identity and the standard `User-Agent` value from package metadata, so adapters do not hand-copy `deepseek-harness/0.0.1` constants.
- `dsh-llm-deepseek` sends the shared `User-Agent` on direct DeepSeek requests and keeps the existing mock-server assertion, updated to the shared value.
- The OpenRouter mapping, wherever implemented, sends `HTTP-Referer`, `X-OpenRouter-Title`, and optional `X-OpenRouter-Categories`, with a test that uses an OpenRouter base URL or explicit OpenRouter target and asserts the exact headers.
- `dsh-llm-pi-ai` either sends the same attribution headers through a real library hook or is removed from adapter registration paths with a follow-up RFC explaining why the twin contract no longer justifies the maintenance cost.
- No app-attribution field carries secrets, local paths, session ids, prompt text, model output, user email, or per-user stable identifiers.
- The relevant adapter READMEs mention the attribution policy and the OpenRouter-specific mapping only where that mapping can actually be enabled.
## Alternatives considered
**OpenRouter headers everywhere.** Rejected. It would satisfy OpenRouter rankings, but it treats a custom OpenRouter contract as a universal standard and sends fields with misleading semantics to providers that did not ask for them. It also risks using `HTTP-Referer` as a generic app URL field even though standard HTTP already has `User-Agent` for product identity and `Referer` for a different browsing-context concept.
**Only `User-Agent`.** Rejected as incomplete. It is the correct baseline and the only standard mechanism, but it cannot create OpenRouter app pages or marketplace rankings because OpenRouter requires `HTTP-Referer` for that product feature.
**Only provider account/project identity.** Rejected. Organization/project headers, API keys, cloud accounts, and billing projects identify who pays or owns the request, not which application is sending traffic. They also expose no public app title/category and do not help gateways like OpenRouter build app rankings.
**End-user `user`/`metadata` fields.** Rejected for this RFC. Those are valuable for abuse monitoring and customer support but describe the human or tenant behind a request. App attribution must be static product identity and safe to send on every request.
**Config-only opt-in attribution.** Rejected. A default-off setting is exactly how adapters keep drifting. This RFC's policy is mandatory default attribution with overrideable public values, not optional attribution.
## Risks / what we give up
**Providers see that traffic comes from DeepSeek Code.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable as the harness. Mitigation: send only static public product data and allow forks/white-label deployments to override the public app title and URL.
**Header support differs by client library.** The hand-rolled adapter can set headers directly; the pi-ai-backed adapter may require an upstream hook or wrapper. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract.
**Version sourcing needs a clean implementation.** The existing `USER_AGENT = 'deepseek-harness/0.0.1'` constant is intentionally manual. Replacing it with package metadata may need a small build-time or runtime helper. That helper is worth it because stale attribution is a low-grade lie that tests can otherwise miss.
**OpenRouter categorization might go stale.** `cli-agent` is correct for the coding-agent demos and terminal use, but future editor-only or cloud-hosted products might deserve `ide-extension` or `cloud-agent`. Keep categories overrideable and treat them as provider-specific presentation, not the core identity.