dsh-codex-ws 0.0.0-stage → 1.3.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/README.md CHANGED
@@ -1,3 +1,69 @@
1
- # Temporary Holding Version
1
+ # dsh-codex-ws
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ - **默认继承 DSH 当前 OpenAI 渠道的接口地址与凭据**:通过 `llm/listConfigurableProviders` 定位 openai 路由的设置命名空间与 profile 路径,读取其 `baseURL`(缺省用渠道默认地址),`apiKeyEnv` 凭据引用经 credentials 服务解析——与 llm-pi-ai 适配器自身的解析方式一致。每次请求重新读取,渠道配置改动即时生效;不读取本地 Codex CLI 配置,也没有写死的中转地址。
12
+ - 插件的 `baseURL` / `apiKey` / `apiKeyEnv` / `envFile` 留空即可继承。显式填写任意一项会进入独立覆盖模式,必须同时提供接口地址和 Key,不会与 DSH 的地址或凭据混用。
13
+ - 覆盖模式 Key 顺序:`apiKey` → `envFile` 中对应变量 → 显式指定的 `apiKeyEnv` 环境变量。
14
+ - `apiKeyEnv` 默认留空;仅配置 `envFile` 时默认读取文件中的 `OPENAI_API_KEY`。不会自动使用进程中的 `OPENAI_API_KEY`。
15
+ - 不再自动沿用 `~/.codex/config.toml`;若要使用同一服务,请在 DSH OpenAI 渠道中配置,或显式提供插件覆盖配置。
16
+ - 升级后若旧配置保存了 `apiKeyEnv: OPENAI_API_KEY`,请清空该字段及其他覆盖项以启用继承。
17
+ - 继承模式下渠道未配置或凭据无法解析时请求明确报错(`INVALID_CONFIGURATION`),不会退回其他端点或凭据。
18
+ - 输入框右侧、模型选择器旁的 **WS / SSE** 小按钮实时切换传输模式(下一次模型请求生效),选择持久化在 `$DSH_HOME/codex-ws/state.json`。**按钮只在该会话当前模型位于 openai 渠道时出现**,其他模型旁不存在。
19
+
20
+ ### Fast 模式
21
+
22
+ WS / SSE 按钮旁新增独立 **Fast** 按钮,默认关闭,开启后高亮。开关与传输模式分别保存,全局影响 OpenAI 请求,从下一次模型请求生效,重启后保留。
23
+
24
+ - 开启时发送 `service_tier: "priority"`,不改变模型、推理强度或输出长度;是否支持、实际速度和额外费用由服务端决定。
25
+ - WS / auto 及 auto 回退 SSE 均保留该参数。**SSE + Fast 由插件发起 Responses 请求,默认继承 DSH OpenAI 端点与凭据**,并继承插件的仅文本投影限制。关闭 Fast 后 SSE 恢复原样放行。
26
+ - WS / auto / SSE + Fast 配置不完整时明确报错,不静默丢弃优先处理参数。切换 Fast 后增量 WS 请求配置指纹改变,自动全量发送。
27
+ - 离线验证:`node test/fast.mjs`(不调用真实 API)。
28
+
29
+ ## 安装
30
+
31
+ 可运行 `npm pack dsh-codex-ws@1.3.1 --registry=https://registry.npmjs.org` 获取安装包,在 DSH 插件管理中安装本地包;或克隆仓库后从本地目录安装。安装后刷新插件和界面。
32
+
33
+ ### v1.3.1 升级说明
34
+
35
+ - 默认继承 DSH OpenAI 渠道的接口地址和凭据,每次请求重新读取渠道配置。
36
+ - 移除本地 Codex CLI 配置与内置中转地址回退;显式覆盖必须成对提供地址和 Key,避免混用端点与凭据。
37
+ - **从旧版升级**:若插件中保存了 `apiKeyEnv: OPENAI_API_KEY`,请清空它及其他覆盖项以启用继承;如需独立覆盖,请同时配置 `baseURL` 和 Key。
38
+ - 配置不完整时,WS / auto / SSE + Fast 明确报错;SSE 且 Fast 关闭仍由原生 OpenAI 适配器处理。
39
+
40
+ ## npm 发布维护
41
+
42
+ 仓库使用 `.github/workflows/publish.yml`,在推送 `v*` 标签时校验标签与版本一致、运行离线测试,再通过 GitHub Actions OIDC 发布到 npm。不配置长期 `NPM_TOKEN`。手动重试时必须选择对应版本标签,分支上的手动运行不会发布。
43
+
44
+ 首次发布需要维护者通过 `npm login --registry=https://registry.npmjs.org` 登录并执行 `npm publish --access public` 创建包。随后在 npm 包 Settings → Trusted Publisher 中选择 GitHub Actions:
45
+
46
+ - Organization or user:`kiritoko1029`
47
+ - Repository:`dsh-codex-ws`
48
+ - Workflow filename:`publish.yml`(不含目录)
49
+ - Environment name:留空
50
+ - Allowed actions:允许 `npm publish`
51
+
52
+ 后续升级版本、提交并推送匹配版本的标签即可自动发布。不要再次发布已存在的 npm 版本。Trusted Publisher 配置完成不等于验证成功,应以一次实际 OIDC 发布成功为准;新绑定若有到期提示,按 npm 要求及时完成首次验证。
53
+
54
+ 参考:[npm Trusted Publishing 文档](https://docs.npmjs.com/trusted-publishers/)。
55
+
56
+ ## 使用
57
+
58
+ 1. 确认 openai 渠道(llm-pi-ai 的 `openai`)已配置好 Key;模型选择器里选任意 GPT 模型。
59
+ 2. 模型选择器旁会出现 WS / SSE 按钮:默认即 WS 模式(可配置);点一下切回 SSE 流式,再点切回 WS。
60
+ 3. 默认无需重复填写地址和 Key。插件配置(设置 → 插件 → Codex WS → 配置)可改:`transport`(websocket / sse / auto,auto = WS 连接失败自动回退流式)、`baseURL`、`apiKey`、`apiKeyEnv`、`envFile`、超时等。
61
+
62
+ ## 说明
63
+
64
+ - **v1.2.0 增量 WS**:首次全量发送;同一会话、同一连接上,只有当前历史严格延续上次输入与已完成输出,且 instructions / tools / model 等请求配置未变时,才使用 `previous_response_id` 并只发送新增 input。仍保持 `store: false`。
65
+ - 连接按完整凭据哈希、端点、会话隔离;并发调用独占连接,不共享正在执行的续传状态。空闲 3 分钟或连接总寿命 50 分钟后关闭,全局最多保留 32 个空闲连接。
66
+ - 历史压缩、编辑、分支、模型或提示/工具配置变化、连接丢失时自动使用全量。明确收到上一响应不存在错误、且还没有输出时,只允许一次新连接全量重试;已发送后遇到普通网络错误不会盲目重发。取消、未完成、失败结果不保存续传状态。
67
+ - 工具 schema 和 instructions 仍随每次请求发送;优化减少历史 input 上传量,不减少模型实际上下文长度,也不保证同等比例的计费下降。DSH 若每一步改写早期历史或系统提示,会触发保守全量发送。
68
+ - 验证:`node test/continuation.mjs` 为离线边界测试;`bun test/continuation-live.mjs` 使用合成消息验证中转站续传与实际发送字节(会产生少量 API 调用)。一次实测为 14,242 → 511 字节,第二轮正确回忆第一轮标记;真实降幅取决于历史与固定请求配置大小。
69
+ - 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
+ })
@@ -0,0 +1,10 @@
1
+ # GPT (Codex backend) WebSocket mode for the OpenAI channel.
2
+ # Leave endpoint/credential fields empty to inherit the DSH OpenAI provider.
3
+ # Explicit overrides require both an endpoint and credentials; no Codex CLI fallback.
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,349 @@
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
+ * current DSH OpenAI endpoint and credentials (or a complete explicit plugin
9
+ * override). No Codex CLI configuration is read. 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, hasOverride, 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', 'settings']
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, resolveRuntime, resolvePath) {
61
+ const { settings, transport } = await resolveRuntime()
62
+ if (settings.configurationError) {
63
+ yield {
64
+ type: 'finish',
65
+ reason: {
66
+ kind: 'error',
67
+ failure: {
68
+ message: settings.configurationError,
69
+ code: 'INVALID_CONFIGURATION',
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
+ /**
121
+ * The DSH OpenAI provider's live endpoint and bearer credential, resolved the
122
+ * same way the pi-ai adapter resolves them: the configurable-provider
123
+ * directory names the settings namespace and profile path; the profile holds
124
+ * an optional baseURL (falling back to the route's catalog default) and an
125
+ * apiKeyEnv credential reference resolved through the credentials service
126
+ * (with the launch environment as a secondary source). Re-read per request
127
+ * so channel edits apply without a restart; every failure is contained and
128
+ * surfaces as the inherited-mode configuration error.
129
+ */
130
+ const inheritedProvider = async () => {
131
+ try {
132
+ const entry = ctx.llm.listConfigurableProviders().find((provider) => provider.provider === TARGET_PROVIDER)
133
+ if (!entry) return {}
134
+ let profile = {}
135
+ try {
136
+ const namespace = ctx.settings
137
+ ?.describe({ redactSecrets: true })
138
+ .find((candidate) => candidate.ns === entry.settingsNs)
139
+ let value = namespace?.value
140
+ for (const key of entry.settingsPath ?? []) value = value?.[key]
141
+ profile = value !== null && typeof value === 'object' ? value : {}
142
+ } catch {
143
+ /* settings service unavailable: fall back to the catalog default */
144
+ }
145
+ const baseURL = typeof profile.baseURL === 'string' ? profile.baseURL.trim() : ''
146
+ const reference = typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv.trim() : ''
147
+ let apiKey = ''
148
+ if (reference) {
149
+ // Bare `ctx.credentials` throws on the Cordis context proxy when the
150
+ // service is not in this plugin's inject list ("cannot get property
151
+ // without inject"); ctx.get is the supported inject-free accessor and
152
+ // matches how llm-pi-ai itself reaches the credentials service.
153
+ let credentials
154
+ try {
155
+ credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
156
+ } catch {
157
+ /* this composition mounts no credential plane */
158
+ }
159
+ try {
160
+ const resolved = credentials === undefined ? undefined : await credentials.resolve?.(reference)
161
+ if (typeof resolved?.value === 'string') apiKey = resolved.value.trim()
162
+ } catch {
163
+ /* an unresolvable reference surfaces as the inherited-mode error */
164
+ }
165
+ if (apiKey.length === 0) {
166
+ // Ambient fallback mirrors the adapter: the launch-environment
167
+ // snapshot (process / project .env / user .env layers), with raw
168
+ // process.env as the last resort for compositions without either.
169
+ let ambient
170
+ try {
171
+ const environment = typeof ctx.get === 'function' ? ctx.get('launchEnvironment') : undefined
172
+ ambient = environment?.get?.(reference)?.value
173
+ } catch {
174
+ /* optional service */
175
+ }
176
+ apiKey = (ambient ?? process.env[reference] ?? '').trim()
177
+ }
178
+ }
179
+ return {
180
+ baseURL: baseURL || (typeof entry.defaultBaseURL === 'string' ? entry.defaultBaseURL.trim() : ''),
181
+ apiKey,
182
+ }
183
+ } catch {
184
+ return {}
185
+ }
186
+ }
187
+
188
+ /** Sync transport/fast decision for the next request; never touches credentials. */
189
+ const runtimeSync = () => {
190
+ const { transport } = resolveSettings(config, {})
191
+ return { transport: state.override ?? transport, fast: state.fast }
192
+ }
193
+
194
+ /** Live settings plus the effective transport; the inherited endpoint and
195
+ * credential resolve asynchronously through the DSH services. */
196
+ const runtime = async () => {
197
+ const provider = hasOverride(config) ? {} : await inheritedProvider()
198
+ const settings = { ...resolveSettings(config, provider), fast: state.fast }
199
+ return { settings, transport: state.override ?? settings.transport }
200
+ }
201
+
202
+ const persistState = (patch) => {
203
+ const transport = patch.transport ?? state.override
204
+ const fast = patch.fast ?? state.fast
205
+ // Commit memory only after persistence succeeds; partial updates preserve the other toggle.
206
+ writeState({ ...(transport !== null ? { transport } : {}), fast })
207
+ state.override = transport
208
+ state.fast = fast
209
+ if (transport === 'sse') closeIdleSockets()
210
+ }
211
+
212
+ /** Execution-world read path for a file attachment, when one is resolvable. */
213
+ const fileReadPath = (attachment) => {
214
+ if (attachment === null || typeof attachment !== 'object') return undefined
215
+ try {
216
+ const resolved = ctx.llm.fileReadPath?.(attachment)
217
+ return typeof resolved === 'string' ? resolved : undefined
218
+ } catch {
219
+ return undefined
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Take the openai provider over the Codex-style transport while WS is on.
225
+ * Registered without `prepend`, so core `llm/stream` listeners (session
226
+ * checkpointing, …) stay outside this veto and keep running.
227
+ */
228
+ ctx.on('llm/stream', (options, next) => {
229
+ if (options?.provider !== TARGET_PROVIDER) return next()
230
+ const { transport, fast } = runtimeSync()
231
+ if (transport === 'sse' && !fast) return next()
232
+ // Fail closed for incomplete overrides or unavailable inherited configuration.
233
+ // Never silently change endpoint, account, or requested transport.
234
+ return interceptedStream(options, runtime, fileReadPath)
235
+ })
236
+
237
+ const publicState = async () => {
238
+ const { settings, transport } = await runtime()
239
+ let providerAvailable = true
240
+ try {
241
+ providerAvailable = ctx.llm.listProviders().some((provider) => provider.id === TARGET_PROVIDER)
242
+ } catch {
243
+ /* optional metadata */
244
+ }
245
+ // A blank session has no model-selection projection yet; the composer then shows the
246
+ // default model, so the client falls back to this provider id (and nothing else).
247
+ let defaultProvider = null
248
+ try {
249
+ const selection = ctx.get('agentDefaultModel')?.currentSelection()
250
+ if (typeof selection?.provider === 'string') defaultProvider = selection.provider
251
+ } catch {
252
+ /* optional service */
253
+ }
254
+ return {
255
+ provider: TARGET_PROVIDER,
256
+ transport,
257
+ fast: settings.fast,
258
+ endpoint: { baseURL: settings.baseURL, hasKey: settings.apiKey.length > 0 },
259
+ providerAvailable,
260
+ defaultProvider,
261
+ }
262
+ }
263
+
264
+ const sendJson = (res, status, payload) => {
265
+ res.statusCode = status
266
+ res.setHeader('content-type', 'application/json; charset=utf-8')
267
+ res.setHeader('cache-control', 'no-store')
268
+ res.end(JSON.stringify(payload))
269
+ }
270
+
271
+ /** Answer an untrusted or unauthenticated request; true when it was rejected. */
272
+ const rejected = (req, res) => {
273
+ const rejection = connection?.requestRejection?.(req)
274
+ if (rejection === undefined) return false
275
+ res.statusCode = rejection
276
+ res.end()
277
+ return true
278
+ }
279
+
280
+ const readBody = (req) =>
281
+ new Promise((resolve, reject) => {
282
+ let size = 0
283
+ const chunks = []
284
+ req.on('data', (chunk) => {
285
+ size += chunk.length
286
+ if (size > 4096) {
287
+ reject(new Error('body too large'))
288
+ req.destroy()
289
+ return
290
+ }
291
+ chunks.push(chunk)
292
+ })
293
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
294
+ req.on('error', reject)
295
+ })
296
+
297
+
298
+
299
+ ctx.effect(
300
+ () =>
301
+ ctx.webServer.register({
302
+ kind: 'exact',
303
+ path: STATE_PATH,
304
+ handler: async (req, res) => {
305
+ try {
306
+ if (rejected(req, res)) return
307
+ if (req.method === 'GET') {
308
+ sendJson(res, 200, await publicState())
309
+ return
310
+ }
311
+ if (req.method !== 'POST') {
312
+ res.statusCode = 405
313
+ res.setHeader('allow', 'GET, POST')
314
+ res.end()
315
+ return
316
+ }
317
+ const body = JSON.parse(await readBody(req))
318
+ if (!body || typeof body !== 'object' || Array.isArray(body) ||
319
+ (!Object.hasOwn(body, 'transport') && !Object.hasOwn(body, 'fast'))) {
320
+ sendJson(res, 400, { error: 'transport or fast is required' })
321
+ return
322
+ }
323
+ const patch = {}
324
+ if (Object.hasOwn(body, 'transport')) {
325
+ const transport = typeof body.transport === 'string' ? body.transport.toLowerCase() : ''
326
+ if (!TRANSPORTS.includes(transport)) {
327
+ sendJson(res, 400, { error: `transport must be one of ${TRANSPORTS.join(', ')}` })
328
+ return
329
+ }
330
+ patch.transport = transport
331
+ }
332
+ if (Object.hasOwn(body, 'fast')) {
333
+ if (typeof body.fast !== 'boolean') {
334
+ sendJson(res, 400, { error: 'fast must be a boolean' })
335
+ return
336
+ }
337
+ patch.fast = body.fast
338
+ }
339
+ persistState(patch)
340
+ sendJson(res, 200, await publicState())
341
+ } catch (error) {
342
+ if (!res.headersSent) sendJson(res, 400, { error: error?.message ?? 'bad request' })
343
+ else res.end()
344
+ }
345
+ },
346
+ }),
347
+ `codex-ws: ${STATE_PATH}`,
348
+ )
349
+ }