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/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
+ }