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 +68 -2
- package/client.js +233 -0
- package/cordis.patch.yml +10 -0
- package/icon.svg +6 -0
- package/index.js +349 -0
- package/lib/config.js +202 -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/lib/transport.js
ADDED
|
@@ -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 inherited from the DSH OpenAI provider by default. Explicit plugin overrides require both an endpoint and credentials; local Codex CLI config is not read. The composer WS/SSE button appears only while the current model is an OpenAI one."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-codex-ws",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.3.1",
|
|
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/config.mjs && node test/client.mjs && node test/fast.mjs && node test/projection.mjs && node test/continuation.mjs && DSH_HOME=$(mktemp -d) 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
|
+
}
|