dsh-lcx-codex 0.3.3 → 0.4.0-rc.13

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/route.js ADDED
@@ -0,0 +1,222 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { attributionHeaders, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
3
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
4
+
5
+ export function normalizeBaseURL(value) {
6
+ return String(value ?? '').trim().replace(/\/+$/u, '')
7
+ }
8
+
9
+ export function isGptModel(model) {
10
+ return /(^|[^a-z])gpt(?:[^a-z]|$)/iu.test(String(model ?? ''))
11
+ }
12
+
13
+ export function baseURLFingerprint(baseURL) {
14
+ return createHash('sha256').update(normalizeBaseURL(baseURL), 'utf8').digest('hex')
15
+ }
16
+
17
+ export function routeFingerprint(route, options = {}) {
18
+ const includeSession = options.includeSession !== false
19
+ const fields = [route?.provider ?? '', route?.model ?? '', normalizeBaseURL(route?.baseURL), includeSession ? route?.sessionId ?? '' : '']
20
+ return createHash('sha256').update(fields.join('\u001f'), 'utf8').digest('hex')
21
+ }
22
+
23
+ function clampPromptCacheKey(value) {
24
+ if (value === undefined) return undefined
25
+ const chars = Array.from(String(value))
26
+ return chars.length <= 64 ? String(value) : chars.slice(0, 64).join('')
27
+ }
28
+
29
+ export function promptCacheSessionId(route, config = {}) {
30
+ if (config?.cacheRetention === 'none') return undefined
31
+ return route?.sessionId ? String(route.sessionId) : undefined
32
+ }
33
+
34
+ export function promptCacheKey(route, config = {}) {
35
+ return clampPromptCacheKey(promptCacheSessionId(route, config))
36
+ }
37
+
38
+ export function promptCacheRetention(config = {}) {
39
+ return config?.cacheRetention === 'long' && config?.supportsLongCacheRetention !== false ? '24h' : undefined
40
+ }
41
+
42
+ function retryAttempts(policy, fallback = 3) {
43
+ if (!policy || typeof policy !== 'object') return fallback
44
+ try {
45
+ const resolved = resolveRetryPolicy(policy, 'llm-pi-ai provider retryPolicy')
46
+ if (resolved.mode === 'normal' && Number.isSafeInteger(resolved.maxRetries)) return Math.min(resolved.maxRetries + 1, 6)
47
+ } catch { /* keep fallback */ }
48
+ return fallback
49
+ }
50
+
51
+ export function settingsValue(ctx, namespace) {
52
+ const settings = ctx?.get?.('settings') ?? ctx?.settings
53
+ return settings?.get?.(settingsNamespace(namespace))
54
+ }
55
+
56
+ const RESPONSES_COMPAT_FIELDS = new Set(['supportsDeveloperRole', 'supportsStrictMode', 'supportsLongCacheRetention'])
57
+
58
+ function copyResponsesCompat(target, source) {
59
+ if (!source || typeof source !== 'object') return
60
+ for (const field of RESPONSES_COMPAT_FIELDS) if (typeof source[field] === 'boolean') target[field] = source[field]
61
+ }
62
+
63
+ function configuredResponsesCompat(profile, modelId) {
64
+ const compat = {}
65
+ copyResponsesCompat(compat, profile?.compat)
66
+ const configuredModels = Array.isArray(profile?.models) ? profile.models : []
67
+ const modelEntry = configuredModels.length > 0
68
+ ? configuredModels.find((entry) => String(entry?.id ?? '') === String(modelId))
69
+ : profile?.modelOverrides?.[String(modelId)]
70
+ copyResponsesCompat(compat, modelEntry?.compat)
71
+ return Object.keys(compat).length > 0 ? compat : undefined
72
+ }
73
+
74
+ export function resolveResponsesRouteConfig(ctx, options, fallbackConfig) {
75
+ if (!isGptModel(options?.model)) return undefined
76
+ const provider = String(options?.provider ?? '')
77
+ const section = settingsValue(ctx, 'llm-pi-ai')
78
+ const profile = section?.providers?.[provider]
79
+ if (profile === undefined) {
80
+ if (provider !== fallbackConfig.provider) return undefined
81
+ return {
82
+ ...fallbackConfig,
83
+ provider,
84
+ model: String(options.model),
85
+ api: 'openai-responses',
86
+ baseURL: normalizeBaseURL(fallbackConfig.baseURL),
87
+ cacheRetention: ['none', 'short', 'long'].includes(fallbackConfig.cacheRetention) ? fallbackConfig.cacheRetention : 'short',
88
+ supportsLongCacheRetention: fallbackConfig.supportsLongCacheRetention !== false,
89
+ responsesCompat: fallbackConfig.responsesCompat && typeof fallbackConfig.responsesCompat === 'object' ? { ...fallbackConfig.responsesCompat } : undefined,
90
+ }
91
+ }
92
+ if (profile.api !== 'openai-responses') return undefined
93
+ const baseURL = profile.baseURL ?? (provider === fallbackConfig.provider ? fallbackConfig.baseURL : undefined)
94
+ const apiKeyEnv = profile.apiKeyEnv ?? (provider === fallbackConfig.provider ? fallbackConfig.apiKeyEnv : undefined)
95
+ if (!baseURL || !apiKeyEnv) return undefined
96
+ const responsesCompat = configuredResponsesCompat(profile, options.model)
97
+ return {
98
+ ...fallbackConfig,
99
+ provider,
100
+ model: String(options.model),
101
+ api: 'openai-responses',
102
+ baseURL: normalizeBaseURL(baseURL),
103
+ apiKeyEnv,
104
+ headers: profile.headers && typeof profile.headers === 'object' ? { ...profile.headers } : { ...(fallbackConfig.headers ?? {}) },
105
+ cacheRetention: ['none', 'short', 'long'].includes(profile.cacheRetention) ? profile.cacheRetention : (['none', 'short', 'long'].includes(fallbackConfig.cacheRetention) ? fallbackConfig.cacheRetention : 'short'),
106
+ supportsLongCacheRetention: responsesCompat?.supportsLongCacheRetention ?? fallbackConfig.supportsLongCacheRetention ?? true,
107
+ responsesCompat,
108
+ timeoutMs: Number.isInteger(profile.timeoutMs) && profile.timeoutMs > 0 ? profile.timeoutMs : fallbackConfig.timeoutMs,
109
+ maxAttempts: retryAttempts(profile.retryPolicy, fallbackConfig.maxAttempts),
110
+ maxRequestImageBytes: Number.isSafeInteger(profile.maxRequestImageBytes) && profile.maxRequestImageBytes > 0
111
+ ? profile.maxRequestImageBytes
112
+ : fallbackConfig.maxRequestImageBytes,
113
+ requestImagePixelBudget: Number.isSafeInteger(profile.requestImagePixelBudget) && profile.requestImagePixelBudget > 0
114
+ ? profile.requestImagePixelBudget
115
+ : fallbackConfig.requestImagePixelBudget,
116
+ requestImageMaxBytes: Number.isSafeInteger(profile.requestImageMaxBytes) && profile.requestImageMaxBytes > 0
117
+ ? profile.requestImageMaxBytes
118
+ : fallbackConfig.requestImageMaxBytes,
119
+ }
120
+ }
121
+
122
+ export async function resolveApiKey(ctx, config) {
123
+ const credentials = ctx?.get?.('credentials') ?? ctx?.credentials
124
+ if (credentials?.resolve) {
125
+ const resolved = await credentials.resolve(config.apiKeyEnv)
126
+ if (typeof resolved?.value === 'string' && resolved.value.trim()) return resolved.value.trim()
127
+ }
128
+ const ambient = String(process.env[config.apiKeyEnv] ?? '').trim()
129
+ if (ambient) return ambient
130
+ const error = new Error(`DSH provider credential is unavailable: ${config.apiKeyEnv}`)
131
+ error.code = 'LCX_CREDENTIAL_UNAVAILABLE'
132
+ throw error
133
+ }
134
+
135
+ function hasHeader(headers, name) { return Object.keys(headers ?? {}).some((key) => key.toLowerCase() === name.toLowerCase()) }
136
+ function hasExplicitSessionAffinity(headers) { return ['session-id', 'session_id', 'x-session-id'].some((name) => hasHeader(headers, name)) }
137
+ function sessionAffinityFormat(config) {
138
+ if (config?.api && config.api !== 'openai-responses') return 'none'
139
+ const provider = String(config?.provider ?? '').toLowerCase()
140
+ const baseURL = String(config?.baseURL ?? '').toLowerCase()
141
+ return provider === 'openrouter' || baseURL.includes('openrouter.ai') ? 'openrouter' : 'openai'
142
+ }
143
+
144
+ export async function authenticatedHeaders(ctx, config, sessionId, requestId) {
145
+ const explicit = { ...(config.headers ?? {}) }
146
+ const headers = {
147
+ ...attributionHeaders(),
148
+ authorization: `Bearer ${await resolveApiKey(ctx, config)}`,
149
+ }
150
+ const sid = sessionId ? String(sessionId) : undefined
151
+ const format = sessionAffinityFormat(config)
152
+ if (sid && !hasExplicitSessionAffinity(explicit)) {
153
+ if (format === 'openai') headers.session_id = sid
154
+ else if (format === 'openrouter') headers['x-session-id'] = sid
155
+ }
156
+ if (requestId !== null && !hasHeader(explicit, 'x-client-request-id')) {
157
+ const correlation = requestId ?? (sid && format === 'openai' ? sid : (!sid ? randomUUID() : undefined))
158
+ if (correlation) headers['x-client-request-id'] = correlation
159
+ }
160
+ return { ...headers, ...explicit }
161
+ }
162
+
163
+ export function currentRoute(options, config) {
164
+ return {
165
+ provider: String(options?.provider ?? config.provider ?? ''),
166
+ model: String(options?.model ?? config.model ?? ''),
167
+ baseURL: normalizeBaseURL(config.baseURL),
168
+ sessionId: String(options?.sessionId ?? ''),
169
+ }
170
+ }
171
+
172
+ export function generationControlsFromHeader(header, route) {
173
+ const config = header?.config
174
+ if (!config || String(config.provider ?? '') !== String(route?.provider ?? '') || String(config.model ?? '') !== String(route?.model ?? '')) return {}
175
+ const controls = {}
176
+ if (config.reasoningEffort !== undefined) controls.reasoningEffort = config.reasoningEffort
177
+ if (config.temperature !== undefined) controls.temperature = config.temperature
178
+ if (config.maxTokens !== undefined) controls.maxTokens = config.maxTokens
179
+ return controls
180
+ }
181
+
182
+ export function generationControlsFromSession(session, route) {
183
+ try { return generationControlsFromHeader(session?.requestHeader?.(), route) }
184
+ catch { return {} }
185
+ }
186
+
187
+ export function updateRequestHeaderCache(cache, session, event) {
188
+ if (!cache?.set || !session?.id) return false
189
+ let header
190
+ if (event?.type === 'request/header') header = event?.data?.header
191
+ else if (event?.type === 'compaction/start') {
192
+ try { header = session.requestHeader?.() } catch { return false }
193
+ }
194
+ if (!header) return false
195
+ cache.set(String(session.id), header)
196
+ return true
197
+ }
198
+
199
+ export function sessionAncestry(ctx, sessionId) {
200
+ if (!sessionId) return []
201
+ const sessions = ctx?.get?.('sessions') ?? ctx?.sessions
202
+ const result = []
203
+ const seen = new Set()
204
+ let current = sessions?.get?.(sessionId)
205
+ while (current && !seen.has(current.id)) {
206
+ seen.add(current.id)
207
+ result.push(String(current.id))
208
+ const parent = current.header?.parentSession
209
+ if (!parent) break
210
+ current = sessions?.get?.(parent)
211
+ }
212
+ return result
213
+ }
214
+
215
+ export function routeCompatible(record, route, ctx) {
216
+ if (!record || ![4, 5].includes(record.version)) return false
217
+ if (record.provider !== route.provider || record.model !== route.model) return false
218
+ if (record.baseURLFingerprint !== baseURLFingerprint(route.baseURL)) return false
219
+ // Ancestry authorizes portable migration only. Opaque native output is
220
+ // replayable exclusively by the session that created the checkpoint.
221
+ return record.sourceSessionId === route.sessionId
222
+ }
@@ -0,0 +1,72 @@
1
+ function abortReason(signal) {
2
+ if (signal?.reason instanceof Error) return signal.reason
3
+ return new DOMException('The operation was aborted', 'AbortError')
4
+ }
5
+
6
+ export class ServiceMutex {
7
+ constructor() {
8
+ this.locked = false
9
+ this.closed = false
10
+ this.closeReason = undefined
11
+ this.queue = []
12
+ this.idleWaiters = []
13
+ }
14
+
15
+ acquire(signal) {
16
+ if (this.closed) return Promise.reject(this.closeReason ?? new Error('service mutex closed'))
17
+ if (signal?.aborted) return Promise.reject(abortReason(signal))
18
+ if (!this.locked) {
19
+ this.locked = true
20
+ return Promise.resolve(() => this.release())
21
+ }
22
+ return new Promise((resolve, reject) => {
23
+ const waiter = { resolve, reject, signal, onAbort: undefined }
24
+ if (signal) {
25
+ waiter.onAbort = () => {
26
+ const index = this.queue.indexOf(waiter)
27
+ if (index >= 0) this.queue.splice(index, 1)
28
+ signal.removeEventListener('abort', waiter.onAbort)
29
+ reject(abortReason(signal))
30
+ }
31
+ signal.addEventListener('abort', waiter.onAbort, { once: true })
32
+ }
33
+ this.queue.push(waiter)
34
+ })
35
+ }
36
+
37
+ release() {
38
+ while (this.queue.length > 0) {
39
+ const waiter = this.queue.shift()
40
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener('abort', waiter.onAbort)
41
+ if (waiter.signal?.aborted) {
42
+ waiter.reject(abortReason(waiter.signal))
43
+ continue
44
+ }
45
+ waiter.resolve(() => this.release())
46
+ return
47
+ }
48
+ this.locked = false
49
+ const idle = this.idleWaiters.splice(0)
50
+ for (const resolve of idle) resolve()
51
+ }
52
+
53
+ async run(signal, task) {
54
+ const release = await this.acquire(signal)
55
+ try { return await task() }
56
+ finally { release() }
57
+ }
58
+
59
+ close(reason = new Error('service mutex closing')) {
60
+ if (!this.closed) {
61
+ this.closed = true
62
+ this.closeReason = reason
63
+ const queued = this.queue.splice(0)
64
+ for (const waiter of queued) {
65
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener('abort', waiter.onAbort)
66
+ waiter.reject(reason)
67
+ }
68
+ }
69
+ if (!this.locked) return Promise.resolve()
70
+ return new Promise((resolve) => this.idleWaiters.push(resolve))
71
+ }
72
+ }