dsh-codex-ws 0.0.0-stage → 1.3.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/README.md +59 -2
- package/client.js +233 -0
- package/cordis.patch.yml +10 -0
- package/icon.svg +6 -0
- package/index.js +274 -0
- package/lib/config.js +239 -0
- package/lib/continuation.js +63 -0
- package/lib/responses.js +517 -0
- package/lib/transport.js +294 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +25 -4
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-codex-ws
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
让 DSH 里的 **OpenAI(GPT)模型走 Codex 风格的 Responses WebSocket 传输**(即 Codex CLI `supports_websockets = true` 的 "WS 模式"),而不是 SSE 流式。不注册额外模型渠道:按钮在 `openai` 渠道原地生效,非 OpenAI 模型旁不会出现。
|
|
4
|
+
|
|
5
|
+
## 它做什么
|
|
6
|
+
|
|
7
|
+
- **不新增模型渠道**。插件拦截 harness 的 `llm/stream` 水流:传输开关为 websocket / auto 时,`openai` 渠道的请求改走 `<baseURL>/responses` 的 Codex 风格传输;切回 SSE 且 Fast 关闭时原样放行给原有 openai 适配器(模型选择器始终只有一组 OpenAI)。
|
|
8
|
+
- **WS 模式**:`wss://<baseURL>/responses`,发送 `{ "type": "response.create", ... }` 后接收与 SSE 完全相同的 Responses 事件流;连接按池复用(干净结束后保留 3 分钟,50 分钟强制换新)。
|
|
9
|
+
- **SSE 模式**:Fast 关闭时不拦截,走 DSH 原有 openai 渠道的流式请求(auto 模式下的回退则是普通 `POST /responses` + `stream: true` 打到同一中转站)。
|
|
10
|
+
- 拦截层复刻了适配器边界的投影(文件 → 可读路径句柄、图片 → 文本占位、developer 消息丢弃、deferLoading 工具声明展开),WS 路径目前仅发送文本,不会把图片原始内容发送给模型;不能视为与原生多模态适配器完全等价。
|
|
11
|
+
- 接口地址与 Key 的解析顺序(`lib/config.js`):
|
|
12
|
+
1. 插件配置里的 `baseURL` / `apiKey`
|
|
13
|
+
2. `envFile`(.env)或环境变量 `apiKeyEnv`(默认 `OPENAI_API_KEY`)
|
|
14
|
+
3. **`~/.codex/config.toml`** 中 `supports_websockets = true` 的 `[model_providers.*]`(取其 `base_url` 与 `experimental_bearer_token`)
|
|
15
|
+
4. 内置默认(`https://sub.cxc2.cn/v1`)
|
|
16
|
+
- 输入框右侧、模型选择器旁的 **WS / SSE** 小按钮实时切换传输模式(下一次模型请求生效),选择持久化在 `$DSH_HOME/codex-ws/state.json`。**按钮只在该会话当前模型位于 openai 渠道时出现**,其他模型旁不存在。
|
|
17
|
+
|
|
18
|
+
### Fast 模式
|
|
19
|
+
|
|
20
|
+
WS / SSE 按钮旁新增独立 **Fast** 按钮,默认关闭,开启后高亮。开关与传输模式分别保存,全局影响 OpenAI 请求,从下一次模型请求生效,重启后保留。
|
|
21
|
+
|
|
22
|
+
- 开启时发送 `service_tier: "priority"`,不改变模型、推理强度或输出长度;是否支持、实际速度和额外费用由服务端决定。
|
|
23
|
+
- WS / auto 及 auto 回退 SSE 均保留该参数。**SSE + Fast 使用插件配置的 Responses 端点与凭据,而非原生适配器**,并继承插件的仅文本投影限制。关闭 Fast 后 SSE 恢复原样放行。
|
|
24
|
+
- 开启 Fast 但缺少插件凭据时明确报错,不静默丢弃优先处理参数。切换 Fast 后增量 WS 请求配置指纹改变,自动全量发送。
|
|
25
|
+
- 离线验证:`node test/fast.mjs`(不调用真实 API)。
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
npm 发布完成后,可运行 `npm pack dsh-codex-ws@1.3.0 --registry=https://registry.npmjs.org` 获取安装包,在 DSH 插件管理中安装本地包;或克隆仓库后从本地目录安装。安装后刷新插件和界面。
|
|
30
|
+
|
|
31
|
+
## npm 发布维护
|
|
32
|
+
|
|
33
|
+
仓库使用 `.github/workflows/publish.yml`,在推送 `v*` 标签时校验标签与版本一致、运行离线测试,再通过 GitHub Actions OIDC 发布到 npm。不配置长期 `NPM_TOKEN`。手动重试时必须选择对应版本标签,分支上的手动运行不会发布。
|
|
34
|
+
|
|
35
|
+
首次发布需要维护者通过 `npm login --registry=https://registry.npmjs.org` 登录并执行 `npm publish --access public` 创建包。随后在 npm 包 Settings → Trusted Publisher 中选择 GitHub Actions:
|
|
36
|
+
|
|
37
|
+
- Organization or user:`kiritoko1029`
|
|
38
|
+
- Repository:`dsh-codex-ws`
|
|
39
|
+
- Workflow filename:`publish.yml`(不含目录)
|
|
40
|
+
- Environment name:留空
|
|
41
|
+
- Allowed actions:允许 `npm publish`
|
|
42
|
+
|
|
43
|
+
后续升级版本、提交并推送匹配版本的标签即可自动发布。不要再次发布已存在的 npm 版本。Trusted Publisher 配置完成不等于验证成功,应以一次实际 OIDC 发布成功为准;新绑定若有到期提示,按 npm 要求及时完成首次验证。
|
|
44
|
+
|
|
45
|
+
参考:[npm Trusted Publishing 文档](https://docs.npmjs.com/trusted-publishers/)。
|
|
46
|
+
|
|
47
|
+
## 使用
|
|
48
|
+
|
|
49
|
+
1. 确认 openai 渠道(llm-pi-ai 的 `openai`)已配置好 Key;模型选择器里选任意 GPT 模型。
|
|
50
|
+
2. 模型选择器旁会出现 WS / SSE 按钮:默认即 WS 模式(可配置);点一下切回 SSE 流式,再点切回 WS。
|
|
51
|
+
3. 插件配置(设置 → 插件 → Codex WS → 配置)可改:`transport`(websocket / sse / auto,auto = WS 连接失败自动回退流式)、`baseURL`、`apiKey`、`apiKeyEnv`、`envFile`、超时等。
|
|
52
|
+
|
|
53
|
+
## 说明
|
|
54
|
+
|
|
55
|
+
- **v1.2.0 增量 WS**:首次全量发送;同一会话、同一连接上,只有当前历史严格延续上次输入与已完成输出,且 instructions / tools / model 等请求配置未变时,才使用 `previous_response_id` 并只发送新增 input。仍保持 `store: false`。
|
|
56
|
+
- 连接按完整凭据哈希、端点、会话隔离;并发调用独占连接,不共享正在执行的续传状态。空闲 3 分钟或连接总寿命 50 分钟后关闭,全局最多保留 32 个空闲连接。
|
|
57
|
+
- 历史压缩、编辑、分支、模型或提示/工具配置变化、连接丢失时自动使用全量。明确收到上一响应不存在错误、且还没有输出时,只允许一次新连接全量重试;已发送后遇到普通网络错误不会盲目重发。取消、未完成、失败结果不保存续传状态。
|
|
58
|
+
- 工具 schema 和 instructions 仍随每次请求发送;优化减少历史 input 上传量,不减少模型实际上下文长度,也不保证同等比例的计费下降。DSH 若每一步改写早期历史或系统提示,会触发保守全量发送。
|
|
59
|
+
- 验证:`node test/continuation.mjs` 为离线边界测试;`bun test/continuation-live.mjs` 使用合成消息验证中转站续传与实际发送字节(会产生少量 API 调用)。一次实测为 14,242 → 511 字节,第二轮正确回忆第一轮标记;真实降幅取决于历史与固定请求配置大小。
|
|
60
|
+
- Key 只在 Host 侧使用,浏览器端只拿到 `/codex-ws/state` 返回的开关状态与端点地址(不含 Key)。
|
package/client.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of dsh-codex-ws: a compact WS/SSE toggle in the composer toolbar
|
|
3
|
+
* (`conversation.input.right`, next to the model selector). It renders ONLY while
|
|
4
|
+
* the session's current model sits on the `openai` provider — the transport it
|
|
5
|
+
* flips belongs to those models, so every other provider never sees the button.
|
|
6
|
+
* It reads and writes the plugin's Host state at GET/POST /codex-ws/state; the
|
|
7
|
+
* API key never reaches the browser.
|
|
8
|
+
*/
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: 'dsh-codex-ws',
|
|
11
|
+
factory(require) {
|
|
12
|
+
const React = require('react')
|
|
13
|
+
const h = React.createElement
|
|
14
|
+
const { useState, useEffect, useCallback, useSyncExternalStore } = React
|
|
15
|
+
|
|
16
|
+
const STATE_ROUTE = 'codex-ws/state'
|
|
17
|
+
const CACHE_KEY = 'dsh-codex-ws:v3'
|
|
18
|
+
const TARGET_PROVIDER = 'openai'
|
|
19
|
+
|
|
20
|
+
const subscribeNothing = () => () => {}
|
|
21
|
+
const snapshotNothing = () => null
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const ICONS = {
|
|
26
|
+
ws: h(
|
|
27
|
+
'svg',
|
|
28
|
+
{ viewBox: '0 0 24 24', width: 13, height: 13, 'aria-hidden': true },
|
|
29
|
+
h('path', { d: 'M13 2 3.5 13.5h6L8 22l10.5-12h-6L13.8 2z', fill: 'currentColor' }),
|
|
30
|
+
),
|
|
31
|
+
sse: h(
|
|
32
|
+
'svg',
|
|
33
|
+
{ viewBox: '0 0 24 24', width: 13, height: 13, 'aria-hidden': true },
|
|
34
|
+
h('path', {
|
|
35
|
+
d: 'M2.5 12h4l2.5-6.5L14 18.5 16.5 12h5',
|
|
36
|
+
fill: 'none',
|
|
37
|
+
stroke: 'currentColor',
|
|
38
|
+
'stroke-width': 2,
|
|
39
|
+
'stroke-linecap': 'round',
|
|
40
|
+
'stroke-linejoin': 'round',
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureStyles() {
|
|
46
|
+
if (document.getElementById('dsh-codex-ws-style')) return
|
|
47
|
+
const style = document.createElement('style')
|
|
48
|
+
style.id = 'dsh-codex-ws-style'
|
|
49
|
+
style.textContent = [
|
|
50
|
+
'.dsh-codex-ws-btn{display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 9px;border-radius:9999px;border:1px solid color-mix(in srgb,currentColor 28%,transparent);background:transparent;color:inherit;font-size:11px;font-weight:600;line-height:1;cursor:pointer;user-select:none;transition:opacity .15s,background .15s,border-color .15s;white-space:nowrap}',
|
|
51
|
+
'.dsh-codex-ws-btn:hover{border-color:color-mix(in srgb,currentColor 55%,transparent)}',
|
|
52
|
+
'.dsh-codex-ws-btn:focus-visible{outline:2px solid currentColor;outline-offset:2px}',
|
|
53
|
+
'.dsh-codex-ws-btn[data-pending="true"]{opacity:.55;cursor:wait}',
|
|
54
|
+
'.dsh-codex-ws-btn[data-mode="websocket"]{background:color-mix(in srgb,#10a37f 16%,transparent);border-color:color-mix(in srgb,#10a37f 55%,transparent);color:#10a37f}',
|
|
55
|
+
'.dsh-codex-ws-btn[data-mode="websocket"]:hover{background:color-mix(in srgb,#10a37f 26%,transparent)}',
|
|
56
|
+
'.dsh-codex-ws-btn[data-error="true"]{color:#e5484d;border-color:color-mix(in srgb,#e5484d 60%,transparent)}',
|
|
57
|
+
].join('\n')
|
|
58
|
+
document.head.appendChild(style)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function TransportToggle({ providerStore }) {
|
|
62
|
+
const [state, setState] = useState(null)
|
|
63
|
+
const [pending, setPending] = useState(false)
|
|
64
|
+
const [error, setError] = useState(false)
|
|
65
|
+
/** The session's live model provider ('openai' | … | null while unknown). */
|
|
66
|
+
const provider = useSyncExternalStore(
|
|
67
|
+
providerStore ? (onChange) => providerStore.subscribe(onChange) : subscribeNothing,
|
|
68
|
+
providerStore ? () => providerStore.getSnapshot() : snapshotNothing,
|
|
69
|
+
snapshotNothing,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
ensureStyles()
|
|
74
|
+
let alive = true
|
|
75
|
+
fetch(STATE_ROUTE, { accept: 'application/json' })
|
|
76
|
+
.then((response) => {
|
|
77
|
+
|
|
78
|
+
return response.ok ? response.json() : null
|
|
79
|
+
})
|
|
80
|
+
.then((data) => {
|
|
81
|
+
if (!alive || !data) return
|
|
82
|
+
setState(data)
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(CACHE_KEY, JSON.stringify(data))
|
|
85
|
+
} catch {}
|
|
86
|
+
})
|
|
87
|
+
.catch(() => {})
|
|
88
|
+
try {
|
|
89
|
+
const cached = localStorage.getItem(CACHE_KEY)
|
|
90
|
+
if (cached) setState(JSON.parse(cached))
|
|
91
|
+
} catch {}
|
|
92
|
+
return () => {
|
|
93
|
+
alive = false
|
|
94
|
+
}
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
const toggle = useCallback(async (field) => {
|
|
98
|
+
if (!state || pending) return
|
|
99
|
+
const patch = field === 'fast'
|
|
100
|
+
? { fast: state.fast !== true }
|
|
101
|
+
: { transport: state.transport === 'sse' ? 'websocket' : 'sse' }
|
|
102
|
+
setPending(true)
|
|
103
|
+
setError(false)
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(STATE_ROUTE, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
body: JSON.stringify(patch),
|
|
109
|
+
})
|
|
110
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
111
|
+
const data = await response.json()
|
|
112
|
+
setState(data)
|
|
113
|
+
try {
|
|
114
|
+
localStorage.setItem(CACHE_KEY, JSON.stringify(data))
|
|
115
|
+
} catch {}
|
|
116
|
+
} catch {
|
|
117
|
+
setError(true)
|
|
118
|
+
setTimeout(() => setError(false), 1600)
|
|
119
|
+
} finally {
|
|
120
|
+
setPending(false)
|
|
121
|
+
}
|
|
122
|
+
}, [state, pending])
|
|
123
|
+
|
|
124
|
+
if (state === null) {
|
|
125
|
+
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
if (state.providerAvailable === false) {
|
|
129
|
+
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
// Only OpenAI models ride this transport; other providers never see the button.
|
|
133
|
+
// A blank session has no projection yet; the composer shows the default model then.
|
|
134
|
+
const current = provider?.provider ?? state.defaultProvider ?? null
|
|
135
|
+
if (!providerStore || current !== TARGET_PROVIDER) {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const mode = state.transport === 'sse' ? 'sse' : 'ws'
|
|
140
|
+
const host = (() => {
|
|
141
|
+
try {
|
|
142
|
+
return new URL(state.endpoint?.baseURL ?? '').host
|
|
143
|
+
} catch {
|
|
144
|
+
return ''
|
|
145
|
+
}
|
|
146
|
+
})()
|
|
147
|
+
const keyHint = state.endpoint?.hasKey === false ? '(尚未解析到 API Key,WS 不会生效)' : ''
|
|
148
|
+
const title =
|
|
149
|
+
state.transport === 'sse'
|
|
150
|
+
? `SSE 流式(${state.fast ? 'Fast:插件 Responses 接口,仅文本' : 'openai 渠道直连'})· 点击让 OpenAI 模型改走 WS 模式${host ? `(${host})` : ''}${keyHint}`
|
|
151
|
+
: `WS 模式 · OpenAI 模型经 ${host || '中转站'} WebSocket 传输 · 点击切回 SSE 流式${keyHint}`
|
|
152
|
+
return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } }, h(
|
|
153
|
+
'button',
|
|
154
|
+
{
|
|
155
|
+
type: 'button',
|
|
156
|
+
className: 'dsh-codex-ws-btn',
|
|
157
|
+
'data-mode': state.transport === 'sse' ? 'sse' : 'websocket',
|
|
158
|
+
'data-pending': pending ? 'true' : 'false',
|
|
159
|
+
'data-error': error ? 'true' : 'false',
|
|
160
|
+
title,
|
|
161
|
+
disabled: pending,
|
|
162
|
+
'aria-pressed': state.transport !== 'sse',
|
|
163
|
+
onClick: () => toggle('transport'),
|
|
164
|
+
},
|
|
165
|
+
ICONS[mode],
|
|
166
|
+
error ? '!' : mode === 'ws' ? 'WS' : 'SSE',
|
|
167
|
+
), h('button', {
|
|
168
|
+
type: 'button',
|
|
169
|
+
className: 'dsh-codex-ws-btn',
|
|
170
|
+
'data-mode': state.fast === true ? 'websocket' : 'sse',
|
|
171
|
+
'data-pending': pending ? 'true' : 'false',
|
|
172
|
+
'data-error': error ? 'true' : 'false',
|
|
173
|
+
'aria-pressed': state.fast === true,
|
|
174
|
+
'aria-label': 'Fast 模式',
|
|
175
|
+
disabled: pending,
|
|
176
|
+
title: `Fast 模式${state.fast === true ? '已开启 · 点击关闭' : '已关闭 · 点击开启'}:优先处理(service_tier=priority),可能额外计费,需中转站支持。全局生效,下一次请求使用;SSE 下使用插件 Responses 接口(仅文本)。`,
|
|
177
|
+
onClick: () => toggle('fast'),
|
|
178
|
+
}, ICONS.ws, 'Fast'))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Wrap any {subscribe,getSnapshot} source into a referentially stable provider snapshot. */
|
|
182
|
+
function providerStoreOf(source, read) {
|
|
183
|
+
let last = { provider: null }
|
|
184
|
+
return {
|
|
185
|
+
subscribe: (onChange) => source.subscribe(onChange),
|
|
186
|
+
getSnapshot: () => {
|
|
187
|
+
const provider = read(source.getSnapshot()) ?? null
|
|
188
|
+
if (last.provider !== provider) last = { provider }
|
|
189
|
+
return last
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
// 'sessions' is declared so the facade exposes it; the session's own
|
|
196
|
+
// modelSelection projection (what the model selector renders from) is a
|
|
197
|
+
// plain object graph, so reading it never crosses a context guard.
|
|
198
|
+
inject: ['slots', 'sessions'],
|
|
199
|
+
apply(ctx) {
|
|
200
|
+
|
|
201
|
+
ctx.slots.inject('conversation.input.right', () =>
|
|
202
|
+
ctx.slots.register(
|
|
203
|
+
{
|
|
204
|
+
name: 'conversation.input.right',
|
|
205
|
+
id: 'codex-ws-transport',
|
|
206
|
+
order: 40,
|
|
207
|
+
label: 'Codex WS',
|
|
208
|
+
/** The session's model-selection projection powers the button's visibility. */
|
|
209
|
+
inject: (sessionId) => {
|
|
210
|
+
if (typeof sessionId !== 'string') {
|
|
211
|
+
|
|
212
|
+
return {}
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const face = ctx.sessions.binding(sessionId).session.projections.faceOf('modelSelection')
|
|
216
|
+
if (face !== undefined && typeof face.subscribe === 'function') {
|
|
217
|
+
|
|
218
|
+
return { providerStore: providerStoreOf(face, (snap) => snap?.next?.provider) }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
} catch (error) {
|
|
222
|
+
|
|
223
|
+
}
|
|
224
|
+
return {}
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
TransportToggle,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# GPT (Codex backend) WebSocket mode for the OpenAI channel.
|
|
2
|
+
# Every field is optional; empty values fall back to ~/.codex/config.toml
|
|
3
|
+
# (model_providers.* with supports_websockets = true), then to the plugin defaults.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: codex-ws
|
|
6
|
+
name: 'dsh-codex-ws'
|
|
7
|
+
config:
|
|
8
|
+
# 'websocket' (WS 模式, openai 渠道原地拦截) | 'sse' (流式, 不拦截)
|
|
9
|
+
# | 'auto' (WS 优先, 连接失败回退流式)
|
|
10
|
+
transport: websocket
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect x="4" y="4" width="56" height="56" rx="14" fill="#0f172a"/>
|
|
3
|
+
<path d="M30 10 L16 34 h11 l-3 20 L45 28 h-12 l2-18 z" fill="#10a37f"/>
|
|
4
|
+
<path d="M12 46 c4-3 8-3 12 0 s8 3 12 0" fill="none" stroke="#7dd3fc" stroke-width="3" stroke-linecap="round"/>
|
|
5
|
+
<path d="M16 53 c4-3 8-3 12 0 s8 3 12 0" fill="none" stroke="#7dd3fc" stroke-width="3" stroke-linecap="round" opacity=".55"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-codex-ws — GPT models over the Codex-style Responses WebSocket transport.
|
|
3
|
+
*
|
|
4
|
+
* The plugin does NOT register a parallel model channel. Instead it intercepts the
|
|
5
|
+
* harness `llm/stream` waterfall for the existing `openai` provider: while the
|
|
6
|
+
* transport toggle is on (websocket / auto), those requests speak the Responses
|
|
7
|
+
* protocol over WebSocket ("WS 模式", Codex CLI `supports_websockets`) against the
|
|
8
|
+
* relay the local Codex CLI uses (base URL + bearer token resolved from this
|
|
9
|
+
* plugin's config, then ~/.codex/config.toml, then defaults). SSE mode passes the
|
|
10
|
+
* request through to the normal openai adapter untouched, so OpenAI models stay a
|
|
11
|
+
* single channel and non-OpenAI models never see the toggle at all.
|
|
12
|
+
*
|
|
13
|
+
* A composer toolbar button (client half, shown only while the session's model is
|
|
14
|
+
* an OpenAI one) flips the transport at runtime through POST /codex-ws/state; the
|
|
15
|
+
* choice is persisted in $DSH_HOME/codex-ws/state.json and applies from the next
|
|
16
|
+
* model step. The API key never reaches the browser.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { Config, resolveSettings, TARGET_PROVIDER, TRANSPORTS } from './lib/config.js'
|
|
23
|
+
import { ResponseStreamMapper, buildCreateBody, projectOptions, abortedFinish, errorFinish } from './lib/responses.js'
|
|
24
|
+
import { runTransport, closeIdleSockets, TransportError } from './lib/transport.js'
|
|
25
|
+
|
|
26
|
+
export const name = 'codex-ws'
|
|
27
|
+
export const inject = ['llm', 'webServer', 'connection']
|
|
28
|
+
export { Config }
|
|
29
|
+
|
|
30
|
+
const STATE_PATH = '/codex-ws/state'
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------------------------
|
|
33
|
+
// Toggle state ($DSH_HOME/codex-ws/state.json)
|
|
34
|
+
// ---------------------------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function stateDir() {
|
|
37
|
+
return join(process.env.DSH_HOME && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh'), 'codex-ws')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readState() {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(join(stateDir(), 'state.json'), 'utf8'))
|
|
43
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
44
|
+
} catch {
|
|
45
|
+
return {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeState(state) {
|
|
50
|
+
const dir = stateDir()
|
|
51
|
+
mkdirSync(dir, { recursive: true })
|
|
52
|
+
writeFileSync(join(dir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------------------------
|
|
56
|
+
// Stream interception
|
|
57
|
+
// ---------------------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/** One openai-provider request over the Codex-style transport; yields harness chunks. */
|
|
60
|
+
async function* interceptedStream(options, runtime, resolvePath) {
|
|
61
|
+
const { settings, transport } = runtime
|
|
62
|
+
if (settings.apiKey.length === 0) {
|
|
63
|
+
yield {
|
|
64
|
+
type: 'finish',
|
|
65
|
+
reason: {
|
|
66
|
+
kind: 'error',
|
|
67
|
+
failure: {
|
|
68
|
+
message: `codex-ws: no API key for the plugin Responses transport; set the key on the plugin's config page (or ${settings.apiKeyEnv} / ~/.codex/config.toml), or disable Fast and switch to SSE`,
|
|
69
|
+
code: 'MISSING_CREDENTIAL',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const projected = projectOptions(options, resolvePath)
|
|
76
|
+
const { body, requestId } = buildCreateBody(settings, projected, { transport })
|
|
77
|
+
const mapper = new ResponseStreamMapper()
|
|
78
|
+
try {
|
|
79
|
+
for await (const event of runTransport(settings, transport, { body, requestId, sessionId: options.sessionId, signal: options.signal })) {
|
|
80
|
+
for (const chunk of mapper.handleEvent(event)) yield chunk
|
|
81
|
+
}
|
|
82
|
+
if (!mapper.completed) {
|
|
83
|
+
yield errorFinish(new TransportError('stream ended without a terminal response event', { phase: 'stream' }), {
|
|
84
|
+
aborted: options.signal?.aborted === true,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (options.signal?.aborted) {
|
|
89
|
+
yield abortedFinish()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (error instanceof TransportError && error.phase === 'connect' && transport === 'websocket') {
|
|
93
|
+
yield errorFinish(
|
|
94
|
+
new TransportError(
|
|
95
|
+
`${error.message} — WebSocket 连接失败;可点输入框旁的按钮切到 SSE,或在插件配置里把 transport 设为 auto`,
|
|
96
|
+
{ code: error.code },
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
yield errorFinish(error)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------------------------
|
|
106
|
+
// Plugin entry
|
|
107
|
+
// ---------------------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export function apply(ctx, config) {
|
|
110
|
+
const connection = Reflect.get(ctx, 'connection')
|
|
111
|
+
const state = { override: null, fast: false }
|
|
112
|
+
|
|
113
|
+
const loadState = () => {
|
|
114
|
+
const stored = readState()
|
|
115
|
+
state.override = TRANSPORTS.includes(stored.transport) ? stored.transport : null
|
|
116
|
+
state.fast = stored.fast === true
|
|
117
|
+
}
|
|
118
|
+
loadState()
|
|
119
|
+
|
|
120
|
+
/** Live settings plus the effective transport for the next request. */
|
|
121
|
+
const runtime = () => {
|
|
122
|
+
const settings = { ...resolveSettings(config), fast: state.fast }
|
|
123
|
+
const transport = state.override ?? settings.transport
|
|
124
|
+
return { settings, transport }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const persistState = (patch) => {
|
|
128
|
+
const transport = patch.transport ?? state.override
|
|
129
|
+
const fast = patch.fast ?? state.fast
|
|
130
|
+
// Commit memory only after persistence succeeds; partial updates preserve the other toggle.
|
|
131
|
+
writeState({ ...(transport !== null ? { transport } : {}), fast })
|
|
132
|
+
state.override = transport
|
|
133
|
+
state.fast = fast
|
|
134
|
+
if (transport === 'sse') closeIdleSockets()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Execution-world read path for a file attachment, when one is resolvable. */
|
|
138
|
+
const fileReadPath = (attachment) => {
|
|
139
|
+
if (attachment === null || typeof attachment !== 'object') return undefined
|
|
140
|
+
try {
|
|
141
|
+
const resolved = ctx.llm.fileReadPath?.(attachment)
|
|
142
|
+
return typeof resolved === 'string' ? resolved : undefined
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Take the openai provider over the Codex-style transport while WS is on.
|
|
150
|
+
* Registered without `prepend`, so core `llm/stream` listeners (session
|
|
151
|
+
* checkpointing, …) stay outside this veto and keep running.
|
|
152
|
+
*/
|
|
153
|
+
ctx.on('llm/stream', (options, next) => {
|
|
154
|
+
if (options?.provider !== TARGET_PROVIDER) return next()
|
|
155
|
+
const { settings, transport } = runtime()
|
|
156
|
+
if (transport === 'sse' && !settings.fast) return next()
|
|
157
|
+
// Do not silently drop an explicitly requested priority tier when credentials are missing.
|
|
158
|
+
if (settings.apiKey.length === 0 && !settings.fast) return next()
|
|
159
|
+
return interceptedStream(options, { settings, transport }, fileReadPath)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const publicState = () => {
|
|
163
|
+
const { settings, transport } = runtime()
|
|
164
|
+
let providerAvailable = true
|
|
165
|
+
try {
|
|
166
|
+
providerAvailable = ctx.llm.listProviders().some((provider) => provider.id === TARGET_PROVIDER)
|
|
167
|
+
} catch {
|
|
168
|
+
/* optional metadata */
|
|
169
|
+
}
|
|
170
|
+
// A blank session has no model-selection projection yet; the composer then shows the
|
|
171
|
+
// default model, so the client falls back to this provider id (and nothing else).
|
|
172
|
+
let defaultProvider = null
|
|
173
|
+
try {
|
|
174
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection()
|
|
175
|
+
if (typeof selection?.provider === 'string') defaultProvider = selection.provider
|
|
176
|
+
} catch {
|
|
177
|
+
/* optional service */
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
provider: TARGET_PROVIDER,
|
|
181
|
+
transport,
|
|
182
|
+
fast: settings.fast,
|
|
183
|
+
endpoint: { baseURL: settings.baseURL, hasKey: settings.apiKey.length > 0 },
|
|
184
|
+
providerAvailable,
|
|
185
|
+
defaultProvider,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sendJson = (res, status, payload) => {
|
|
190
|
+
res.statusCode = status
|
|
191
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
192
|
+
res.setHeader('cache-control', 'no-store')
|
|
193
|
+
res.end(JSON.stringify(payload))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Answer an untrusted or unauthenticated request; true when it was rejected. */
|
|
197
|
+
const rejected = (req, res) => {
|
|
198
|
+
const rejection = connection?.requestRejection?.(req)
|
|
199
|
+
if (rejection === undefined) return false
|
|
200
|
+
res.statusCode = rejection
|
|
201
|
+
res.end()
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const readBody = (req) =>
|
|
206
|
+
new Promise((resolve, reject) => {
|
|
207
|
+
let size = 0
|
|
208
|
+
const chunks = []
|
|
209
|
+
req.on('data', (chunk) => {
|
|
210
|
+
size += chunk.length
|
|
211
|
+
if (size > 4096) {
|
|
212
|
+
reject(new Error('body too large'))
|
|
213
|
+
req.destroy()
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
chunks.push(chunk)
|
|
217
|
+
})
|
|
218
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
219
|
+
req.on('error', reject)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
ctx.effect(
|
|
225
|
+
() =>
|
|
226
|
+
ctx.webServer.register({
|
|
227
|
+
kind: 'exact',
|
|
228
|
+
path: STATE_PATH,
|
|
229
|
+
handler: async (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
if (rejected(req, res)) return
|
|
232
|
+
if (req.method === 'GET') {
|
|
233
|
+
sendJson(res, 200, publicState())
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
if (req.method !== 'POST') {
|
|
237
|
+
res.statusCode = 405
|
|
238
|
+
res.setHeader('allow', 'GET, POST')
|
|
239
|
+
res.end()
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
const body = JSON.parse(await readBody(req))
|
|
243
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
244
|
+
(!Object.hasOwn(body, 'transport') && !Object.hasOwn(body, 'fast'))) {
|
|
245
|
+
sendJson(res, 400, { error: 'transport or fast is required' })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
const patch = {}
|
|
249
|
+
if (Object.hasOwn(body, 'transport')) {
|
|
250
|
+
const transport = typeof body.transport === 'string' ? body.transport.toLowerCase() : ''
|
|
251
|
+
if (!TRANSPORTS.includes(transport)) {
|
|
252
|
+
sendJson(res, 400, { error: `transport must be one of ${TRANSPORTS.join(', ')}` })
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
patch.transport = transport
|
|
256
|
+
}
|
|
257
|
+
if (Object.hasOwn(body, 'fast')) {
|
|
258
|
+
if (typeof body.fast !== 'boolean') {
|
|
259
|
+
sendJson(res, 400, { error: 'fast must be a boolean' })
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
patch.fast = body.fast
|
|
263
|
+
}
|
|
264
|
+
persistState(patch)
|
|
265
|
+
sendJson(res, 200, publicState())
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (!res.headersSent) sendJson(res, 400, { error: error?.message ?? 'bad request' })
|
|
268
|
+
else res.end()
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
`codex-ws: ${STATE_PATH}`,
|
|
273
|
+
)
|
|
274
|
+
}
|