@exvio/os-backend-core 0.4.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.
@@ -0,0 +1,731 @@
1
+ import { parseSseJson, SseDecodeError } from './sse.ts'
2
+ import { matchesAIJSONSchema } from '../schema.ts'
3
+ import {
4
+ AIError,
5
+ aiErrorFromHttpStatus,
6
+ unsupportedAIProviderOperation,
7
+ } from '../errors.ts'
8
+ import type {
9
+ AIChatOutput,
10
+ AIContentPart,
11
+ AIFinishReason,
12
+ AIMessage,
13
+ AIProvider,
14
+ AIProviderCapabilities,
15
+ AIProviderChatRequest,
16
+ AIProviderContentRequest,
17
+ AIProviderRequestBase,
18
+ AIProviderTextRequest,
19
+ AIResult,
20
+ AIStreamEvent,
21
+ AIToolCall,
22
+ AIToolChoice,
23
+ AIToolDefinition,
24
+ AIUsage,
25
+ AIOperation,
26
+ } from '../types.ts'
27
+
28
+ type Fetch = typeof globalThis.fetch
29
+
30
+ export interface OpenAICompatibleProviderOptions {
31
+ name: string
32
+ apiKey: string
33
+ /** A complete chat-completions URL, not merely an origin. */
34
+ endpoint: string
35
+ fetch: Fetch
36
+ capabilities?: Partial<AIProviderCapabilities>
37
+ /** DeepSeek V4 accepts a provider-specific thinking switch. */
38
+ disableThinkingByDefault?: (model: string) => boolean
39
+ }
40
+
41
+ interface OpenAIContentPart {
42
+ type: 'text' | 'image_url'
43
+ text?: string
44
+ image_url?: { url: string }
45
+ }
46
+
47
+ interface OpenAIToolCall {
48
+ id: string
49
+ type: 'function'
50
+ function: { name: string; arguments: string }
51
+ }
52
+
53
+ interface OpenAIToolCallDelta {
54
+ index: number
55
+ id?: string
56
+ type?: 'function'
57
+ function?: { name?: string; arguments?: string }
58
+ }
59
+
60
+ interface OpenAIMessage {
61
+ role: 'system' | 'user' | 'assistant' | 'tool'
62
+ content: string | OpenAIContentPart[] | null
63
+ name?: string
64
+ tool_call_id?: string
65
+ tool_calls?: OpenAIToolCall[]
66
+ }
67
+
68
+ interface OpenAIUsage {
69
+ prompt_tokens?: number
70
+ completion_tokens?: number
71
+ total_tokens?: number
72
+ prompt_cache_hit_tokens?: number
73
+ prompt_cache_miss_tokens?: number
74
+ prompt_tokens_details?: { cached_tokens?: number }
75
+ completion_tokens_details?: { reasoning_tokens?: number }
76
+ }
77
+
78
+ interface OpenAIResponse {
79
+ id?: string
80
+ model?: string
81
+ choices?: Array<{
82
+ message?: {
83
+ content?: string | null
84
+ tool_calls?: OpenAIToolCall[]
85
+ }
86
+ delta?: {
87
+ content?: string | null
88
+ tool_calls?: OpenAIToolCallDelta[]
89
+ }
90
+ finish_reason?: string | null
91
+ }>
92
+ usage?: OpenAIUsage | null
93
+ error?: unknown
94
+ }
95
+
96
+ /**
97
+ * Provider for the common POST /chat/completions contract.
98
+ *
99
+ * Endpoint allowlisting happens in the runtime before constructing a provider.
100
+ * This last line of defence still rejects plaintext URLs and URL credentials
101
+ * before it creates an Authorization header.
102
+ */
103
+ export class OpenAICompatibleProvider implements AIProvider {
104
+ readonly name: string
105
+ readonly capabilities: AIProviderCapabilities
106
+
107
+ readonly #apiKey: string
108
+ readonly #endpoint: string
109
+ readonly #fetch: Fetch
110
+ readonly #disableThinkingByDefault: (model: string) => boolean
111
+
112
+ constructor(options: OpenAICompatibleProviderOptions) {
113
+ if (!options.apiKey.trim()) {
114
+ throw new AIError({
115
+ code: 'not_configured',
116
+ message: 'AI provider API key is missing',
117
+ provider: options.name,
118
+ })
119
+ }
120
+
121
+ this.name = options.name
122
+ this.#apiKey = options.apiKey
123
+ this.#endpoint = validateEndpoint(options.endpoint)
124
+ this.#fetch = options.fetch
125
+ this.#disableThinkingByDefault = options.disableThinkingByDefault ?? (() => false)
126
+ this.capabilities = {
127
+ text: true,
128
+ content: true,
129
+ chat: true,
130
+ streaming: true,
131
+ tools: true,
132
+ object: false,
133
+ embedding: false,
134
+ ...options.capabilities,
135
+ }
136
+ }
137
+
138
+ async generateText(request: AIProviderTextRequest): Promise<AIResult<string>> {
139
+ const messages = withSystemInstruction(
140
+ [{ role: 'user', content: request.prompt }],
141
+ request.systemInstruction,
142
+ )
143
+ const response = await this.#post(this.#buildBody(request, messages), request.signal, 'text', request.model)
144
+ const choice = requireFirstChoice(response.data)
145
+ if (normalizeFinishReason(choice.finish_reason) === 'content_filter') {
146
+ throw contentFiltered(this.name, response.data.model || request.model, 'text')
147
+ }
148
+ const output = choice.message?.content
149
+ if (typeof output !== 'string' || output.trim().length === 0) {
150
+ throw invalidResponse('AI provider returned no text')
151
+ }
152
+
153
+ return this.#result(
154
+ output.trim(),
155
+ request.model,
156
+ response.data,
157
+ choice.finish_reason,
158
+ response.requestId,
159
+ )
160
+ }
161
+
162
+ async generateContent(request: AIProviderContentRequest): Promise<AIResult<string>> {
163
+ if (!this.capabilities.content) {
164
+ throw unsupportedAIProviderOperation(this.name, 'content', request.model)
165
+ }
166
+
167
+ const content: OpenAIContentPart[] = request.content.map((part) => {
168
+ if (part.type === 'text') return { type: 'text', text: part.text }
169
+ if (part.type === 'image') {
170
+ return {
171
+ type: 'image_url',
172
+ image_url: { url: `data:${validateMimeType(part.mimeType)};base64,${validateBase64(part.data)}` },
173
+ }
174
+ }
175
+ throw unsupportedAIProviderOperation(this.name, 'content', request.model)
176
+ })
177
+
178
+ const messages = withSystemInstruction(
179
+ [{ role: 'user', content }],
180
+ request.systemInstruction,
181
+ )
182
+ const response = await this.#post(this.#buildBody(request, messages), request.signal, 'content', request.model)
183
+ const choice = requireFirstChoice(response.data)
184
+ if (normalizeFinishReason(choice.finish_reason) === 'content_filter') {
185
+ throw contentFiltered(this.name, response.data.model || request.model, 'content')
186
+ }
187
+ const output = choice.message?.content
188
+ if (typeof output !== 'string' || output.trim().length === 0) {
189
+ throw invalidResponse('AI provider returned no text')
190
+ }
191
+
192
+ return this.#result(
193
+ output.trim(),
194
+ request.model,
195
+ response.data,
196
+ choice.finish_reason,
197
+ response.requestId,
198
+ )
199
+ }
200
+
201
+ async generateChat(request: AIProviderChatRequest): Promise<AIResult<AIChatOutput>> {
202
+ const messages = withSystemInstruction(
203
+ toOpenAIMessages(request.messages),
204
+ request.systemInstruction,
205
+ )
206
+ const response = await this.#post(
207
+ this.#buildBody(request, messages, false, request.tools, request.toolChoice),
208
+ request.signal,
209
+ 'chat',
210
+ request.model,
211
+ )
212
+ const choice = requireFirstChoice(response.data)
213
+ if (normalizeFinishReason(choice.finish_reason) === 'content_filter') {
214
+ throw contentFiltered(this.name, response.data.model || request.model, 'chat')
215
+ }
216
+ const output = {
217
+ text: typeof choice.message?.content === 'string' ? choice.message.content : '',
218
+ toolCalls: (choice.message?.tool_calls ?? []).map(call => parseToolCall(call, request.tools)),
219
+ }
220
+ if (output.text.length === 0 && output.toolCalls.length === 0) {
221
+ throw invalidResponse('AI provider returned an empty chat turn')
222
+ }
223
+
224
+ return this.#result(
225
+ output,
226
+ request.model,
227
+ response.data,
228
+ choice.finish_reason,
229
+ response.requestId,
230
+ )
231
+ }
232
+
233
+ async *streamChat(request: AIProviderChatRequest): AsyncGenerator<AIStreamEvent, void, undefined> {
234
+ const messages = withSystemInstruction(
235
+ toOpenAIMessages(request.messages),
236
+ request.systemInstruction,
237
+ )
238
+ const response = await this.#postStream(
239
+ this.#buildBody(request, messages, true, request.tools, request.toolChoice),
240
+ request.signal,
241
+ request.model,
242
+ )
243
+
244
+ let actualModel = request.model
245
+ let requestId = response.requestId
246
+ let usage: AIUsage | null = null
247
+ let providerFinishReason: string | undefined
248
+ let text = ''
249
+ const pending = new Map<number, { id: string; name: string; arguments: string }>()
250
+
251
+ try {
252
+ for await (const event of parseSseJson<OpenAIResponse>(response.body, { signal: request.signal })) {
253
+ if (event.type === 'done') break
254
+ const chunk = event.data
255
+
256
+ if (chunk.error !== undefined) {
257
+ throw providerReportedError(this.name, request.model, 'stream')
258
+ }
259
+ if (typeof chunk.model === 'string' && chunk.model.length > 0) actualModel = chunk.model
260
+ if (typeof chunk.id === 'string' && chunk.id.length > 0) requestId ??= chunk.id
261
+ if (chunk.usage) usage = normalizeUsage(chunk.usage)
262
+
263
+ const choice = chunk.choices?.[0]
264
+ if (!choice) continue // usage-only terminal chunks are valid
265
+ if (choice.finish_reason) providerFinishReason = choice.finish_reason
266
+
267
+ const delta = choice.delta?.content
268
+ if (typeof delta === 'string' && delta.length > 0) {
269
+ text += delta
270
+ yield { type: 'text-delta', delta }
271
+ }
272
+
273
+ for (const fragment of choice.delta?.tool_calls ?? []) {
274
+ if (!Number.isSafeInteger(fragment.index) || fragment.index < 0) {
275
+ throw invalidResponse(
276
+ 'AI provider returned an invalid tool-call index',
277
+ this.name,
278
+ request.model,
279
+ 'stream',
280
+ )
281
+ }
282
+ const slot = pending.get(fragment.index) ?? { id: '', name: '', arguments: '' }
283
+ if (fragment.id) slot.id = fragment.id
284
+ if (fragment.function?.name) slot.name += fragment.function.name
285
+ if (fragment.function?.arguments) slot.arguments += fragment.function.arguments
286
+ pending.set(fragment.index, slot)
287
+ }
288
+ }
289
+ } catch (error) {
290
+ if (error instanceof AIError) throw error
291
+ if (request.signal.aborted) throw aborted(this.name, request.model, 'stream')
292
+ if (error instanceof TypeError) {
293
+ throw new AIError({
294
+ code: 'network',
295
+ message: 'AI provider stream transport failed',
296
+ provider: this.name,
297
+ model: request.model,
298
+ operation: 'stream',
299
+ retryable: true,
300
+ })
301
+ }
302
+ if (error instanceof SseDecodeError) {
303
+ throw invalidResponse(
304
+ 'AI provider stream could not be decoded',
305
+ this.name,
306
+ request.model,
307
+ 'stream',
308
+ )
309
+ }
310
+ throw invalidResponse(
311
+ 'AI provider stream could not be decoded',
312
+ this.name,
313
+ request.model,
314
+ 'stream',
315
+ )
316
+ }
317
+
318
+ const normalizedReason = normalizeFinishReason(providerFinishReason)
319
+ if (normalizedReason === 'content_filter') {
320
+ throw contentFiltered(this.name, actualModel, 'stream')
321
+ }
322
+
323
+ const toolCalls: AIToolCall[] = []
324
+ for (const [index, pendingCall] of [...pending].sort(([left], [right]) => left - right)) {
325
+ const toolCall = parseToolCall({
326
+ id: pendingCall.id || `${this.name}_tool_${index}`,
327
+ type: 'function',
328
+ function: { name: pendingCall.name, arguments: pendingCall.arguments },
329
+ }, request.tools)
330
+ toolCalls.push(toolCall)
331
+ yield { type: 'tool-call', toolCall }
332
+ }
333
+
334
+ if (text.length === 0 && toolCalls.length === 0) {
335
+ throw invalidResponse('AI provider returned an empty stream', this.name, actualModel, 'stream')
336
+ }
337
+ const result: AIResult<AIChatOutput> = {
338
+ output: { text, toolCalls },
339
+ provider: this.name,
340
+ model: actualModel,
341
+ usage,
342
+ finishReason: normalizedReason,
343
+ ...(providerFinishReason ? { providerFinishReason } : {}),
344
+ ...(requestId ? { requestId } : {}),
345
+ }
346
+ const finish: AIStreamEvent = {
347
+ type: 'finish',
348
+ result,
349
+ }
350
+ yield finish
351
+ }
352
+
353
+ #buildBody(
354
+ request: AIProviderRequestBase,
355
+ messages: OpenAIMessage[],
356
+ stream = false,
357
+ tools?: readonly AIToolDefinition[],
358
+ toolChoice?: AIToolChoice,
359
+ ): Record<string, unknown> {
360
+ const body: Record<string, unknown> = {
361
+ model: request.model,
362
+ messages,
363
+ temperature: request.temperature ?? 0.3,
364
+ max_tokens: request.maxOutputTokens ?? 4_000,
365
+ }
366
+ if (request.topP !== undefined) body['top_p'] = request.topP
367
+ if (request.stopSequences?.length) body['stop'] = request.stopSequences
368
+ if (request.responseFormat === 'json') body['response_format'] = { type: 'json_object' }
369
+ if (stream) {
370
+ body['stream'] = true
371
+ body['stream_options'] = { include_usage: true }
372
+ }
373
+ if (tools?.length) {
374
+ body['tools'] = tools.map(toOpenAITool)
375
+ body['tool_choice'] = typeof toolChoice === 'object'
376
+ ? { type: 'function', function: { name: toolChoice.name } }
377
+ : toolChoice ?? 'auto'
378
+ } else if (toolChoice === 'required') {
379
+ throw new AIError({
380
+ code: 'invalid_request',
381
+ message: 'toolChoice "required" needs at least one tool',
382
+ provider: this.name,
383
+ model: request.model,
384
+ })
385
+ } else if (typeof toolChoice === 'object') {
386
+ throw new AIError({
387
+ code: 'invalid_request',
388
+ message: 'Named toolChoice needs at least one tool',
389
+ provider: this.name,
390
+ model: request.model,
391
+ })
392
+ } else if (toolChoice === 'none') {
393
+ body['tool_choice'] = 'none'
394
+ }
395
+
396
+ const disableThinking = request.disableThinking ?? this.#disableThinkingByDefault(request.model)
397
+ if (disableThinking) body['thinking'] = { type: 'disabled' }
398
+ return body
399
+ }
400
+
401
+ async #post(
402
+ body: Record<string, unknown>,
403
+ signal: AbortSignal,
404
+ operation: AIOperation,
405
+ model: string,
406
+ ): Promise<{ data: OpenAIResponse; requestId?: string }> {
407
+ const response = await this.#request(body, signal, false, operation, model)
408
+ let data: unknown
409
+ try {
410
+ data = await response.json()
411
+ } catch {
412
+ throw invalidResponse('AI provider returned malformed JSON', this.name, model, operation)
413
+ }
414
+ if (!isRecord(data)) {
415
+ throw invalidResponse('AI provider returned an invalid response', this.name, model, operation)
416
+ }
417
+ const parsed = data as OpenAIResponse
418
+ if (parsed.error !== undefined) throw providerReportedError(this.name, model, operation)
419
+ const requestId = getRequestId(response, parsed)
420
+ return requestId ? { data: parsed, requestId } : { data: parsed }
421
+ }
422
+
423
+ async #postStream(
424
+ body: Record<string, unknown>,
425
+ signal: AbortSignal,
426
+ model: string,
427
+ ): Promise<{ body: ReadableStream<Uint8Array>; requestId?: string }> {
428
+ const response = await this.#request(body, signal, true, 'stream', model)
429
+ if (!response.body) {
430
+ throw invalidResponse('AI provider returned no stream', this.name, model, 'stream')
431
+ }
432
+ const contentType = response.headers.get('content-type')?.toLowerCase()
433
+ if (!contentType?.startsWith('text/event-stream')) {
434
+ try { await response.body.cancel() } catch { /* best-effort connection cleanup */ }
435
+ throw invalidResponse('AI provider returned an invalid stream type', this.name, model, 'stream')
436
+ }
437
+ const requestId = getRequestId(response)
438
+ return requestId ? { body: response.body, requestId } : { body: response.body }
439
+ }
440
+
441
+ async #request(
442
+ body: Record<string, unknown>,
443
+ signal: AbortSignal,
444
+ stream: boolean,
445
+ operation: AIOperation,
446
+ model: string,
447
+ ): Promise<Response> {
448
+ if (signal.aborted) throw aborted(this.name, model, operation)
449
+
450
+ let response: Response
451
+ try {
452
+ response = await this.#fetch(this.#endpoint, {
453
+ method: 'POST',
454
+ headers: {
455
+ Authorization: `Bearer ${this.#apiKey}`,
456
+ 'Content-Type': 'application/json',
457
+ Accept: stream ? 'text/event-stream' : 'application/json',
458
+ },
459
+ body: JSON.stringify(body),
460
+ signal,
461
+ // Never forward a bearer key across a provider-controlled redirect.
462
+ redirect: 'error',
463
+ })
464
+ } catch {
465
+ if (signal.aborted) throw aborted(this.name, model, operation)
466
+ // Do not retain the transport error as a cause: some fetch
467
+ // implementations include request headers or body excerpts in it.
468
+ throw new AIError({
469
+ code: 'network',
470
+ message: 'AI provider request failed',
471
+ provider: this.name,
472
+ model,
473
+ operation,
474
+ retryable: true,
475
+ })
476
+ }
477
+
478
+ if (!response.ok) {
479
+ throw aiErrorFromHttpStatus(response.status, { provider: this.name, model, operation })
480
+ }
481
+ return response
482
+ }
483
+
484
+ #result<T>(
485
+ output: T,
486
+ requestedModel: string,
487
+ response: OpenAIResponse,
488
+ providerFinishReason: string | null | undefined,
489
+ requestId: string | undefined,
490
+ ): AIResult<T> {
491
+ const id = requestId ?? (typeof response.id === 'string' ? response.id : undefined)
492
+ return {
493
+ output,
494
+ provider: this.name,
495
+ model: response.model || requestedModel,
496
+ usage: response.usage ? normalizeUsage(response.usage) : null,
497
+ finishReason: normalizeFinishReason(providerFinishReason),
498
+ ...(providerFinishReason ? { providerFinishReason } : {}),
499
+ ...(id ? { requestId: id } : {}),
500
+ }
501
+ }
502
+ }
503
+
504
+ export function validateEndpoint(input: string): string {
505
+ let url: URL
506
+ try {
507
+ url = new URL(input)
508
+ } catch {
509
+ throw new AIError({ code: 'endpoint_not_allowed', message: 'AI provider endpoint is invalid' })
510
+ }
511
+ if (url.protocol !== 'https:' || url.username || url.password) {
512
+ throw new AIError({ code: 'endpoint_not_allowed', message: 'AI provider endpoint is not allowed' })
513
+ }
514
+ if (url.hash || url.search) {
515
+ throw new AIError({ code: 'endpoint_not_allowed', message: 'AI provider endpoint is not allowed' })
516
+ }
517
+ return url.toString()
518
+ }
519
+
520
+ function withSystemInstruction(messages: OpenAIMessage[], instruction: string | undefined): OpenAIMessage[] {
521
+ return instruction
522
+ ? [{ role: 'system', content: instruction }, ...messages]
523
+ : messages
524
+ }
525
+
526
+ function toOpenAIMessages(messages: readonly AIMessage[]): OpenAIMessage[] {
527
+ return messages.map((message) => {
528
+ if (message.role === 'system') return { role: 'system', content: message.content }
529
+ if (message.role === 'user') {
530
+ return {
531
+ role: 'user',
532
+ content: typeof message.content === 'string'
533
+ ? message.content
534
+ : message.content.map(toOpenAIContentPart),
535
+ }
536
+ }
537
+ if (message.role === 'assistant') {
538
+ const converted: OpenAIMessage = { role: 'assistant', content: message.content || null }
539
+ if (message.toolCalls?.length) {
540
+ converted.tool_calls = message.toolCalls.map((call) => ({
541
+ id: call.id,
542
+ type: 'function',
543
+ function: { name: call.name, arguments: JSON.stringify(call.arguments) },
544
+ }))
545
+ }
546
+ return converted
547
+ }
548
+ const converted: OpenAIMessage = {
549
+ role: 'tool',
550
+ content: message.content,
551
+ tool_call_id: message.toolCallId,
552
+ }
553
+ if (message.toolName) converted.name = message.toolName
554
+ return converted
555
+ })
556
+ }
557
+
558
+ function toOpenAIContentPart(part: AIContentPart): OpenAIContentPart {
559
+ if (part.type === 'text') return { type: 'text', text: part.text }
560
+ if (part.type === 'image') {
561
+ return {
562
+ type: 'image_url',
563
+ image_url: { url: `data:${validateMimeType(part.mimeType)};base64,${validateBase64(part.data)}` },
564
+ }
565
+ }
566
+ throw new AIError({
567
+ code: 'unsupported_operation',
568
+ message: 'OpenAI-compatible chat completions do not support file content',
569
+ operation: 'content',
570
+ })
571
+ }
572
+
573
+ function toOpenAITool(tool: AIToolDefinition): Record<string, unknown> {
574
+ return {
575
+ type: 'function',
576
+ function: {
577
+ name: tool.name,
578
+ description: tool.description ?? '',
579
+ parameters: tool.parameters,
580
+ },
581
+ }
582
+ }
583
+
584
+ function parseToolCall(
585
+ raw: OpenAIToolCall,
586
+ declarations: readonly AIToolDefinition[] | undefined,
587
+ ): AIToolCall {
588
+ if (!raw || raw.type !== 'function' || typeof raw.id !== 'string' || !raw.id) {
589
+ throw invalidResponse('AI provider returned an invalid tool call')
590
+ }
591
+ const name = raw.function?.name
592
+ const encoded = raw.function?.arguments
593
+ if (typeof name !== 'string' || !name || typeof encoded !== 'string' || !encoded) {
594
+ throw invalidResponse('AI provider returned an invalid tool call')
595
+ }
596
+
597
+ let args: unknown
598
+ try {
599
+ args = JSON.parse(encoded)
600
+ } catch {
601
+ throw invalidResponse('AI provider returned invalid tool arguments')
602
+ }
603
+ if (!isRecord(args)) throw invalidResponse('AI provider returned non-object tool arguments')
604
+ const declaration = declarations?.find(tool => tool.name === name)
605
+ if (!declaration || !matchesAIJSONSchema(args, declaration.parameters)) {
606
+ throw invalidResponse('AI provider returned invalid tool arguments')
607
+ }
608
+ return { id: raw.id, name, arguments: args }
609
+ }
610
+
611
+ function requireFirstChoice(response: OpenAIResponse): NonNullable<OpenAIResponse['choices']>[number] {
612
+ const choice = response.choices?.[0]
613
+ if (!choice) throw invalidResponse('AI provider response had no choice')
614
+ return choice
615
+ }
616
+
617
+ function normalizeUsage(raw: OpenAIUsage): AIUsage | null {
618
+ const inputTokens = tokenCount(raw.prompt_tokens)
619
+ const outputTokens = tokenCount(raw.completion_tokens)
620
+ const totalTokens = tokenCount(raw.total_tokens)
621
+ if (inputTokens === undefined || outputTokens === undefined || totalTokens === undefined) return null
622
+
623
+ const cachedRaw = raw.prompt_tokens_details?.cached_tokens ?? raw.prompt_cache_hit_tokens
624
+ const missRaw = raw.prompt_cache_miss_tokens
625
+ const reasoningRaw = raw.completion_tokens_details?.reasoning_tokens
626
+ const cachedInputTokens = tokenCount(cachedRaw)
627
+ const cacheMissInputTokens = tokenCount(missRaw)
628
+ const reasoningTokens = tokenCount(reasoningRaw)
629
+ if (
630
+ (cachedRaw !== undefined && cachedInputTokens === undefined)
631
+ || (missRaw !== undefined && cacheMissInputTokens === undefined)
632
+ || (reasoningRaw !== undefined && reasoningTokens === undefined)
633
+ || totalTokens < inputTokens + outputTokens
634
+ || (cachedInputTokens ?? 0) + (cacheMissInputTokens ?? 0) > inputTokens
635
+ ) return null
636
+
637
+ return {
638
+ inputTokens,
639
+ outputTokens,
640
+ totalTokens,
641
+ ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
642
+ ...(cacheMissInputTokens !== undefined ? { cacheMissInputTokens } : {}),
643
+ ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
644
+ }
645
+ }
646
+
647
+ function normalizeFinishReason(raw: string | null | undefined): AIFinishReason {
648
+ switch (raw) {
649
+ case 'stop':
650
+ return 'stop'
651
+ case 'length':
652
+ case 'max_tokens':
653
+ return 'length'
654
+ case 'tool_calls':
655
+ case 'function_call':
656
+ return 'tool_calls'
657
+ case 'content_filter':
658
+ return 'content_filter'
659
+ default:
660
+ return 'other'
661
+ }
662
+ }
663
+
664
+ function getRequestId(response: Response, body?: OpenAIResponse): string | undefined {
665
+ const fromHeader = response.headers.get('x-request-id') ?? response.headers.get('request-id')
666
+ if (fromHeader) return fromHeader.slice(0, 256)
667
+ return typeof body?.id === 'string' ? body.id.slice(0, 256) : undefined
668
+ }
669
+
670
+ function providerReportedError(provider: string, model: string, operation: AIOperation): AIError {
671
+ return new AIError({
672
+ code: 'provider_error',
673
+ message: 'AI provider reported an error',
674
+ provider,
675
+ model,
676
+ operation,
677
+ })
678
+ }
679
+
680
+ function invalidResponse(
681
+ message: string,
682
+ provider?: string,
683
+ model?: string,
684
+ operation?: AIOperation,
685
+ ): AIError {
686
+ return new AIError({ code: 'invalid_response', message, provider, model, operation })
687
+ }
688
+
689
+ function contentFiltered(provider: string, model: string, operation: AIOperation): AIError {
690
+ return new AIError({
691
+ code: 'content_filtered',
692
+ message: 'AI provider blocked the response for safety reasons',
693
+ provider,
694
+ model,
695
+ operation,
696
+ })
697
+ }
698
+
699
+ function aborted(provider: string, model: string, operation: AIOperation): AIError {
700
+ return new AIError({
701
+ code: 'aborted',
702
+ message: 'AI request was aborted',
703
+ provider,
704
+ model,
705
+ operation,
706
+ })
707
+ }
708
+
709
+ function tokenCount(value: unknown): number | undefined {
710
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
711
+ ? value
712
+ : undefined
713
+ }
714
+
715
+ function validateMimeType(value: string): string {
716
+ if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i.test(value)) {
717
+ throw new AIError({ code: 'invalid_request', message: 'Content MIME type is invalid' })
718
+ }
719
+ return value
720
+ }
721
+
722
+ function validateBase64(value: string): string {
723
+ if (value.length === 0 || !/^[a-z0-9+/]*={0,2}$/i.test(value) || value.length % 4 !== 0) {
724
+ throw new AIError({ code: 'invalid_request', message: 'Content data is not valid base64' })
725
+ }
726
+ return value
727
+ }
728
+
729
+ function isRecord(value: unknown): value is Record<string, unknown> {
730
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
731
+ }