@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,1059 @@
1
+ import { getCurrentTenant } from '../db/tenant-context.ts'
2
+ import {
3
+ AIError,
4
+ isRetryableAIError,
5
+ toAIError,
6
+ unsupportedAIProviderOperation,
7
+ } from './errors.ts'
8
+ import { createAIProviderRegistry, type AIProviderRegistry } from './registry.ts'
9
+ import {
10
+ assertAIObjectSchema,
11
+ createAIObjectPrompt,
12
+ isAIJSONValue,
13
+ isSupportedAIJSONSchema,
14
+ matchesAIJSONSchema,
15
+ parseAIConfig,
16
+ parseAIJSON,
17
+ validateAIObject,
18
+ } from './schema.ts'
19
+ import { assertActiveAIModelId } from './model-policy.ts'
20
+ import type {
21
+ AIChatOutput,
22
+ AIChatRequestOptions,
23
+ AIClient,
24
+ AIClientOptions,
25
+ AIConfig,
26
+ AIContentPart,
27
+ AIEmbedRequestOptions,
28
+ AIEmbeddingOutput,
29
+ AIMessage,
30
+ AIModelRef,
31
+ AIObjectSchema,
32
+ AIOperation,
33
+ AIProvider,
34
+ AIProviderChatRequest,
35
+ AIProviderConfig,
36
+ AIProviderContentRequest,
37
+ AIProviderEmbedRequest,
38
+ AIProviderFactory,
39
+ AIProviderRequestBase,
40
+ AIProviderTextRequest,
41
+ AIRequestOptions,
42
+ AIResult,
43
+ AIRetryOptions,
44
+ AIStreamEvent,
45
+ AITelemetryEvent,
46
+ AIToolCall,
47
+ AIToolChoice,
48
+ AIToolDefinition,
49
+ } from './types.ts'
50
+
51
+ const DEFAULT_TIMEOUT_MS = 60_000
52
+ const MAX_TIMEOUT_MS = 10 * 60_000
53
+ const MAX_ATTEMPTS = 4
54
+ const DEFAULT_RETRY = Object.freeze({ maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 2_000 })
55
+ const FINISH_REASONS = new Set(['stop', 'length', 'tool_calls', 'content_filter', 'error', 'other'])
56
+ const SENSITIVE_METADATA_KEY = /(?:api.?key|secret|token|authorization|password|credential|prompt|content)/i
57
+
58
+ interface NormalizedRetry {
59
+ readonly maxAttempts: number
60
+ readonly baseDelayMs: number
61
+ readonly maxDelayMs: number
62
+ }
63
+
64
+ interface DeadlineScope {
65
+ readonly signal: AbortSignal
66
+ readonly timeoutMs: number
67
+ readonly startedAt: number
68
+ readonly deadlineAt: number
69
+ readonly externalSignal?: AbortSignal
70
+ readonly timedOut: () => boolean
71
+ readonly cleanup: () => void
72
+ }
73
+
74
+ interface ResolvedInvocation {
75
+ readonly tenantId: number
76
+ readonly selection: AIModelRef
77
+ readonly provider: AIProvider
78
+ readonly timeout: DeadlineScope
79
+ readonly retry: NormalizedRetry
80
+ readonly metadata?: Readonly<Record<string, string | number | boolean>>
81
+ }
82
+
83
+ export function createAIClient(options: AIClientOptions): AIClient {
84
+ if (!options || (typeof options !== 'object' && typeof options !== 'function')) {
85
+ throw invalidConfig('AI client options are invalid')
86
+ }
87
+
88
+ const registry = createAIProviderRegistry(options.providers)
89
+ const loadConfig = configLoader(options)
90
+ const resolveTenantFromContext = options.tenantResolver ?? (() => {
91
+ const store = getCurrentTenant()
92
+ return store && !store.bypass ? store.tenantId : undefined
93
+ })
94
+ const missingTenantPolicy = options.missingTenantPolicy ?? 'throw'
95
+ const defaultTenantId = options.defaultTenantId
96
+ const defaultTimeoutMs = normalizeTimeout(options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, 'invalid_config')
97
+ const defaultRetry = normalizeRetry(options.retry ?? DEFAULT_RETRY, 'invalid_config')
98
+ const fetchImpl = options.fetch ?? globalThis.fetch
99
+ const now = options.now ?? Date.now
100
+ const random = options.random ?? Math.random
101
+
102
+ if (typeof fetchImpl !== 'function') throw invalidConfig('AI client requires a fetch implementation')
103
+ if (typeof now !== 'function' || typeof random !== 'function') throw invalidConfig('AI client clock is invalid')
104
+ if (missingTenantPolicy !== 'throw' && missingTenantPolicy !== 'use-default') {
105
+ throw invalidConfig('AI missing tenant policy is invalid')
106
+ }
107
+ if (missingTenantPolicy === 'use-default' && !isTenantId(defaultTenantId)) {
108
+ throw invalidConfig('AI default tenant must be a positive safe integer')
109
+ }
110
+
111
+ const resolveTenant = (explicit: number | undefined): number => {
112
+ let contextual: number | null | undefined
113
+ try {
114
+ contextual = resolveTenantFromContext()
115
+ } catch {
116
+ throw new AIError({ code: 'missing_tenant', message: 'AI tenant context is unavailable' })
117
+ }
118
+ if (contextual !== null && contextual !== undefined) {
119
+ if (!isTenantId(contextual)) {
120
+ throw new AIError({ code: 'missing_tenant', message: 'AI tenant context is invalid' })
121
+ }
122
+ }
123
+
124
+ if (explicit !== undefined) {
125
+ if (!isTenantId(explicit)) throw invalidRequest('AI tenantId must be a positive safe integer')
126
+ if (isTenantId(contextual) && explicit !== contextual) {
127
+ throw invalidRequest('AI tenantId does not match the active tenant context')
128
+ }
129
+ return explicit
130
+ }
131
+ if (isTenantId(contextual)) return contextual
132
+ if (missingTenantPolicy === 'use-default') return defaultTenantId!
133
+ throw new AIError({ code: 'missing_tenant', message: 'AI tenant context is required' })
134
+ }
135
+
136
+ const resolveInvocation = async (
137
+ operation: AIOperation,
138
+ requestOptions: AIRequestOptions | AIEmbedRequestOptions,
139
+ timeout: DeadlineScope,
140
+ ): Promise<ResolvedInvocation> => {
141
+ const tenantId = resolveTenant(requestOptions.tenantId)
142
+ const retry = normalizeRetry(requestOptions.retry ?? defaultRetry, 'invalid_request')
143
+ // Reject invalid/retired explicit overrides before loading tenant credentials.
144
+ // Configured lane models are validated centrally by parseAIConfig below.
145
+ if (requestOptions.model !== undefined) validateModelRef(requestOptions.model)
146
+ const raw = await raceWithDeadline(loadConfigSafely(loadConfig, tenantId), timeout)
147
+ const config = parseAIConfig(raw)
148
+ const selection = resolveModel(config, operation, requestOptions)
149
+ const factory = registry.get(selection.provider)
150
+ if (!factory) {
151
+ throw new AIError({
152
+ code: 'unknown_provider',
153
+ message: 'AI provider is not registered',
154
+ provider: selection.provider,
155
+ model: selection.model,
156
+ operation,
157
+ })
158
+ }
159
+ const configured = config.providers[selection.provider]
160
+ if (!configured?.apiKey?.trim()) {
161
+ throw new AIError({
162
+ code: 'not_configured',
163
+ message: 'AI provider is not configured for this tenant',
164
+ provider: selection.provider,
165
+ model: selection.model,
166
+ operation,
167
+ })
168
+ }
169
+ const providerConfig = await raceWithDeadline(
170
+ approveEndpoint(options, factory, configured, tenantId, selection),
171
+ timeout,
172
+ )
173
+ let provider: AIProvider
174
+ try {
175
+ provider = await raceWithDeadline(
176
+ Promise.resolve(factory({ name: selection.provider, config: providerConfig, fetch: fetchImpl })),
177
+ timeout,
178
+ )
179
+ } catch (error) {
180
+ throw normalizeError(error, timeout, selection, operation)
181
+ }
182
+ if (!provider || provider.name !== selection.provider) {
183
+ throw new AIError({
184
+ code: 'invalid_config',
185
+ message: 'AI provider factory returned an invalid provider',
186
+ provider: selection.provider,
187
+ model: selection.model,
188
+ operation,
189
+ })
190
+ }
191
+ return {
192
+ tenantId,
193
+ selection,
194
+ provider,
195
+ timeout,
196
+ retry,
197
+ ...('metadata' in requestOptions && requestOptions.metadata
198
+ ? { metadata: sanitizeMetadata(requestOptions.metadata) }
199
+ : {}),
200
+ }
201
+ }
202
+
203
+ const execute = async <TRaw, T>(
204
+ operation: Exclude<AIOperation, 'stream'>,
205
+ requestOptions: AIRequestOptions | AIEmbedRequestOptions,
206
+ invoke: (invocation: ResolvedInvocation, remainingMs: number) => Promise<AIResult<TRaw>>,
207
+ validateOutput: (output: unknown) => T,
208
+ ): Promise<AIResult<T>> => {
209
+ const timeout = createDeadline(
210
+ normalizeTimeout(requestOptions.timeoutMs ?? defaultTimeoutMs, 'invalid_request'),
211
+ requestOptions.signal,
212
+ now,
213
+ )
214
+ let invocation: ResolvedInvocation | undefined
215
+ let attempts = 0
216
+ try {
217
+ invocation = await resolveInvocation(operation, requestOptions, timeout)
218
+ for (;;) {
219
+ attempts += 1
220
+ try {
221
+ const result = await raceWithDeadline(
222
+ invoke(invocation, remainingMs(timeout, now)),
223
+ timeout,
224
+ )
225
+ const normalized = normalizeResult(result, invocation.selection, operation, validateOutput)
226
+ emitTelemetry(options, telemetryEvent(invocation, operation, attempts, now, {
227
+ success: true,
228
+ usage: normalized.usage,
229
+ finishReason: normalized.finishReason,
230
+ }, normalized))
231
+ return normalized
232
+ } catch (error) {
233
+ const normalized = normalizeError(error, timeout, invocation.selection, operation)
234
+ if (!mayRetry(normalized, attempts, invocation.retry, timeout)) throw normalized
235
+ await waitBeforeRetry(invocation.retry, attempts, timeout, now, random)
236
+ }
237
+ }
238
+ } catch (error) {
239
+ const normalized = normalizeError(error, timeout, invocation?.selection, operation)
240
+ if (invocation) {
241
+ emitTelemetry(options, telemetryEvent(invocation, operation, attempts, now, {
242
+ success: false,
243
+ usage: null,
244
+ errorCode: normalized.code,
245
+ }))
246
+ }
247
+ throw normalized
248
+ } finally {
249
+ timeout.cleanup()
250
+ }
251
+ }
252
+
253
+ const generateText = (prompt: string, requestOptions: AIRequestOptions = {}) => {
254
+ assertNonEmptyText(prompt, 'AI text prompt must not be empty')
255
+ return execute('text', requestOptions, async (invocation, timeoutMs) => {
256
+ assertCapability(invocation.provider, 'text', invocation.selection)
257
+ const request: AIProviderTextRequest = {
258
+ ...generationRequestBase(requestOptions, invocation, timeoutMs),
259
+ prompt,
260
+ }
261
+ return invocation.provider.generateText(request)
262
+ }, stringOutput)
263
+ }
264
+
265
+ const generateContent = (content: readonly AIContentPart[], requestOptions: AIRequestOptions = {}) => {
266
+ if (!Array.isArray(content) || content.length === 0) {
267
+ throw invalidRequest('AI content must contain at least one part')
268
+ }
269
+ return execute('content', requestOptions, async (invocation, timeoutMs) => {
270
+ assertCapability(invocation.provider, 'content', invocation.selection)
271
+ const request: AIProviderContentRequest = {
272
+ ...generationRequestBase(requestOptions, invocation, timeoutMs),
273
+ content,
274
+ }
275
+ return invocation.provider.generateContent(request)
276
+ }, stringOutput)
277
+ }
278
+
279
+ const generateChat = (messages: readonly AIMessage[], requestOptions: AIChatRequestOptions = {}) => {
280
+ assertMessages(messages)
281
+ assertToolConfiguration(requestOptions)
282
+ return execute('chat', requestOptions, async (invocation, timeoutMs) => {
283
+ assertCapability(invocation.provider, 'chat', invocation.selection)
284
+ if (requestOptions.tools?.length) assertCapability(invocation.provider, 'tools', invocation.selection)
285
+ return invocation.provider.generateChat(chatRequest(messages, requestOptions, invocation, timeoutMs))
286
+ }, (output) => chatOutput(output, requestOptions.tools, requestOptions.toolChoice))
287
+ }
288
+
289
+ const generateObject = <T>(
290
+ prompt: string,
291
+ schema: AIObjectSchema<T>,
292
+ requestOptions: AIRequestOptions = {},
293
+ ): Promise<AIResult<T>> => {
294
+ assertNonEmptyText(prompt, 'AI object prompt must not be empty')
295
+ assertAIObjectSchema(schema)
296
+ return execute<unknown, T>('object', requestOptions, async (invocation, timeoutMs) => {
297
+ const base = generationRequestBase(
298
+ { ...requestOptions, responseFormat: 'json' },
299
+ invocation,
300
+ timeoutMs,
301
+ )
302
+ if (invocation.provider.capabilities.object && invocation.provider.generateObject) {
303
+ return invocation.provider.generateObject({ ...base, prompt, schema })
304
+ }
305
+ assertCapability(invocation.provider, 'text', invocation.selection)
306
+ const result = await invocation.provider.generateText({
307
+ ...base,
308
+ prompt: createAIObjectPrompt(prompt, schema),
309
+ })
310
+ return { ...result, output: parseAIJSON(result.output) }
311
+ }, (output) => validateAIObject(output, schema))
312
+ }
313
+
314
+ const embed = (
315
+ input: string | readonly string[],
316
+ requestOptions: AIEmbedRequestOptions = {},
317
+ ): Promise<AIResult<AIEmbeddingOutput>> => {
318
+ const values = typeof input === 'string' ? [input] : [...input]
319
+ if (values.length === 0 || values.some(value => typeof value !== 'string' || value.length === 0)) {
320
+ throw invalidRequest('AI embedding input must not be empty')
321
+ }
322
+ if (
323
+ requestOptions.dimensions !== undefined
324
+ && (!Number.isSafeInteger(requestOptions.dimensions) || requestOptions.dimensions <= 0)
325
+ ) {
326
+ throw invalidRequest('AI embedding dimensions must be a positive safe integer')
327
+ }
328
+ return execute('embed', requestOptions, async (invocation, timeoutMs) => {
329
+ assertCapability(invocation.provider, 'embedding', invocation.selection)
330
+ if (!invocation.provider.embed) {
331
+ throw unsupportedAIProviderOperation(invocation.provider.name, 'embed', invocation.selection.model)
332
+ }
333
+ const request: AIProviderEmbedRequest = {
334
+ model: invocation.selection.model,
335
+ signal: invocation.timeout.signal,
336
+ timeoutMs,
337
+ input: values,
338
+ ...(requestOptions.dimensions === undefined ? {} : { dimensions: requestOptions.dimensions }),
339
+ }
340
+ return invocation.provider.embed(request)
341
+ }, output => embeddingOutput(output, values.length, requestOptions.dimensions))
342
+ }
343
+
344
+ const streamChat = async function* (
345
+ messages: readonly AIMessage[],
346
+ requestOptions: AIChatRequestOptions = {},
347
+ ): AsyncGenerator<AIStreamEvent> {
348
+ assertMessages(messages)
349
+ assertToolConfiguration(requestOptions)
350
+ const timeout = createDeadline(
351
+ normalizeTimeout(requestOptions.timeoutMs ?? defaultTimeoutMs, 'invalid_request'),
352
+ requestOptions.signal,
353
+ now,
354
+ )
355
+ let invocation: ResolvedInvocation | undefined
356
+ let attempts = 0
357
+ let emitted = false
358
+ let successTelemetrySent = false
359
+ try {
360
+ invocation = await resolveInvocation('stream', requestOptions, timeout)
361
+ assertCapability(invocation.provider, 'streaming', invocation.selection)
362
+ assertCapability(invocation.provider, 'chat', invocation.selection)
363
+ if (requestOptions.tools?.length) assertCapability(invocation.provider, 'tools', invocation.selection)
364
+
365
+ for (;;) {
366
+ attempts += 1
367
+ let finished = false
368
+ const streamedToolCallIds = new Set<string>()
369
+ try {
370
+ const stream = invocation.provider.streamChat(
371
+ chatRequest(messages, requestOptions, invocation, remainingMs(timeout, now)),
372
+ )
373
+ const iterator = stream[Symbol.asyncIterator]()
374
+ try {
375
+ while (true) {
376
+ const next = await raceWithDeadline(Promise.resolve(iterator.next()), timeout)
377
+ if (next.done) break
378
+ const event = next.value
379
+ if (timeout.signal.aborted) throw deadlineError(timeout, invocation.selection, 'stream')
380
+ if (finished) {
381
+ throw new AIError({
382
+ code: 'invalid_response',
383
+ message: 'AI provider emitted data after stream completion',
384
+ ...invocation.selection,
385
+ operation: 'stream',
386
+ })
387
+ }
388
+ const normalized = normalizeStreamEvent(
389
+ event,
390
+ invocation.selection,
391
+ requestOptions.tools,
392
+ requestOptions.toolChoice,
393
+ )
394
+ if (normalized.type === 'tool-call') {
395
+ if (streamedToolCallIds.has(normalized.toolCall.id)) {
396
+ throw new AIError({
397
+ code: 'invalid_response',
398
+ message: 'AI provider returned duplicate tool-call ids',
399
+ ...invocation.selection,
400
+ operation: 'stream',
401
+ })
402
+ }
403
+ streamedToolCallIds.add(normalized.toolCall.id)
404
+ }
405
+ emitted = true
406
+ if (normalized.type === 'finish') {
407
+ finished = true
408
+ emitTelemetry(options, telemetryEvent(invocation, 'stream', attempts, now, {
409
+ success: true,
410
+ usage: normalized.result.usage,
411
+ finishReason: normalized.result.finishReason,
412
+ }, normalized.result))
413
+ successTelemetrySent = true
414
+ }
415
+ yield normalized
416
+ }
417
+ } finally {
418
+ // Do not await a provider that ignores cancellation; the total
419
+ // deadline must remain enforceable even for a broken iterator.
420
+ if (typeof iterator.return === 'function') {
421
+ try {
422
+ void Promise.resolve(iterator.return()).catch(() => undefined)
423
+ } catch {
424
+ // Preserve the request outcome/deadline.
425
+ }
426
+ }
427
+ }
428
+ if (!finished) {
429
+ throw new AIError({
430
+ code: 'invalid_response',
431
+ message: 'AI provider stream ended without a finish event',
432
+ provider: invocation.selection.provider,
433
+ model: invocation.selection.model,
434
+ operation: 'stream',
435
+ })
436
+ }
437
+ if (!successTelemetrySent) {
438
+ throw new AIError({
439
+ code: 'invalid_response',
440
+ message: 'AI provider stream completed without telemetry metadata',
441
+ provider: invocation.selection.provider,
442
+ model: invocation.selection.model,
443
+ operation: 'stream',
444
+ })
445
+ }
446
+ return
447
+ } catch (error) {
448
+ const normalized = normalizeError(error, timeout, invocation.selection, 'stream')
449
+ if (emitted || !mayRetry(normalized, attempts, invocation.retry, timeout)) throw normalized
450
+ await waitBeforeRetry(invocation.retry, attempts, timeout, now, random)
451
+ }
452
+ }
453
+ } catch (error) {
454
+ const normalized = normalizeError(error, timeout, invocation?.selection, 'stream')
455
+ if (invocation) {
456
+ emitTelemetry(options, telemetryEvent(invocation, 'stream', attempts, now, {
457
+ success: false,
458
+ usage: null,
459
+ errorCode: normalized.code,
460
+ }))
461
+ }
462
+ throw normalized
463
+ } finally {
464
+ timeout.cleanup()
465
+ }
466
+ }
467
+
468
+ return Object.freeze({ generateText, generateContent, generateChat, streamChat, generateObject, embed })
469
+ }
470
+
471
+ function configLoader(options: AIClientOptions): (tenantId: number) => unknown | Promise<unknown> {
472
+ if (typeof options.configSource === 'function') return options.configSource
473
+ if (options.configSource && typeof options.configSource.loadConfig === 'function') {
474
+ return options.configSource.loadConfig.bind(options.configSource)
475
+ }
476
+ throw invalidConfig('AI config source is invalid')
477
+ }
478
+
479
+ async function loadConfigSafely(
480
+ loadConfig: (tenantId: number) => unknown | Promise<unknown>,
481
+ tenantId: number,
482
+ ): Promise<unknown> {
483
+ try {
484
+ return await loadConfig(tenantId)
485
+ } catch (error) {
486
+ if (error instanceof AIError) throw error
487
+ throw new AIError({ code: 'invalid_config', message: 'AI configuration could not be loaded' })
488
+ }
489
+ }
490
+
491
+ function resolveModel(
492
+ config: AIConfig,
493
+ operation: AIOperation,
494
+ options: AIRequestOptions | AIEmbedRequestOptions,
495
+ ): AIModelRef {
496
+ const explicit = options.model
497
+ if (explicit !== undefined) return validateModelRef(explicit)
498
+ const lane = options.lane ?? defaultLane(operation)
499
+ const selected = config.lanes[lane]
500
+ if (!selected) {
501
+ throw new AIError({
502
+ code: 'not_configured',
503
+ message: 'AI lane is not configured for this tenant',
504
+ operation,
505
+ })
506
+ }
507
+ return validateModelRef(selected)
508
+ }
509
+
510
+ function validateModelRef(value: AIModelRef): AIModelRef {
511
+ if (!value || !nonEmpty(value.provider) || !nonEmpty(value.model)) {
512
+ throw invalidRequest('AI model must explicitly include provider and model')
513
+ }
514
+ const model = value.model.trim()
515
+ assertActiveAIModelId(model)
516
+ return { provider: value.provider.trim(), model }
517
+ }
518
+
519
+ function defaultLane(operation: AIOperation): string {
520
+ switch (operation) {
521
+ case 'text':
522
+ case 'object': return 'text'
523
+ case 'content': return 'vision'
524
+ case 'chat':
525
+ case 'stream': return 'chat'
526
+ case 'embed': return 'embedding'
527
+ }
528
+ }
529
+
530
+ async function approveEndpoint(
531
+ clientOptions: AIClientOptions,
532
+ factory: AIProviderFactory,
533
+ config: AIProviderConfig,
534
+ tenantId: number,
535
+ selection: AIModelRef,
536
+ ): Promise<AIProviderConfig> {
537
+ if (config.endpoint && config.baseUrl && config.endpoint !== config.baseUrl) {
538
+ throw endpointError(selection)
539
+ }
540
+ const configuredValue = config.endpoint ?? config.baseUrl
541
+ const configured = configuredValue === undefined ? undefined : safeEndpoint(configuredValue, selection)
542
+ const official = factory.defaultEndpoint === undefined
543
+ ? undefined
544
+ : safeEndpoint(factory.defaultEndpoint, selection, 'invalid_config')
545
+
546
+ if (configured && configured.href !== official?.href) {
547
+ let allowed = false
548
+ try {
549
+ allowed = await clientOptions.validateEndpoint?.({
550
+ tenantId,
551
+ provider: selection.provider,
552
+ endpoint: configured,
553
+ ...(official ? { defaultEndpoint: official } : {}),
554
+ }) === true
555
+ } catch {
556
+ allowed = false
557
+ }
558
+ if (!allowed) throw endpointError(selection)
559
+ }
560
+
561
+ if (!configured) return config
562
+ return Object.freeze({ ...config, endpoint: configured.href, baseUrl: configured.href })
563
+ }
564
+
565
+ function safeEndpoint(
566
+ value: string,
567
+ selection: AIModelRef,
568
+ code: 'invalid_config' | 'endpoint_not_allowed' = 'endpoint_not_allowed',
569
+ ): URL {
570
+ let endpoint: URL
571
+ try {
572
+ endpoint = new URL(value)
573
+ } catch {
574
+ throw new AIError({
575
+ code,
576
+ message: code === 'invalid_config' ? 'AI provider default endpoint is invalid' : 'AI provider endpoint is not allowed',
577
+ ...selection,
578
+ })
579
+ }
580
+ if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) {
581
+ throw new AIError({
582
+ code,
583
+ message: code === 'invalid_config' ? 'AI provider default endpoint is invalid' : 'AI provider endpoint is not allowed',
584
+ ...selection,
585
+ })
586
+ }
587
+ return endpoint
588
+ }
589
+
590
+ function endpointError(selection: AIModelRef): AIError {
591
+ return new AIError({
592
+ code: 'endpoint_not_allowed',
593
+ message: 'AI provider endpoint is not allowed',
594
+ ...selection,
595
+ })
596
+ }
597
+
598
+ function generationRequestBase(
599
+ options: AIRequestOptions,
600
+ invocation: ResolvedInvocation,
601
+ timeoutMs: number,
602
+ ): AIProviderRequestBase {
603
+ return {
604
+ model: invocation.selection.model,
605
+ signal: invocation.timeout.signal,
606
+ timeoutMs,
607
+ ...(options.systemInstruction === undefined ? {} : { systemInstruction: options.systemInstruction }),
608
+ ...(options.maxOutputTokens === undefined ? {} : { maxOutputTokens: options.maxOutputTokens }),
609
+ ...(options.temperature === undefined ? {} : { temperature: options.temperature }),
610
+ ...(options.topP === undefined ? {} : { topP: options.topP }),
611
+ ...(options.stopSequences === undefined ? {} : { stopSequences: options.stopSequences }),
612
+ ...(options.responseFormat === undefined ? {} : { responseFormat: options.responseFormat }),
613
+ ...(options.disableThinking === undefined ? {} : { disableThinking: options.disableThinking }),
614
+ }
615
+ }
616
+
617
+ function chatRequest(
618
+ messages: readonly AIMessage[],
619
+ options: AIChatRequestOptions,
620
+ invocation: ResolvedInvocation,
621
+ timeoutMs: number,
622
+ ): AIProviderChatRequest {
623
+ return {
624
+ ...generationRequestBase(options, invocation, timeoutMs),
625
+ messages,
626
+ ...(options.tools === undefined ? {} : { tools: options.tools }),
627
+ ...(options.toolChoice === undefined ? {} : { toolChoice: options.toolChoice }),
628
+ }
629
+ }
630
+
631
+ function assertCapability(
632
+ provider: AIProvider,
633
+ capability: keyof AIProvider['capabilities'],
634
+ selection: AIModelRef,
635
+ ): void {
636
+ if (!provider.capabilities[capability]) {
637
+ const operation: AIOperation = capability === 'embedding'
638
+ ? 'embed'
639
+ : capability === 'streaming'
640
+ ? 'stream'
641
+ : capability === 'tools'
642
+ ? 'chat'
643
+ : capability
644
+ throw unsupportedAIProviderOperation(provider.name, operation, selection.model)
645
+ }
646
+ }
647
+
648
+ function normalizeResult<T>(
649
+ result: AIResult<unknown>,
650
+ selected: AIModelRef,
651
+ operation: AIOperation,
652
+ validateOutput: (output: unknown) => T,
653
+ ): AIResult<T> {
654
+ if (!result || typeof result !== 'object' || result.provider !== selected.provider || !nonEmpty(result.model)) {
655
+ throw invalidProviderResponse(selected, operation)
656
+ }
657
+ if (!FINISH_REASONS.has(result.finishReason)) throw invalidProviderResponse(selected, operation)
658
+ if (result.finishReason === 'content_filter') {
659
+ throw new AIError({
660
+ code: 'content_filtered',
661
+ message: 'AI provider blocked the response for safety reasons',
662
+ provider: result.provider,
663
+ model: result.model,
664
+ operation,
665
+ })
666
+ }
667
+ const usage = normalizeUsage(result.usage, selected, operation)
668
+ return {
669
+ ...result,
670
+ output: validateOutput(result.output),
671
+ provider: result.provider,
672
+ model: result.model,
673
+ usage,
674
+ }
675
+ }
676
+
677
+ function normalizeUsage(
678
+ usage: AIResult<unknown>['usage'],
679
+ selected: AIModelRef,
680
+ operation: AIOperation,
681
+ ): AIResult<unknown>['usage'] {
682
+ if (usage === null) return null
683
+ const values = [usage.inputTokens, usage.outputTokens, usage.totalTokens]
684
+ if (values.some(value => !Number.isSafeInteger(value) || value < 0)) {
685
+ throw invalidProviderResponse(selected, operation)
686
+ }
687
+ for (const optional of [usage.cachedInputTokens, usage.cacheMissInputTokens, usage.reasoningTokens]) {
688
+ if (optional !== undefined && (!Number.isSafeInteger(optional) || optional < 0)) {
689
+ throw invalidProviderResponse(selected, operation)
690
+ }
691
+ }
692
+ const cached = usage.cachedInputTokens ?? 0
693
+ const missed = usage.cacheMissInputTokens ?? 0
694
+ if (
695
+ usage.totalTokens < usage.inputTokens + usage.outputTokens
696
+ || cached > usage.inputTokens
697
+ || missed > usage.inputTokens
698
+ || cached + missed > usage.inputTokens
699
+ ) {
700
+ throw invalidProviderResponse(selected, operation)
701
+ }
702
+ return usage
703
+ }
704
+
705
+ function normalizeStreamEvent(
706
+ event: AIStreamEvent,
707
+ selection: AIModelRef,
708
+ tools?: readonly AIToolDefinition[],
709
+ toolChoice?: AIToolChoice,
710
+ ): AIStreamEvent {
711
+ if (!event || typeof event !== 'object') throw invalidProviderResponse(selection, 'stream')
712
+ switch (event.type) {
713
+ case 'text-delta':
714
+ if (typeof event.delta !== 'string') throw invalidProviderResponse(selection, 'stream')
715
+ return event
716
+ case 'tool-call':
717
+ if (!event.toolCall || !nonEmpty(event.toolCall.id) || !nonEmpty(event.toolCall.name)) {
718
+ throw invalidProviderResponse(selection, 'stream')
719
+ }
720
+ assertReturnedToolCall(event.toolCall, tools, toolChoice)
721
+ return event
722
+ case 'finish':
723
+ return {
724
+ type: 'finish',
725
+ result: normalizeResult(
726
+ event.result,
727
+ selection,
728
+ 'stream',
729
+ output => chatOutput(output, tools, toolChoice),
730
+ ),
731
+ }
732
+ }
733
+ }
734
+
735
+ function stringOutput(value: unknown): string {
736
+ if (typeof value !== 'string') throw new AIError({ code: 'invalid_response', message: 'AI provider returned invalid text' })
737
+ return value
738
+ }
739
+
740
+ function chatOutput(
741
+ value: unknown,
742
+ tools?: readonly AIToolDefinition[],
743
+ toolChoice?: AIToolChoice,
744
+ ): AIChatOutput {
745
+ if (!value || typeof value !== 'object') {
746
+ throw new AIError({ code: 'invalid_response', message: 'AI provider returned invalid chat output' })
747
+ }
748
+ const output = value as Partial<AIChatOutput>
749
+ if (typeof output.text !== 'string' || !Array.isArray(output.toolCalls)) {
750
+ throw new AIError({ code: 'invalid_response', message: 'AI provider returned invalid chat output' })
751
+ }
752
+ const ids = new Set<string>()
753
+ for (const call of output.toolCalls) {
754
+ assertReturnedToolCall(call, tools, toolChoice)
755
+ if (ids.has(call.id)) {
756
+ throw new AIError({
757
+ code: 'invalid_response',
758
+ message: 'AI provider returned duplicate tool-call ids',
759
+ operation: 'chat',
760
+ })
761
+ }
762
+ ids.add(call.id)
763
+ }
764
+ if ((toolChoice === 'required' || typeof toolChoice === 'object') && output.toolCalls.length === 0) {
765
+ throw new AIError({
766
+ code: 'invalid_response',
767
+ message: 'AI provider did not return the required tool call',
768
+ operation: 'chat',
769
+ })
770
+ }
771
+ return output as AIChatOutput
772
+ }
773
+
774
+ function assertToolConfiguration(options: AIChatRequestOptions): void {
775
+ const tools = options.tools ?? []
776
+ const names = new Set<string>()
777
+ for (const tool of tools) {
778
+ if (
779
+ !tool
780
+ || !nonEmpty(tool.name)
781
+ || names.has(tool.name)
782
+ || !tool.parameters
783
+ || typeof tool.parameters !== 'object'
784
+ || Array.isArray(tool.parameters)
785
+ || !isAIJSONValue(tool.parameters)
786
+ || !isSupportedAIJSONSchema(tool.parameters)
787
+ ) {
788
+ throw invalidRequest('AI tool declaration is invalid')
789
+ }
790
+ names.add(tool.name)
791
+ }
792
+ if ((options.toolChoice === 'required' || typeof options.toolChoice === 'object') && tools.length === 0) {
793
+ throw invalidRequest('AI tool choice requires declared tools')
794
+ }
795
+ if (typeof options.toolChoice === 'object' && !names.has(options.toolChoice.name)) {
796
+ throw invalidRequest('AI named tool choice is not declared')
797
+ }
798
+ }
799
+
800
+ function assertReturnedToolCall(
801
+ call: unknown,
802
+ tools?: readonly AIToolDefinition[],
803
+ toolChoice?: AIToolChoice,
804
+ ): void {
805
+ if (!call || typeof call !== 'object') throw new AIError({
806
+ code: 'invalid_response', message: 'AI provider returned invalid tool arguments', operation: 'chat',
807
+ })
808
+ const candidate = call as Partial<AIToolCall>
809
+ const declaration = tools?.find(tool => tool.name === candidate.name)
810
+ if (
811
+ !nonEmpty(candidate.id)
812
+ || !nonEmpty(candidate.name)
813
+ || toolChoice === 'none'
814
+ || (typeof toolChoice === 'object' && candidate.name !== toolChoice.name)
815
+ || !declaration
816
+ || !candidate.arguments
817
+ || typeof candidate.arguments !== 'object'
818
+ || Array.isArray(candidate.arguments)
819
+ || !isAIJSONValue(candidate.arguments)
820
+ || !matchesAIJSONSchema(candidate.arguments, declaration.parameters)
821
+ ) {
822
+ throw new AIError({
823
+ code: 'invalid_response',
824
+ message: 'AI provider returned invalid tool arguments',
825
+ operation: 'chat',
826
+ })
827
+ }
828
+ }
829
+
830
+ function embeddingOutput(
831
+ value: unknown,
832
+ expectedRows: number,
833
+ expectedDimensions: number | undefined,
834
+ ): AIEmbeddingOutput {
835
+ if (!value || typeof value !== 'object') {
836
+ throw new AIError({ code: 'invalid_response', message: 'AI provider returned invalid embeddings' })
837
+ }
838
+ const embeddings = (value as Partial<AIEmbeddingOutput>).embeddings
839
+ const firstDimensions = Array.isArray(embeddings?.[0]) ? embeddings[0].length : 0
840
+ if (
841
+ !Array.isArray(embeddings)
842
+ || embeddings.length !== expectedRows
843
+ || firstDimensions <= 0
844
+ || (expectedDimensions !== undefined && firstDimensions !== expectedDimensions)
845
+ || embeddings.some(row => (
846
+ !Array.isArray(row)
847
+ || row.length !== firstDimensions
848
+ || row.some(item => typeof item !== 'number' || !Number.isFinite(item))
849
+ ))
850
+ ) {
851
+ throw new AIError({ code: 'invalid_response', message: 'AI provider returned invalid embeddings' })
852
+ }
853
+ return { embeddings }
854
+ }
855
+
856
+ function invalidProviderResponse(selection: AIModelRef, operation: AIOperation): AIError {
857
+ return new AIError({
858
+ code: 'invalid_response',
859
+ message: 'AI provider returned an invalid response',
860
+ ...selection,
861
+ operation,
862
+ })
863
+ }
864
+
865
+ function normalizeError(
866
+ error: unknown,
867
+ deadline: DeadlineScope,
868
+ selection: AIModelRef | undefined,
869
+ operation: AIOperation,
870
+ ): AIError {
871
+ if (deadline.signal.aborted) return deadlineError(deadline, selection, operation)
872
+ if (error instanceof TypeError) {
873
+ return new AIError({
874
+ code: 'network',
875
+ message: 'AI provider network request failed',
876
+ ...(selection ?? {}),
877
+ operation,
878
+ retryable: true,
879
+ cause: error,
880
+ })
881
+ }
882
+ return toAIError(error, { ...(selection ?? {}), operation })
883
+ }
884
+
885
+ function createDeadline(timeoutMs: number, externalSignal: AbortSignal | undefined, now: () => number): DeadlineScope {
886
+ const startedAt = now()
887
+ const controller = new AbortController()
888
+ let expired = false
889
+ const timeoutHandle = setTimeout(() => {
890
+ expired = true
891
+ controller.abort()
892
+ }, timeoutMs)
893
+ const abortFromExternal = () => controller.abort()
894
+ if (externalSignal?.aborted) controller.abort()
895
+ else externalSignal?.addEventListener('abort', abortFromExternal, { once: true })
896
+
897
+ return {
898
+ signal: controller.signal,
899
+ timeoutMs,
900
+ startedAt,
901
+ deadlineAt: startedAt + timeoutMs,
902
+ ...(externalSignal ? { externalSignal } : {}),
903
+ timedOut: () => expired,
904
+ cleanup: () => {
905
+ clearTimeout(timeoutHandle)
906
+ externalSignal?.removeEventListener('abort', abortFromExternal)
907
+ },
908
+ }
909
+ }
910
+
911
+ function deadlineError(
912
+ deadline: DeadlineScope,
913
+ selection: AIModelRef | undefined,
914
+ operation: AIOperation,
915
+ ): AIError {
916
+ return new AIError({
917
+ code: deadline.timedOut() ? 'timeout' : 'aborted',
918
+ message: deadline.timedOut() ? 'AI request timed out' : 'AI request was aborted',
919
+ ...(selection ?? {}),
920
+ operation,
921
+ })
922
+ }
923
+
924
+ async function raceWithDeadline<T>(work: Promise<T>, deadline: DeadlineScope): Promise<T> {
925
+ if (deadline.signal.aborted) throw deadlineError(deadline, undefined, 'text')
926
+ return await new Promise<T>((resolve, reject) => {
927
+ const abort = () => reject(deadlineError(deadline, undefined, 'text'))
928
+ deadline.signal.addEventListener('abort', abort, { once: true })
929
+ work.then(resolve, reject).finally(() => deadline.signal.removeEventListener('abort', abort))
930
+ })
931
+ }
932
+
933
+ function remainingMs(deadline: DeadlineScope, now: () => number): number {
934
+ return Math.max(1, Math.ceil(deadline.deadlineAt - now()))
935
+ }
936
+
937
+ function mayRetry(
938
+ error: AIError,
939
+ attempts: number,
940
+ retry: NormalizedRetry,
941
+ deadline: DeadlineScope,
942
+ ): boolean {
943
+ return !deadline.signal.aborted && attempts < retry.maxAttempts && isRetryableAIError(error)
944
+ }
945
+
946
+ async function waitBeforeRetry(
947
+ retry: NormalizedRetry,
948
+ attempts: number,
949
+ deadline: DeadlineScope,
950
+ now: () => number,
951
+ random: () => number,
952
+ ): Promise<void> {
953
+ const exponential = Math.min(retry.maxDelayMs, retry.baseDelayMs * (2 ** Math.max(0, attempts - 1)))
954
+ const boundedRandom = Math.min(1, Math.max(0, random()))
955
+ const delayMs = Math.min(retry.maxDelayMs, Math.round(exponential * (0.5 + boundedRandom)))
956
+ if (delayMs <= 0) return
957
+ await new Promise<void>((resolve, reject) => {
958
+ const complete = () => {
959
+ deadline.signal.removeEventListener('abort', abort)
960
+ resolve()
961
+ }
962
+ const handle = setTimeout(complete, delayMs)
963
+ const abort = () => {
964
+ clearTimeout(handle)
965
+ deadline.signal.removeEventListener('abort', abort)
966
+ reject(deadlineError(deadline, undefined, 'text'))
967
+ }
968
+ if (deadline.signal.aborted) abort()
969
+ else deadline.signal.addEventListener('abort', abort, { once: true })
970
+ })
971
+ if (remainingMs(deadline, now) <= 0) throw deadlineError(deadline, undefined, 'text')
972
+ }
973
+
974
+ function normalizeRetry(value: AIRetryOptions, code: 'invalid_config' | 'invalid_request'): NormalizedRetry {
975
+ const attempts = value.maxAttempts ?? DEFAULT_RETRY.maxAttempts
976
+ const base = value.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs
977
+ const max = value.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs
978
+ if (!Number.isFinite(attempts) || !Number.isFinite(base) || !Number.isFinite(max) || base < 0 || max < 0) {
979
+ throw new AIError({ code, message: 'AI retry options are invalid' })
980
+ }
981
+ return {
982
+ maxAttempts: Math.max(1, Math.min(MAX_ATTEMPTS, Math.floor(attempts))),
983
+ baseDelayMs: Math.min(30_000, Math.floor(base)),
984
+ maxDelayMs: Math.min(30_000, Math.max(Math.floor(base), Math.floor(max))),
985
+ }
986
+ }
987
+
988
+ function normalizeTimeout(value: number, code: 'invalid_config' | 'invalid_request'): number {
989
+ if (!Number.isFinite(value) || value <= 0) throw new AIError({ code, message: 'AI timeout is invalid' })
990
+ return Math.min(MAX_TIMEOUT_MS, Math.floor(value))
991
+ }
992
+
993
+ function telemetryEvent(
994
+ invocation: ResolvedInvocation,
995
+ operation: AIOperation,
996
+ attempts: number,
997
+ now: () => number,
998
+ outcome: Pick<AITelemetryEvent, 'success' | 'usage'> &
999
+ Partial<Pick<AITelemetryEvent, 'finishReason' | 'errorCode'>>,
1000
+ actual?: Pick<AIResult<unknown>, 'provider' | 'model'>,
1001
+ ): AITelemetryEvent {
1002
+ return {
1003
+ operation,
1004
+ tenantId: invocation.tenantId,
1005
+ provider: actual?.provider ?? invocation.selection.provider,
1006
+ model: actual?.model ?? invocation.selection.model,
1007
+ durationMs: Math.max(0, now() - invocation.timeout.startedAt),
1008
+ attempts,
1009
+ ...outcome,
1010
+ ...(invocation.metadata && Object.keys(invocation.metadata).length > 0
1011
+ ? { metadata: invocation.metadata }
1012
+ : {}),
1013
+ }
1014
+ }
1015
+
1016
+ function emitTelemetry(options: AIClientOptions, event: AITelemetryEvent): void {
1017
+ if (!options.telemetry) return
1018
+ try {
1019
+ void Promise.resolve(options.telemetry(event)).catch(() => undefined)
1020
+ } catch {
1021
+ // Observability must never change the application-visible AI result.
1022
+ }
1023
+ }
1024
+
1025
+ function sanitizeMetadata(
1026
+ metadata: Readonly<Record<string, string | number | boolean>>,
1027
+ ): Readonly<Record<string, string | number | boolean>> {
1028
+ const safe: Record<string, string | number | boolean> = {}
1029
+ for (const [key, value] of Object.entries(metadata)) {
1030
+ if (!SENSITIVE_METADATA_KEY.test(key)) safe[key] = value
1031
+ }
1032
+ return Object.freeze(safe)
1033
+ }
1034
+
1035
+ function assertMessages(messages: readonly AIMessage[]): void {
1036
+ if (!Array.isArray(messages) || messages.length === 0) {
1037
+ throw invalidRequest('AI chat requires at least one message')
1038
+ }
1039
+ }
1040
+
1041
+ function assertNonEmptyText(value: unknown, message: string): asserts value is string {
1042
+ if (typeof value !== 'string' || value.length === 0) throw invalidRequest(message)
1043
+ }
1044
+
1045
+ function isTenantId(value: unknown): value is number {
1046
+ return Number.isSafeInteger(value) && (value as number) > 0
1047
+ }
1048
+
1049
+ function nonEmpty(value: unknown): value is string {
1050
+ return typeof value === 'string' && value.trim().length > 0
1051
+ }
1052
+
1053
+ function invalidConfig(message: string): AIError {
1054
+ return new AIError({ code: 'invalid_config', message })
1055
+ }
1056
+
1057
+ function invalidRequest(message: string): AIError {
1058
+ return new AIError({ code: 'invalid_request', message })
1059
+ }