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.
@@ -0,0 +1,294 @@
1
+ import { poolKey, continuationBody, commitContinuation, missingPreviousResponse } from './continuation.js'
2
+
3
+ const OPENAI_BETA_WEBSOCKETS = 'responses_websockets=2026-02-06'
4
+ const IDLE_POOL_SIZE = 2
5
+ const GLOBAL_IDLE_LIMIT = 32
6
+ const IDLE_SOCKET_TTL_MS = 3 * 60 * 1000
7
+ const MAX_SOCKET_AGE_MS = 50 * 60 * 1000
8
+
9
+ export class TransportError extends Error {
10
+ constructor(message, { phase = 'stream', code } = {}) {
11
+ super(message)
12
+ this.name = 'TransportError'
13
+ this.phase = phase
14
+ this.code = code
15
+ }
16
+ }
17
+ function checkAbort(signal) {
18
+ if (signal?.aborted) throw new TransportError('Request was aborted', { code: 'ABORTED' })
19
+ }
20
+ function closeSocket(socket, reason = 'done') {
21
+ try { socket.close(1000, reason) } catch {}
22
+ }
23
+ function reusable(entry) {
24
+ return entry.socket.readyState === 1 && Date.now() - entry.createdAt < MAX_SOCKET_AGE_MS
25
+ }
26
+ function requestHeaders(settings, requestId) {
27
+ return {
28
+ authorization: `Bearer ${settings.apiKey}`,
29
+ 'user-agent': 'codex_cli_rs/0.69.0 (Mac OS X 15.0.0; arm64) DSH-codex-ws',
30
+ originator: 'codex_cli_rs',
31
+ ...(requestId ? { 'session-id': requestId, 'x-client-request-id': requestId } : {}),
32
+ }
33
+ }
34
+
35
+ // Only idle leases are retained globally. Acquisition removes a lease before any await,
36
+ // so concurrent requests (including within one session) always own separate sockets.
37
+ const idle = new Set()
38
+ let generation = 0
39
+ function remove(entry, close = false) {
40
+ idle.delete(entry)
41
+ clearTimeout(entry.timer)
42
+ entry.timer = null
43
+ if (close) { entry.continuation = null; closeSocket(entry.socket) }
44
+ }
45
+ function release(entry, keep) {
46
+ if (!keep || entry.generation !== generation || !reusable(entry)) { remove(entry, true); return }
47
+ idle.add(entry)
48
+ entry.timer = setTimeout(() => remove(entry, true), Math.min(IDLE_SOCKET_TTL_MS, MAX_SOCKET_AGE_MS - (Date.now() - entry.createdAt)))
49
+ entry.timer.unref?.()
50
+ const peers = [...idle].filter((other) => other.key === entry.key)
51
+ while (peers.length > IDLE_POOL_SIZE) remove(peers.shift(), true)
52
+ while (idle.size > GLOBAL_IDLE_LIMIT) remove(idle.values().next().value, true)
53
+ }
54
+ async function acquire(settings, request, fresh) {
55
+ checkAbort(request.signal)
56
+ const key = poolKey(settings, request.sessionId)
57
+ if (!fresh) {
58
+ for (const entry of [...idle].reverse()) {
59
+ if (!reusable(entry)) { remove(entry, true); continue }
60
+ if (entry.key === key) { remove(entry); return entry }
61
+ }
62
+ }
63
+ const epoch = generation
64
+ const createdAt = Date.now()
65
+ const socket = await connect(settings, request)
66
+ return { key, socket, createdAt, generation: epoch, continuation: null, timer: null }
67
+ }
68
+ function connect(settings, { requestId, signal }) {
69
+ checkAbort(signal)
70
+ return new Promise((resolve, reject) => {
71
+ let socket, timer, settled = false
72
+ const cleanup = () => {
73
+ clearTimeout(timer)
74
+ socket?.removeEventListener('open', onOpen)
75
+ socket?.removeEventListener('error', onError)
76
+ socket?.removeEventListener('close', onClose)
77
+ signal?.removeEventListener('abort', onAbort)
78
+ }
79
+ const fail = (error) => {
80
+ if (settled) return
81
+ settled = true; cleanup()
82
+ if (socket) closeSocket(socket)
83
+ reject(error)
84
+ }
85
+ const onOpen = () => { if (settled) return; settled = true; cleanup(); resolve(socket) }
86
+ const onError = (event) => fail(new TransportError(`WebSocket connect failed: ${event?.message ?? 'network error'}`, { phase: 'connect' }))
87
+ const onClose = () => fail(new TransportError('WebSocket closed during connect', { phase: 'connect' }))
88
+ const onAbort = () => fail(new TransportError('Request was aborted', { code: 'ABORTED' }))
89
+ try {
90
+ if (typeof globalThis.WebSocket !== 'function') throw new Error('WebSocket unavailable')
91
+ socket = new globalThis.WebSocket(settings.wsUrl, { headers: { ...requestHeaders(settings, requestId), 'openai-beta': OPENAI_BETA_WEBSOCKETS } })
92
+ socket.addEventListener('open', onOpen)
93
+ socket.addEventListener('error', onError)
94
+ socket.addEventListener('close', onClose)
95
+ signal?.addEventListener('abort', onAbort, { once: true })
96
+ if (signal?.aborted) { onAbort(); return }
97
+ timer = setTimeout(() => fail(new TransportError('WebSocket connect timeout', { phase: 'connect' })), settings.connectTimeoutMs)
98
+ } catch (error) { fail(new TransportError(`WebSocket connect failed: ${error.message}`, { phase: 'connect' })) }
99
+ })
100
+ }
101
+ const TERMINAL_EVENTS = new Set(['response.completed', 'response.failed', 'response.incomplete', 'error'])
102
+ function decodeText(data) {
103
+ if (typeof data === 'string') return data
104
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return new TextDecoder().decode(data)
105
+ throw new Error('unsupported WebSocket message type')
106
+ }
107
+
108
+ /** One attempt; every failure after send is stream-phase (unsafe to transparently retry). */
109
+ async function* exchange(entry, settings, body, signal) {
110
+ const socket = entry.socket
111
+ const queue = []
112
+ let failure, wake, timer, terminalReceived = false
113
+ const notify = () => { const fn = wake; wake = null; fn?.() }
114
+ const resetTimer = () => {
115
+ clearTimeout(timer)
116
+ timer = setTimeout(() => { failure = new TransportError('WebSocket idle timeout'); notify() }, settings.streamIdleTimeoutMs)
117
+ }
118
+ const onMessage = (event) => {
119
+ try {
120
+ const data = JSON.parse(decodeText(event.data))
121
+ if (TERMINAL_EVENTS.has(data.type)) terminalReceived = true
122
+ queue.push(data); resetTimer(); notify()
123
+ } catch (error) { failure = new TransportError(`invalid WebSocket JSON: ${error.message}`); notify() }
124
+ }
125
+ const onError = (event) => { failure = new TransportError(`WebSocket error: ${event?.message ?? 'network error'}`); notify() }
126
+ const onClose = () => { if (!terminalReceived) failure = new TransportError('WebSocket closed before response finished'); notify() }
127
+ const onAbort = () => { failure = new TransportError('Request was aborted', { code: 'ABORTED' }); notify() }
128
+ socket.addEventListener('message', onMessage)
129
+ socket.addEventListener('error', onError)
130
+ socket.addEventListener('close', onClose)
131
+ signal?.addEventListener('abort', onAbort, { once: true })
132
+ try {
133
+ checkAbort(signal)
134
+ resetTimer()
135
+ socket.send(JSON.stringify({ ...body, type: 'response.create' }))
136
+ while (true) {
137
+ checkAbort(signal)
138
+ if (queue.length) {
139
+ const event = queue.shift()
140
+ yield event
141
+ if (TERMINAL_EVENTS.has(event.type)) return
142
+ } else {
143
+ if (failure) throw failure
144
+ await new Promise((resolve) => { wake = resolve })
145
+ }
146
+ }
147
+ } finally {
148
+ clearTimeout(timer)
149
+ socket.removeEventListener('message', onMessage)
150
+ socket.removeEventListener('error', onError)
151
+ socket.removeEventListener('close', onClose)
152
+ signal?.removeEventListener('abort', onAbort)
153
+ }
154
+ }
155
+
156
+ export async function* websocketEvents(settings, request) {
157
+ checkAbort(request.signal)
158
+ // Snapshot the full request; callers cannot mutate the baseline while the stream is running.
159
+ const full = JSON.parse(JSON.stringify(request.body))
160
+ delete full.previous_response_id
161
+ let fresh = false
162
+ for (let attempt = 0; attempt < 2; attempt++) {
163
+ const entry = await acquire(settings, request, fresh)
164
+ const body = continuationBody(full, entry.continuation, request.sessionId)
165
+ entry.continuation = null
166
+ let keep = false, retry = false, emittedOutput = false
167
+ const buffered = []
168
+ try {
169
+ for await (const event of exchange(entry, settings, body, request.signal)) {
170
+ if (attempt === 0 && body.previous_response_id && !emittedOutput && missingPreviousResponse(event)) {
171
+ retry = true
172
+ break
173
+ }
174
+ // Hold only non-output lifecycle events, so a rejected continuation emits no stale response id.
175
+ const lifecycle = ['response.created', 'response.in_progress', 'rate_limits.updated'].includes(event.type)
176
+ if (body.previous_response_id && !emittedOutput && lifecycle) { buffered.push(event); continue }
177
+ emittedOutput = true
178
+ for (const previous of buffered.splice(0)) yield previous
179
+ yield event
180
+ if (event.type === 'response.completed') {
181
+ checkAbort(request.signal)
182
+ entry.continuation = commitContinuation(full, event.response, request.sessionId)
183
+ keep = true
184
+ }
185
+ }
186
+ } finally { release(entry, keep && !request.signal?.aborted) }
187
+ if (!retry) return
188
+ fresh = true // Exactly one retry, only explicit missing previous id, on a new connection.
189
+ }
190
+ }
191
+
192
+ export function closeIdleSockets() {
193
+ generation++ // In-flight leases from the previous mode may finish but cannot repopulate the pool.
194
+ for (const entry of [...idle]) remove(entry, true)
195
+ }
196
+
197
+ /** POST the create body and yield the SSE `data:` events as decoded JSON. */
198
+ export async function* sseEvents(settings, { body, requestId, signal }) {
199
+ checkAbort(signal)
200
+ const headers = {
201
+ ...requestHeaders(settings, requestId),
202
+ 'openai-beta': 'responses=experimental',
203
+ accept: 'text/event-stream',
204
+ 'content-type': 'application/json',
205
+ }
206
+ const response = await fetch(settings.sseUrl, { method: 'POST', headers, body: JSON.stringify({ ...body, stream: true }), signal })
207
+ if (!response.ok) {
208
+ const text = await response.text().catch(() => '')
209
+ let message = `HTTP ${response.status}`
210
+ try {
211
+ const parsed = JSON.parse(text)
212
+ message = parsed?.error?.message ?? message
213
+ } catch {
214
+ if (text.length > 0 && text.length < 300) message = `${message}: ${text}`
215
+ }
216
+ const error = new TransportError(message, { phase: 'connect', code: response.status })
217
+ error.status = response.status
218
+ throw error
219
+ }
220
+ if (!response.body) throw new TransportError('SSE response has no body', { phase: 'stream' })
221
+ const reader = response.body.getReader()
222
+ const decoder = new TextDecoder()
223
+ let buffer = ''
224
+ const onAbort = () => {
225
+ void reader.cancel().catch(() => {})
226
+ }
227
+ signal?.addEventListener('abort', onAbort, { once: true })
228
+ const idleMs = settings.streamIdleTimeoutMs
229
+ try {
230
+ while (true) {
231
+ if (signal?.aborted) throw new TransportError('Request was aborted', { phase: 'stream' })
232
+ const read = reader.read()
233
+ let result
234
+ if (idleMs > 0) {
235
+ // One resettable idle watchdog per chunk: cancel the reader on fire so the
236
+ // pending read settles, then surface the timeout as a transport failure.
237
+ let timer
238
+ const timeout = new Promise((_, reject) => {
239
+ timer = setTimeout(() => {
240
+ void reader.cancel().catch(() => {})
241
+ reject(new TransportError(`SSE idle timeout after ${idleMs}ms`, { phase: 'stream' }))
242
+ }, idleMs)
243
+ })
244
+ try {
245
+ result = await Promise.race([read, timeout])
246
+ } finally {
247
+ clearTimeout(timer)
248
+ }
249
+ } else {
250
+ result = await read
251
+ }
252
+ buffer += result.done ? decoder.decode() : decoder.decode(result.value, { stream: true }).replace(/\r\n/g, '\n')
253
+ if (result.done && buffer.trim()) buffer += '\n\n'
254
+ let index = buffer.indexOf('\n\n')
255
+ while (index !== -1) {
256
+ const frame = buffer.slice(0, index)
257
+ buffer = buffer.slice(index + 2)
258
+ const data = frame
259
+ .split('\n')
260
+ .filter((line) => line.startsWith('data:'))
261
+ .map((line) => line.slice(5).trim())
262
+ .join('\n')
263
+ .trim()
264
+ if (data.length > 0 && data !== '[DONE]') {
265
+ try {
266
+ yield JSON.parse(data)
267
+ } catch (error) {
268
+ throw new TransportError(`invalid SSE JSON: ${error?.message ?? error}`, { phase: 'stream' })
269
+ }
270
+ }
271
+ index = buffer.indexOf('\n\n')
272
+ }
273
+ if (result.done) return
274
+ }
275
+ } finally {
276
+ signal?.removeEventListener('abort', onAbort)
277
+ void reader.cancel().catch(() => {})
278
+ }
279
+ }
280
+
281
+
282
+ /** Auto fallback is permitted only when establishing a socket failed, never after send. */
283
+ export async function* runTransport(settings, transport, request) {
284
+ checkAbort(request.signal)
285
+ if (transport === 'sse') { yield* sseEvents(settings, request); return }
286
+ try { yield* websocketEvents(settings, request) }
287
+ catch (error) {
288
+ if (transport === 'auto' && error?.phase === 'connect' && !request.signal?.aborted) {
289
+ yield* sseEvents(settings, request)
290
+ return
291
+ }
292
+ throw error
293
+ }
294
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Codex WS",
4
+ "description": "GPT models over the Codex-style Responses WebSocket transport (Codex CLI's supports_websockets): no extra model channel — the openai provider is intercepted in place, with the endpoint and key resolved from the local Codex CLI config (~/.codex/config.toml). The composer WS/SSE button appears only while the current model is an OpenAI one."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Codex WS",
4
+ "description": "GPT 模型走 Codex 风格的 Responses WebSocket 传输(对应 Codex CLI 的 supports_websockets):不注册额外渠道,直接在 openai 渠道上拦截生效,接口地址与 Key 自动沿用本地 Codex CLI 配置(~/.codex/config.toml);输入框模型选择器旁的 WS/SSE 按钮仅在当前模型为 OpenAI 时出现。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "dsh-codex-ws",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.3.0",
4
+ "description": "GPT models over the Codex-style Responses WebSocket transport (like Codex CLI 'supports_websockets'), toggled in place on the openai channel with an in-composer WS/SSE button shown only for OpenAI models.",
5
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
+ "scripts": {
7
+ "test": "node test/client.mjs && node test/fast.mjs && node test/projection.mjs && node test/continuation.mjs && DSH_HOME=$(mktemp -d) OPENAI_API_KEY=test-only node test/intercept.mjs"
8
+ },
9
+ "repository": { "type": "git", "url": "https://github.com/kiritoko1029/dsh-codex-ws.git" },
10
+ "homepage": "https://github.com/kiritoko1029/dsh-codex-ws#readme",
11
+ "type": "module",
12
+ "exports": {
13
+ ".": "./index.js",
14
+ "./client": "./client.js",
15
+ "./locale/*.json": "./locale/*.json",
16
+ "./icon": "./icon.svg"
17
+ },
18
+ "files": ["index.js", "client.js", "lib/*.js", "cordis.patch.yml", "locale/*.json", "icon.svg", "README.md"],
19
+ "dsh": {
20
+ "bundle": { "patch": "./cordis.patch.yml" },
21
+ "client": {
22
+ "platform": "web",
23
+ "immediately": true,
24
+ "inject": ["@deepseek-ai/dsh-client-ui-conversation"]
25
+ }
26
+ }
27
+ }