dsh-my-opencode-session-header 0.1.0
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 +23 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/cordis.patch.yml +9 -0
- package/lib/config.js +76 -0
- package/lib/context.js +62 -0
- package/lib/fetch-patch.js +149 -0
- package/lib/headers.js +56 -0
- package/lib/index.js +72 -0
- package/lib/session-value.js +13 -0
- package/package.json +48 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-09-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- 修复 OpenCode Go 网关 `400 MissingSessionID`:为走 `opencode` / `opencode-go` 路由的推理请求注入
|
|
13
|
+
`x-opencode-session`(该会话的稳定 id)
|
|
14
|
+
- 拦截链:同步 `llm/stream` waterfall handler 包装流 + `AsyncLocalStorage` 会话上下文(每次
|
|
15
|
+
`next()`/`return()` 均在上下文中执行,适配 pi-ai 惰性发请求)+ 单层 `globalThis.fetch` 补丁
|
|
16
|
+
- 路由判定(AND):provider 白名单(默认 `opencode` / `opencode-go`)× 主机命中(默认 `opencode.ai` 及子域),
|
|
17
|
+
任一不满足即原样调用原 fetch(零副作用)
|
|
18
|
+
- 会话头值确定性:同会话跨轮次 / 压缩 / 重试 / 重启稳定,不同会话不同值;`valueMode` 支持 `uuid`(提取裸 UUID,
|
|
19
|
+
无则回退原串)与 `raw`
|
|
20
|
+
- 幂等与优先级:已有同名头(`Headers` / 数组 / 普通对象三形态,大小写不敏感)默认不覆盖,`override: true` 才覆盖
|
|
21
|
+
- 退化安全:无会话上下文或会话 id 为空时不注入(首次 warn 一次);插件内部异常一律吞掉并降级为原行为
|
|
22
|
+
- 卸载还原:disposer 仅当当前 `globalThis.fetch` 仍是本插件安装的那个时才还原,不误还原他人 patch
|
|
23
|
+
- 配置项(含校验与明确报错):`enabled` / `providers` / `hosts` / `headerName` / `valueMode` / `override`
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bsfeng
|
|
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
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# dsh-my-opencode-session-header
|
|
2
|
+
|
|
3
|
+
[](https://github.com/topics/dsh)
|
|
4
|
+
|
|
5
|
+
> **无 UI 说明**:纯 server 插件(无 client / 无面板 / 无设置页),不产生界面截图——README 效果图门禁按 `package.json` 的 `dsh.ui=false` + `dsh.uiReason` 显式豁免。
|
|
6
|
+
|
|
7
|
+
**DSH OpenCode 会话头注入插件**:让走 `opencode` / `opencode-go` 路由的推理请求自动携带 `x-opencode-session`(该会话的稳定 id),修复 OpenCode Go 网关对缺失该头的请求返回 **400 MissingSessionID** 的问题。上游讨论:<https://github.com/deepseek-ai/deepseek-harness/discussions/5495>。
|
|
8
|
+
|
|
9
|
+
## 功能与边界
|
|
10
|
+
|
|
11
|
+
- 包装 `llm/stream` 事件 + 单层补丁 `globalThis.fetch`,命中配置的 provider 与目标 host 时注入会话头,零运行时依赖。
|
|
12
|
+
- **确定性**:同一会话在跨轮次 / 压缩摘要 / 重试 / 重启后得到同一个值,不同会话不同值;辅助 LLM 调用(标题、摘要)同样带上同一会话的头。
|
|
13
|
+
- **幂等**:请求里已存在同名头(`Headers` / 数组 / 普通对象三种形态,大小写不敏感)时默认不覆盖,`override: true` 才覆盖。
|
|
14
|
+
- **退化安全**:无会话上下文 / 会话 id 为空 / 插件内部异常 → 不注入,原样放行,绝不影响推理请求。
|
|
15
|
+
- **卸载干净**:disposer 仅在「当前 `globalThis.fetch` 仍是自己装的那层」时还原,不误还原他人的 patch。
|
|
16
|
+
|
|
17
|
+
## 配置项
|
|
18
|
+
|
|
19
|
+
| 配置项 | 默认值 | 说明 |
|
|
20
|
+
| ------------ | ----------------------------- | ------------------------------------------------------------------- |
|
|
21
|
+
| `enabled` | `true` | 总开关(`false` 不注册监听、不装 fetch 补丁) |
|
|
22
|
+
| `providers` | `["opencode", "opencode-go"]` | provider 路由白名单(空数组 / 非法值启动即报错) |
|
|
23
|
+
| `hosts` | `["opencode.ai"]` | 目标主机白名单,自动含子域(`zen.opencode.ai` 命中 `opencode.ai`) |
|
|
24
|
+
| `headerName` | `"x-opencode-session"` | 注入的请求头名 |
|
|
25
|
+
| `valueMode` | `"uuid"` | `uuid` = 取会话 id 中的裸 UUID(无则回退原串);`raw` = 原始会话 id |
|
|
26
|
+
| `override` | `false` | 已有同名头时是否覆盖 |
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
- id: opencode-session-header
|
|
30
|
+
config:
|
|
31
|
+
enabled: true
|
|
32
|
+
providers: ['opencode', 'opencode-go']
|
|
33
|
+
hosts: ['opencode.ai']
|
|
34
|
+
valueMode: uuid
|
|
35
|
+
# override: true # 仅当确有需要覆盖已有同名头时才开启
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 安装
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 方式一:npm 安装
|
|
42
|
+
dsh plugin --profile web add dsh-my-opencode-session-header
|
|
43
|
+
|
|
44
|
+
# 方式二:本地 link 安装(开发中)
|
|
45
|
+
git clone https://github.com/baosfeng/my-dsh-plugins.git
|
|
46
|
+
dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-opencode-session-header
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
安装后必须**完全重启 `dsh`(Web 服务)**才生效:插件在启动时注册 `llm/stream` 监听并安装 fetch 补丁,只刷新浏览器不生效。
|
|
50
|
+
|
|
51
|
+
> **与静态 header 互斥(重要)**:若 `settings.yaml` 里给 opencode-go 配了写死的 `x-opencode-session`,请先删除该项——本插件默认 `override: false` 不覆盖已有头,静态值会让插件形同未启用,且该固定值会被网关当成同一个会话。确需保留静态头又要插件接管时,改用 `override: true`。
|
|
52
|
+
|
|
53
|
+
## 验证是否生效
|
|
54
|
+
|
|
55
|
+
1. 完全重启 `dsh`,启动日志应出现 `[opencode-session-header] active for providers […] with valueMode uuid`;没有则插件未挂载(查 `dsh plugin list` / `cordis.patch.yml`)或配置校验失败。
|
|
56
|
+
2. 新开一个走 opencode-go 路由的会话发消息:正常回复、不再出现 400 即为生效;再开第二个会话同样正常,说明两个会话各带自己的 id(互相串上下文说明仍在用静态头)。
|
|
57
|
+
3. 需要看实际发出的头值时,可临时把 `settings.yaml` 的 `llm-pi-ai.providers.opencode-go.baseURL` 指向本机一个打印请求头的 HTTP 服务(HTTPS 下 `tcpdump` 看不到明文),验证完改回原地址并重启。
|
|
58
|
+
|
|
59
|
+
## 卸载
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
dsh plugin --profile web remove dsh-my-opencode-session-header
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
卸载后完全重启 `dsh`,`globalThis.fetch` 由 disposer 还原为安装前的实现(仅还原自己装的那层)。
|
|
66
|
+
|
|
67
|
+
## 已知限界
|
|
68
|
+
|
|
69
|
+
- 只拦截 `globalThis.fetch` 且只在 `llm/stream` 上下文内注入:走 websocket / 绕过 fetch 的原生客户端、其它非推理 fetch 调用都不加头(opencode-go 走 HTTPS fetch,不受影响)。
|
|
70
|
+
- 与静态 `headers` 配置互斥(见上):静态值优先,除非 `override: true`。
|
|
71
|
+
- `valueMode: uuid` 在会话 id 不含 UUID 时回退为原始会话 id 字符串(仍确定性、会话间唯一)。
|
|
72
|
+
|
|
73
|
+
## 相关文档
|
|
74
|
+
|
|
75
|
+
→ [OpenCode 会话头模块文档](../../docs/OpenCode会话头/概述.md) · [CHANGELOG](CHANGELOG.md)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-my-opencode-session-header bundle patch: inserts the plugin row into the
|
|
2
|
+
# profile roster. The row mounts the server half (llm/stream wrapper +
|
|
3
|
+
# globalThis.fetch patch injecting the x-opencode-session header). Install with:
|
|
4
|
+
#
|
|
5
|
+
# git clone https://github.com/baosfeng/my-dsh-plugins.git
|
|
6
|
+
# dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-opencode-session-header
|
|
7
|
+
- insert:
|
|
8
|
+
- id: opencode-session-header
|
|
9
|
+
name: 'dsh-my-opencode-session-header'
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-opencode-session-header — 配置解析与校验。
|
|
3
|
+
*
|
|
4
|
+
* cordis 插件 config 的运行时校验:空数组 / 非法值一律抛出明确错误
|
|
5
|
+
* (loader 启动即失败,不静默降级成"插件没生效但又没提示")。
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_PROVIDERS = ['opencode', 'opencode-go'];
|
|
8
|
+
const DEFAULT_HOSTS = ['opencode.ai'];
|
|
9
|
+
const DEFAULT_HEADER_NAME = 'x-opencode-session';
|
|
10
|
+
/** 主机名形态(裸主机名,无 scheme / 端口 / 路径)。 */
|
|
11
|
+
const HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
12
|
+
/** HTTP 头名 token 形态(RFC 7230)。 */
|
|
13
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
14
|
+
/** 构造带插件前缀的配置错误(仅本模块内部使用)。 */
|
|
15
|
+
function configError(message) {
|
|
16
|
+
return new Error(`[opencode-session-header] ${message}`);
|
|
17
|
+
}
|
|
18
|
+
/** 解析并校验插件配置(非法值抛错)。 */
|
|
19
|
+
export function resolveConfig(config) {
|
|
20
|
+
const source = (config ?? {});
|
|
21
|
+
return {
|
|
22
|
+
enabled: booleanOf(source.enabled, 'enabled', true),
|
|
23
|
+
providers: stringListOf(source.providers, 'providers', DEFAULT_PROVIDERS),
|
|
24
|
+
hosts: hostListOf(source.hosts),
|
|
25
|
+
headerName: headerNameOf(source.headerName),
|
|
26
|
+
valueMode: valueModeOf(source.valueMode),
|
|
27
|
+
override: booleanOf(source.override, 'override', false),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** 布尔字段校验(缺省用 fallback)。 */
|
|
31
|
+
function booleanOf(value, field, fallback) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return fallback;
|
|
34
|
+
if (typeof value !== 'boolean')
|
|
35
|
+
throw configError(`config.${field} must be a boolean`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
/** 非空字符串数组字段校验(缺省用 fallback)。 */
|
|
39
|
+
function stringListOf(value, field, fallback) {
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return [...fallback];
|
|
42
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
43
|
+
throw configError(`config.${field} must be a non-empty array of strings`);
|
|
44
|
+
}
|
|
45
|
+
for (const item of value) {
|
|
46
|
+
if (typeof item !== 'string' || item === '')
|
|
47
|
+
throw configError(`config.${field} entries must be non-empty strings`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
/** hosts 字段:非空数组 + 每项必须是裸主机名。 */
|
|
52
|
+
function hostListOf(value) {
|
|
53
|
+
const hosts = stringListOf(value, 'hosts', DEFAULT_HOSTS);
|
|
54
|
+
for (const host of hosts) {
|
|
55
|
+
if (!HOST_PATTERN.test(host))
|
|
56
|
+
throw configError(`config.hosts entries must be bare host names (got "${host}")`);
|
|
57
|
+
}
|
|
58
|
+
return hosts;
|
|
59
|
+
}
|
|
60
|
+
/** headerName 字段:合法 HTTP 头名。 */
|
|
61
|
+
function headerNameOf(value) {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return DEFAULT_HEADER_NAME;
|
|
64
|
+
if (typeof value !== 'string' || !HEADER_NAME_PATTERN.test(value)) {
|
|
65
|
+
throw configError('config.headerName must be a valid HTTP header name');
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
/** valueMode 字段:uuid | raw。 */
|
|
70
|
+
function valueModeOf(value) {
|
|
71
|
+
if (value === undefined)
|
|
72
|
+
return 'uuid';
|
|
73
|
+
if (value !== 'uuid' && value !== 'raw')
|
|
74
|
+
throw configError('config.valueMode must be "uuid" or "raw"');
|
|
75
|
+
return value;
|
|
76
|
+
}
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-opencode-session-header — 会话上下文(AsyncLocalStorage)与流包装。
|
|
3
|
+
*
|
|
4
|
+
* pi-ai 的 Models.streamSimple() 是惰性的:真正发 HTTP 发生在消费(迭代)
|
|
5
|
+
* 流时。因此包装 generator 的**每次** next()/return() 都必须在
|
|
6
|
+
* `sessionContext.run(store, ...)` 内执行,否则 fetch 侧读不到会话上下文。
|
|
7
|
+
*
|
|
8
|
+
* 注意:handler 本身必须是同步函数(返回流对象),async handler 会把
|
|
9
|
+
* waterfall 返回值变成 Promise,破坏下游 `yield*` 委托(见踩坑
|
|
10
|
+
* docs/踩坑/README.md)。
|
|
11
|
+
*/
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
|
+
/** 请求期会话上下文存储(模块级单例:插件实例共用无害)。 */
|
|
14
|
+
export const sessionContext = new AsyncLocalStorage();
|
|
15
|
+
/**
|
|
16
|
+
* 用会话上下文包装推理流;非异步可迭代对象原样返回(绝不破坏调用方)。
|
|
17
|
+
*/
|
|
18
|
+
export function wrapStreamWithContext(store, stream) {
|
|
19
|
+
const iterator = iteratorOf(stream);
|
|
20
|
+
if (iterator === undefined)
|
|
21
|
+
return stream;
|
|
22
|
+
return iterateWithContext(store, iterator);
|
|
23
|
+
}
|
|
24
|
+
/** 取异步迭代器(失败返回 undefined,由调用方原样透传)。 */
|
|
25
|
+
function iteratorOf(stream) {
|
|
26
|
+
if (stream === null || typeof stream !== 'object')
|
|
27
|
+
return undefined;
|
|
28
|
+
const factory = stream[Symbol.asyncIterator];
|
|
29
|
+
if (typeof factory !== 'function')
|
|
30
|
+
return undefined;
|
|
31
|
+
try {
|
|
32
|
+
return factory.call(stream);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** 逐 chunk 透传;每次 next()/return() 都在会话上下文内执行。 */
|
|
39
|
+
async function* iterateWithContext(store, iterator) {
|
|
40
|
+
try {
|
|
41
|
+
for (;;) {
|
|
42
|
+
const result = await sessionContext.run(store, () => iterator.next());
|
|
43
|
+
if (result.done === true)
|
|
44
|
+
return result.value;
|
|
45
|
+
yield result.value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
// 消费方 early break(return())或异常时,关闭内层流,避免流泄漏。
|
|
50
|
+
await closeInner(store, iterator);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** 关闭内层迭代器(失败静默:关闭错误不得影响消费方)。 */
|
|
54
|
+
async function closeInner(store, iterator) {
|
|
55
|
+
try {
|
|
56
|
+
if (typeof iterator.return === 'function')
|
|
57
|
+
await sessionContext.run(store, () => iterator.return?.(undefined));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* 内层流已终止或拒绝关闭:忽略 */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-opencode-session-header — globalThis.fetch 单层补丁。
|
|
3
|
+
*
|
|
4
|
+
* 安装时保存原 fetch 并替换;卸载时**仅当当前 fetch 仍是我们装的那个**
|
|
5
|
+
* 才还原(避免误还原他人的 patch)。包装函数同步判定路由(provider 白名单
|
|
6
|
+
* AND 主机命中),命中且当前有会话上下文时注入会话头;任一不满足或任何
|
|
7
|
+
* 内部异常 → 原样调用原 fetch,零副作用。
|
|
8
|
+
*
|
|
9
|
+
* 安全性:openai SDK 与 anthropic SDK 都在每次请求时解析 globalThis.fetch,
|
|
10
|
+
* 且 DSH 自身没有任何 globalThis.fetch 补丁,故请求前安装即被采用。
|
|
11
|
+
*/
|
|
12
|
+
import { sessionContext } from './context.js';
|
|
13
|
+
import { hasHeader, withHeader } from './headers.js';
|
|
14
|
+
/** 补丁层标记属性(不可枚举;用于识别"这一层也是我们装的")。 */
|
|
15
|
+
const LAYER_KEY = 'dshOpencodeSessionHeaderLayer';
|
|
16
|
+
/** 安装 fetch 补丁,返回卸载函数(仅还原/摘除自己的补丁层)。 */
|
|
17
|
+
export function installFetchPatch(options) {
|
|
18
|
+
const target = globalThis;
|
|
19
|
+
const { patched, layer } = createPatchedFetch(target.fetch, options);
|
|
20
|
+
target.fetch = patched;
|
|
21
|
+
return () => {
|
|
22
|
+
const inner = layer.current();
|
|
23
|
+
if (target.fetch === patched) {
|
|
24
|
+
target.fetch = inner;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// 本层被同类补丁压在下面(重复 apply / 乱序卸载):从链上摘除自己,
|
|
28
|
+
// 不触碰他人的 fetch 实现。
|
|
29
|
+
patchLayerOf(target.fetch)?.rewire(inner);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** 取补丁层(非本插件安装的 fetch 返回 undefined)。 */
|
|
33
|
+
function patchLayerOf(value) {
|
|
34
|
+
if (typeof value !== 'function')
|
|
35
|
+
return undefined;
|
|
36
|
+
const layer = value[LAYER_KEY];
|
|
37
|
+
if (layer === null || typeof layer !== 'object')
|
|
38
|
+
return undefined;
|
|
39
|
+
const candidate = layer;
|
|
40
|
+
return typeof candidate.rewire === 'function' && typeof candidate.current === 'function'
|
|
41
|
+
? candidate
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
/** 包装原 fetch:命中则注入,否则/异常时原样调用。 */
|
|
45
|
+
function createPatchedFetch(initial, options) {
|
|
46
|
+
let inner = initial;
|
|
47
|
+
const patched = (input, init) => {
|
|
48
|
+
try {
|
|
49
|
+
const plan = planInjection(input, init, options);
|
|
50
|
+
return plan === undefined ? inner(input, init) : inner(plan[0], plan[1]);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// 插件内部任何异常都必须降级为原行为,绝不阻断推理请求。
|
|
54
|
+
return inner(input, init);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const layer = {
|
|
58
|
+
rewire: (next) => {
|
|
59
|
+
inner = next;
|
|
60
|
+
},
|
|
61
|
+
current: () => inner,
|
|
62
|
+
};
|
|
63
|
+
Object.defineProperty(patched, LAYER_KEY, { value: layer });
|
|
64
|
+
return { patched, layer };
|
|
65
|
+
}
|
|
66
|
+
/** 判定是否注入;命中返回 [input, init],否则 undefined。 */
|
|
67
|
+
function planInjection(input, init, options) {
|
|
68
|
+
const url = urlOf(input);
|
|
69
|
+
if (url === undefined || !hostMatches(url, options.hosts))
|
|
70
|
+
return undefined;
|
|
71
|
+
const store = storeForRequest(options);
|
|
72
|
+
if (store === undefined || !options.providers.includes(store.provider))
|
|
73
|
+
return undefined;
|
|
74
|
+
if (!options.override && hasHeader(headersOf(input, init), options.headerName))
|
|
75
|
+
return undefined;
|
|
76
|
+
return applyHeader(input, init, options.headerName, store.value);
|
|
77
|
+
}
|
|
78
|
+
/** 取当前会话上下文;缺失时按"退化"告警一次并放弃注入。 */
|
|
79
|
+
function storeForRequest(options) {
|
|
80
|
+
const store = sessionContext.getStore();
|
|
81
|
+
if (store !== undefined && store.value !== '')
|
|
82
|
+
return store;
|
|
83
|
+
options.warnOnce(`[opencode-session-header] host matched but no llm session context; ${options.headerName} not injected`);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/** 注入头:优先 init.headers(Headers/数组/对象三种形态),其次 Request 实例。 */
|
|
87
|
+
function applyHeader(input, init, name, value) {
|
|
88
|
+
const initHeaders = propertyOf(init, 'headers');
|
|
89
|
+
if (initHeaders !== undefined)
|
|
90
|
+
return [input, { ...init, headers: withHeader(initHeaders, name, value) }];
|
|
91
|
+
if (isRequestInstance(input))
|
|
92
|
+
return [withRequestHeader(input, name, value), init];
|
|
93
|
+
return [input, { ...init, headers: { [name]: value } }];
|
|
94
|
+
}
|
|
95
|
+
/** init / Request 上现有的请求头(用于幂等判定)。 */
|
|
96
|
+
function headersOf(input, init) {
|
|
97
|
+
const initHeaders = propertyOf(init, 'headers');
|
|
98
|
+
if (initHeaders !== undefined)
|
|
99
|
+
return initHeaders;
|
|
100
|
+
return isRequestInstance(input) ? propertyOf(input, 'headers') : undefined;
|
|
101
|
+
}
|
|
102
|
+
/** 请求目标 URL(string / URL / Request 三种形态)。 */
|
|
103
|
+
function urlOf(input) {
|
|
104
|
+
if (typeof input === 'string')
|
|
105
|
+
return input;
|
|
106
|
+
if (input instanceof URL)
|
|
107
|
+
return input.href;
|
|
108
|
+
const url = propertyOf(input, 'url');
|
|
109
|
+
return typeof url === 'string' ? url : undefined;
|
|
110
|
+
}
|
|
111
|
+
/** 主机命中判定:精确匹配或子域(`zen.opencode.ai` 命中 `opencode.ai`)。 */
|
|
112
|
+
export function hostMatches(url, hosts) {
|
|
113
|
+
const hostname = hostnameOf(url);
|
|
114
|
+
if (hostname === undefined)
|
|
115
|
+
return false;
|
|
116
|
+
return hosts.some((host) => {
|
|
117
|
+
const wanted = host.toLowerCase();
|
|
118
|
+
return hostname === wanted || hostname.endsWith(`.${wanted}`);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** URL → 主机名(不可解析返回 undefined)。 */
|
|
122
|
+
function hostnameOf(url) {
|
|
123
|
+
try {
|
|
124
|
+
return new URL(url).hostname.toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** 属性读取(非对象或不可读时返回 undefined)。 */
|
|
131
|
+
function propertyOf(target, key) {
|
|
132
|
+
if (target === null || typeof target !== 'object')
|
|
133
|
+
return undefined;
|
|
134
|
+
return target[key];
|
|
135
|
+
}
|
|
136
|
+
/** 是否为 Request 实例(仅在构造器可用时判定)。 */
|
|
137
|
+
function isRequestInstance(value) {
|
|
138
|
+
const ctor = globalThis.Request;
|
|
139
|
+
if (typeof ctor !== 'function')
|
|
140
|
+
return false;
|
|
141
|
+
return value instanceof ctor;
|
|
142
|
+
}
|
|
143
|
+
/** 复制 Request 并合入会话头(不改动原 Request)。 */
|
|
144
|
+
function withRequestHeader(request, name, value) {
|
|
145
|
+
const ctor = globalThis.Request;
|
|
146
|
+
if (ctor === undefined)
|
|
147
|
+
return request;
|
|
148
|
+
return new ctor(request, { headers: withHeader(propertyOf(request, 'headers'), name, value) });
|
|
149
|
+
}
|
package/lib/headers.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-opencode-session-header — 请求头形态归一化。
|
|
3
|
+
*
|
|
4
|
+
* fetch init.headers / Request.headers 有三种形态(Headers 实例、数组、
|
|
5
|
+
* 普通对象),大小写不敏感:判定"是否已有该头"与"写入该头"都必须覆盖
|
|
6
|
+
* 三种形态,且不可就地修改调用方的对象(避免污染共享 headers)。
|
|
7
|
+
*/
|
|
8
|
+
/** 取运行时 Headers 构造器(Node 18+ 全局可用;缺失时降级为其它形态处理)。 */
|
|
9
|
+
function headersConstructor() {
|
|
10
|
+
const ctor = globalThis.Headers;
|
|
11
|
+
return typeof ctor === 'function' ? ctor : undefined;
|
|
12
|
+
}
|
|
13
|
+
function isHeadersInstance(value) {
|
|
14
|
+
const ctor = headersConstructor();
|
|
15
|
+
return ctor !== undefined && value instanceof ctor;
|
|
16
|
+
}
|
|
17
|
+
/** 条目 → 头名(数组形态的第 0 项)。 */
|
|
18
|
+
function entryName(entry) {
|
|
19
|
+
return Array.isArray(entry) ? String(entry[0]).toLowerCase() : '';
|
|
20
|
+
}
|
|
21
|
+
/** 是否已存在同名头(大小写不敏感)。 */
|
|
22
|
+
export function hasHeader(headers, name) {
|
|
23
|
+
const wanted = name.toLowerCase();
|
|
24
|
+
if (isHeadersInstance(headers))
|
|
25
|
+
return headers.has(name);
|
|
26
|
+
if (Array.isArray(headers))
|
|
27
|
+
return headers.some((entry) => entryName(entry) === wanted);
|
|
28
|
+
if (headers !== null && typeof headers === 'object') {
|
|
29
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === wanted);
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
/** 返回写入该头后的新 headers 值(不改动入参;形态与入参一致)。 */
|
|
34
|
+
export function withHeader(headers, name, value) {
|
|
35
|
+
if (isHeadersInstance(headers))
|
|
36
|
+
return clonedWithHeader(headers, name, value);
|
|
37
|
+
if (Array.isArray(headers))
|
|
38
|
+
return [...headers.filter((entry) => entryName(entry) !== name.toLowerCase()), [name, value]];
|
|
39
|
+
const base = headers !== null && typeof headers === 'object' ? { ...headers } : {};
|
|
40
|
+
for (const key of Object.keys(base)) {
|
|
41
|
+
if (key.toLowerCase() === name.toLowerCase())
|
|
42
|
+
delete base[key];
|
|
43
|
+
}
|
|
44
|
+
base[name] = value;
|
|
45
|
+
return base;
|
|
46
|
+
}
|
|
47
|
+
/** 复制 Headers 实例并写入(避免改动调用方共享的实例)。 */
|
|
48
|
+
function clonedWithHeader(headers, name, value) {
|
|
49
|
+
const ctor = headersConstructor();
|
|
50
|
+
if (ctor === undefined)
|
|
51
|
+
return headers;
|
|
52
|
+
const copy = new ctor();
|
|
53
|
+
headers.forEach((headerValue, key) => copy.set(key, headerValue));
|
|
54
|
+
copy.set(name, value);
|
|
55
|
+
return copy;
|
|
56
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-opencode-session-header — OpenCode Go 网关会话头注入(server 端)。
|
|
3
|
+
*
|
|
4
|
+
* 问题:opencode.ai(OpenCode Go 网关)要求每个推理请求携带
|
|
5
|
+
* `x-opencode-session`,否则返回 400 MissingSessionID;DSH 的 pi-ai 适配器
|
|
6
|
+
* 从不发这个头,且 DSH 的 `headers` 配置是静态字符串字典(无插值)、
|
|
7
|
+
* session 头名写死——纯配置不可行。
|
|
8
|
+
*
|
|
9
|
+
* 方案(唯一可用拦截点):
|
|
10
|
+
* 1. `ctx.on('llm/stream', ...)`(waterfall)同步包装流,用 AsyncLocalStorage
|
|
11
|
+
* 携带 { provider, 会话头值 }(options 同时带 provider 与 sessionId);
|
|
12
|
+
* 2. 安装单层 `globalThis.fetch` 补丁:请求的 provider 命中白名单且目标
|
|
13
|
+
* 主机命中配置时,注入会话头(已有同名头默认不覆盖)。
|
|
14
|
+
*
|
|
15
|
+
* 会话头值确定性:同会话跨轮次 / 压缩 / 重试 / 重启得到同一个值。
|
|
16
|
+
* 任何内部异常都吞掉并降级为原行为,绝不影响推理请求。
|
|
17
|
+
*/
|
|
18
|
+
import { resolveConfig } from './config.js';
|
|
19
|
+
import { wrapStreamWithContext } from './context.js';
|
|
20
|
+
import { installFetchPatch } from './fetch-patch.js';
|
|
21
|
+
import { sessionValueOf } from './session-value.js';
|
|
22
|
+
export const name = 'dsh-my-opencode-session-header';
|
|
23
|
+
export function apply(ctx, config) {
|
|
24
|
+
const cfg = resolveConfig(config);
|
|
25
|
+
if (!cfg.enabled)
|
|
26
|
+
return;
|
|
27
|
+
const warnOnce = createWarnOnce(ctx);
|
|
28
|
+
ctx.on('llm/stream', (options, next) => handleStream(options, next, cfg.valueMode));
|
|
29
|
+
ctx.effect(() => installFetchPatch({
|
|
30
|
+
providers: cfg.providers,
|
|
31
|
+
hosts: cfg.hosts,
|
|
32
|
+
headerName: cfg.headerName,
|
|
33
|
+
override: cfg.override,
|
|
34
|
+
warnOnce,
|
|
35
|
+
}), 'dsh-my-opencode-session-header: fetch patch');
|
|
36
|
+
ctx.logger?.info?.(`[opencode-session-header] active for providers [${cfg.providers.join(', ')}] with valueMode ${cfg.valueMode}`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* llm/stream waterfall handler:**必须是同步函数**(async 会让 waterfall
|
|
40
|
+
* 返回 Promise,破坏下游 `yield*` 委托),先取流再决定是否包装。
|
|
41
|
+
*/
|
|
42
|
+
function handleStream(options, next, valueMode) {
|
|
43
|
+
const stream = next();
|
|
44
|
+
const sessionId = stringOf(propertyOf(options, 'sessionId'));
|
|
45
|
+
if (sessionId === '')
|
|
46
|
+
return stream;
|
|
47
|
+
const store = {
|
|
48
|
+
provider: stringOf(propertyOf(options, 'provider')),
|
|
49
|
+
value: sessionValueOf(sessionId, valueMode),
|
|
50
|
+
};
|
|
51
|
+
return wrapStreamWithContext(store, stream);
|
|
52
|
+
}
|
|
53
|
+
/** 退化告警(首次出现时 warn 一次,之后静默)。 */
|
|
54
|
+
function createWarnOnce(ctx) {
|
|
55
|
+
let warned = false;
|
|
56
|
+
return (message) => {
|
|
57
|
+
if (warned)
|
|
58
|
+
return;
|
|
59
|
+
warned = true;
|
|
60
|
+
ctx.logger?.warn?.(message);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** 属性读取(非对象或不可读时返回 undefined)。 */
|
|
64
|
+
function propertyOf(target, key) {
|
|
65
|
+
if (target === null || typeof target !== 'object')
|
|
66
|
+
return undefined;
|
|
67
|
+
return target[key];
|
|
68
|
+
}
|
|
69
|
+
/** 字符串读取(非字符串返回空串)。 */
|
|
70
|
+
function stringOf(value) {
|
|
71
|
+
return typeof value === 'string' ? value : '';
|
|
72
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** 裸 UUID(大小写不敏感)。 */
|
|
2
|
+
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
3
|
+
/**
|
|
4
|
+
* 计算会话头取值。
|
|
5
|
+
* @param sessionId - llm/stream options.sessionId(会话稳定 id)。
|
|
6
|
+
* @param mode - uuid(提取裸 UUID,无则回退原串)或 raw(原始会话 id)。
|
|
7
|
+
*/
|
|
8
|
+
export function sessionValueOf(sessionId, mode) {
|
|
9
|
+
if (mode === 'raw')
|
|
10
|
+
return sessionId;
|
|
11
|
+
const match = UUID_PATTERN.exec(sessionId);
|
|
12
|
+
return match === null ? sessionId : match[0];
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-my-opencode-session-header",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH opencode 会话头注入插件:包装 llm/stream 建立 AsyncLocalStorage 会话上下文 + 单层 globalThis.fetch 补丁,为 opencode.ai(含子域)的推理请求注入 x-opencode-session 头,修复 OpenCode Go 网关 400 MissingSessionID。DSH web plugin: injects the x-opencode-session header for opencode-go model routes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/baosfeng/my-dsh-plugins"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "npx tsc -p tsconfig.json",
|
|
16
|
+
"typecheck": "npx tsc --noEmit -p tsconfig.json",
|
|
17
|
+
"test": "npx vitest run --coverage && cucumber-js test/features --import \"test/features/steps/*.mjs\""
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"default": "./lib/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"lib",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"README.md",
|
|
29
|
+
"CHANGELOG.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"dsh": {
|
|
33
|
+
"bundle": {
|
|
34
|
+
"patch": "./cordis.patch.yml"
|
|
35
|
+
},
|
|
36
|
+
"ui": false,
|
|
37
|
+
"uiReason": "纯 server 插件:只在出站推理请求上注入 x-opencode-session 头,DSH Web UI 无任何可见产物(无 client / 无面板 / 无设置页)"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"cordis": "^4.0.0-rc.10"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"cordis": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT"
|
|
48
|
+
}
|