@verneuil/dsh-ocg-session-head 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/cordis.patch.yml +18 -0
- package/index.js +281 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +49 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### 改进
|
|
6
|
+
- README 新增开篇《先读这段》:明确本插件仅适用于 DeepSeek Harness 0.2.2 及以上,且仅用于「自定义供应商名称」的 OpenCode 接入;官方路径接入无需本插件。
|
|
7
|
+
- README 安装说明更新为 npm 优先,并修正已失效的 DSH_HOME 路径示例。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
### Improvements
|
|
11
|
+
- README now opens with a "Read this first" section: the plugin requires DeepSeek Harness 0.2.2 or newer, and is only needed for OpenCode connected under a custom provider name — the official path needs no plugin.
|
|
12
|
+
- Install instructions now prefer npm, and the outdated DSH_HOME path samples are corrected.
|
|
13
|
+
|
|
14
|
+
## 0.1.0
|
|
15
|
+
|
|
16
|
+
### 新功能
|
|
17
|
+
- 首个版本:通过宿主官方 `llm-pi-ai/request-headers` 瀑布,为 OpenCode Go 路由按会话注入 `x-opencode-session` 请求头,解决 400 MissingSessionID,并保留按会话的路由与提示词缓存亲和。
|
|
18
|
+
- 支持自定义路由:`providers` 路由键白名单与 `hostSuffixes` 主机后缀两条匹配门,默认适配 `ocg`。
|
|
19
|
+
- 支持 `session-id` 与 `uuid` 两种头值模式;`uuid` 模式可隐藏真实会话 ID。
|
|
20
|
+
- 配置错误会在插件激活阶段直接报错并指出字段,不会静默回落。
|
|
21
|
+
- 自带零依赖自测:`node scripts/self-test.mjs`。
|
|
22
|
+
|
|
23
|
+
*需要 DeepSeek Harness 0.2.2 或更高版本。*
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
### New Features
|
|
27
|
+
- First release: injects the `x-opencode-session` header per conversation for OpenCode Go routes through the host's official `llm-pi-ai/request-headers` waterfall, fixing 400 MissingSessionID while preserving per-conversation routing and prompt-cache affinity.
|
|
28
|
+
- Custom routes supported: a provider-key allowlist plus a baseURL host-suffix gate, tailored to `ocg` by default.
|
|
29
|
+
- Two header-value modes, `session-id` and `uuid`; `uuid` hides the real session id.
|
|
30
|
+
- Invalid configuration fails loudly at activation and names the offending field.
|
|
31
|
+
- Ships a zero-dependency self-test: `node scripts/self-test.mjs`.
|
|
32
|
+
|
|
33
|
+
*Requires DeepSeek Harness 0.2.2 or newer.*
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 verneuil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,179 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OCG 会话头(@verneuil/dsh-ocg-session-head)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
给 **OpenCode Go** 路由按会话注入 `x-opencode-session` 请求头。
|
|
4
|
+
|
|
5
|
+
> ### ⚠️ 先读这段:不满足以下任一条,你不需要本插件
|
|
6
|
+
>
|
|
7
|
+
> **1)宿主必须是 DeepSeek Harness 0.2.2 或更高版本**
|
|
8
|
+
>
|
|
9
|
+
> 本插件只做一件事:在宿主官方预留的请求头席位(`llm-pi-ai/request-headers` 瀑布)里写一个头。
|
|
10
|
+
> 该席位自 **0.2.2-alpha.3** 才引入、0.2.2-rc.1 起稳定,**更早的版本没有这个能力底座**。
|
|
11
|
+
> 在低版本上装它不会报错,但**永远不会生效**——看起来像「装了没用」,其实是无处可挂。
|
|
12
|
+
>
|
|
13
|
+
> **2)只对「自定义供应商名称」的 OpenCode 接入才有意义**
|
|
14
|
+
>
|
|
15
|
+
> - 如果你是通过 DSH 的**官方添加路径**接入 OpenCode / OpenCode Go(走 pi-ai 内置的
|
|
16
|
+
> `opencode` / `opencode-go` 路由),新版 pi-ai 已经会**自动**带上会话请求头,
|
|
17
|
+
> **不需要本插件**,装了纯属多余。
|
|
18
|
+
> - 只有当你是这样接入的——**确实在用 OpenCode 服务,但供应商名是自己起的**
|
|
19
|
+
> (手写 `baseURL` + `api: openai-completions`,例如把它叫 `ocg`)——
|
|
20
|
+
> 才拿不到那层自动包装,需要本插件补上这个头。
|
|
21
|
+
>
|
|
22
|
+
> 一句话:**官方路径接入 → 不用装;自定义命名接入 → 装本插件。**
|
|
23
|
+
|
|
24
|
+
这是 `dsh-opencode-session`(fetch 猴补丁版)的**替代实现**:不替换 `globalThis.fetch`、不使用
|
|
25
|
+
`AsyncLocalStorage`,只在宿主官方预留的请求头席位里写一个头。
|
|
26
|
+
|
|
27
|
+
- 仓库:<https://github.com/verneuil/dsh-ocg-session-head>
|
|
28
|
+
- 自测:`node scripts/self-test.mjs`(零依赖,不需要宿主)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 1. 为什么需要
|
|
33
|
+
|
|
34
|
+
OpenCode Go 自 2026-09-05 起,推理请求必须携带稳定的 `x-opencode-session`,否则返回:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
400 {"type":"MissingSessionID", ...}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
官方对**内置** `opencode` / `opencode-go` 路由已经在 pi-ai 里做了包装;但**自定义命名**的路由
|
|
41
|
+
(自己起个名字、手写 `baseURL` + `api: openai-completions`)拿不到那层包装,因此需要插件补上。
|
|
42
|
+
|
|
43
|
+
> 不确定自己属于哪种情况?回到开篇《先读这段》第 2 条对号入座。
|
|
44
|
+
|
|
45
|
+
## 2. 版本要求(重要)
|
|
46
|
+
|
|
47
|
+
| 项 | 要求 |
|
|
48
|
+
|---|---|
|
|
49
|
+
| DSH | **≥ 0.2.2**(`llm-pi-ai/request-headers` 席位自 0.2.2-alpha.3 引入,0.2.2 系列起可用) |
|
|
50
|
+
| Node | 无额外要求(纯 ESM,零依赖) |
|
|
51
|
+
|
|
52
|
+
在**低于该版本**的宿主上安装是安全的:插件能加载、会打一行 ready 日志,但事件永不触发 →
|
|
53
|
+
行为等价于不装(OCG 仍会 400)。**可用这一特性做「先装后升级」的分步验证。**
|
|
54
|
+
|
|
55
|
+
## 3. 安装
|
|
56
|
+
|
|
57
|
+
### 3.1 从 npm 安装(推荐)
|
|
58
|
+
|
|
59
|
+
在 DSH 的插件管理里安装 `@verneuil/dsh-ocg-session-head`(或指定版本 `@verneuil/dsh-ocg-session-head@0.1.1`),
|
|
60
|
+
或直接在 profile 的 `package.json` 里加:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
"@verneuil/dsh-ocg-session-head": "^0.1.1"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3.2 离线 / 本地 tarball
|
|
67
|
+
|
|
68
|
+
> ⚠️ **绝不要把「包目录」当安装 target。** pnpm 对目录使用 `link:`(junction),模块解析会
|
|
69
|
+
> 退化成 Node 原生解析,DSH 解析拦截层失效 → 条目 `failed to import`。
|
|
70
|
+
> **只有 tarball 会被解包成 profile 内的实体目录。**
|
|
71
|
+
|
|
72
|
+
在 profile 的 `dependencies` 里写成 `file:` 形式:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
"@verneuil/dsh-ocg-session-head": "file:<你的绝对路径>/dsh-ocg-session-head-<版本>.tgz"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
写入依赖后,用 DSH 自带的 node/pnpm 安装(本机不需要系统 npm):
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
$node = Join-Path $env:DSH_HOME 'dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe'
|
|
82
|
+
$pnpm = Join-Path $env:DSH_HOME 'dsh-runtimes\dsh-primary-runtime\dependencies\pnpm\bin\pnpm.mjs'
|
|
83
|
+
$target = Join-Path $env:DSH_HOME 'profiles\desktop'
|
|
84
|
+
& $node $pnpm install --dir $target
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
无论哪种方式,安装后都需要**重启 DSH**(重启会杀掉宿主里的 AI 进程,需人工执行)。
|
|
88
|
+
|
|
89
|
+
## 4. 配置
|
|
90
|
+
|
|
91
|
+
`cordis.patch.yml` 自带一份默认 config;也可在 profile 的 `cordis.patch.yml` 里按 id 覆盖:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
- id: dsh-ocg-session-head
|
|
95
|
+
config:
|
|
96
|
+
providers:
|
|
97
|
+
- ocg
|
|
98
|
+
debug: true
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
> 按 id 覆盖是**整体替换** config。
|
|
102
|
+
|
|
103
|
+
| 键 | 默认 | 说明 |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `providers` | `['ocg']` | 路由键白名单,精确匹配事件里的 `provider` |
|
|
106
|
+
| `hostSuffixes` | `['opencode.ai']` | baseURL 主机后缀白名单;两条门是**或**关系,换路由键时兜底 |
|
|
107
|
+
| `header` | `x-opencode-session` | 注入的头名,必须是合法 HTTP header 名 |
|
|
108
|
+
| `mode` | `session-id` | `session-id`:直接用宿主会话 id(跨轮次/压缩/重启稳定);`uuid`:按会话派生进程内稳定的不透明 uuid |
|
|
109
|
+
| `debug` | `false` | 每次成功注入打一行日志 |
|
|
110
|
+
| `maxTrackedSessions` | `4096` | `uuid` 模式下会话映射上限,超出按最旧淘汰 |
|
|
111
|
+
|
|
112
|
+
### 配置校验(错误会响亮报错)
|
|
113
|
+
|
|
114
|
+
遵循官方「配置错误要响亮」原则:**非法配置会让插件激活失败并给出字段名**,不会静默回落。
|
|
115
|
+
|
|
116
|
+
| 情况 | 行为 |
|
|
117
|
+
|---|---|
|
|
118
|
+
| 键未配置(`undefined`) | 使用默认值 |
|
|
119
|
+
| 出现未知键名(疑似拼写错误) | ❌ 报错并列出合法键 |
|
|
120
|
+
| `providers` / `hostSuffixes` 不是字符串数组、含空串、含重复项 | ❌ 报错并指出下标 |
|
|
121
|
+
| `header` 为空或不是合法 HTTP header 名 | ❌ 报错 |
|
|
122
|
+
| `header` 指向 Harness 归属头(`user-agent`、`x-deepseek-harness-*`) | ❌ 报错(宿主会在所有贡献者之后覆盖它,写了也无效) |
|
|
123
|
+
| `mode` 不是 `session-id` / `uuid` | ❌ 报错 |
|
|
124
|
+
| `debug` 不是布尔、`maxTrackedSessions` 不是正整数 | ❌ 报错 |
|
|
125
|
+
| `providers` 与 `hostSuffixes` **同时为空** | ❌ 报错(永远匹配不到任何请求) |
|
|
126
|
+
|
|
127
|
+
> 数组字段语义:「未配置」用默认值;**显式传空数组表示关闭这道门**。
|
|
128
|
+
> 例如 `hostSuffixes: []` 表示只按路由键匹配、不再按 baseURL 主机兜底。
|
|
129
|
+
|
|
130
|
+
## 5. 行为保证
|
|
131
|
+
|
|
132
|
+
- **已有同名头一律让路**:不覆盖静态 `headers` 配置、官方实现或其它插件写入的值。
|
|
133
|
+
- **无会话 id 不注入**:辅助调用若没有 `sessionId`,原样放行。
|
|
134
|
+
- **失败即放行**:插件内部任何异常只 `warn` 一行,然后继续 `next()`,绝不因本插件打断推理。
|
|
135
|
+
- **零全局副作用**:不修改 `globalThis`、不替换 `fetch`、不写文件。
|
|
136
|
+
- **随插件卸载自动撤销**:监听器由 `ctx.on()` 注册,归 fiber 生命周期管理。
|
|
137
|
+
|
|
138
|
+
## 6. 验证(四关)
|
|
139
|
+
|
|
140
|
+
1. **启动日志**:宿主控制台出现
|
|
141
|
+
`[dsh-ocg-session-head] ready (header=x-opencode-session mode=session-id providers=ocg ...)`
|
|
142
|
+
2. **注入日志**:把 `debug` 打开后,每次模型调用出现
|
|
143
|
+
`[dsh-ocg-session-head] x-opencode-session=<id> provider=ocg model=... purpose=chat`
|
|
144
|
+
3. **真请求 200**:正常对话不出现 400 `MissingSessionID`。
|
|
145
|
+
4. **反向证明**:禁用本插件后立刻 400 —— 证明头确实是它在发。
|
|
146
|
+
|
|
147
|
+
## 7. 卸载与回滚
|
|
148
|
+
|
|
149
|
+
- 插件管理页卸载,或从 profile `dependencies` 删除该包后重装依赖。
|
|
150
|
+
- 旧插件 `dsh-opencode-session` 可与之**共存**(两者都「已有不覆盖」,新实现先写入、旧的自动 no-op)。
|
|
151
|
+
切换期建议:**旧插件保持安装但禁用**,出问题 30 秒切回。
|
|
152
|
+
|
|
153
|
+
## 8. 开发与打包
|
|
154
|
+
|
|
155
|
+
```powershell
|
|
156
|
+
# 自测(零依赖,不需要宿主)
|
|
157
|
+
node scripts/self-test.mjs
|
|
158
|
+
|
|
159
|
+
# 用 DSH 自带运行时打包(无需系统 node/pnpm)
|
|
160
|
+
$node = Join-Path $env:DSH_HOME 'dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe'
|
|
161
|
+
$pnpm = Join-Path $env:DSH_HOME 'dsh-runtimes\dsh-primary-runtime\dependencies\pnpm\bin\pnpm.mjs'
|
|
162
|
+
& $node $pnpm pack
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
本地测试包可命名 `<版本>-rc.<YYYYMMDD-HHmm>`(临时改版本 → 打包 → 立即还原)。
|
|
166
|
+
|
|
167
|
+
## 9. 已知限制
|
|
168
|
+
|
|
169
|
+
- 只覆盖 **pi-ai 路由**(`@deepseek-ai/dsh-llm-pi-ai`)。`deepseek-official` 走
|
|
170
|
+
`@deepseek-ai/dsh-llm-deepseek`,它自己发 `x-deepseek-harness-session-id`,与本插件无关。
|
|
171
|
+
- **不覆盖模型发现**(宿主契约明确该事件 `excludes model discovery`)。
|
|
172
|
+
- 事件是**同步**的:监听器必须就地修改 `headers` 并立刻 `return next()`,不得保存该对象引用。
|
|
173
|
+
- 未声明 `engines.dsh`。官方推荐声明兼容范围,但 SemVer 的预发布规则会让 `>=0.2.2` 拒绝
|
|
174
|
+
`0.2.2-rc.1`、让 `>=0.2.2-rc.1` 拒绝将来的 `0.2.3-rc.1`,对跟着 rc 升级的用户反而更危险;
|
|
175
|
+
因此这里选择不声明(等价于不做版本门禁),运行期依赖单一公开事件,兼容面本就很窄。
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# 插入本插件的 loader row。
|
|
2
|
+
#
|
|
3
|
+
# 注意:这个 id 与 index.js 的 `export const name` 保持一致,
|
|
4
|
+
# 同时是 profile 里「按 id 覆盖 config」的锚点。
|
|
5
|
+
#
|
|
6
|
+
# profile 侧按 id 覆盖会**整体替换** config。未写的键由 index.js 的 DEFAULTS 兜底;
|
|
7
|
+
# 写错的键/类型/枚举会在插件激活阶段直接报错(官方「配置错误要响亮」),不会静默回落。
|
|
8
|
+
- insert:
|
|
9
|
+
- id: dsh-ocg-session-head
|
|
10
|
+
name: '@verneuil/dsh-ocg-session-head'
|
|
11
|
+
config:
|
|
12
|
+
providers:
|
|
13
|
+
- ocg
|
|
14
|
+
hostSuffixes:
|
|
15
|
+
- opencode.ai
|
|
16
|
+
header: x-opencode-session
|
|
17
|
+
mode: session-id
|
|
18
|
+
debug: false
|
package/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ocg-session-head — 给 OpenCode Go 路由按会话注入 `x-opencode-session`。
|
|
3
|
+
*
|
|
4
|
+
* ## 它挂在哪里
|
|
5
|
+
*
|
|
6
|
+
* 监听宿主 dsh-llm-pi-ai 的同步瀑布事件 `llm-pi-ai/request-headers`
|
|
7
|
+
* (DSH 0.2.2-alpha.3 引入、0.2.2-rc.1 确认保留)。
|
|
8
|
+
*
|
|
9
|
+
* 事件签名(官方源码 packages/llm/llm-pi-ai/src/types.ts、README「Request headers」节):
|
|
10
|
+
*
|
|
11
|
+
* 'llm-pi-ai/request-headers'(
|
|
12
|
+
* request: PiAiRequestHeaderContext,
|
|
13
|
+
* headers: Headers,
|
|
14
|
+
* next: () => Headers,
|
|
15
|
+
* ): Headers
|
|
16
|
+
*
|
|
17
|
+
* interface PiAiRequestHeaderContext
|
|
18
|
+
* extends Readonly<Pick<GenerateOptions, 'provider' | 'model' | 'sessionId' | 'purpose'>> {
|
|
19
|
+
* readonly baseURL: string
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* 官方契约要点:
|
|
23
|
+
* - 每次**推理调用**派发前触发(含起标题 / 压缩等辅助调用),**不含模型发现**;
|
|
24
|
+
* - `headers` 是该次请求独占的一份副本,监听者**就地修改**后调用 `next()`;
|
|
25
|
+
* - 宿主归因头在所有贡献者之后统一写入,监听者无法覆盖;
|
|
26
|
+
* - 监听者抛错或写入非法 HTTP 头 → 该次调用以 `REQUEST_HEADERS` 在发请求前失败;
|
|
27
|
+
* - `ctx.on()` 注册的监听器随插件卸载自动移除。
|
|
28
|
+
*
|
|
29
|
+
* ## 为什么不猴补丁 fetch
|
|
30
|
+
*
|
|
31
|
+
* 旧方案(dsh-opencode-session)在 `globalThis.fetch` 上打补丁 + AsyncLocalStorage
|
|
32
|
+
* 传递会话身份。这条链依赖「宿主一定走全局 fetch」,网络栈一变就静默失效。
|
|
33
|
+
* 本插件只在参数层写一个头,与传输实现无关。
|
|
34
|
+
*
|
|
35
|
+
* @module dsh-ocg-session-head
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Cordis 插件名。与 cordis.patch.yml 里的 row id 保持一致,便于按 id 覆盖 config。 */
|
|
39
|
+
export const name = 'dsh-ocg-session-head'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 不依赖任何宿主服务:事件由适配器派发,注册本身不需要 llm 服务句柄。
|
|
43
|
+
* 空数组表示「无 inject」,插件在 profile 装配时即可加载。
|
|
44
|
+
*/
|
|
45
|
+
export const inject = []
|
|
46
|
+
|
|
47
|
+
/** 配置默认值。profile 里的按 id 覆盖会**整体替换** config,缺省键在此兜底。 */
|
|
48
|
+
const DEFAULTS = Object.freeze({
|
|
49
|
+
/** 路由键白名单(精确匹配 request.provider)。 */
|
|
50
|
+
providers: ['ocg'],
|
|
51
|
+
/** baseURL 主机后缀白名单(忽略大小写)。显式空数组表示不按主机匹配。 */
|
|
52
|
+
hostSuffixes: ['opencode.ai'],
|
|
53
|
+
/** 注入的请求头名。 */
|
|
54
|
+
header: 'x-opencode-session',
|
|
55
|
+
/** session-id:直接用宿主会话 id;uuid:按会话派生进程内稳定的不透明 uuid。 */
|
|
56
|
+
mode: 'session-id',
|
|
57
|
+
/** true 时每次成功注入打一行日志(默认关闭,避免刷屏)。 */
|
|
58
|
+
debug: false,
|
|
59
|
+
/** uuid 模式下最多记住多少个会话(超出按最旧淘汰,防止长跑进程无限增长)。 */
|
|
60
|
+
maxTrackedSessions: 4096,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** 允许出现的配置键。写错键名属于配置错误,必须响亮报错而不是被静默忽略。 */
|
|
64
|
+
const CONFIG_KEYS = Object.freeze(Object.keys(DEFAULTS))
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* RFC 9110 `token` 文法:HTTP 请求头名必须由这些字符组成且非空。
|
|
68
|
+
* @see https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2
|
|
69
|
+
*/
|
|
70
|
+
const HTTP_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 抛出配置错误。
|
|
74
|
+
*
|
|
75
|
+
* 依据官方配置指南「配置错误要响亮」:非法配置在插件激活阶段直接失败,
|
|
76
|
+
* 而不是悄悄回落到默认值后带着错误设定继续跑。
|
|
77
|
+
*
|
|
78
|
+
* @param {string} message 面向用户的原因。
|
|
79
|
+
* @returns {never} 永远抛出。
|
|
80
|
+
*/
|
|
81
|
+
function fail(message) {
|
|
82
|
+
throw new Error(`[${name}] invalid config: ${message}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 读取字符串数组配置。
|
|
87
|
+
*
|
|
88
|
+
* 语义刻意区分「未配置」与「配置错误」:
|
|
89
|
+
* - 未配置(`undefined`)→ 使用默认值;
|
|
90
|
+
* - 已配置但不是数组、含非字符串、含空串或重复项 → 响亮报错;
|
|
91
|
+
* - 显式空数组 → 采信为空数组(表示关闭该匹配门)。
|
|
92
|
+
*
|
|
93
|
+
* @param {Record<string, unknown>} config 原始配置。
|
|
94
|
+
* @param {string} key 字段名。
|
|
95
|
+
* @param {readonly string[]} fallback 默认值。
|
|
96
|
+
* @returns {string[]} 规范化后的数组。
|
|
97
|
+
*/
|
|
98
|
+
function readStringList(config, key, fallback) {
|
|
99
|
+
const raw = config[key]
|
|
100
|
+
if (raw === undefined) return [...fallback]
|
|
101
|
+
if (!Array.isArray(raw)) fail(`"${key}" must be an array of strings`)
|
|
102
|
+
const seen = new Set()
|
|
103
|
+
return raw.map((item, index) => {
|
|
104
|
+
if (typeof item !== 'string') fail(`"${key}[${index}]" must be a string`)
|
|
105
|
+
const value = item.trim()
|
|
106
|
+
if (value.length === 0) fail(`"${key}[${index}]" must be a non-empty string`)
|
|
107
|
+
if (seen.has(value)) fail(`"${key}" lists ${JSON.stringify(value)} more than once`)
|
|
108
|
+
seen.add(value)
|
|
109
|
+
return value
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 把原始 config 解析成运行时选项。
|
|
115
|
+
*
|
|
116
|
+
* 任何非法配置都会抛出带字段名的错误(官方「配置错误要响亮」)。运行时注入失败则走
|
|
117
|
+
* fail-open(见 `apply`),两者互不影响:配置错了直接拒绝启用,运行中偶发异常则放行请求。
|
|
118
|
+
*
|
|
119
|
+
* @param {Record<string, unknown>} [config] loader row 传入的 config。
|
|
120
|
+
* @returns {object} 运行时选项。
|
|
121
|
+
*/
|
|
122
|
+
export function resolveOptions(config = {}) {
|
|
123
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
124
|
+
fail('config must be an object')
|
|
125
|
+
}
|
|
126
|
+
for (const key of Object.keys(config)) {
|
|
127
|
+
if (!CONFIG_KEYS.includes(key)) {
|
|
128
|
+
fail(`unknown key ${JSON.stringify(key)}; valid keys are ${CONFIG_KEYS.join(', ')}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const providers = readStringList(config, 'providers', DEFAULTS.providers)
|
|
133
|
+
const hostSuffixes = readStringList(config, 'hostSuffixes', DEFAULTS.hostSuffixes)
|
|
134
|
+
.map((suffix) => suffix.toLowerCase().replace(/^\./, ''))
|
|
135
|
+
|
|
136
|
+
const rawHeader = config.header === undefined ? DEFAULTS.header : config.header
|
|
137
|
+
if (typeof rawHeader !== 'string' || rawHeader.trim().length === 0) {
|
|
138
|
+
fail('"header" must be a non-empty string')
|
|
139
|
+
}
|
|
140
|
+
const header = rawHeader.trim()
|
|
141
|
+
if (!HTTP_TOKEN.test(header)) {
|
|
142
|
+
fail(`"header" is not a valid HTTP header name: ${JSON.stringify(header)}`)
|
|
143
|
+
}
|
|
144
|
+
const headerLower = header.toLowerCase()
|
|
145
|
+
if (headerLower === 'user-agent' || headerLower.startsWith('x-deepseek-harness-')) {
|
|
146
|
+
fail(`"header" must not target a Harness attribution header (${header}); the Harness applies its own value after every contributor`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const mode = config.mode === undefined ? DEFAULTS.mode : config.mode
|
|
150
|
+
if (mode !== 'session-id' && mode !== 'uuid') {
|
|
151
|
+
fail(`"mode" must be "session-id" or "uuid"; got ${JSON.stringify(mode)}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const debug = config.debug === undefined ? DEFAULTS.debug : config.debug
|
|
155
|
+
if (typeof debug !== 'boolean') fail('"debug" must be a boolean')
|
|
156
|
+
|
|
157
|
+
const maxTrackedSessions = config.maxTrackedSessions === undefined
|
|
158
|
+
? DEFAULTS.maxTrackedSessions
|
|
159
|
+
: config.maxTrackedSessions
|
|
160
|
+
if (!Number.isSafeInteger(maxTrackedSessions) || maxTrackedSessions <= 0) {
|
|
161
|
+
fail('"maxTrackedSessions" must be a positive integer')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (providers.length === 0 && hostSuffixes.length === 0) {
|
|
165
|
+
fail('"providers" and "hostSuffixes" are both empty, so no request could ever match')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
providers: new Set(providers),
|
|
170
|
+
hostSuffixes,
|
|
171
|
+
header,
|
|
172
|
+
mode,
|
|
173
|
+
debug,
|
|
174
|
+
maxTrackedSessions,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 取 baseURL 的主机名(小写);无法解析时返回空串,绝不抛错。
|
|
180
|
+
* @param {unknown} baseURL 模型解析后的端点。
|
|
181
|
+
* @returns {string} 主机名或空串。
|
|
182
|
+
*/
|
|
183
|
+
function hostOf(baseURL) {
|
|
184
|
+
if (typeof baseURL !== 'string' || baseURL.length === 0) return ''
|
|
185
|
+
try {
|
|
186
|
+
return new URL(baseURL).hostname.toLowerCase()
|
|
187
|
+
} catch {
|
|
188
|
+
return ''
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 判断这次调用是否属于本插件负责的 OpenCode 路由。
|
|
194
|
+
*
|
|
195
|
+
* 两条门是「或」关系:
|
|
196
|
+
* 1. 路由键在 providers 白名单里(最精确,本机就是 `ocg`);
|
|
197
|
+
* 2. baseURL 主机命中 hostSuffixes(换路由键、或别名路由时兜底)。
|
|
198
|
+
*
|
|
199
|
+
* @param {ReturnType<typeof resolveOptions>} options 运行时选项。
|
|
200
|
+
* @param {Record<string, unknown>} request 事件携带的路由元数据。
|
|
201
|
+
* @returns {boolean} 是否由本插件接管。
|
|
202
|
+
*/
|
|
203
|
+
export function isOpenCodeRoute(options, request) {
|
|
204
|
+
const provider = typeof request.provider === 'string' ? request.provider : ''
|
|
205
|
+
if (provider.length > 0 && options.providers.has(provider)) return true
|
|
206
|
+
if (options.hostSuffixes.length === 0) return false
|
|
207
|
+
const host = hostOf(request.baseURL)
|
|
208
|
+
if (host.length === 0) return false
|
|
209
|
+
return options.hostSuffixes.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 计算某次会话要写入的头值。
|
|
214
|
+
* @param {ReturnType<typeof resolveOptions>} options 运行时选项。
|
|
215
|
+
* @param {Map<string, string>} table uuid 模式下会话 → uuid 的映射。
|
|
216
|
+
* @param {unknown} sessionId 宿主会话 id。
|
|
217
|
+
* @returns {string | undefined} 头值;无法产生时返回 undefined。
|
|
218
|
+
*/
|
|
219
|
+
export function headerValueFor(options, table, sessionId) {
|
|
220
|
+
const raw = String(sessionId ?? '')
|
|
221
|
+
if (raw.length === 0) return undefined
|
|
222
|
+
if (options.mode !== 'uuid') return raw
|
|
223
|
+
const cached = table.get(raw)
|
|
224
|
+
if (cached !== undefined) return cached
|
|
225
|
+
const value = globalThis.crypto?.randomUUID?.()
|
|
226
|
+
if (value === undefined) return raw
|
|
227
|
+
if (table.size >= options.maxTrackedSessions) {
|
|
228
|
+
const oldest = table.keys().next()
|
|
229
|
+
if (oldest.done !== true) table.delete(oldest.value)
|
|
230
|
+
}
|
|
231
|
+
table.set(raw, value)
|
|
232
|
+
return value
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 安装插件。
|
|
237
|
+
* @param {import('cordis').Context} ctx 宿主上下文。
|
|
238
|
+
* @param {Record<string, unknown>} [config] loader row 的 config。
|
|
239
|
+
*/
|
|
240
|
+
export function apply(ctx, config) {
|
|
241
|
+
const options = resolveOptions(config)
|
|
242
|
+
/** @type {Map<string, string>} */
|
|
243
|
+
const uuidBySession = new Map()
|
|
244
|
+
|
|
245
|
+
ctx.on('llm-pi-ai/request-headers', (request, headers, next) => {
|
|
246
|
+
// 宿主契约要求监听者必须调用 next();任何异常都不允许把整次推理打断,
|
|
247
|
+
// 因此在本地逻辑上失败即「放弃注入、原样放行」。
|
|
248
|
+
try {
|
|
249
|
+
if (typeof next !== 'function') return headers
|
|
250
|
+
if (request === null || typeof request !== 'object') return next()
|
|
251
|
+
if (headers === null || typeof headers !== 'object' || typeof headers.set !== 'function') return next()
|
|
252
|
+
if (!isOpenCodeRoute(options, request)) return next()
|
|
253
|
+
// 已有同名头一律让路:与官方实现、其它插件、静态 headers 配置共存。
|
|
254
|
+
if (typeof headers.has === 'function' && headers.has(options.header)) return next()
|
|
255
|
+
const value = headerValueFor(options, uuidBySession, request.sessionId)
|
|
256
|
+
if (value === undefined) return next()
|
|
257
|
+
headers.set(options.header, value)
|
|
258
|
+
if (options.debug) {
|
|
259
|
+
ctx.logger.info(
|
|
260
|
+
`[dsh-ocg-session-head] ${options.header}=${value} `
|
|
261
|
+
+ `provider=${String(request.provider)} model=${String(request.model)} `
|
|
262
|
+
+ `purpose=${String(request.purpose ?? 'chat')}`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
ctx.logger.warn(
|
|
267
|
+
`[dsh-ocg-session-head] header preparation skipped: ${error instanceof Error ? error.message : String(error)}`,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
return next()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
ctx.logger.info(
|
|
274
|
+
`[dsh-ocg-session-head] ready (header=${options.header} mode=${options.mode} `
|
|
275
|
+
+ `providers=${[...options.providers].join(',') || '(none)'} `
|
|
276
|
+
+ `hostSuffixes=${options.hostSuffixes.join(',') || '(none)'} debug=${String(options.debug)}`
|
|
277
|
+
+ ')—— 需要宿主提供 llm-pi-ai/request-headers 事件(DSH >= 0.2.2-rc.1)',
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default { name, inject, apply }
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@verneuil/dsh-ocg-session-head",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenCode Go 会话头:监听宿主 llm-pi-ai/request-headers 瀑布,按会话为 ocg 路由注入 x-opencode-session(不猴补丁 fetch、不修改宿主)。",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "verneuil",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/verneuil/dsh-ocg-session-head.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/verneuil/dsh-ocg-session-head#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/verneuil/dsh-ocg-session-head/issues"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.js",
|
|
19
|
+
"./locale/*.json": "./locale/*.json",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.js",
|
|
24
|
+
"cordis.patch.yml",
|
|
25
|
+
"locale",
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node scripts/self-test.mjs",
|
|
32
|
+
"check": "node scripts/ci-check.mjs"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"dsh",
|
|
36
|
+
"dsh-plugin",
|
|
37
|
+
"deepseek-harness",
|
|
38
|
+
"opencode-go",
|
|
39
|
+
"session-header"
|
|
40
|
+
],
|
|
41
|
+
"meta": {
|
|
42
|
+
"title": "OCG 会话头",
|
|
43
|
+
"description": "为 OpenCode Go 路由按会话注入 x-opencode-session 请求头(官方 request-headers 席位实现)。"
|
|
44
|
+
},
|
|
45
|
+
"dsh": {
|
|
46
|
+
"manifestVersion": 1,
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|