Merge remote-tracking branch 'origin/master' into worktree/naming-contract-proposal

This commit is contained in:
Tianyi Cui
2026-08-11 21:16:36 +08:00
41 changed files with 227 additions and 68 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md
2026-06-21-mandatory-app-attribution-headers.md: 39050a53ec76e8c5a6cac4d8e31fa15b992c406e 2026-06-21-mandatory-app-attribution-headers.md: c90b136a556da81e1c15828926f18b865cea4ece
2026-06-21-mandatory-app-attribution-headers.zh.md: bfd6aa2540022f68cf9f69ccc2c12b3bc0978960 2026-06-21-mandatory-app-attribution-headers.zh.md: f68cad7e9f642319dc508de9261e33180bfe5748

View File

@@ -24,7 +24,7 @@ The immediate prompt came from OpenRouter's [App Attribution](https://openrouter
## Decision ## Decision
Provider request attribution is mandatory at the LLM adapter boundary, using the standard `User-Agent` header only. The rule: every product LLM adapter sends a static, non-secret application identity on every provider HTTP request, and every adapter has tests proving that `User-Agent` 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). Provider-neutral app attribution is mandatory at the LLM adapter boundary, using the standard `User-Agent` header only. The rule: every product LLM adapter sends a static, non-secret application identity on every provider HTTP request, and every adapter has tests proving that `User-Agent` 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). This rule governs app attribution, not provider-specific request identity: [the DeepSeek request-identity decision](../feature/2026-08-11-deepseek-request-user-id-header.md) separately owns its user and session headers.
OpenRouter app attribution is deliberately not implemented. `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, and `X-OpenRouter-Categories` are OpenRouter-specific product-surface headers, not provider-neutral model-request attribution. They can be proposed later by an OpenRouter adapter or explicit OpenRouter mode, with its own privacy/product decision, tests, and docs. Until then, even requests pointed at OpenRouter send only the shared `User-Agent` attribution from this decision. OpenRouter app attribution is deliberately not implemented. `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, and `X-OpenRouter-Categories` are OpenRouter-specific product-surface headers, not provider-neutral model-request attribution. They can be proposed later by an OpenRouter adapter or explicit OpenRouter mode, with its own privacy/product decision, tests, and docs. Until then, even requests pointed at OpenRouter send only the shared `User-Agent` attribution from this decision.
@@ -41,7 +41,7 @@ Wire mapping (`attributionHeaders`; header names lowercase in code - HTTP field
| Target | Mapping | | Target | Mapping |
|---|---| |---|---|
| All HTTP-based adapters | `User-Agent: {product}/{version} (+{url})` - the parenthesized `+url` comment stays within RFC 9110's conservative product/comment syntax. | | All HTTP-based adapters | `User-Agent: {product}/{version} (+{url})` - the parenthesized `+url` comment stays within RFC 9110's conservative product/comment syntax. |
| Direct DeepSeek endpoint | `User-Agent`; do not send OpenRouter-only headers unless DeepSeek documents an equivalent contract. | | Direct DeepSeek endpoint | `User-Agent` for app attribution; `x-deepseek-harness-user-id` and conditional `x-deepseek-harness-session-id` are separate request identity under the DeepSeek-specific decision. Do not send OpenRouter-only headers unless DeepSeek documents an equivalent contract. |
| OpenRouter endpoints | `User-Agent` only for now. Do not send `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, or `X-OpenRouter-Categories` under this decision. | | OpenRouter endpoints | `User-Agent` only for now. Do not send `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, or `X-OpenRouter-Categories` under this decision. |
| Future providers | `User-Agent` only unless a later provider-specific Agent Note accepts additional headers. Do not reuse `HTTP-Referer` by analogy. | | Future providers | `User-Agent` only unless a later provider-specific Agent Note accepts additional headers. Do not reuse `HTTP-Referer` by analogy. |

View File

@@ -24,7 +24,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
## 决策 ## 决策
在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。 在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.md)另行负责其用户与会话头部。
OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本决策定义的共享 `User-Agent` 归属。 OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本决策定义的共享 `User-Agent` 归属。
@@ -41,7 +41,7 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`
| 目标 | 映射 | | 目标 | 映射 |
|---|---| |---|---|
| 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 | | 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 |
| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档化了等效约定,否则不发送 OpenRouter 特有头部。 | | 直连 DeepSeek 端点 | `User-Agent` 用于应用归属;`x-deepseek-harness-user-id` 与条件性的 `x-deepseek-harness-session-id` 由 DeepSeek 特有决策作为独立请求身份管理。除非 DeepSeek 文档化了等效约定,否则不发送 OpenRouter 特有头部。 |
| OpenRouter 端点 | 目前仅 `User-Agent`。本决策下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | | OpenRouter 端点 | 目前仅 `User-Agent`。本决策下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 |
| 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 Agent Note 接受额外头部。不要类比复用 `HTTP-Referer`。 | | 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 Agent Note 接受额外头部。不要类比复用 `HTTP-Referer`。 |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md
2026-08-07-shared-feedback-telemetry-user-id.md: 5ef487646c94808177e1263b94b8c23a9a045d97 2026-08-07-shared-feedback-telemetry-user-id.md: 9e4bd02d78b11858163c36376dbb438fca8aef73
2026-08-07-shared-feedback-telemetry-user-id.zh.md: 1ee433396f83a8caf5e35c9cfd1b4f3c04d6c59f 2026-08-07-shared-feedback-telemetry-user-id.zh.md: ecc451718812eb0a8b7e50bc949137fe8571ea09

View File

@@ -1,4 +1,4 @@
# Agent Note: Shared feedback and telemetry anonymous user id # Agent Note: Shared anonymous user id across telemetry, feedback, and DeepSeek requests
Status: implemented Status: implemented
@@ -8,11 +8,11 @@ English | [中文](2026-08-07-shared-feedback-telemetry-user-id.zh.md)
The OpenTelemetry backend already persisted one anonymous UUID in `$DSH_HOME/.userid`. `/feedback` now needs to report both the receiving session id and a user id so an operator can correlate the acknowledgement with exported records. Duplicating or independently generating that identity would make the reported user meaningless, while importing it from `session-telemetry-otel` would make a direct command depend on an exporter backend and create a dependency cycle when feedback export is mounted by telemetry. The OpenTelemetry backend already persisted one anonymous UUID in `$DSH_HOME/.userid`. `/feedback` now needs to report both the receiving session id and a user id so an operator can correlate the acknowledgement with exported records. Duplicating or independently generating that identity would make the reported user meaningless, while importing it from `session-telemetry-otel` would make a direct command depend on an exporter backend and create a dependency cycle when feedback export is mounted by telemetry.
The earlier [anonymous-user-id decision](../feature/2026-07-31-telemetry-anonymous-user-id.md) deliberately kept the helper inside the OTel backend until a second real consumer existed. Feedback is that consumer. The earlier [anonymous-user-id decision](../feature/2026-07-31-telemetry-anonymous-user-id.md) deliberately kept the helper inside the OTel backend until a second real consumer existed. Feedback became that second consumer. [Direct DeepSeek request identity](../feature/2026-08-11-deepseek-request-user-id-header.md) is the third.
## Decision ## Decision
`@deepseek-ai/dsh-user-id` owns `getOrCreateAnonymousUserId()` and the `$DSH_HOME/.userid` storage contract. `session-telemetry-otel` uses the returned id as OpenTelemetry Resource `user.id`; the `/feedback` success acknowledgement reports `Feedback recorded for session {sessionId}` followed by `User: {userId}` on a second line, which keeps both identifiers available through the generic command row's expandable body. Invalid feedback is rejected before resolving the id, so an empty command does not create `.userid`. `@deepseek-ai/dsh-user-id` owns `getOrCreateAnonymousUserId()` and the `$DSH_HOME/.userid` storage contract. `session-telemetry-otel` uses the returned id as OpenTelemetry Resource `user.id`; the `/feedback` success acknowledgement reports `Feedback recorded for session {sessionId}` followed by `User: {userId}` on a second line; and direct DeepSeek requests carry it as `x-deepseek-harness-user-id`. Invalid feedback is rejected before resolving the id, and the DeepSeek adapter resolves it only after credentials succeed, so neither an empty command nor a credential failure creates `.userid`.
The extraction preserves the existing random UUID, home resolution, process memo, exclusive-create concurrency, corruption replacement, and best-effort write semantics. The extraction preserves the existing random UUID, home resolution, process memo, exclusive-create concurrency, corruption replacement, and best-effort write semantics.
@@ -26,7 +26,7 @@ The extraction preserves the existing random UUID, home resolution, process memo
## Consequences ## Consequences
- One harness home now has one anonymous id shared by feedback acknowledgements and session telemetry exports. - One harness home has one anonymous id shared by feedback acknowledgements, session telemetry exports, and direct DeepSeek requests.
- The feedback package depends only on the identity capability, not the telemetry seam or OTel SDK. - The feedback package depends only on the identity capability, not the telemetry seam or OTel SDK.
- The new package is a justified shared seam with two consumers; its empty invariant companion explains why reading the private file is not a useful runtime relationship check. - The package is a justified shared library with three consumers; its empty invariant companion explains why reading the private file is not a useful runtime relationship check.
- The original anonymous-user-id Note remains authoritative for storage and privacy semantics, while this Note supersedes only its OTel-local ownership decision. - The original anonymous-user-id Note remains authoritative for storage and privacy semantics, while this Note supersedes only its OTel-local ownership decision.

View File

@@ -1,4 +1,4 @@
# Agent Note: 反馈与遥测共享匿名用户 id # Agent Note: 遥测、反馈与 DeepSeek 请求共享匿名用户 id
Status: implemented Status: implemented
@@ -8,11 +8,11 @@ Status: implemented
OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。`/feedback` 需要同时报告接收反馈的会话 id 与用户 id,以便运维人员将确认文本与导出的记录相关联。复制该身份或单独生成身份会使报告的用户失去意义;从 `session-telemetry-otel` 导入身份则会让直接命令依赖导出后端,并在遥测侧挂载反馈导出时形成依赖环。 OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。`/feedback` 需要同时报告接收反馈的会话 id 与用户 id,以便运维人员将确认文本与导出的记录相关联。复制该身份或单独生成身份会使报告的用户失去意义;从 `session-telemetry-otel` 导入身份则会让直接命令依赖导出后端,并在遥测侧挂载反馈导出时形成依赖环。
早先的[匿名用户 id 决策](../feature/2026-07-31-telemetry-anonymous-user-id.md)刻意将辅助函数留在 OTel 后端内,直至出现第二个真实消费方。反馈就是这个消费方。 早先的[匿名用户 id 决策](../feature/2026-07-31-telemetry-anonymous-user-id.md)刻意将辅助函数留在 OTel 后端内,直至出现第二个真实消费方。反馈成为第二个消费方,[直连 DeepSeek 请求身份](../feature/2026-08-11-deepseek-request-user-id-header.md)则是第三个。
## 决策 ## 决策
`@deepseek-ai/dsh-user-id` 负责 `getOrCreateAnonymousUserId()` 和 `$DSH_HOME/.userid` 存储契约。`session-telemetry-otel` 将返回的 id 用作 OpenTelemetry Resource 的 `user.id`;`/feedback` 的成功确认先报告 `Feedback recorded for session {sessionId}`,再在第二行显示 `User: {userId}`,使两个标识符都可通过通用命令行的可展开正文查看。系统在获取 id 前拒绝无效反馈,因此空命令不会创建 `.userid`。 `@deepseek-ai/dsh-user-id` 负责 `getOrCreateAnonymousUserId()` 和 `$DSH_HOME/.userid` 存储契约。`session-telemetry-otel` 将返回的 id 用作 OpenTelemetry Resource 的 `user.id`;`/feedback` 的成功确认先报告 `Feedback recorded for session {sessionId}`,再在第二行显示 `User: {userId}`;直连 DeepSeek 请求则通过 `x-deepseek-harness-user-id` 携带它。系统在获取 id 前拒绝无效反馈,DeepSeek 适配器也仅在凭据解析成功后获取 id,因此空命令和凭据失败都不会创建 `.userid`。
此次抽取保留既有的随机 UUID、home 解析、进程内缓存、独占创建并发、损坏文件替换与 best-effort 写入语义。 此次抽取保留既有的随机 UUID、home 解析、进程内缓存、独占创建并发、损坏文件替换与 best-effort 写入语义。
@@ -26,7 +26,7 @@ OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。`
## 后果 ## 后果
- 一个 harness home 只有一个匿名 id,由反馈确认与会话遥测导出共享。 - 一个 harness home 只有一个匿名 id,由反馈确认、会话遥测导出与直连 DeepSeek 请求共享。
- 反馈包只依赖身份能力,不依赖遥测 seam 或 OTel SDK。 - 反馈包只依赖身份能力,不依赖遥测 seam 或 OTel SDK。
- 新包由两个消费方使用,成为有充分依据的共享 seam;其空不变式伴生插件解释了为何读取私有文件并非有用的运行时关系检查。 - 该包由三个消费方使用,成为有充分依据的共享库;其空不变式伴生插件解释了为何读取私有文件并非有用的运行时关系检查。
- 原始匿名用户 id Note 仍是存储与隐私语义的权威记录;本 Note 仅取代其中由 OTel 本地拥有身份的决策。 - 原始匿名用户 id Note 仍是存储与隐私语义的权威记录;本 Note 仅取代其中由 OTel 本地拥有身份的决策。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md
2026-07-31-telemetry-anonymous-user-id.md: 4d5d14cb63ebbf9d9f71c729da998ad9804e56bf 2026-07-31-telemetry-anonymous-user-id.md: 6231065126d0884abe3ba6f45a40c42365fe520b
2026-07-31-telemetry-anonymous-user-id.zh.md: 69c48076ceb97a4bbc3cc35a2aaa3e632b1b1b06 2026-07-31-telemetry-anonymous-user-id.zh.md: 4f6bab8df5d479d2df42194581a4229c6de221e6

View File

@@ -10,7 +10,7 @@ Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-tel
## Decision ## Decision
`getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. The original implementation lived inside `session-telemetry-otel` because no second real consumer existed. `/feedback` later became that consumer, so [the shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) moves ownership to `@deepseek-ai/dsh-user-id` without changing the storage, anonymity, concurrency, or loss semantics recorded here. `getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. The original implementation lived inside `session-telemetry-otel` because no second real consumer existed. `/feedback` later became that consumer, so [the shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) moves ownership to `@deepseek-ai/dsh-user-id` without changing the storage, anonymity, concurrency, or loss semantics recorded here. [Direct DeepSeek request identity](2026-08-11-deepseek-request-user-id-header.md) is a third consumer of the same id.
| Ruling | Value | Rationale | | Ruling | Value | Rationale |
|---|---|---| |---|---|---|
@@ -22,8 +22,8 @@ Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-tel
| Write failure | Best-effort: return the in-memory id | Telemetry is never blocked by a read-only home | | Write failure | Best-effort: return the in-memory id | Telemetry is never blocked by a read-only home |
| Report position | Resource attribute, not per-record attributes | Once per batch suffices for Resource-dimension aggregation; per-record injection would touch the seam contract and grow the wire | | Report position | Resource attribute, not per-record attributes | Once per batch suffices for Resource-dimension aggregation; per-record injection would touch the seam contract and grow the wire |
| semconv dependency | `@opentelemetry/semantic-conventions` is not imported | One string constant does not justify a dependency | | semconv dependency | `@opentelemetry/semantic-conventions` is not imported | One string constant does not justify a dependency |
| Home | `@deepseek-ai/dsh-user-id`, shared by the OTel backend and `/feedback` | The second real consumer now exists; direct feedback must not depend on an exporter backend | | Home | `@deepseek-ai/dsh-user-id`, shared by the OTel backend, `/feedback`, and direct DeepSeek requests | Consumers share one storage contract without depending on an exporter backend |
| Separate switch | None | Either consumer can create the identity; `DSH_TELEMETRY_DISABLED` stops telemetry reporting but does not disable feedback acknowledgement | | Separate switch | None | Any consumer can create the identity; `DSH_TELEMETRY_DISABLED` stops telemetry reporting but does not disable feedback acknowledgement or the DeepSeek request header |
## Alternatives considered ## Alternatives considered
@@ -38,6 +38,6 @@ Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-tel
## Consequences ## Consequences
- One `$DSH_HOME` is one stable user in the OTel feed; separate homes are separate users by construction, with no cross-home linking mechanism. - One `$DSH_HOME` is one stable user in the OTel feed; separate homes are separate users by construction, with no cross-home linking mechanism.
- The OTel feed and `/feedback` share `.userid`. - The OTel feed, `/feedback`, and direct DeepSeek requests share `.userid`.
- Deleting `.userid` resets the identity (effective next launch); on an unwritable home each process holds its own in-memory id until the home becomes writable. - Deleting `.userid` resets the identity (effective next launch); on an unwritable home each process holds its own in-memory id until the home becomes writable.
- The [default-mount Note](2026-07-31-web-telemetry-default-mount.md)'s identity follow-up is closed for the anonymous-user-id part by this decision; hostname/surface dimensions, the redaction rule, and the usage-metrics track remain open. - The [default-mount Note](2026-07-31-web-telemetry-default-mount.md)'s identity follow-up is closed for the anonymous-user-id part by this decision; hostname/surface dimensions, the redaction rule, and the usage-metrics track remain open.

View File

@@ -10,7 +10,7 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry
## Decision ## Decision
`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;后端构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。原始实现位于 `session-telemetry-otel`,因为当时不存在第二个真实消费方。`/feedback` 后来成为该消费方,因此[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)将所有权移交给 `@deepseek-ai/dsh-user-id`,但不改变本 Note 记录的存储、匿名、并发与丢失语义。 `getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;后端构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。原始实现位于 `session-telemetry-otel`,因为当时不存在第二个真实消费方。`/feedback` 后来成为该消费方,因此[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)将所有权移交给 `@deepseek-ai/dsh-user-id`,但不改变本 Note 记录的存储、匿名、并发与丢失语义。[直连 DeepSeek 请求身份](2026-08-11-deepseek-request-user-id-header.md)是同一 id 的第三个消费方。
| 裁定 | 取值 | 理由 | | 裁定 | 取值 | 理由 |
|---|---|---| |---|---|---|
@@ -22,8 +22,8 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry
| 写失败 | best-effort 返回内存 id | telemetry 永不因 home 只读被阻塞 | | 写失败 | best-effort 返回内存 id | telemetry 永不因 home 只读被阻塞 |
| 上报位置 | Resource 属性,非逐条 attributes | 每批一次即够接收端按 Resource 维度聚合;逐条注入要动 seam 约定且涨 wire 体积 | | 上报位置 | Resource 属性,非逐条 attributes | 每批一次即够接收端按 Resource 维度聚合;逐条注入要动 seam 约定且涨 wire 体积 |
| semconv 依赖 | 不引 `@opentelemetry/semantic-conventions` 包 | 一个字符串常量不值一个依赖 | | semconv 依赖 | 不引 `@opentelemetry/semantic-conventions` 包 | 一个字符串常量不值一个依赖 |
| 落点 | `@deepseek-ai/dsh-user-id`,由 OTel 后端与 `/feedback` 共享 | 第二个真实消费方已经出现;直接反馈不能依赖导出后端 | | 落点 | `@deepseek-ai/dsh-user-id`,由 OTel 后端、`/feedback` 与直连 DeepSeek 请求共享 | 消费方共用同一存储契约,且不依赖导出后端 |
| 单独开关 | 无 | 任一消费方都可创建该身份;`DSH_TELEMETRY_DISABLED` 会停止遥测上报,但不会禁用反馈确认 | | 单独开关 | 无 | 任一消费方都可创建该身份;`DSH_TELEMETRY_DISABLED` 会停止遥测上报,但不会禁用反馈确认或 DeepSeek 请求头 |
## Alternatives considered ## Alternatives considered
@@ -38,6 +38,6 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry
## Consequences ## Consequences
- 一个 `$DSH_HOME` 在 OTel 回流中是一个稳定用户;不同 home 在构造上就是不同用户,无跨 home 关联机制。 - 一个 `$DSH_HOME` 在 OTel 回流中是一个稳定用户;不同 home 在构造上就是不同用户,无跨 home 关联机制。
- OTel 回流与 `/feedback` 共享 `.userid`。 - OTel 回流、`/feedback` 与直连 DeepSeek 请求共享 `.userid`。
- 删除 `.userid` 即重置身份(下次启动生效);home 不可写时每进程各自持有一个内存 id 直至恢复可写。 - 删除 `.userid` 即重置身份(下次启动生效);home 不可写时每进程各自持有一个内存 id 直至恢复可写。
- [默认挂载 Note](2026-07-31-web-telemetry-default-mount.md) 的身份 follow-up 中「匿名用户 id」项由本决定关闭;hostname/surface 维度与脱敏规则、usage-metrics track 仍是待办。 - [默认挂载 Note](2026-07-31-web-telemetry-default-mount.md) 的身份 follow-up 中「匿名用户 id」项由本决定关闭;hostname/surface 维度与脱敏规则、usage-metrics track 仍是待办。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md
2026-08-11-deepseek-request-user-id-header.md: 246021d3ea69a6341def90f81051da78a267f051
2026-08-11-deepseek-request-user-id-header.zh.md: 6861d2b6ed1294c8e29efb7c883f56f8aa1d31ca

View File

@@ -0,0 +1,44 @@
# Agent Note: DeepSeek request user and session identity headers
Status: implemented
English | [中文](2026-08-11-deepseek-request-user-id-header.zh.md)
## Problem
Direct DeepSeek requests already carried `x-deepseek-harness-session-id` when the caller supplied `GenerateOptions.sessionId`, which lets provider-side support and diagnostics correlate turns within one conversation. They lacked a stable identity across sessions even though the harness already persists an anonymous user id for telemetry and feedback. A separate id would break correlation, while putting it in the provider-neutral attribution helper would send a stable per-user identifier through every HTTP adapter.
The user id is transport metadata, not model input. It must not enter the request body, prompt, token accounting, KV-cache identity, or session log. The destination is the adapter's resolved `baseURL`, which can be DeepSeek itself or a configured gateway, so the privacy boundary must be explicit.
## Decision
`dsh-llm-deepseek` sends `x-deepseek-harness-user-id` on every provider request sent after successful credential resolution. The value comes from `@deepseek-ai/dsh-user-id` and therefore matches the OpenTelemetry Resource `user.id` and `/feedback` acknowledgement for the same `$DSH_HOME`. The adapter continues to send `x-deepseek-harness-session-id` only when `GenerateOptions.sessionId` is present; the agent loop supplies the current durable `Session.id` for ordinary agent, title-generation, and compaction requests.
The plugin resolves the user id lazily after credentials succeed and memoizes it for that plugin instance. A missing credential therefore does not create `.userid`, while the first authorized provider request can create it even when `DSH_TELEMETRY_DISABLED` is set. The direct adapter constructor accepts a `resolveUserId` dependency so wire behavior remains deterministic in unit tests.
Both headers are model-hidden HTTP metadata sent to the resolved `baseURL`. They are absent from the JSON request body and do not become model-visible inputs or session events. A configured gateway receives them. Telemetry sharing controls only telemetry export and does not disable provider request identity.
## Verification
- The mock provider asserts that an authorized request carries the same user id returned by `getOrCreateAnonymousUserId()` and omits the session header when no session id is supplied.
- The session-identity wire test asserts both headers and preserves the exact supplied session id.
- A direct-adapter test asserts that user-id resolution happens once per stream, while the keyless configuration test proves a credential failure does not create `.userid`.
- The real Loader composition test asserts that the assembled plugin uses the shared user-id package rather than a test-only value.
- No keyless snapshot changes because the headers are not model-visible or user-visible transcript content.
## Alternatives considered
| Rejected | Reason |
|---|---|
| Add the id to generic `attributionHeaders()` | That helper is provider-neutral and static; a per-user value there would reach unrelated providers and violate its app-identity privacy contract |
| Configure a fixed custom header in `cordis.yml` | Deployment configuration cannot derive the current session id and would expose a stable identity as mutable config instead of using its owning runtime contract |
| Mint a DeepSeek-specific user id | Provider requests could not correlate with telemetry and feedback for the same harness home |
| Disable the header with telemetry sharing | Provider request identity and telemetry export have different recipients and purposes; one switch would hide the actual privacy boundary |
| Put the id in OpenAI-compatible `user` or `metadata` request fields | Body fields can affect provider schema, logging, caching, tokenization, or model-visible reconstruction; HTTP metadata preserves the intended boundary |
## Consequences
- DeepSeek support can correlate requests across sessions by one anonymous harness-home id and within a conversation by the durable session id.
- The first authorized DeepSeek request may create `$DSH_HOME/.userid` independently of telemetry export.
- Custom DeepSeek gateways receive the stable user id and any available session id, so operators must treat the configured `baseURL` as an identity recipient.
- The request body, prompt, token count, KV-cache identity, and session log remain unchanged.

View File

@@ -0,0 +1,44 @@
# Agent Note: DeepSeek 请求用户与会话身份头部
Status: implemented
[English](2026-08-11-deepseek-request-user-id-header.md) | 中文
## 问题
当调用方提供 `GenerateOptions.sessionId` 时,直连 DeepSeek 请求已携带 `x-deepseek-harness-session-id`,让提供方侧支持与诊断可以关联同一对话中的多个轮次。但请求缺少跨会话的稳定身份,而 harness 已为遥测与反馈持久化匿名用户 id。另行生成 id 会破坏关联;把它放进提供方无关的归属辅助函数,则会让每个 HTTP 适配器都发送稳定的逐用户标识。
用户 id 是传输元数据,不是模型输入。它不得进入请求体、提示词、token 计量、KV cache 身份或会话日志。发送目标是适配器解析后的 `baseURL`,既可能是 DeepSeek 自身,也可能是配置的网关,因此必须明确隐私边界。
## 决策
`dsh-llm-deepseek` 在凭据解析成功后发出的每个提供方请求上发送 `x-deepseek-harness-user-id`。该值来自 `@deepseek-ai/dsh-user-id`,因此与同一 `$DSH_HOME` 的 OpenTelemetry Resource `user.id` 及 `/feedback` 确认一致。适配器继续仅在存在 `GenerateOptions.sessionId` 时发送 `x-deepseek-harness-session-id`;普通 agent、标题生成与压缩请求由 agent loop 提供当前持久化 `Session.id`。
插件在凭据解析成功后惰性获取用户 id,并在该插件实例内缓存。缺少凭据不会创建 `.userid`;即使设置了 `DSH_TELEMETRY_DISABLED`,首个已授权的提供方请求仍可能创建它。直连适配器构造函数接收 `resolveUserId` 依赖,使线路行为可在单元测试中保持确定性。
两个头部都是发送到解析后 `baseURL` 的模型不可见 HTTP 元数据。它们不在 JSON 请求体中,也不会成为模型可见输入或会话事件。配置的网关会收到它们。遥测共享只控制遥测导出,不会禁用提供方请求身份。
## 验证
- mock 提供方断言已授权请求携带 `getOrCreateAnonymousUserId()` 返回的同一用户 id,并在未提供会话 id 时省略会话头部。
- 会话身份线路测试断言两个头部都存在,并原样保留传入的会话 id。
- 直连适配器测试断言每条 stream 仅解析一次用户 id,keyless 配置测试则证明凭据失败不会创建 `.userid`。
- 真实 Loader 组合测试断言组装后的插件使用共享 user-id 包,而非测试专用值。
- 无需修改 keyless snapshot,因为这些头部不是模型可见或用户可见的 transcript 内容。
## 考虑过的替代方案
| 已否决 | 原因 |
|---|---|
| 把 id 加进通用 `attributionHeaders()` | 该辅助函数是提供方无关且静态的;加入逐用户值会把它发送给无关提供方,并违反其应用身份隐私契约 |
| 在 `cordis.yml` 中配置固定自定义头部 | 部署配置无法推导当前会话 id,且会把稳定身份暴露为可变配置,而不是使用其所属运行时契约 |
| 生成 DeepSeek 专用用户 id | 提供方请求将无法与同一 harness home 的遥测和反馈关联 |
| 随遥测共享关闭该头部 | 提供方请求身份与遥测导出的接收方和目的不同;共用开关会掩盖真实隐私边界 |
| 把 id 放进 OpenAI 兼容的 `user` 或 `metadata` 请求字段 | body 字段可能影响提供方 schema、日志、缓存、token 化或模型可见重建;HTTP 元数据可保留预期边界 |
## 后果
- DeepSeek 支持可以通过一个匿名 harness-home id 跨会话关联请求,并通过持久化 session id 关联同一对话。
- 首个已授权 DeepSeek 请求可独立于遥测导出创建 `$DSH_HOME/.userid`。
- 自定义 DeepSeek 网关会收到稳定用户 id 与可用的会话 id,因此运维方必须将配置的 `baseURL` 视为身份接收方。
- 请求体、提示词、token 数、KV cache 身份和会话日志保持不变。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md # pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 0f81ea3279a7c52b6769bf5008dd832736c1398f config-catalog.md: e4bf604e08afc8b35208caf0425a1964d2ac992e
config-catalog.zh.md: 1db10a199fcab548726d75cc031c1b41d4f2aeb3 config-catalog.zh.md: fc5a4520650ea429e77415471e509d9309b5fecb

View File

@@ -783,7 +783,7 @@ export interface DeepSeekCatalogModel {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:61`](../packages/llm/llm-deepseek/src/index.ts) Source: [`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai` ## `@deepseek-ai/dsh-llm-pi-ai`

View File

@@ -785,7 +785,7 @@ export interface DeepSeekCatalogModel {
依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) 依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
来源:[`packages/llm/llm-deepseek/src/index.ts:61`](../packages/llm/llm-deepseek/src/index.ts) 来源:[`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai` ## `@deepseek-ai/dsh-llm-pi-ai`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md # pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: c57619850cd155b0cdc4a178dc75cefe3897c46e module-graph.md: e68b0ca0f7e7a3694985827822c71e923af6053f
module-graph.zh.md: a062c0d59b4f7c635c46bd2724da3210398f416e module-graph.zh.md: a5fe499c53234fc29bd83515ce565b458df519f3

View File

@@ -395,6 +395,7 @@ flowchart TD
pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout pkg_llm_deepseek --> pkg_timeout
pkg_llm_deepseek --> pkg_user_id
pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_attachment
pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_environment pkg_llm_pi_ai --> pkg_environment
@@ -1373,7 +1374,7 @@ flowchart TD
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout), [`user-id`](../packages/session/user-id) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

View File

@@ -397,6 +397,7 @@ flowchart TD
pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout pkg_llm_deepseek --> pkg_timeout
pkg_llm_deepseek --> pkg_user_id
pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_attachment
pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_environment pkg_llm_pi_ai --> pkg_environment
@@ -1375,7 +1376,7 @@ flowchart TD
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout), [`user-id`](../packages/session/user-id) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
llm-streaming.md: bd2c9b856d34e625b023a20c8be331eb7ea89880 llm-streaming.md: 41ae943b1945209bc7996379dc326434b5803d17
llm-streaming.zh.md: 34420669ca8cbdf9d9a0d8b38e67bc196c290cc8 llm-streaming.zh.md: b67cdfd4eb5a3c1320e7afe333d4d06516295f7d

View File

@@ -484,8 +484,8 @@ interface GenerateOptions {
stop?: string[] stop?: string[]
signal?: AbortSignal signal?: AbortSignal
/** /**
* Session identity stamped by the loop for listener routing. Adapters ignore * Session identity stamped by the loop for request routing. Replay uses it
* it; replay uses it to keep concurrent parent and child cursors independent. * to separate cursors; adapters may map it to model-hidden transport metadata.
*/ */
sessionId?: Branded<'SessionId'> sessionId?: Branded<'SessionId'>
/** /**

View File

@@ -492,8 +492,8 @@ interface GenerateOptions {
stop?: string[] stop?: string[]
signal?: AbortSignal signal?: AbortSignal
/** /**
* Session identity stamped by the loop for listener routing. Adapters ignore * Session identity stamped by the loop for request routing. Replay uses it
* it; replay uses it to keep concurrent parent and child cursors independent. * to separate cursors; adapters may map it to model-hidden transport metadata.
*/ */
sessionId?: Branded<'SessionId'> sessionId?: Branded<'SessionId'>
/** /**

View File

@@ -31,10 +31,10 @@ const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInte
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ /** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
const lf = (text: string): string => text.replace(/\r\n/g, '\n') const lf = (text: string): string => text.replace(/\r\n/g, '\n')
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */ /** Filesystem path equality across macOS temp symlinks and Windows drive-letter casing. */
function samePath(actual: string, expected: string): boolean { function samePath(actual: string, expected: string): boolean {
const norm = (value: string) => ( const norm = (value: string) => (
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : realpathSync.native(value)
) )
return norm(actual) === norm(expected) return norm(actual) === norm(expected)
} }

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: a21f9f0464e9d43d2091bd446eb123d4d0990c3d README.md: 1fde02dc8c764a189eb78226de4c194bbf0b8a5e
README.zh.md: 2c45f2144694785590b339642cb62b62a1aa4198 README.zh.md: 711bc92101c4df0abd40375e3d73f993105ea81c

View File

@@ -62,6 +62,8 @@ The plugin also declares its route in the configurable-provider directory (`ctx.
Every request carries the shared attribution header 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 and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. Every request carries the shared attribution header 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 and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests.
DeepSeek request identity is separate from app attribution. After credential resolution, every provider request carries `x-deepseek-harness-user-id` with the stable anonymous id from [`@deepseek-ai/dsh-user-id`](../../session/user-id/README.md); a request carrying `GenerateOptions.sessionId` also sends that exact value as `x-deepseek-harness-session-id`, while a direct call without a session omits the session header. Both headers go to the resolved `baseURL`, including a configured gateway, and remain outside the request body and model-visible content.
## Wire-format notes ## Wire-format notes
- 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`. - 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

@@ -62,6 +62,8 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。
DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提供方请求都会通过 `x-deepseek-harness-user-id` 携带来自 [`@deepseek-ai/dsh-user-id`](../../session/user-id/README.md) 的稳定匿名 id;携带 `GenerateOptions.sessionId` 的请求还会通过 `x-deepseek-harness-session-id` 发送该确切值,缺少会话的直接调用则省略会话标头。两个标头都会发送至解析后的 `baseURL`(包括已配置的 gateway),且不会进入请求正文或模型可见内容。
## 协议格式说明 ## 协议格式说明
- 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish 分片上,也可能作为尾随的纯 usage 分片到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。 - 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish 分片上,也可能作为尾随的纯 usage 分片到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。

View File

@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/cordis": "workspace:^" "@deepseek-ai/cordis": "workspace:^"
}, },
"dependencies": { "dependencies": {
@@ -51,6 +52,7 @@
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/cordis": "workspace:^" "@deepseek-ai/cordis": "workspace:^"
} }
} }

View File

@@ -19,6 +19,7 @@ import type {
} from '@deepseek-ai/dsh-llm' } from '@deepseek-ai/dsh-llm'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import type { AnonymousUserId } from '@deepseek-ai/dsh-user-id'
import { serializeRequest } from './serialize.ts' import { serializeRequest } from './serialize.ts'
import type { RequestDefaults } from './serialize.ts' import type { RequestDefaults } from './serialize.ts'
import { parseSse } from './sse.ts' import { parseSse } from './sse.ts'
@@ -69,7 +70,7 @@ export interface DeepSeekConnectionOptions {
retryPolicy: ResolvedRetryPolicy retryPolicy: ResolvedRetryPolicy
} }
/** Constructor options for {@link DeepSeekAdapter}: the two resolution hooks the plugin owns. */ /** Constructor options for {@link DeepSeekAdapter}: the operation-local resolution hooks the plugin owns. */
export interface DeepSeekAdapterOptions { export interface DeepSeekAdapterOptions {
/** Current validated connection facts; called once per operation. */ /** Current validated connection facts; called once per operation. */
options: () => DeepSeekConnectionOptions options: () => DeepSeekConnectionOptions
@@ -80,6 +81,8 @@ export interface DeepSeekAdapterOptions {
* `MISSING_CREDENTIAL` when no key is available anywhere. * `MISSING_CREDENTIAL` when no key is available anywhere.
*/ */
resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise<string> resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise<string>
/** Resolve the harness-home anonymous id shared with telemetry and feedback. */
resolveUserId: () => AnonymousUserId
} }
/** Default maximum idle interval while an adapter stream read is outstanding. */ /** Default maximum idle interval while an adapter stream read is outstanding. */
@@ -216,6 +219,7 @@ export class DeepSeekAdapter extends LlmAdapter {
// sent to it can never come from different configuration generations. // sent to it can never come from different configuration generations.
const connection = this.config.options() const connection = this.config.options()
const apiKey = await this.config.resolveApiKey(connection) const apiKey = await this.config.resolveApiKey(connection)
const userId = this.config.resolveUserId()
const consumer = new AbortController() const consumer = new AbortController()
const upstream = options.signal === undefined const upstream = options.signal === undefined
? consumer.signal ? consumer.signal
@@ -226,6 +230,7 @@ export class DeepSeekAdapter extends LlmAdapter {
watchdog.signal, watchdog.signal,
connection, connection,
apiKey, apiKey,
userId,
() => { watchdog.pulse() }, () => { watchdog.pulse() },
)[Symbol.asyncIterator]() )[Symbol.asyncIterator]()
let exhausted = false let exhausted = false
@@ -268,6 +273,7 @@ export class DeepSeekAdapter extends LlmAdapter {
signal: AbortSignal, signal: AbortSignal,
connection: DeepSeekConnectionOptions, connection: DeepSeekConnectionOptions,
apiKey: string, apiKey: string,
userId: AnonymousUserId,
onComment: () => void, onComment: () => void,
): AsyncIterable<StreamChunk> { ): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, connection.defaults) const body = serializeRequest(options, connection.defaults)
@@ -279,6 +285,7 @@ export class DeepSeekAdapter extends LlmAdapter {
'content-type': 'application/json', 'content-type': 'application/json',
'accept': 'text/event-stream', 'accept': 'text/event-stream',
...attributionHeaders(), ...attributionHeaders(),
'x-deepseek-harness-user-id': String(userId),
...options.sessionId !== undefined ...options.sessionId !== undefined
? { 'x-deepseek-harness-session-id': String(options.sessionId) } ? { 'x-deepseek-harness-session-id': String(options.sessionId) }
: {}, : {},

View File

@@ -19,6 +19,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-user-id'
import { import {
DEFAULT_CONTEXT_WINDOW, DEFAULT_CONTEXT_WINDOW,
DEFAULT_MAX_TOKENS, DEFAULT_MAX_TOKENS,
@@ -244,7 +245,9 @@ export function apply(ctx: Context, config: Config): void {
) )
} }
const adapter = new DeepSeekAdapter({ options, resolveApiKey }) let userId: AnonymousUserId | undefined
const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId()
const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId })
ctx.llm.registerConfigurableProviders([ ctx.llm.registerConfigurableProviders([
{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
]) ])

View File

@@ -1,7 +1,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -19,6 +19,12 @@ import { assemble, type AssembledResult } from './assemble.ts'
const FLASH = 'deepseek-v4-flash' const FLASH = 'deepseek-v4-flash'
const PRO = 'deepseek-v4-pro' const PRO = 'deepseek-v4-pro'
const contexts: Context[] = [] const contexts: Context[] = []
let identityHome: string
beforeEach(async () => {
identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-'))
vi.stubEnv('DSH_HOME', identityHome)
})
async function harness(_model: string, config: Partial<Config> = {}) { async function harness(_model: string, config: Partial<Config> = {}) {
const ctx = new Context() const ctx = new Context()
@@ -30,6 +36,8 @@ async function harness(_model: string, config: Partial<Config> = {}) {
afterEach(async () => { afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
vi.unstubAllEnvs()
await rm(identityHome, { recursive: true, force: true })
}) })
function ask(text: string): Message[] { function ask(text: string): Message[] {

View File

@@ -1,4 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import LlmService, { createUserMessage, import LlmService, { createUserMessage,
@@ -9,6 +12,7 @@ import LlmService, { createUserMessage,
userAgent, userAgent,
} from '@deepseek-ai/dsh-llm' } from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-user-id'
import { SessionId } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
@@ -17,10 +21,19 @@ import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
import type { Behavior } from './mock-server.ts' import type { Behavior } from './mock-server.ts'
const TEST_USER_ID = '00000000-0000-4000-8000-000000000001' as AnonymousUserId
let testHome: string
beforeEach(() => {
testHome = mkdtempSync(join(tmpdir(), 'dsh-llm-deepseek-'))
vi.stubEnv('DSH_HOME', testHome)
})
afterEach(async () => { afterEach(async () => {
await closeMockServers() await closeMockServers()
vi.unstubAllEnvs() vi.unstubAllEnvs()
vi.useRealTimers() vi.useRealTimers()
rmSync(testHome, { recursive: true, force: true })
}) })
async function harness(baseURL: string, config: object = {}) { async function harness(baseURL: string, config: object = {}) {
@@ -39,6 +52,7 @@ function adapterOf(config: Partial<LlmDeepSeek.Config> & { apiKey?: string } = {
return new DeepSeekAdapter({ return new DeepSeekAdapter({
options: () => resolveAdapterOptions(rest), options: () => resolveAdapterOptions(rest),
resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), resolveApiKey: () => Promise.resolve(apiKey ?? 'k'),
resolveUserId: () => TEST_USER_ID,
}) })
} }
@@ -66,9 +80,10 @@ describe('DeepSeekAdapter against a mock server', () => {
stream: true, stream: true,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
}) })
// Attribution reaches the wire: the exact shared User-Agent, and no // App attribution and DeepSeek request identity are independent wire facts.
// provider-specific headers under the User-Agent-only contract.
expect(server.headers[0]?.['user-agent']).toBe(userAgent()) expect(server.headers[0]?.['user-agent']).toBe(userAgent())
expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId())
expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-session-id')
expect(server.headers[0]).not.toHaveProperty('http-referer') 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-title')
expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories')
@@ -93,7 +108,7 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
}) })
it('forwards the harness session id for host-side trajectory routing', async () => { it('forwards the harness user and session ids for host-side trajectory routing', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }]) const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url) const ctx = await harness(server.url)
@@ -107,6 +122,7 @@ describe('DeepSeekAdapter against a mock server', () => {
}) })
expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session')
expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId())
}) })
it('marks the auxiliary compaction call on the wire', async () => { it('marks the auxiliary compaction call on the wire', async () => {
@@ -997,12 +1013,14 @@ describe('plugin registration and config', () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }]) const server = await mockServer([{ kind: 'sse', events: textEvents }])
const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url }))
const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key'))
const adapter = new DeepSeekAdapter({ options, resolveApiKey }) const resolveUserId = vi.fn(() => TEST_USER_ID)
const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId })
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ }
expect(options).toHaveBeenCalledTimes(1) expect(options).toHaveBeenCalledTimes(1)
expect(resolveApiKey).toHaveBeenCalledTimes(1) expect(resolveApiKey).toHaveBeenCalledTimes(1)
expect(resolveUserId).toHaveBeenCalledTimes(1)
expect(server.headers[0]?.authorization).toBe('Bearer per-request-key') expect(server.headers[0]?.authorization).toBe('Bearer per-request-key')
}) })

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import LlmService, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' import LlmService, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm'
@@ -41,6 +41,7 @@ interface Harness {
* file watching is the providers' own covered concern. * file watching is the providers' own covered concern.
*/ */
async function boot(dir: string, config: object): Promise<Harness> { async function boot(dir: string, config: object): Promise<Harness> {
vi.stubEnv('DSH_HOME', dir)
const ctx = new Context() const ctx = new Context()
cleanups.push(async () => { cleanups.push(async () => {
await ctx.fiber.dispose() await ctx.fiber.dispose()
@@ -86,9 +87,11 @@ describe('request-level dynamic configuration', () => {
const keyless = await prompt(ctx) const keyless = await prompt(ctx)
expect(keyless.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) expect(keyless.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
await expect(access(join(dir, '.userid'))).rejects.toMatchObject({ code: 'ENOENT' })
await ctx.credentials.set(KEY_REF, 'sk-arrived') await ctx.credentials.set(KEY_REF, 'sk-arrived')
await prompt(ctx) await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
await expect(access(join(dir, '.userid'))).resolves.toBeUndefined()
}) })
it('rejects a stored credential no header can carry, never echoing it in the failure', async () => { it('rejects a stored credential no header can carry, never echoing it in the failure', async () => {

View File

@@ -21,6 +21,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import SettingsLocal from '@deepseek-ai/dsh-settings-local' import SettingsLocal from '@deepseek-ai/dsh-settings-local'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts' import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
@@ -47,6 +48,7 @@ async function loadComposition(
// exactly as the previous process left them. // exactly as the previous process left them.
const fresh = options.reuseRoot === undefined const fresh = options.reuseRoot === undefined
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
vi.stubEnv('DSH_HOME', root)
const settingsPath = join(root, 'settings.yaml') const settingsPath = join(root, 'settings.yaml')
const credentialsPath = join(root, '.credentials.yaml') const credentialsPath = join(root, '.credentials.yaml')
if (options.withDynamic && fresh) { if (options.withDynamic && fresh) {
@@ -115,6 +117,7 @@ describe('llm-deepseek real dynamic composition', () => {
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS])
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key')
expect(serverA.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId())
// External edits, exactly as a user or the web UI would leave them on disk. // External edits, exactly as a user or the web UI would leave them on disk.
await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`) await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`)

View File

@@ -34,6 +34,9 @@
}, },
{ {
"path": "../../util/timeout" "path": "../../util/timeout"
},
{
"path": "../../session/user-id"
} }
] ]
} }

View File

@@ -343,8 +343,8 @@ export interface GenerateOptions {
stop?: string[] stop?: string[]
signal?: AbortSignal signal?: AbortSignal
/** /**
* Session identity stamped by the loop for listener routing. Adapters ignore * Session identity stamped by the loop for request routing. Replay uses it
* it; replay uses it to keep concurrent parent and child cursors independent. * to separate cursors; adapters may map it to model-hidden transport metadata.
*/ */
sessionId?: Branded<'SessionId'> sessionId?: Branded<'SessionId'>
/** /**

View File

@@ -33,7 +33,10 @@ const WORKSPACE_CLOSURE = [
// from the registry). // from the registry).
'packages/sandbox/sandbox-windows-acl', 'packages/sandbox/sandbox-windows-acl',
'packages/sandbox/sandbox', 'packages/sandbox/sandbox',
'packages/core/session',
'packages/core/scope',
'packages/llm/llm', 'packages/llm/llm',
'packages/typert/type-meta',
'packages/attachment/attachment', 'packages/attachment/attachment',
'packages/util/brand', 'packages/util/brand',
'packages/util/timeout', 'packages/util/timeout',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/user-id/README.md # pnpm run verify-translation-pairing --write packages/session/user-id/README.md
README.md: eb50bb06af52b3d977068b73388361bc1c25087f README.md: 55bc54e4a5b666880f1908f4ccdf720e1122fc7a
README.zh.md: 54287676c4e524c2458e4bab7e6bb3f52850ff25 README.zh.md: 7816b62e581959ac3c5f3f277b86b147280fce17

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md) English | [中文](README.zh.md)
Shared anonymous identity for session telemetry and direct feedback acknowledgement. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.userid` (`~/.dsh/.userid` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement so an operator can correlate a submitted session and user with exported telemetry. Shared anonymous identity for session telemetry, direct feedback acknowledgement, and DeepSeek provider requests. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.userid` (`~/.dsh/.userid` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement; and `dsh-llm-deepseek` sends it as `x-deepseek-harness-user-id`, allowing the receiving systems to correlate records without independently generated identities.
The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities. The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities.
@@ -12,18 +12,19 @@ Reads and writes are synchronous because both boot-time telemetry construction a
## Composition ## Composition
This package is a shared library, not a Cordis plugin. Consumers import `getOrCreateAnonymousUserId()` directly. Its invariant companion is intentionally empty because the package owns no event stream or public mutable relation that can be checked without creating the identity as a side effect. This package is a shared library, not a Cordis plugin. Consumers import `getOrCreateAnonymousUserId()` directly. Its invariant companion is intentionally empty because the package owns no event stream or public mutable relation that can be checked without creating the identity as a side effect. `DSH_TELEMETRY_DISABLED` stops telemetry export only; it does not suppress direct feedback acknowledgement or the DeepSeek provider header.
## Model Experience ## Model Experience
None, as the identifier is used only in telemetry metadata and a direct human command response; it never enters a model request. None, as the identifier reaches DeepSeek only as model-hidden HTTP transport metadata and never enters the request body, prompt, or model-visible content.
#### KV Cache effect #### KV Cache effect
None; this package never contributes to a model request. None; the transport header changes neither tokens nor the model-visible prefix.
## Known Limitations and Deferred Work ## Known Limitations and Deferred Work
- **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity. - **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity.
- **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value. - **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value.
- **No cross-home identity** — different `$DSH_HOME` values cannot be correlated. - **No cross-home identity** — different `$DSH_HOME` values cannot be correlated.
- **Configured DeepSeek gateways receive the id** — `dsh-llm-deepseek` sends the stable header to its resolved `baseURL`, including deployment overrides, independently of telemetry sharing mode.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文 [English](README.md) | 中文
会话遥测与直接反馈确认共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4,并以裸行形式持久化到 `$DSH_HOME/.userid`(未设置 `DSH_HOME` 时为 `~/.dsh/.userid`)。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值,以便运维人员将所报告的会话和用户与导出的遥测相关联。 会话遥测、直接反馈确认与 DeepSeek 提供方请求共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4,并以裸行形式持久化到 `$DSH_HOME/.userid`(未设置 `DSH_HOME` 时为 `~/.dsh/.userid`)。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值;`dsh-llm-deepseek` 则通过 `x-deepseek-harness-user-id` 发送该值,使接收系统无需独立生成身份即可关联记录。
该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份。 该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份。
@@ -12,18 +12,19 @@
## 组合 ## 组合
本包是共享库,并非 Cordis 插件。消费方直接导入 `getOrCreateAnonymousUserId()`。其不变式伴生插件刻意留空,因为本包既不拥有事件流,也不拥有任何可以在不触发创建身份这一副作用的情况下检查的公开可变关系。 本包是共享库,并非 Cordis 插件。消费方直接导入 `getOrCreateAnonymousUserId()`。其不变式伴生插件刻意留空,因为本包既不拥有事件流,也不拥有任何可以在不触发创建身份这一副作用的情况下检查的公开可变关系。`DSH_TELEMETRY_DISABLED` 只会停止遥测导出,不会禁止直接反馈确认或 DeepSeek 提供方标头。
## 模型体验 ## 模型体验
无,因为该标识符只用于遥测元数据和面向用户的直接命令响应;它绝不会进入模型请求。 无,因为该标识符只会作为模型不可见的 HTTP 传输元数据发送给 DeepSeek,绝不会进入请求正文、提示词或模型可见内容。
#### KV Cache 影响 #### KV Cache 影响
无;本包绝不会向模型请求贡献任何内容。 无;该传输标头既不会改变 token,也不会改变模型可见前缀。
## 已知限制与暂缓工作 ## 已知限制与暂缓工作
- **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。 - **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。
- **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID;后续启动会收敛到已持久化的值。 - **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID;后续启动会收敛到已持久化的值。
- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联。 - **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联。
- **已配置的 DeepSeek gateway 会收到该 id**:`dsh-llm-deepseek` 会把稳定标头发送至解析后的 `baseURL`(包括部署覆盖),且不受遥测共享模式影响。

6
pnpm-lock.yaml generated
View File

@@ -5082,6 +5082,9 @@ importers:
'@deepseek-ai/dsh-timeout': '@deepseek-ai/dsh-timeout':
specifier: workspace:^ specifier: workspace:^
version: link:../../util/timeout version: link:../../util/timeout
'@deepseek-ai/dsh-user-id':
specifier: workspace:^
version: link:../../session/user-id
packages/llm/llm-pi-ai: packages/llm/llm-pi-ai:
dependencies: dependencies:
@@ -8169,6 +8172,9 @@ importers:
'@deepseek-ai/dsh-user-approval': '@deepseek-ai/dsh-user-approval':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/interaction/user-approval version: link:../../packages/interaction/user-approval
'@deepseek-ai/dsh-user-id':
specifier: workspace:^
version: link:../../packages/session/user-id
'@deepseek-ai/dsh-user-interaction': '@deepseek-ai/dsh-user-interaction':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/interaction/user-interaction version: link:../../packages/interaction/user-interaction

View File

@@ -100,6 +100,7 @@
"@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^",
"@deepseek-ai/dsh-web-fetch-local": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^",

View File

@@ -127,7 +127,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers nothing model-facing.' }, 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers nothing model-facing.' },
'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers nothing model-facing.' }, 'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers nothing model-facing.' },
'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers nothing model-facing.' }, 'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers nothing model-facing.' },
'packages/session/user-id': { kind: 'none', reason: 'The shared identifier appears only in telemetry metadata and a direct human command response; it registers nothing model-facing.' }, 'packages/session/user-id': { kind: 'none', reason: 'The shared identifier reaches DeepSeek only as model-hidden HTTP metadata; it registers nothing model-facing.' },
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' },
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },