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