@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.
- package/README.md +466 -0
- package/package.json +48 -0
- package/src/ai/client.ts +1059 -0
- package/src/ai/errors.ts +114 -0
- package/src/ai/index.ts +27 -0
- package/src/ai/model-policy.ts +27 -0
- package/src/ai/pricing.ts +158 -0
- package/src/ai/providers/deepseek.ts +61 -0
- package/src/ai/providers/gemini.ts +919 -0
- package/src/ai/providers/openai-compatible.ts +731 -0
- package/src/ai/providers/sse.ts +163 -0
- package/src/ai/registry.ts +65 -0
- package/src/ai/schema.ts +382 -0
- package/src/ai/types.ts +282 -0
- package/src/auth/browser-exchange.ts +316 -0
- package/src/auth/errors.ts +57 -0
- package/src/auth/index.ts +29 -0
- package/src/auth/login-policy.ts +95 -0
- package/src/auth/oauth-state.ts +332 -0
- package/src/auth/passkey.ts +760 -0
- package/src/auth/redaction.ts +142 -0
- package/src/auth/session.ts +106 -0
- package/src/auth/types.ts +72 -0
- package/src/changelog/catalogue.ts +140 -0
- package/src/changelog/index.ts +5 -0
- package/src/changelog/locale.ts +122 -0
- package/src/changelog/service.ts +148 -0
- package/src/changelog/types.ts +122 -0
- package/src/db/bypass-tenant.ts +15 -0
- package/src/db/plugins/tenant-filter.ts +518 -0
- package/src/db/tenant-context.ts +51 -0
- package/src/guide/index.ts +5 -0
- package/src/guide/markdown.ts +108 -0
- package/src/guide/service.ts +345 -0
- package/src/guide/source.ts +116 -0
- package/src/guide/types.ts +177 -0
- package/src/index.ts +1 -0
- package/src/tenant.ts +14 -0
package/src/ai/client.ts
ADDED
|
@@ -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
|
+
}
|