dsh-lcx-codex 0.4.0 → 0.4.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/CHANGELOG.md +29 -2
- package/README.md +138 -183
- package/README_EN.md +137 -118
- package/assets/dsh-lcx-codex-banner.jpg +0 -0
- package/lib/compact-v2.js +129 -16
- package/lib/dsh-compat.js +124 -0
- package/lib/index.js +126 -92
- package/lib/native-checkpoint.js +241 -22
- package/lib/route.js +174 -12
- package/lib/token-budget.js +165 -0
- package/lib/web-search-alpha.js +148 -7
- package/lib/web-search-capability.js +5 -3
- package/package.json +4 -2
package/lib/route.js
CHANGED
|
@@ -1,44 +1,134 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
4
|
import { attributionHeaders, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
|
3
5
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
4
6
|
|
|
7
|
+
/** @typedef {Record<string, string>} HeaderMap */
|
|
8
|
+
/** @typedef {Parameters<typeof resolveRetryPolicy>[0]} RetryPolicyConfig */
|
|
9
|
+
/** @typedef {'none' | 'short' | 'long'} CacheRetention */
|
|
10
|
+
/** @typedef {{ supportsDeveloperRole?: boolean, supportsStrictMode?: boolean, supportsLongCacheRetention?: boolean }} ResponsesCompat */
|
|
11
|
+
/** @typedef {{ provider: string, model: string, baseURL: string, sessionId: string }} RouteIdentity */
|
|
12
|
+
/** @typedef {{ provider?: unknown, model?: unknown, sessionId?: unknown }} RouteOptions */
|
|
13
|
+
/** @typedef {{ id?: unknown, compat?: unknown }} ProviderModelProfile */
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} ProviderProfile
|
|
16
|
+
* @property {string} [api]
|
|
17
|
+
* @property {string} [baseURL]
|
|
18
|
+
* @property {string} [apiKeyEnv]
|
|
19
|
+
* @property {HeaderMap} [headers]
|
|
20
|
+
* @property {unknown} [cacheRetention]
|
|
21
|
+
* @property {number} [timeoutMs]
|
|
22
|
+
* @property {number} [maxRequestImageBytes]
|
|
23
|
+
* @property {number} [requestImagePixelBudget]
|
|
24
|
+
* @property {number} [requestImageMaxBytes]
|
|
25
|
+
* @property {RetryPolicyConfig} [retryPolicy]
|
|
26
|
+
* @property {unknown} [compat]
|
|
27
|
+
* @property {ProviderModelProfile[]} [models]
|
|
28
|
+
* @property {Record<string, ProviderModelProfile>} [modelOverrides]
|
|
29
|
+
*/
|
|
30
|
+
/** @typedef {{ providers?: Record<string, ProviderProfile> }} LlmSettingsSection */
|
|
31
|
+
/** @typedef {{ get?: (namespace: unknown) => LlmSettingsSection | undefined }} SettingsService */
|
|
32
|
+
/** @typedef {{ resolve?: (name: string) => Promise<{ value?: unknown } | undefined> }} CredentialsService */
|
|
33
|
+
/** @typedef {{ id: string, header?: { parentSession?: string }, requestHeader?: () => RequestHeader | undefined }} RouteSession */
|
|
34
|
+
/** @typedef {{ get?: (id: string) => RouteSession | undefined }} SessionsService */
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} RouteContext
|
|
37
|
+
* @property {((name: string) => unknown)} [get]
|
|
38
|
+
* @property {SettingsService} [settings]
|
|
39
|
+
* @property {CredentialsService} [credentials]
|
|
40
|
+
* @property {SessionsService} [sessions]
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Raw fallback values are not a resolved Responses route. cacheRetention is
|
|
44
|
+
* intentionally unknown until resolveResponsesRouteConfig normalizes it.
|
|
45
|
+
* @typedef {object} UnresolvedRouteConfig
|
|
46
|
+
* @property {string} provider
|
|
47
|
+
* @property {string} model
|
|
48
|
+
* @property {string} baseURL
|
|
49
|
+
* @property {string} apiKeyEnv
|
|
50
|
+
* @property {HeaderMap} [headers]
|
|
51
|
+
* @property {unknown} [cacheRetention]
|
|
52
|
+
* @property {unknown} [supportsLongCacheRetention]
|
|
53
|
+
* @property {unknown} [responsesCompat]
|
|
54
|
+
* @property {number} [timeoutMs]
|
|
55
|
+
* @property {number} [maxAttempts]
|
|
56
|
+
* @property {number} [maxRequestImageBytes]
|
|
57
|
+
* @property {number} [requestImagePixelBudget]
|
|
58
|
+
* @property {number} [requestImageMaxBytes]
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {UnresolvedRouteConfig & {
|
|
62
|
+
* api: 'openai-responses',
|
|
63
|
+
* cacheRetention: CacheRetention,
|
|
64
|
+
* supportsLongCacheRetention: boolean,
|
|
65
|
+
* responsesCompat?: ResponsesCompat
|
|
66
|
+
* }} ResolvedResponsesRoute
|
|
67
|
+
*/
|
|
68
|
+
/** @typedef {{ provider?: unknown, model?: unknown, reasoningEffort?: unknown, temperature?: unknown, maxTokens?: unknown }} RequestHeaderConfig */
|
|
69
|
+
/** @typedef {{ config?: RequestHeaderConfig }} RequestHeader */
|
|
70
|
+
/** @typedef {{ reasoningEffort?: unknown, temperature?: unknown, maxTokens?: unknown }} GenerationControls */
|
|
71
|
+
/** @typedef {{ version: number, provider: unknown, model: unknown, baseURLFingerprint: unknown, sourceSessionId: unknown }} CheckpointRouteRecord */
|
|
72
|
+
/** @typedef {Error & { code?: string }} LcxError */
|
|
73
|
+
|
|
74
|
+
/** @param {unknown} value */
|
|
5
75
|
export function normalizeBaseURL(value) {
|
|
6
76
|
return String(value ?? '').trim().replace(/\/+$/u, '')
|
|
7
77
|
}
|
|
8
78
|
|
|
79
|
+
/** @param {unknown} model */
|
|
9
80
|
export function isGptModel(model) {
|
|
10
81
|
return /(^|[^a-z])gpt(?:[^a-z]|$)/iu.test(String(model ?? ''))
|
|
11
82
|
}
|
|
12
83
|
|
|
84
|
+
/** @param {unknown} baseURL */
|
|
13
85
|
export function baseURLFingerprint(baseURL) {
|
|
14
86
|
return createHash('sha256').update(normalizeBaseURL(baseURL), 'utf8').digest('hex')
|
|
15
87
|
}
|
|
16
88
|
|
|
89
|
+
/**
|
|
90
|
+
* @param {Partial<RouteIdentity> | null | undefined} route
|
|
91
|
+
* @param {{ includeSession?: boolean }} [options]
|
|
92
|
+
*/
|
|
17
93
|
export function routeFingerprint(route, options = {}) {
|
|
18
94
|
const includeSession = options.includeSession !== false
|
|
19
95
|
const fields = [route?.provider ?? '', route?.model ?? '', normalizeBaseURL(route?.baseURL), includeSession ? route?.sessionId ?? '' : '']
|
|
20
96
|
return createHash('sha256').update(fields.join('\u001f'), 'utf8').digest('hex')
|
|
21
97
|
}
|
|
22
98
|
|
|
99
|
+
/** @param {unknown} value */
|
|
23
100
|
function clampPromptCacheKey(value) {
|
|
24
101
|
if (value === undefined) return undefined
|
|
25
102
|
const chars = Array.from(String(value))
|
|
26
103
|
return chars.length <= 64 ? String(value) : chars.slice(0, 64).join('')
|
|
27
104
|
}
|
|
28
105
|
|
|
106
|
+
/**
|
|
107
|
+
* @param {Partial<RouteIdentity> | null | undefined} route
|
|
108
|
+
* @param {Partial<Pick<ResolvedResponsesRoute, 'cacheRetention'>>} [config]
|
|
109
|
+
*/
|
|
29
110
|
export function promptCacheSessionId(route, config = {}) {
|
|
30
111
|
if (config?.cacheRetention === 'none') return undefined
|
|
31
112
|
return route?.sessionId ? String(route.sessionId) : undefined
|
|
32
113
|
}
|
|
33
114
|
|
|
115
|
+
/**
|
|
116
|
+
* @param {Partial<RouteIdentity> | null | undefined} route
|
|
117
|
+
* @param {Partial<Pick<ResolvedResponsesRoute, 'cacheRetention'>>} [config]
|
|
118
|
+
*/
|
|
34
119
|
export function promptCacheKey(route, config = {}) {
|
|
35
120
|
return clampPromptCacheKey(promptCacheSessionId(route, config))
|
|
36
121
|
}
|
|
37
122
|
|
|
123
|
+
/** @param {Partial<Pick<ResolvedResponsesRoute, 'cacheRetention' | 'supportsLongCacheRetention'>>} [config] */
|
|
38
124
|
export function promptCacheRetention(config = {}) {
|
|
39
125
|
return config?.cacheRetention === 'long' && config?.supportsLongCacheRetention !== false ? '24h' : undefined
|
|
40
126
|
}
|
|
41
127
|
|
|
128
|
+
/**
|
|
129
|
+
* @param {RetryPolicyConfig | null | undefined} policy
|
|
130
|
+
* @param {number} [fallback]
|
|
131
|
+
*/
|
|
42
132
|
function retryAttempts(policy, fallback = 3) {
|
|
43
133
|
if (!policy || typeof policy !== 'object') return fallback
|
|
44
134
|
try {
|
|
@@ -48,19 +138,34 @@ function retryAttempts(policy, fallback = 3) {
|
|
|
48
138
|
return fallback
|
|
49
139
|
}
|
|
50
140
|
|
|
141
|
+
/**
|
|
142
|
+
* @param {RouteContext | null | undefined} ctx
|
|
143
|
+
* @param {string} namespace
|
|
144
|
+
*/
|
|
51
145
|
export function settingsValue(ctx, namespace) {
|
|
52
|
-
const settings = ctx?.get?.('settings') ?? ctx?.settings
|
|
146
|
+
const settings = /** @type {SettingsService | undefined} */ (ctx?.get?.('settings') ?? ctx?.settings)
|
|
53
147
|
return settings?.get?.(settingsNamespace(namespace))
|
|
54
148
|
}
|
|
55
149
|
|
|
150
|
+
/** @type {Set<keyof ResponsesCompat>} */
|
|
56
151
|
const RESPONSES_COMPAT_FIELDS = new Set(['supportsDeveloperRole', 'supportsStrictMode', 'supportsLongCacheRetention'])
|
|
57
152
|
|
|
153
|
+
/**
|
|
154
|
+
* @param {ResponsesCompat} target
|
|
155
|
+
* @param {unknown} source
|
|
156
|
+
*/
|
|
58
157
|
function copyResponsesCompat(target, source) {
|
|
59
158
|
if (!source || typeof source !== 'object') return
|
|
60
|
-
for (const field of RESPONSES_COMPAT_FIELDS) if (typeof source[field] === 'boolean') target[field] = source[field]
|
|
159
|
+
for (const field of RESPONSES_COMPAT_FIELDS) if (typeof /** @type {Record<keyof ResponsesCompat, unknown>} */ (source)[field] === 'boolean') target[field] = /** @type {boolean} */ (/** @type {Record<keyof ResponsesCompat, unknown>} */ (source)[field])
|
|
61
160
|
}
|
|
62
161
|
|
|
162
|
+
/**
|
|
163
|
+
* @param {ProviderProfile | null | undefined} profile
|
|
164
|
+
* @param {unknown} modelId
|
|
165
|
+
* @returns {ResponsesCompat | undefined}
|
|
166
|
+
*/
|
|
63
167
|
function configuredResponsesCompat(profile, modelId) {
|
|
168
|
+
/** @type {ResponsesCompat} */
|
|
64
169
|
const compat = {}
|
|
65
170
|
copyResponsesCompat(compat, profile?.compat)
|
|
66
171
|
const configuredModels = Array.isArray(profile?.models) ? profile.models : []
|
|
@@ -71,6 +176,12 @@ function configuredResponsesCompat(profile, modelId) {
|
|
|
71
176
|
return Object.keys(compat).length > 0 ? compat : undefined
|
|
72
177
|
}
|
|
73
178
|
|
|
179
|
+
/**
|
|
180
|
+
* @param {RouteContext | null | undefined} ctx
|
|
181
|
+
* @param {RouteOptions} options
|
|
182
|
+
* @param {UnresolvedRouteConfig} fallbackConfig
|
|
183
|
+
* @returns {ResolvedResponsesRoute | undefined}
|
|
184
|
+
*/
|
|
74
185
|
export function resolveResponsesRouteConfig(ctx, options, fallbackConfig) {
|
|
75
186
|
if (!isGptModel(options?.model)) return undefined
|
|
76
187
|
const provider = String(options?.provider ?? '')
|
|
@@ -84,9 +195,9 @@ export function resolveResponsesRouteConfig(ctx, options, fallbackConfig) {
|
|
|
84
195
|
model: String(options.model),
|
|
85
196
|
api: 'openai-responses',
|
|
86
197
|
baseURL: normalizeBaseURL(fallbackConfig.baseURL),
|
|
87
|
-
cacheRetention: ['none', 'short', 'long'].includes(fallbackConfig.cacheRetention) ? fallbackConfig.cacheRetention : 'short',
|
|
198
|
+
cacheRetention: /** @type {CacheRetention} */ (['none', 'short', 'long'].includes(/** @type {string} */ (fallbackConfig.cacheRetention)) ? fallbackConfig.cacheRetention : 'short'),
|
|
88
199
|
supportsLongCacheRetention: fallbackConfig.supportsLongCacheRetention !== false,
|
|
89
|
-
responsesCompat: fallbackConfig.responsesCompat && typeof fallbackConfig.responsesCompat === 'object' ? { ...fallbackConfig.responsesCompat } : undefined,
|
|
200
|
+
responsesCompat: fallbackConfig.responsesCompat && typeof fallbackConfig.responsesCompat === 'object' ? /** @type {ResponsesCompat} */ ({ ...fallbackConfig.responsesCompat }) : undefined,
|
|
90
201
|
}
|
|
91
202
|
}
|
|
92
203
|
if (profile.api !== 'openai-responses') return undefined
|
|
@@ -102,38 +213,49 @@ export function resolveResponsesRouteConfig(ctx, options, fallbackConfig) {
|
|
|
102
213
|
baseURL: normalizeBaseURL(baseURL),
|
|
103
214
|
apiKeyEnv,
|
|
104
215
|
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,
|
|
216
|
+
cacheRetention: /** @type {CacheRetention} */ (['none', 'short', 'long'].includes(/** @type {string} */ (profile.cacheRetention)) ? profile.cacheRetention : (['none', 'short', 'long'].includes(/** @type {string} */ (fallbackConfig.cacheRetention)) ? fallbackConfig.cacheRetention : 'short')),
|
|
217
|
+
supportsLongCacheRetention: responsesCompat?.supportsLongCacheRetention ?? /** @type {boolean | undefined} */ (fallbackConfig.supportsLongCacheRetention) ?? true,
|
|
107
218
|
responsesCompat,
|
|
108
|
-
timeoutMs: Number.isInteger(profile.timeoutMs) && profile.timeoutMs > 0 ? profile.timeoutMs : fallbackConfig.timeoutMs,
|
|
219
|
+
timeoutMs: Number.isInteger(profile.timeoutMs) && /** @type {number} */ (profile.timeoutMs) > 0 ? profile.timeoutMs : fallbackConfig.timeoutMs,
|
|
109
220
|
maxAttempts: retryAttempts(profile.retryPolicy, fallbackConfig.maxAttempts),
|
|
110
|
-
maxRequestImageBytes: Number.isSafeInteger(profile.maxRequestImageBytes) && profile.maxRequestImageBytes > 0
|
|
221
|
+
maxRequestImageBytes: Number.isSafeInteger(profile.maxRequestImageBytes) && /** @type {number} */ (profile.maxRequestImageBytes) > 0
|
|
111
222
|
? profile.maxRequestImageBytes
|
|
112
223
|
: fallbackConfig.maxRequestImageBytes,
|
|
113
|
-
requestImagePixelBudget: Number.isSafeInteger(profile.requestImagePixelBudget) && profile.requestImagePixelBudget > 0
|
|
224
|
+
requestImagePixelBudget: Number.isSafeInteger(profile.requestImagePixelBudget) && /** @type {number} */ (profile.requestImagePixelBudget) > 0
|
|
114
225
|
? profile.requestImagePixelBudget
|
|
115
226
|
: fallbackConfig.requestImagePixelBudget,
|
|
116
|
-
requestImageMaxBytes: Number.isSafeInteger(profile.requestImageMaxBytes) && profile.requestImageMaxBytes > 0
|
|
227
|
+
requestImageMaxBytes: Number.isSafeInteger(profile.requestImageMaxBytes) && /** @type {number} */ (profile.requestImageMaxBytes) > 0
|
|
117
228
|
? profile.requestImageMaxBytes
|
|
118
229
|
: fallbackConfig.requestImageMaxBytes,
|
|
119
230
|
}
|
|
120
231
|
}
|
|
121
232
|
|
|
233
|
+
/**
|
|
234
|
+
* @param {RouteContext | null | undefined} ctx
|
|
235
|
+
* @param {Pick<ResolvedResponsesRoute, 'apiKeyEnv'>} config
|
|
236
|
+
*/
|
|
122
237
|
export async function resolveApiKey(ctx, config) {
|
|
123
|
-
const credentials = ctx?.get?.('credentials') ?? ctx?.credentials
|
|
238
|
+
const credentials = /** @type {CredentialsService | undefined} */ (ctx?.get?.('credentials') ?? ctx?.credentials)
|
|
124
239
|
if (credentials?.resolve) {
|
|
125
240
|
const resolved = await credentials.resolve(config.apiKeyEnv)
|
|
126
241
|
if (typeof resolved?.value === 'string' && resolved.value.trim()) return resolved.value.trim()
|
|
127
242
|
}
|
|
128
243
|
const ambient = String(process.env[config.apiKeyEnv] ?? '').trim()
|
|
129
244
|
if (ambient) return ambient
|
|
245
|
+
/** @type {LcxError} */
|
|
130
246
|
const error = new Error(`DSH provider credential is unavailable: ${config.apiKeyEnv}`)
|
|
131
247
|
error.code = 'LCX_CREDENTIAL_UNAVAILABLE'
|
|
132
248
|
throw error
|
|
133
249
|
}
|
|
134
250
|
|
|
251
|
+
/**
|
|
252
|
+
* @param {HeaderMap | null | undefined} headers
|
|
253
|
+
* @param {string} name
|
|
254
|
+
*/
|
|
135
255
|
function hasHeader(headers, name) { return Object.keys(headers ?? {}).some((key) => key.toLowerCase() === name.toLowerCase()) }
|
|
256
|
+
/** @param {HeaderMap | null | undefined} headers */
|
|
136
257
|
function hasExplicitSessionAffinity(headers) { return ['session-id', 'session_id', 'x-session-id'].some((name) => hasHeader(headers, name)) }
|
|
258
|
+
/** @param {Partial<ResolvedResponsesRoute> | null | undefined} config */
|
|
137
259
|
function sessionAffinityFormat(config) {
|
|
138
260
|
if (config?.api && config.api !== 'openai-responses') return 'none'
|
|
139
261
|
const provider = String(config?.provider ?? '').toLowerCase()
|
|
@@ -141,8 +263,16 @@ function sessionAffinityFormat(config) {
|
|
|
141
263
|
return provider === 'openrouter' || baseURL.includes('openrouter.ai') ? 'openrouter' : 'openai'
|
|
142
264
|
}
|
|
143
265
|
|
|
266
|
+
/**
|
|
267
|
+
* @param {RouteContext | null | undefined} ctx
|
|
268
|
+
* @param {ResolvedResponsesRoute} config
|
|
269
|
+
* @param {unknown} sessionId
|
|
270
|
+
* @param {string | null | undefined} requestId
|
|
271
|
+
* @returns {Promise<HeaderMap>}
|
|
272
|
+
*/
|
|
144
273
|
export async function authenticatedHeaders(ctx, config, sessionId, requestId) {
|
|
145
274
|
const explicit = { ...(config.headers ?? {}) }
|
|
275
|
+
/** @type {HeaderMap} */
|
|
146
276
|
const headers = {
|
|
147
277
|
...attributionHeaders(),
|
|
148
278
|
authorization: `Bearer ${await resolveApiKey(ctx, config)}`,
|
|
@@ -160,6 +290,11 @@ export async function authenticatedHeaders(ctx, config, sessionId, requestId) {
|
|
|
160
290
|
return { ...headers, ...explicit }
|
|
161
291
|
}
|
|
162
292
|
|
|
293
|
+
/**
|
|
294
|
+
* @param {RouteOptions | null | undefined} options
|
|
295
|
+
* @param {Pick<UnresolvedRouteConfig, 'provider' | 'model' | 'baseURL'>} config
|
|
296
|
+
* @returns {RouteIdentity}
|
|
297
|
+
*/
|
|
163
298
|
export function currentRoute(options, config) {
|
|
164
299
|
return {
|
|
165
300
|
provider: String(options?.provider ?? config.provider ?? ''),
|
|
@@ -169,9 +304,15 @@ export function currentRoute(options, config) {
|
|
|
169
304
|
}
|
|
170
305
|
}
|
|
171
306
|
|
|
307
|
+
/**
|
|
308
|
+
* @param {RequestHeader | null | undefined} header
|
|
309
|
+
* @param {Partial<RouteIdentity> | null | undefined} route
|
|
310
|
+
* @returns {GenerationControls}
|
|
311
|
+
*/
|
|
172
312
|
export function generationControlsFromHeader(header, route) {
|
|
173
313
|
const config = header?.config
|
|
174
314
|
if (!config || String(config.provider ?? '') !== String(route?.provider ?? '') || String(config.model ?? '') !== String(route?.model ?? '')) return {}
|
|
315
|
+
/** @type {GenerationControls} */
|
|
175
316
|
const controls = {}
|
|
176
317
|
if (config.reasoningEffort !== undefined) controls.reasoningEffort = config.reasoningEffort
|
|
177
318
|
if (config.temperature !== undefined) controls.temperature = config.temperature
|
|
@@ -179,11 +320,21 @@ export function generationControlsFromHeader(header, route) {
|
|
|
179
320
|
return controls
|
|
180
321
|
}
|
|
181
322
|
|
|
323
|
+
/**
|
|
324
|
+
* @param {RouteSession | null | undefined} session
|
|
325
|
+
* @param {Partial<RouteIdentity> | null | undefined} route
|
|
326
|
+
* @returns {GenerationControls}
|
|
327
|
+
*/
|
|
182
328
|
export function generationControlsFromSession(session, route) {
|
|
183
329
|
try { return generationControlsFromHeader(session?.requestHeader?.(), route) }
|
|
184
330
|
catch { return {} }
|
|
185
331
|
}
|
|
186
332
|
|
|
333
|
+
/**
|
|
334
|
+
* @param {{ set?: (key: string, value: RequestHeader) => unknown } | null | undefined} cache
|
|
335
|
+
* @param {RouteSession | null | undefined} session
|
|
336
|
+
* @param {{ type?: string, data?: { header?: RequestHeader } } | null | undefined} event
|
|
337
|
+
*/
|
|
187
338
|
export function updateRequestHeaderCache(cache, session, event) {
|
|
188
339
|
if (!cache?.set || !session?.id) return false
|
|
189
340
|
let header
|
|
@@ -196,10 +347,16 @@ export function updateRequestHeaderCache(cache, session, event) {
|
|
|
196
347
|
return true
|
|
197
348
|
}
|
|
198
349
|
|
|
350
|
+
/**
|
|
351
|
+
* @param {RouteContext | null | undefined} ctx
|
|
352
|
+
* @param {string | null | undefined} sessionId
|
|
353
|
+
* @returns {string[]}
|
|
354
|
+
*/
|
|
199
355
|
export function sessionAncestry(ctx, sessionId) {
|
|
200
356
|
if (!sessionId) return []
|
|
201
|
-
const sessions = ctx?.get?.('sessions') ?? ctx?.sessions
|
|
357
|
+
const sessions = /** @type {SessionsService | undefined} */ (ctx?.get?.('sessions') ?? ctx?.sessions)
|
|
202
358
|
const result = []
|
|
359
|
+
/** @type {Set<string>} */
|
|
203
360
|
const seen = new Set()
|
|
204
361
|
let current = sessions?.get?.(sessionId)
|
|
205
362
|
while (current && !seen.has(current.id)) {
|
|
@@ -212,6 +369,11 @@ export function sessionAncestry(ctx, sessionId) {
|
|
|
212
369
|
return result
|
|
213
370
|
}
|
|
214
371
|
|
|
372
|
+
/**
|
|
373
|
+
* @param {CheckpointRouteRecord | null | undefined} record
|
|
374
|
+
* @param {RouteIdentity} route
|
|
375
|
+
* @param {unknown} ctx
|
|
376
|
+
*/
|
|
215
377
|
export function routeCompatible(record, route, ctx) {
|
|
216
378
|
if (!record || ![4, 5].includes(record.version)) return false
|
|
217
379
|
if (record.provider !== route.provider || record.model !== route.model) return false
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {Record<string, unknown>} UnknownRecord */
|
|
4
|
+
|
|
5
|
+
export const PORTABLE_BUDGET_ERROR_CODE = 'LCX_PORTABLE_BUDGET_EXCEEDED'
|
|
6
|
+
const MAX_BUDGET_INPUT_CHARS = 2_000_000
|
|
7
|
+
const CONSERVATIVE_IMAGE_TOKEN_COST = 2_048
|
|
8
|
+
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
|
|
9
|
+
const ASCII_WORD = /[A-Za-z0-9]/u
|
|
10
|
+
const STRUCTURAL = /[\p{P}\p{S}]/u
|
|
11
|
+
const DATA_URI = /^data:[^;,\s]+(?:;[^,\s]*)?;base64,[A-Za-z0-9+/\s]+={0,2}$/u
|
|
12
|
+
const ENCODED_TEXT = /^[A-Za-z0-9+/_-]{512,}={0,2}$/u
|
|
13
|
+
|
|
14
|
+
/** @param {string} value */
|
|
15
|
+
function looksEncodedText(value) { return DATA_URI.test(value) || ENCODED_TEXT.test(value) }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A small conservative fallback, deliberately not a tokenizer. The baseline
|
|
19
|
+
* preserves legacy /4 while CJK and structural characters cost more.
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
* @returns {number | undefined}
|
|
22
|
+
*/
|
|
23
|
+
export function estimateTextTokens(value) {
|
|
24
|
+
if (typeof value !== 'string') return undefined
|
|
25
|
+
if (value.length > MAX_BUDGET_INPUT_CHARS || looksEncodedText(value)) return Math.max(1, value.length)
|
|
26
|
+
let weighted = 0
|
|
27
|
+
let asciiRun = 0
|
|
28
|
+
const flushAscii = () => { if (asciiRun > 0) weighted += Math.ceil(asciiRun / 4); asciiRun = 0 }
|
|
29
|
+
for (const char of value) {
|
|
30
|
+
if (ASCII_WORD.test(char)) { asciiRun += 1; continue }
|
|
31
|
+
flushAscii()
|
|
32
|
+
if (/\s/u.test(char)) continue
|
|
33
|
+
if (CJK.test(char) || STRUCTURAL.test(char)) weighted += 1
|
|
34
|
+
else weighted += Math.ceil(char.length / 2)
|
|
35
|
+
}
|
|
36
|
+
flushAscii()
|
|
37
|
+
return Math.max(1, Math.ceil(value.length / 4), weighted)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {unknown} value
|
|
42
|
+
* @returns {value is UnknownRecord}
|
|
43
|
+
*/
|
|
44
|
+
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
|
|
45
|
+
/**
|
|
46
|
+
* @param {unknown} value
|
|
47
|
+
* @param {Set<object>} [seen]
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function containsOpaqueValue(value, seen = new Set()) {
|
|
51
|
+
if (value === null || typeof value !== 'object') return false
|
|
52
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer || seen.has(value)) return true
|
|
53
|
+
seen.add(value)
|
|
54
|
+
return Object.values(value).some((entry) => containsOpaqueValue(entry, seen))
|
|
55
|
+
}
|
|
56
|
+
/** @param {unknown} value */
|
|
57
|
+
function safeJson(value) { try { if (containsOpaqueValue(value)) return undefined; const encoded = JSON.stringify(value); return typeof encoded === 'string' ? encoded : undefined } catch { return undefined } }
|
|
58
|
+
/** @param {unknown} value */
|
|
59
|
+
function safeScalar(value) { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : undefined }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {unknown} content
|
|
63
|
+
* @returns {unknown[] | undefined}
|
|
64
|
+
*/
|
|
65
|
+
function visibleContent(content) {
|
|
66
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
|
67
|
+
if (!Array.isArray(content)) return undefined
|
|
68
|
+
const parts = []
|
|
69
|
+
for (const part of content) {
|
|
70
|
+
if (!isObject(part) || typeof part.type !== 'string') return undefined
|
|
71
|
+
if (['text', 'input_text', 'output_text'].includes(part.type)) {
|
|
72
|
+
if (typeof part.text !== 'string') return undefined
|
|
73
|
+
parts.push({ type: part.type, text: part.text })
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (part.type === 'reasoning') {
|
|
77
|
+
if (typeof part.text !== 'string') return undefined
|
|
78
|
+
parts.push({ type: 'reasoning', text: part.text })
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (part.type === 'tool-call') {
|
|
82
|
+
const id = safeScalar(part.id); const name = safeScalar(part.name); const argumentsText = safeJson(part.arguments)
|
|
83
|
+
if (id === undefined || name === undefined || argumentsText === undefined) return undefined
|
|
84
|
+
parts.push({ type: 'tool-call', id, name, arguments: argumentsText })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (part.type === 'tool-result') {
|
|
88
|
+
const toolCallId = safeScalar(part.toolCallId); const toolName = safeScalar(part.toolName ?? part.name ?? 'unknown'); const nested = visibleContent(part.content)
|
|
89
|
+
if (toolCallId === undefined || toolName === undefined || nested === undefined) return undefined
|
|
90
|
+
parts.push({ type: 'tool-result', toolCallId, toolName, content: nested })
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (['image', 'input_image', 'output_image', 'dsh_image_attachment'].includes(part.type)) {
|
|
94
|
+
parts.push({ type: part.type, image: true })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
return parts
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Project an item to model-visible fields only. Opaque provider state, raw
|
|
104
|
+
* binary, and replay/session metadata are intentionally excluded.
|
|
105
|
+
* @param {unknown} item
|
|
106
|
+
* @returns {unknown | undefined}
|
|
107
|
+
*/
|
|
108
|
+
export function modelVisibleBudgetView(item) {
|
|
109
|
+
if (!isObject(item)) return undefined
|
|
110
|
+
if (item.type === 'function_call') {
|
|
111
|
+
const callId = safeScalar(item.call_id); const name = safeScalar(item.name); const argumentsText = safeJson(item.arguments)
|
|
112
|
+
return callId === undefined || name === undefined || argumentsText === undefined ? undefined : { type: 'function_call', call_id: callId, name, arguments: argumentsText }
|
|
113
|
+
}
|
|
114
|
+
if (item.type === 'function_call_output') {
|
|
115
|
+
const callId = safeScalar(item.call_id); const output = typeof item.output === 'string' ? item.output : safeJson(item.output)
|
|
116
|
+
return callId === undefined || output === undefined ? undefined : { type: 'function_call_output', call_id: callId, output }
|
|
117
|
+
}
|
|
118
|
+
if (item.type !== undefined && item.type !== 'message') return undefined
|
|
119
|
+
if (!['developer', 'system', 'user', 'assistant'].includes(String(item.role ?? ''))) return undefined
|
|
120
|
+
const content = visibleContent(item.content)
|
|
121
|
+
return content === undefined ? undefined : { role: String(item.role), content }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Images have provider/model-dependent costs. Count each known image block with
|
|
126
|
+
* a fixed conservative surcharge without inspecting base64 or attachment data.
|
|
127
|
+
* @param {unknown} value
|
|
128
|
+
* @returns {number}
|
|
129
|
+
*/
|
|
130
|
+
function imageTokenCost(value) {
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
let total = 0
|
|
133
|
+
for (const entry of value) total += imageTokenCost(entry)
|
|
134
|
+
return total
|
|
135
|
+
}
|
|
136
|
+
if (!isObject(value)) return 0
|
|
137
|
+
let total = value.image === true ? CONSERVATIVE_IMAGE_TOKEN_COST : 0
|
|
138
|
+
for (const entry of Object.values(value)) total += imageTokenCost(entry)
|
|
139
|
+
return total
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {unknown} item
|
|
144
|
+
* @returns {number | undefined}
|
|
145
|
+
*/
|
|
146
|
+
export function estimateBudgetItem(item) {
|
|
147
|
+
const view = modelVisibleBudgetView(item)
|
|
148
|
+
if (view === undefined) return undefined
|
|
149
|
+
const encoded = safeJson(view)
|
|
150
|
+
const textCost = estimateTextTokens(encoded)
|
|
151
|
+
return textCost === undefined ? undefined : textCost + imageTokenCost(view)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @param {unknown} maxChars */
|
|
155
|
+
export function portableTokenCeiling(maxChars) {
|
|
156
|
+
return Number.isSafeInteger(maxChars) && /** @type {number} */ (maxChars) > 0 ? Math.ceil(/** @type {number} */ (maxChars) / 4) : undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {string} message */
|
|
160
|
+
export function portableBudgetError(message) {
|
|
161
|
+
/** @type {Error & { code?: string }} */
|
|
162
|
+
const error = new Error(message)
|
|
163
|
+
error.code = PORTABLE_BUDGET_ERROR_CODE
|
|
164
|
+
return error
|
|
165
|
+
}
|