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/lib/config.js ADDED
@@ -0,0 +1,202 @@
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
+ * Endpoints and credentials are inherited together from the DSH OpenAI provider.
7
+ * Explicit plugin overrides must supply their own endpoint and credential pair.
8
+ */
9
+
10
+ import { readFileSync } from 'node:fs'
11
+
12
+ // ---------------------------------------------------------------------------------------------
13
+ // Schema (serialized schemastery document; generated shape, do not hand-edit refs' uids)
14
+ // ---------------------------------------------------------------------------------------------
15
+
16
+ const SCHEMA = {
17
+ uid: 62,
18
+ refs: {
19
+ 2: {
20
+ type: 'string',
21
+ meta: {
22
+ default: '',
23
+ description:
24
+ '接口地址。默认与 Key 一起继承 DSH 的 OpenAI 渠道。显式覆盖时须同时提供本插件的 Key(或环境变量引用),不会混用 DSH 凭据。请求访问 <baseURL>/responses。',
25
+ },
26
+ },
27
+ 5: {
28
+ type: 'string',
29
+ meta: {
30
+ role: 'secret',
31
+ default: '',
32
+ description:
33
+ 'Bearer API Key。默认沿用 DSH OpenAI 渠道;显式覆盖须同时填写 baseURL。覆盖模式下可通过 envFile / apiKeyEnv 提供 Key。Key 仅在 Host 使用。',
34
+ },
35
+ },
36
+ 7: { type: 'string', meta: { default: '', description: '显式覆盖 Key 的环境变量名;留空不自动读取 OPENAI_API_KEY。使用 envFile 时留空默认读取其中的 OPENAI_API_KEY。' } },
37
+ 9: { type: 'string', meta: { default: '', description: '可选的 .env 文件绝对路径(KEY=VALUE,每行一条)。' } },
38
+ 11: {
39
+ type: 'string',
40
+ meta: {
41
+ default: 'websocket',
42
+ description: "默认传输模式:websocket(WS 模式)/ sse(流式,走原有 openai 渠道)/ auto(WS 优先,连接失败自动回退流式)。输入框旁的切换按钮会覆盖该默认值。",
43
+ },
44
+ },
45
+ 15: {
46
+ type: 'string',
47
+ meta: { default: '', description: 'GPT-6 文本冗长度(low / medium / high),留空使用接口默认。' },
48
+ },
49
+ 17: {
50
+ type: 'number',
51
+ meta: {
52
+ min: 10000,
53
+ max: 2147483647,
54
+ default: 600000,
55
+ description: '两条流式事件之间允许的最大间隔(毫秒),超时按失败处理。',
56
+ },
57
+ },
58
+ 19: {
59
+ type: 'number',
60
+ meta: { min: 1000, max: 120000, default: 15000, description: 'WebSocket 连接超时(毫秒)。' },
61
+ },
62
+ 62: {
63
+ type: 'object',
64
+ meta: { default: {} },
65
+ dict: {
66
+ baseURL: 2,
67
+ apiKey: 5,
68
+ apiKeyEnv: 7,
69
+ envFile: 9,
70
+ transport: 11,
71
+ verbosity: 15,
72
+ streamIdleTimeoutMs: 17,
73
+ connectTimeoutMs: 19,
74
+ },
75
+ },
76
+ },
77
+ }
78
+
79
+ /** The same rows as plain nodes, which is the shape the settings form projects values through. */
80
+ function inlineNode(uid, refs) {
81
+ const ref = refs[uid]
82
+ const node = { ...ref }
83
+ if (ref.dict) node.dict = Object.fromEntries(Object.entries(ref.dict).map(([key, value]) => [key, inlineNode(value, refs)]))
84
+ if (ref.inner !== undefined) node.inner = inlineNode(ref.inner, refs)
85
+ return node
86
+ }
87
+
88
+ /** Exported as the row's Config so Settings can describe the fields; validation never throws. */
89
+ export const Config = {
90
+ ...SCHEMA,
91
+ type: 'object',
92
+ dict: inlineNode(SCHEMA.uid, SCHEMA.refs).dict,
93
+ meta: { default: {} },
94
+ toJSON() {
95
+ return SCHEMA
96
+ },
97
+ '~standard': {
98
+ version: 1,
99
+ vendor: 'dsh-codex-ws',
100
+ validate(value) {
101
+ return { value: value !== null && typeof value === 'object' ? value : {} }
102
+ },
103
+ },
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------------------------
107
+ // Resolution
108
+ // ---------------------------------------------------------------------------------------------
109
+
110
+ export const TARGET_PROVIDER = 'openai'
111
+ export const TRANSPORTS = ['websocket', 'sse', 'auto']
112
+
113
+ function text(value) {
114
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
115
+ return typeof value === 'string' ? value.trim() : ''
116
+ }
117
+
118
+ function clampInt(value, fallback, min, max) {
119
+ const n = typeof value === 'number' ? value : Number(value)
120
+ if (value === '' || value === null || value === undefined || !Number.isFinite(n)) return fallback
121
+ return Math.min(max, Math.max(min, Math.round(n)))
122
+ }
123
+
124
+ /** Parse the flat KEY=VALUE .env lines; a missing file is simply empty. */
125
+ function readEnvFile(file) {
126
+ const result = {}
127
+ let raw
128
+ try {
129
+ raw = readFileSync(file, 'utf8')
130
+ } catch {
131
+ return result
132
+ }
133
+ for (const line of raw.split(/\r?\n/)) {
134
+ const trimmed = line.trim()
135
+ if (!trimmed || trimmed.startsWith('#')) continue
136
+ const eq = trimmed.indexOf('=')
137
+ if (eq <= 0) continue
138
+ const key = trimmed.slice(0, eq).trim()
139
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
140
+ let value = trimmed.slice(eq + 1).trim()
141
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1)
142
+ result[key] = value
143
+ }
144
+ return result
145
+ }
146
+
147
+ /** True when any explicit endpoint/credential field opts out of inheritance. */
148
+ export function hasOverride(config) {
149
+ const cfg = config !== null && typeof config === 'object' ? config : {}
150
+ return ['baseURL', 'apiKey', 'apiKeyEnv', 'envFile'].some((key) => text(cfg[key]))
151
+ }
152
+
153
+ /** Resolve one request without mixing endpoint and credentials from different sources.
154
+ * `provider` is the current, Host-only DSH OpenAI transport configuration
155
+ * ({ baseURL, apiKey }); inherited mode never reads local Codex files or
156
+ * ambient credentials.
157
+ */
158
+ export function resolveSettings(config, provider = {}) {
159
+ const cfg = config !== null && typeof config === 'object' ? config : {}
160
+ const override = hasOverride(config)
161
+ const apiKeyEnv = text(cfg.apiKeyEnv) || 'OPENAI_API_KEY'
162
+ const envFile = text(cfg.envFile)
163
+ const fileEnv = override && envFile ? readEnvFile(envFile) : {}
164
+ const baseURL = text(override ? cfg.baseURL : provider?.baseURL).replace(/\/+$/, '')
165
+ const apiKey = override
166
+ ? text(cfg.apiKey) || text(fileEnv[apiKeyEnv]) || (text(cfg.apiKeyEnv) ? text(process.env[apiKeyEnv]) : '')
167
+ : text(provider?.apiKey)
168
+ let configurationError = ''
169
+ if (!baseURL || !apiKey) {
170
+ configurationError = override
171
+ ? 'codex-ws: plugin overrides require both baseURL and an API key (apiKey, envFile or apiKeyEnv); DSH credentials are never mixed with overrides'
172
+ : 'codex-ws: DSH OpenAI endpoint or credential is unavailable — configure the openai channel in DSH settings (an API-key credential with baseURL), or supply a complete plugin override (baseURL and key together)'
173
+ } else {
174
+ try {
175
+ const url = new URL(baseURL)
176
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('invalid endpoint')
177
+ } catch {
178
+ configurationError = 'codex-ws: baseURL must be an HTTP(S) URL without userinfo, query or fragment'
179
+ }
180
+ }
181
+
182
+ const rawTransport = text(cfg.transport).toLowerCase()
183
+ const transport = TRANSPORTS.includes(rawTransport) ? rawTransport : 'websocket'
184
+
185
+ const verbosity = text(cfg.verbosity).toLowerCase()
186
+
187
+ return {
188
+ configurationError,
189
+ source: override ? 'plugin' : 'dsh',
190
+ baseURL,
191
+ sseUrl: baseURL.endsWith('/responses') ? baseURL : `${baseURL}/responses`,
192
+ wsUrl: (baseURL.endsWith('/responses') ? baseURL : `${baseURL}/responses`)
193
+ .replace(/^https:\/\//, 'wss://')
194
+ .replace(/^http:\/\//, 'ws://'),
195
+ apiKey,
196
+ apiKeyEnv,
197
+ transport,
198
+ verbosity: ['low', 'medium', 'high'].includes(verbosity) ? verbosity : '',
199
+ streamIdleTimeoutMs: clampInt(cfg.streamIdleTimeoutMs, 600000, 10000, 2147483647),
200
+ connectTimeoutMs: clampInt(cfg.connectTimeoutMs, 15000, 1000, 120000),
201
+ }
202
+ }
@@ -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
+ }