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/lib/config.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Config: the serialized schema Settings uses to describe the fields, plus the
|
|
3
|
+
* normalizer the plugin reads them through (same inline-schemastery pattern as the
|
|
4
|
+
* other local bundles — a profile-installed bundle cannot import DSH's own packages).
|
|
5
|
+
*
|
|
6
|
+
* Endpoint resolution mirrors the local Codex CLI setup this plugin adapts:
|
|
7
|
+
* plugin config → ~/.codex/config.toml [model_providers.*] (supports_websockets) → defaults.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------------------------
|
|
15
|
+
// Schema (serialized schemastery document; generated shape, do not hand-edit refs' uids)
|
|
16
|
+
// ---------------------------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const SCHEMA = {
|
|
19
|
+
uid: 62,
|
|
20
|
+
refs: {
|
|
21
|
+
2: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
meta: {
|
|
24
|
+
default: '',
|
|
25
|
+
description:
|
|
26
|
+
'中转站 / 接口地址(Codex CLI 的 base_url,如 https://sub.cxc2.cn/v1)。请求实际访问 <baseURL>/responses(WS 为 wss://…/responses)。留空读取 ~/.codex/config.toml 中 supports_websockets = true 的模型渠道,再回退默认值。',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
5: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
meta: {
|
|
32
|
+
role: 'secret',
|
|
33
|
+
default: '',
|
|
34
|
+
description:
|
|
35
|
+
'Bearer API Key(sk-…)。留空依次读取:envFile / 环境变量 apiKeyEnv / ~/.codex/config.toml 的 experimental_bearer_token。Key 只在 Host 侧使用,不会发送到浏览器。',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
7: { type: 'string', meta: { default: 'OPENAI_API_KEY', description: 'API Key 的环境变量 / 凭据引用名。' } },
|
|
39
|
+
9: { type: 'string', meta: { default: '', description: '可选的 .env 文件绝对路径(KEY=VALUE,每行一条)。' } },
|
|
40
|
+
11: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
meta: {
|
|
43
|
+
default: 'websocket',
|
|
44
|
+
description: "默认传输模式:websocket(WS 模式)/ sse(流式,走原有 openai 渠道)/ auto(WS 优先,连接失败自动回退流式)。输入框旁的切换按钮会覆盖该默认值。",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
15: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
meta: { default: '', description: 'GPT-6 文本冗长度(low / medium / high),留空使用接口默认。' },
|
|
50
|
+
},
|
|
51
|
+
17: {
|
|
52
|
+
type: 'number',
|
|
53
|
+
meta: {
|
|
54
|
+
min: 10000,
|
|
55
|
+
max: 2147483647,
|
|
56
|
+
default: 600000,
|
|
57
|
+
description: '两条流式事件之间允许的最大间隔(毫秒),超时按失败处理。',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
19: {
|
|
61
|
+
type: 'number',
|
|
62
|
+
meta: { min: 1000, max: 120000, default: 15000, description: 'WebSocket 连接超时(毫秒)。' },
|
|
63
|
+
},
|
|
64
|
+
62: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
meta: { default: {} },
|
|
67
|
+
dict: {
|
|
68
|
+
baseURL: 2,
|
|
69
|
+
apiKey: 5,
|
|
70
|
+
apiKeyEnv: 7,
|
|
71
|
+
envFile: 9,
|
|
72
|
+
transport: 11,
|
|
73
|
+
verbosity: 15,
|
|
74
|
+
streamIdleTimeoutMs: 17,
|
|
75
|
+
connectTimeoutMs: 19,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The same rows as plain nodes, which is the shape the settings form projects values through. */
|
|
82
|
+
function inlineNode(uid, refs) {
|
|
83
|
+
const ref = refs[uid]
|
|
84
|
+
const node = { ...ref }
|
|
85
|
+
if (ref.dict) node.dict = Object.fromEntries(Object.entries(ref.dict).map(([key, value]) => [key, inlineNode(value, refs)]))
|
|
86
|
+
if (ref.inner !== undefined) node.inner = inlineNode(ref.inner, refs)
|
|
87
|
+
return node
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Exported as the row's Config so Settings can describe the fields; validation never throws. */
|
|
91
|
+
export const Config = {
|
|
92
|
+
...SCHEMA,
|
|
93
|
+
type: 'object',
|
|
94
|
+
dict: inlineNode(SCHEMA.uid, SCHEMA.refs).dict,
|
|
95
|
+
meta: { default: {} },
|
|
96
|
+
toJSON() {
|
|
97
|
+
return SCHEMA
|
|
98
|
+
},
|
|
99
|
+
'~standard': {
|
|
100
|
+
version: 1,
|
|
101
|
+
vendor: 'dsh-codex-ws',
|
|
102
|
+
validate(value) {
|
|
103
|
+
return { value: value !== null && typeof value === 'object' ? value : {} }
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------------------------
|
|
109
|
+
// Resolution
|
|
110
|
+
// ---------------------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export const TARGET_PROVIDER = 'openai'
|
|
113
|
+
export const DEFAULT_BASE_URL = 'https://sub.cxc2.cn/v1'
|
|
114
|
+
export const TRANSPORTS = ['websocket', 'sse', 'auto']
|
|
115
|
+
|
|
116
|
+
function text(value) {
|
|
117
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
118
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clampInt(value, fallback, min, max) {
|
|
122
|
+
const n = typeof value === 'number' ? value : Number(value)
|
|
123
|
+
if (value === '' || value === null || value === undefined || !Number.isFinite(n)) return fallback
|
|
124
|
+
return Math.min(max, Math.max(min, Math.round(n)))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Parse the flat KEY=VALUE .env lines; a missing file is simply empty. */
|
|
128
|
+
function readEnvFile(file) {
|
|
129
|
+
const result = {}
|
|
130
|
+
let raw
|
|
131
|
+
try {
|
|
132
|
+
raw = readFileSync(file, 'utf8')
|
|
133
|
+
} catch {
|
|
134
|
+
return result
|
|
135
|
+
}
|
|
136
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
137
|
+
const trimmed = line.trim()
|
|
138
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
139
|
+
const eq = trimmed.indexOf('=')
|
|
140
|
+
if (eq <= 0) continue
|
|
141
|
+
const key = trimmed.slice(0, eq).trim()
|
|
142
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
|
|
143
|
+
let value = trimmed.slice(eq + 1).trim()
|
|
144
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1)
|
|
145
|
+
result[key] = value
|
|
146
|
+
}
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Minimal TOML reader for the one thing this plugin needs from the Codex CLI config:
|
|
152
|
+
* `[model_providers.<id>]` tables with string/boolean values. Ignores everything else
|
|
153
|
+
* (arrays, nested tables, multiline strings), which is exactly the shape config.toml uses.
|
|
154
|
+
*/
|
|
155
|
+
function readCodexModelProviders(file) {
|
|
156
|
+
let raw
|
|
157
|
+
try {
|
|
158
|
+
raw = readFileSync(file, 'utf8')
|
|
159
|
+
} catch {
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
const providers = []
|
|
163
|
+
let current = null
|
|
164
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
165
|
+
const trimmed = line.trim()
|
|
166
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
167
|
+
const header = /^\[\s*([^\]]+?)\s*\]$/.exec(trimmed)
|
|
168
|
+
if (header) {
|
|
169
|
+
const path = header[1].split('.').map((p) => (p.startsWith('"') || p.startsWith("'") ? p.slice(1, -1) : p.trim()))
|
|
170
|
+
current = path.length === 2 && path[0] === 'model_providers' ? { id: path[1] } : null
|
|
171
|
+
if (current) providers.push(current)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (!current) continue
|
|
175
|
+
const eq = trimmed.indexOf('=')
|
|
176
|
+
if (eq <= 0) continue
|
|
177
|
+
const key = trimmed.slice(0, eq).trim()
|
|
178
|
+
let value = trimmed.slice(eq + 1).trim()
|
|
179
|
+
if (/^".*"$/.test(value)) {
|
|
180
|
+
try {
|
|
181
|
+
value = JSON.parse(value)
|
|
182
|
+
} catch {
|
|
183
|
+
value = value.slice(1, -1)
|
|
184
|
+
}
|
|
185
|
+
} else if (/^'.*'$/.test(value)) value = value.slice(1, -1)
|
|
186
|
+
else if (value === 'true' || value === 'false') value = value === 'true'
|
|
187
|
+
else if (/^-?\d+$/.test(value)) value = Number(value)
|
|
188
|
+
if (typeof value === 'string' || typeof value === 'boolean') current[key] = value
|
|
189
|
+
}
|
|
190
|
+
return providers.filter((p) => typeof p.base_url === 'string' && p.base_url.length > 0)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The WebSocket-capable provider the local Codex CLI uses, when there is one. */
|
|
194
|
+
export function localCodexProvider(codexHome) {
|
|
195
|
+
const providers = readCodexModelProviders(join(codexHome ?? join(homedir(), '.codex'), 'config.toml'))
|
|
196
|
+
return (
|
|
197
|
+
providers.find((p) => p.supports_websockets === true) ??
|
|
198
|
+
providers.find((p) => typeof p.experimental_bearer_token === 'string' && p.experimental_bearer_token.length > 0) ??
|
|
199
|
+
null
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fully-defaulted settings for one request. Precedence per value: row config, then
|
|
205
|
+
* `envFile`, then the process environment, then ~/.codex/config.toml, then built-in default.
|
|
206
|
+
* Resolved on every read so an edited .env or config.toml applies without a restart.
|
|
207
|
+
*/
|
|
208
|
+
export function resolveSettings(config) {
|
|
209
|
+
const cfg = config !== null && typeof config === 'object' ? config : {}
|
|
210
|
+
const envFile = text(cfg.envFile)
|
|
211
|
+
const fileEnv = envFile ? readEnvFile(envFile) : {}
|
|
212
|
+
const env = (key) => text(fileEnv[key]) || text(process.env[key])
|
|
213
|
+
|
|
214
|
+
const codex = localCodexProvider()
|
|
215
|
+
|
|
216
|
+
const baseURL = (text(cfg.baseURL) || text(codex?.base_url) || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
217
|
+
|
|
218
|
+
const apiKeyEnv = text(cfg.apiKeyEnv) || 'OPENAI_API_KEY'
|
|
219
|
+
const apiKey = text(cfg.apiKey) || env(apiKeyEnv) || text(codex?.experimental_bearer_token)
|
|
220
|
+
|
|
221
|
+
const rawTransport = text(cfg.transport).toLowerCase()
|
|
222
|
+
const transport = TRANSPORTS.includes(rawTransport) ? rawTransport : 'websocket'
|
|
223
|
+
|
|
224
|
+
const verbosity = text(cfg.verbosity).toLowerCase()
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
baseURL,
|
|
228
|
+
sseUrl: baseURL.endsWith('/responses') ? baseURL : `${baseURL}/responses`,
|
|
229
|
+
wsUrl: (baseURL.endsWith('/responses') ? baseURL : `${baseURL}/responses`)
|
|
230
|
+
.replace(/^https:\/\//, 'wss://')
|
|
231
|
+
.replace(/^http:\/\//, 'ws://'),
|
|
232
|
+
apiKey,
|
|
233
|
+
apiKeyEnv,
|
|
234
|
+
transport,
|
|
235
|
+
verbosity: ['low', 'medium', 'high'].includes(verbosity) ? verbosity : '',
|
|
236
|
+
streamIdleTimeoutMs: clampInt(cfg.streamIdleTimeoutMs, 600000, 10000, 2147483647),
|
|
237
|
+
connectTimeoutMs: clampInt(cfg.connectTimeoutMs, 15000, 1000, 120000),
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Canonical JSON equality: object key order is irrelevant, array order and all values are exact.
|
|
4
|
+
export function canonical(value) {
|
|
5
|
+
const wire = JSON.parse(JSON.stringify(value))
|
|
6
|
+
const sort = (v) => Array.isArray(v) ? v.map(sort) : v && typeof v === 'object'
|
|
7
|
+
? Object.fromEntries(Object.keys(v).sort().map((key) => [key, sort(v[key])])) : v
|
|
8
|
+
return JSON.stringify(sort(wire))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function poolKey(settings, sessionId) {
|
|
12
|
+
return createHash('sha256').update(JSON.stringify([settings.wsUrl, settings.apiKey, sessionId ?? null])).digest('hex')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function configFingerprint(body) {
|
|
16
|
+
const { input, ...config } = body
|
|
17
|
+
return createHash('sha256').update(canonical(config)).digest('hex')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Match buildCreateBody's assistant replay: all text concatenated, then ordered function calls. */
|
|
21
|
+
export function projectCompletedOutput(response) {
|
|
22
|
+
if (!Array.isArray(response?.output)) return null
|
|
23
|
+
let text = ''
|
|
24
|
+
const calls = []
|
|
25
|
+
for (const item of response.output) {
|
|
26
|
+
if (item.type === 'reasoning') continue // buildCreateBody does not replay reasoning blocks.
|
|
27
|
+
if (item.type === 'message') {
|
|
28
|
+
if (item.role !== 'assistant' || !Array.isArray(item.content)) return null
|
|
29
|
+
for (const part of item.content) {
|
|
30
|
+
if (part.type !== 'output_text' || typeof part.text !== 'string') return null
|
|
31
|
+
text += part.text
|
|
32
|
+
}
|
|
33
|
+
} else if (item.type === 'function_call') {
|
|
34
|
+
if (typeof item.call_id !== 'string' || typeof item.name !== 'string' || typeof item.arguments !== 'string') return null
|
|
35
|
+
calls.push({ type: 'function_call', call_id: item.call_id, name: item.name, arguments: item.arguments })
|
|
36
|
+
} else return null
|
|
37
|
+
}
|
|
38
|
+
const result = text ? [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] }] : []
|
|
39
|
+
return [...result, ...calls]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function commitContinuation(body, response, sessionId) {
|
|
43
|
+
if (!sessionId || typeof response?.id !== 'string' || !response.id || !Array.isArray(body.input)) return null
|
|
44
|
+
const output = projectCompletedOutput(response)
|
|
45
|
+
if (output === null || output.length === 0) return null
|
|
46
|
+
return { sessionId, responseId: response.id, fingerprint: configFingerprint(body),
|
|
47
|
+
prefix: [...body.input, ...output].map(canonical) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function continuationBody(body, state, sessionId) {
|
|
51
|
+
if (!sessionId || state?.sessionId !== sessionId || !Array.isArray(body.input) ||
|
|
52
|
+
body.input.length <= state.prefix.length || configFingerprint(body) !== state.fingerprint) return body
|
|
53
|
+
if (!state.prefix.every((item, i) => canonical(body.input[i]) === item)) return body
|
|
54
|
+
return { ...body, previous_response_id: state.responseId, input: body.input.slice(state.prefix.length) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Only explicit unknown-previous-response failures are safe to recover, never generic 400/network errors. */
|
|
58
|
+
export function missingPreviousResponse(event) {
|
|
59
|
+
const error = event?.type === 'error' ? (event.error ?? event) : event?.type === 'response.failed' ? event.response?.error : null
|
|
60
|
+
if (!error) return false
|
|
61
|
+
return ['previous_response_not_found', 'previous_response_id_not_found'].includes(error.code) ||
|
|
62
|
+
(error.param === 'previous_response_id' && /not found|does not exist|unknown|expired/i.test(error.message ?? ''))
|
|
63
|
+
}
|