@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,919 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ApiError,
|
|
3
|
+
FunctionCallingConfigMode,
|
|
4
|
+
GoogleGenAI,
|
|
5
|
+
type EmbedContentParameters,
|
|
6
|
+
type EmbedContentResponse,
|
|
7
|
+
type Content,
|
|
8
|
+
type GenerateContentConfig,
|
|
9
|
+
type GenerateContentParameters,
|
|
10
|
+
type GenerateContentResponse,
|
|
11
|
+
type GoogleGenAIOptions,
|
|
12
|
+
type Part,
|
|
13
|
+
} from '@google/genai'
|
|
14
|
+
|
|
15
|
+
import { AIError, aiErrorFromHttpStatus } from '../errors.ts'
|
|
16
|
+
import { isSupportedAIJSONSchema, matchesAIJSONSchema } from '../schema.ts'
|
|
17
|
+
import type {
|
|
18
|
+
AIChatOutput,
|
|
19
|
+
AIEmbeddingOutput,
|
|
20
|
+
AIFinishReason,
|
|
21
|
+
AIOperation,
|
|
22
|
+
AIMessage,
|
|
23
|
+
AIProvider,
|
|
24
|
+
AIProviderChatRequest,
|
|
25
|
+
AIProviderContentRequest,
|
|
26
|
+
AIProviderEmbedRequest,
|
|
27
|
+
AIProviderFactory,
|
|
28
|
+
AIProviderFactoryContext,
|
|
29
|
+
AIProviderObjectRequest,
|
|
30
|
+
AIProviderTextRequest,
|
|
31
|
+
AIResult,
|
|
32
|
+
AIStreamEvent,
|
|
33
|
+
AIUsage,
|
|
34
|
+
AIContentPart,
|
|
35
|
+
AIToolCall,
|
|
36
|
+
AIToolChoice,
|
|
37
|
+
AIToolDefinition,
|
|
38
|
+
} from '../types.ts'
|
|
39
|
+
|
|
40
|
+
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com'
|
|
41
|
+
const TOOL_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/
|
|
42
|
+
|
|
43
|
+
/** The narrow SDK surface used by the provider, exposed for transport-free tests. */
|
|
44
|
+
export interface GeminiClientLike {
|
|
45
|
+
readonly models: {
|
|
46
|
+
generateContent(parameters: GenerateContentParameters): Promise<GenerateContentResponse>
|
|
47
|
+
generateContentStream(
|
|
48
|
+
parameters: GenerateContentParameters,
|
|
49
|
+
): Promise<AsyncGenerator<GenerateContentResponse, void, unknown>>
|
|
50
|
+
embedContent(parameters: EmbedContentParameters): Promise<EmbedContentResponse>
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type GeminiClientFactory = (options: GoogleGenAIOptions) => GeminiClientLike
|
|
55
|
+
|
|
56
|
+
export interface GeminiProviderFactoryOptions {
|
|
57
|
+
/** Tests inject a fake client here. Production uses the maintained Google SDK. */
|
|
58
|
+
readonly createClient?: GeminiClientFactory
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Creates a stateless provider factory. `create` builds a fresh SDK client for
|
|
63
|
+
* every invocation, so an API key can never bleed across tenant requests.
|
|
64
|
+
*/
|
|
65
|
+
export function createGeminiProviderFactory(
|
|
66
|
+
options: GeminiProviderFactoryOptions = {},
|
|
67
|
+
): AIProviderFactory {
|
|
68
|
+
const createClient = options.createClient ?? ((clientOptions) => new GoogleGenAI(clientOptions))
|
|
69
|
+
|
|
70
|
+
const factory: AIProviderFactory = (context: AIProviderFactoryContext): AIProvider => {
|
|
71
|
+
if (!options.createClient && context.fetch !== globalThis.fetch) {
|
|
72
|
+
throw new AIError({
|
|
73
|
+
code: 'invalid_config',
|
|
74
|
+
message: 'Gemini does not support the configured custom fetch transport',
|
|
75
|
+
provider: context.name,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
const apiKey = context.config.apiKey?.trim()
|
|
79
|
+
if (!apiKey) {
|
|
80
|
+
throw new AIError({
|
|
81
|
+
code: 'not_configured',
|
|
82
|
+
message: 'Gemini is not configured',
|
|
83
|
+
provider: context.name,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The Gemini adapter intentionally accepts only Google's API origin. A
|
|
88
|
+
// configurable arbitrary base URL would forward the tenant API key to it.
|
|
89
|
+
const endpoint = normalizeGeminiEndpoint(
|
|
90
|
+
context.config.endpoint ?? context.config.baseUrl,
|
|
91
|
+
context.name,
|
|
92
|
+
)
|
|
93
|
+
const client = createClient({
|
|
94
|
+
apiKey,
|
|
95
|
+
...(endpoint ? { httpOptions: { baseUrl: endpoint } } : {}),
|
|
96
|
+
})
|
|
97
|
+
return new GeminiProvider(client, context.name)
|
|
98
|
+
}
|
|
99
|
+
Object.defineProperty(factory, 'defaultEndpoint', {
|
|
100
|
+
value: GEMINI_ENDPOINT,
|
|
101
|
+
enumerable: true,
|
|
102
|
+
writable: false,
|
|
103
|
+
configurable: false,
|
|
104
|
+
})
|
|
105
|
+
return Object.freeze(factory)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const geminiProviderFactory = createGeminiProviderFactory()
|
|
109
|
+
|
|
110
|
+
/** Convenient constructor matching the other built-in provider subpaths. */
|
|
111
|
+
export function createGeminiProvider(): AIProviderFactory {
|
|
112
|
+
return geminiProviderFactory
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class GeminiProvider implements AIProvider {
|
|
116
|
+
readonly name: string
|
|
117
|
+
readonly capabilities = Object.freeze({
|
|
118
|
+
text: true,
|
|
119
|
+
content: true,
|
|
120
|
+
chat: true,
|
|
121
|
+
streaming: true,
|
|
122
|
+
object: true,
|
|
123
|
+
embedding: true,
|
|
124
|
+
tools: true,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
constructor(
|
|
128
|
+
private readonly client: GeminiClientLike,
|
|
129
|
+
private readonly provider: string,
|
|
130
|
+
) {
|
|
131
|
+
this.name = provider
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async generateText(request: AIProviderTextRequest): Promise<AIResult<string>> {
|
|
135
|
+
assertRequestBase(request, this.provider)
|
|
136
|
+
if (typeof request.prompt !== 'string' || request.prompt.length === 0) {
|
|
137
|
+
throw invalidRequest(this.provider, request.model, 'Gemini prompt must not be empty')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return this.generateTextLike(request, request.prompt)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async generateContent(request: AIProviderContentRequest): Promise<AIResult<string>> {
|
|
144
|
+
assertRequestBase(request, this.provider)
|
|
145
|
+
if (!Array.isArray(request.content) || request.content.length === 0) {
|
|
146
|
+
throw invalidRequest(this.provider, request.model, 'Gemini content must not be empty')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return this.generateTextLike(request, [{ role: 'user', parts: toGeminiParts(request.content) }])
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async generateChat(request: AIProviderChatRequest): Promise<AIResult<AIChatOutput>> {
|
|
153
|
+
assertRequestBase(request, this.provider)
|
|
154
|
+
validateChat(request, this.provider)
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const response = await this.client.models.generateContent({
|
|
158
|
+
model: request.model,
|
|
159
|
+
contents: toGeminiMessages(request.messages, this.provider, request.model),
|
|
160
|
+
config: buildGenerationConfig(request, buildToolConfig(request, this.provider)),
|
|
161
|
+
})
|
|
162
|
+
const toolCalls = extractToolCalls(
|
|
163
|
+
response,
|
|
164
|
+
request.tools ?? [],
|
|
165
|
+
this.provider,
|
|
166
|
+
request.model,
|
|
167
|
+
)
|
|
168
|
+
const text = extractText(response)
|
|
169
|
+
const finishReason = normalizeFinishReason(response, toolCalls.length > 0)
|
|
170
|
+
|
|
171
|
+
if (finishReason === 'content_filter') {
|
|
172
|
+
throw contentFiltered(this.provider, response.modelVersion ?? request.model, 'chat')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (text.trim().length === 0 && toolCalls.length === 0) {
|
|
176
|
+
throw emptyResponse(this.provider, request.model)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
output: { text, toolCalls },
|
|
181
|
+
...responseMetadata(response, this.provider, request.model, finishReason),
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw mapGeminiError(error, this.provider, request.model, request.signal)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async *streamChat(request: AIProviderChatRequest): AsyncIterable<AIStreamEvent> {
|
|
189
|
+
assertRequestBase(request, this.provider)
|
|
190
|
+
validateChat(request, this.provider)
|
|
191
|
+
|
|
192
|
+
let emitted = false
|
|
193
|
+
let text = ''
|
|
194
|
+
const toolCalls: AIToolCall[] = []
|
|
195
|
+
const seenToolCallIds = new Set<string>()
|
|
196
|
+
let lastResponse: GenerateContentResponse | undefined
|
|
197
|
+
let promptBlocked = false
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const stream = await this.client.models.generateContentStream({
|
|
201
|
+
model: request.model,
|
|
202
|
+
contents: toGeminiMessages(request.messages, this.provider, request.model),
|
|
203
|
+
config: buildGenerationConfig(request, buildToolConfig(request, this.provider)),
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
for await (const chunk of stream) {
|
|
207
|
+
lastResponse = chunk
|
|
208
|
+
promptBlocked ||= isPromptBlocked(chunk)
|
|
209
|
+
const delta = extractText(chunk)
|
|
210
|
+
if (delta.length > 0) {
|
|
211
|
+
emitted = true
|
|
212
|
+
text += delta
|
|
213
|
+
yield { type: 'text-delta', delta }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const chunkCalls = extractToolCalls(
|
|
217
|
+
chunk,
|
|
218
|
+
request.tools ?? [],
|
|
219
|
+
this.provider,
|
|
220
|
+
request.model,
|
|
221
|
+
toolCalls.length,
|
|
222
|
+
)
|
|
223
|
+
for (const toolCall of chunkCalls) {
|
|
224
|
+
if (seenToolCallIds.has(toolCall.id)) {
|
|
225
|
+
throw invalidToolArguments(this.provider, request.model)
|
|
226
|
+
}
|
|
227
|
+
seenToolCallIds.add(toolCall.id)
|
|
228
|
+
emitted = true
|
|
229
|
+
toolCalls.push(toolCall)
|
|
230
|
+
yield { type: 'tool-call', toolCall }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!lastResponse) {
|
|
235
|
+
throw new AIError({
|
|
236
|
+
code: 'invalid_response',
|
|
237
|
+
message: 'Gemini stream ended without a response',
|
|
238
|
+
provider: this.provider,
|
|
239
|
+
model: request.model,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const finishReason = promptBlocked
|
|
244
|
+
? 'content_filter'
|
|
245
|
+
: normalizeFinishReason(lastResponse, toolCalls.length > 0)
|
|
246
|
+
if (finishReason === 'content_filter') {
|
|
247
|
+
throw contentFiltered(this.provider, lastResponse.modelVersion ?? request.model, 'stream')
|
|
248
|
+
}
|
|
249
|
+
if (!emitted) {
|
|
250
|
+
throw emptyResponse(this.provider, request.model)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
yield {
|
|
254
|
+
type: 'finish',
|
|
255
|
+
result: {
|
|
256
|
+
output: { text, toolCalls },
|
|
257
|
+
...responseMetadata(lastResponse, this.provider, request.model, finishReason),
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const mapped = mapGeminiError(error, this.provider, request.model, request.signal)
|
|
262
|
+
if (emitted && mapped.code !== 'invalid_response' && mapped.code !== 'content_filtered') {
|
|
263
|
+
throw new AIError({
|
|
264
|
+
code: 'invalid_response',
|
|
265
|
+
message: 'Gemini stream was interrupted',
|
|
266
|
+
provider: this.provider,
|
|
267
|
+
model: request.model,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
throw mapped
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async generateObject(request: AIProviderObjectRequest<unknown>): Promise<AIResult<unknown>> {
|
|
275
|
+
assertRequestBase(request, this.provider)
|
|
276
|
+
if (typeof request.prompt !== 'string' || request.prompt.length === 0) {
|
|
277
|
+
throw invalidRequest(this.provider, request.model, 'Gemini prompt must not be empty')
|
|
278
|
+
}
|
|
279
|
+
if (
|
|
280
|
+
!isPlainRecord(request.schema.jsonSchema)
|
|
281
|
+
|| !isJsonValue(request.schema.jsonSchema)
|
|
282
|
+
|| !isSupportedAIJSONSchema(request.schema.jsonSchema)
|
|
283
|
+
) {
|
|
284
|
+
throw invalidRequest(this.provider, request.model, 'Gemini structured output schema is invalid')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const response = await this.client.models.generateContent({
|
|
289
|
+
model: request.model,
|
|
290
|
+
contents: request.prompt,
|
|
291
|
+
config: buildGenerationConfig(request, {
|
|
292
|
+
responseMimeType: 'application/json',
|
|
293
|
+
responseJsonSchema: request.schema.jsonSchema,
|
|
294
|
+
}),
|
|
295
|
+
})
|
|
296
|
+
const finishReason = normalizeFinishReason(response, false)
|
|
297
|
+
if (finishReason === 'content_filter') {
|
|
298
|
+
throw contentFiltered(this.provider, response.modelVersion ?? request.model, 'object')
|
|
299
|
+
}
|
|
300
|
+
const text = extractText(response)
|
|
301
|
+
if (text.trim().length === 0) throw emptyResponse(this.provider, request.model)
|
|
302
|
+
|
|
303
|
+
let decoded: unknown
|
|
304
|
+
try {
|
|
305
|
+
decoded = JSON.parse(text)
|
|
306
|
+
} catch {
|
|
307
|
+
throw structuredOutputError(this.provider, request.model)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
output: decoded,
|
|
312
|
+
...responseMetadata(response, this.provider, request.model),
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw mapGeminiError(error, this.provider, request.model, request.signal)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async embed(request: AIProviderEmbedRequest): Promise<AIResult<AIEmbeddingOutput>> {
|
|
320
|
+
assertRequestBase(request, this.provider)
|
|
321
|
+
if (!Array.isArray(request.input) || request.input.length === 0) {
|
|
322
|
+
throw invalidRequest(this.provider, request.model, 'Gemini embedding input must not be empty')
|
|
323
|
+
}
|
|
324
|
+
if (request.input.some((input) => typeof input !== 'string' || input.length === 0)) {
|
|
325
|
+
throw invalidRequest(this.provider, request.model, 'Gemini embedding inputs must be non-empty strings')
|
|
326
|
+
}
|
|
327
|
+
if (request.dimensions !== undefined && (!Number.isSafeInteger(request.dimensions) || request.dimensions <= 0)) {
|
|
328
|
+
throw invalidRequest(this.provider, request.model, 'Gemini embedding dimensions must be positive')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const response = await this.client.models.embedContent({
|
|
333
|
+
model: request.model,
|
|
334
|
+
contents: [...request.input],
|
|
335
|
+
config: {
|
|
336
|
+
abortSignal: request.signal,
|
|
337
|
+
httpOptions: requestHttpOptions(request.timeoutMs),
|
|
338
|
+
...(request.dimensions ? { outputDimensionality: request.dimensions } : {}),
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
const embeddings = response.embeddings?.map((embedding) => embedding.values)
|
|
342
|
+
if (
|
|
343
|
+
!embeddings
|
|
344
|
+
|| embeddings.length !== request.input.length
|
|
345
|
+
|| embeddings.some((values) => (
|
|
346
|
+
!Array.isArray(values)
|
|
347
|
+
|| values.length === 0
|
|
348
|
+
|| values.some((value) => typeof value !== 'number' || !Number.isFinite(value))
|
|
349
|
+
))
|
|
350
|
+
) {
|
|
351
|
+
throw new AIError({
|
|
352
|
+
code: 'invalid_response',
|
|
353
|
+
message: 'Gemini returned invalid embeddings',
|
|
354
|
+
provider: this.provider,
|
|
355
|
+
model: request.model,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
output: { embeddings: embeddings as number[][] },
|
|
361
|
+
provider: this.provider,
|
|
362
|
+
model: request.model,
|
|
363
|
+
usage: null,
|
|
364
|
+
finishReason: 'stop',
|
|
365
|
+
requestId: requestIdFromResponse(response),
|
|
366
|
+
}
|
|
367
|
+
} catch (error) {
|
|
368
|
+
throw mapGeminiError(error, this.provider, request.model, request.signal)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async generateTextLike(
|
|
373
|
+
request: AIProviderTextRequest | AIProviderContentRequest,
|
|
374
|
+
contents: GenerateContentParameters['contents'],
|
|
375
|
+
): Promise<AIResult<string>> {
|
|
376
|
+
try {
|
|
377
|
+
const response = await this.client.models.generateContent({
|
|
378
|
+
model: request.model,
|
|
379
|
+
contents,
|
|
380
|
+
config: buildGenerationConfig(request),
|
|
381
|
+
})
|
|
382
|
+
const finishReason = normalizeFinishReason(response, false)
|
|
383
|
+
if (finishReason === 'content_filter') {
|
|
384
|
+
throw contentFiltered(
|
|
385
|
+
this.provider,
|
|
386
|
+
response.modelVersion ?? request.model,
|
|
387
|
+
'prompt' in request ? 'text' : 'content',
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
const text = extractText(response)
|
|
391
|
+
if (text.trim().length === 0) throw emptyResponse(this.provider, request.model)
|
|
392
|
+
return {
|
|
393
|
+
output: text,
|
|
394
|
+
...responseMetadata(response, this.provider, request.model),
|
|
395
|
+
}
|
|
396
|
+
} catch (error) {
|
|
397
|
+
throw mapGeminiError(error, this.provider, request.model, request.signal)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function normalizeGeminiEndpoint(endpoint: string | undefined, provider: string): string | undefined {
|
|
403
|
+
if (endpoint === undefined) return undefined
|
|
404
|
+
|
|
405
|
+
let parsed: URL
|
|
406
|
+
try {
|
|
407
|
+
parsed = new URL(endpoint)
|
|
408
|
+
} catch {
|
|
409
|
+
throw new AIError({ code: 'endpoint_not_allowed', message: 'Gemini endpoint is not allowed', provider })
|
|
410
|
+
}
|
|
411
|
+
const expected = new URL(GEMINI_ENDPOINT)
|
|
412
|
+
if (
|
|
413
|
+
parsed.protocol !== 'https:'
|
|
414
|
+
|| parsed.username !== ''
|
|
415
|
+
|| parsed.password !== ''
|
|
416
|
+
|| parsed.origin !== expected.origin
|
|
417
|
+
|| (parsed.pathname !== '' && parsed.pathname !== '/')
|
|
418
|
+
|| parsed.search !== ''
|
|
419
|
+
|| parsed.hash !== ''
|
|
420
|
+
) {
|
|
421
|
+
throw new AIError({ code: 'endpoint_not_allowed', message: 'Gemini endpoint is not allowed', provider })
|
|
422
|
+
}
|
|
423
|
+
return parsed.origin
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function assertRequestBase(
|
|
427
|
+
request:
|
|
428
|
+
| AIProviderTextRequest
|
|
429
|
+
| AIProviderContentRequest
|
|
430
|
+
| AIProviderChatRequest
|
|
431
|
+
| AIProviderObjectRequest
|
|
432
|
+
| AIProviderEmbedRequest,
|
|
433
|
+
provider: string,
|
|
434
|
+
): void {
|
|
435
|
+
if (typeof request.model !== 'string' || request.model.trim().length === 0) {
|
|
436
|
+
throw invalidRequest(provider, '', 'Gemini model must not be empty')
|
|
437
|
+
}
|
|
438
|
+
if (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs <= 0) {
|
|
439
|
+
throw invalidRequest(provider, request.model, 'Gemini timeout must be a positive integer')
|
|
440
|
+
}
|
|
441
|
+
const maxOutputTokens = 'maxOutputTokens' in request ? request.maxOutputTokens : undefined
|
|
442
|
+
if (maxOutputTokens !== undefined && (
|
|
443
|
+
!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0
|
|
444
|
+
)) {
|
|
445
|
+
throw invalidRequest(provider, request.model, 'Gemini maxOutputTokens must be positive')
|
|
446
|
+
}
|
|
447
|
+
const temperature = 'temperature' in request ? request.temperature : undefined
|
|
448
|
+
if (temperature !== undefined && !Number.isFinite(temperature)) {
|
|
449
|
+
throw invalidRequest(provider, request.model, 'Gemini temperature must be finite')
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function buildGenerationConfig(
|
|
454
|
+
request: AIProviderTextRequest | AIProviderContentRequest | AIProviderChatRequest | AIProviderObjectRequest,
|
|
455
|
+
toolConfig: Partial<GenerateContentConfig> = {},
|
|
456
|
+
): GenerateContentConfig {
|
|
457
|
+
const systemMessages = 'messages' in request
|
|
458
|
+
? request.messages
|
|
459
|
+
.filter((message) => message.role === 'system')
|
|
460
|
+
.map((message) => message.content)
|
|
461
|
+
: []
|
|
462
|
+
const systemInstruction = [request.systemInstruction, ...systemMessages]
|
|
463
|
+
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
|
464
|
+
.join('\n\n')
|
|
465
|
+
return {
|
|
466
|
+
abortSignal: request.signal,
|
|
467
|
+
httpOptions: requestHttpOptions(request.timeoutMs),
|
|
468
|
+
...(systemInstruction ? { systemInstruction } : {}),
|
|
469
|
+
...(request.maxOutputTokens !== undefined ? { maxOutputTokens: request.maxOutputTokens } : {}),
|
|
470
|
+
...(request.temperature !== undefined ? { temperature: request.temperature } : {}),
|
|
471
|
+
...(request.topP !== undefined ? { topP: request.topP } : {}),
|
|
472
|
+
...(request.stopSequences !== undefined ? { stopSequences: [...request.stopSequences] } : {}),
|
|
473
|
+
...(request.disableThinking ? { thinkingConfig: { thinkingBudget: 0 } } : {}),
|
|
474
|
+
...(request.responseFormat === 'json' ? { responseMimeType: 'application/json' } : {}),
|
|
475
|
+
...toolConfig,
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function requestHttpOptions(timeoutMs: number): NonNullable<GenerateContentConfig['httpOptions']> {
|
|
480
|
+
return {
|
|
481
|
+
timeout: timeoutMs,
|
|
482
|
+
// Retry belongs to the shared runtime. Disable the SDK's own retry loop so
|
|
483
|
+
// attempts, cancellation and stream replay rules remain observable.
|
|
484
|
+
retryOptions: { attempts: 1 },
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function buildToolConfig(
|
|
489
|
+
request: AIProviderChatRequest,
|
|
490
|
+
provider: string,
|
|
491
|
+
): Partial<GenerateContentConfig> {
|
|
492
|
+
const declarations = request.tools ?? []
|
|
493
|
+
if (declarations.length > 512) {
|
|
494
|
+
throw invalidRequest(provider, request.model, 'Gemini accepts at most 512 tools')
|
|
495
|
+
}
|
|
496
|
+
for (const declaration of declarations) validateToolDeclaration(declaration, provider, request.model)
|
|
497
|
+
|
|
498
|
+
const choice = request.toolChoice ?? 'auto'
|
|
499
|
+
if ((choice === 'required' || typeof choice === 'object') && declarations.length === 0) {
|
|
500
|
+
throw invalidRequest(provider, request.model, 'Gemini tool choice requires declared tools')
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const tools = declarations.length > 0
|
|
504
|
+
? [{
|
|
505
|
+
functionDeclarations: declarations.map((declaration) => ({
|
|
506
|
+
name: declaration.name,
|
|
507
|
+
...(declaration.description ? { description: declaration.description } : {}),
|
|
508
|
+
parametersJsonSchema: declaration.parameters,
|
|
509
|
+
})),
|
|
510
|
+
}]
|
|
511
|
+
: undefined
|
|
512
|
+
|
|
513
|
+
const functionCallingConfig = toGeminiToolChoice(choice, declarations, provider, request.model)
|
|
514
|
+
return {
|
|
515
|
+
...(tools ? { tools } : {}),
|
|
516
|
+
...(tools || choice !== 'auto' ? { toolConfig: { functionCallingConfig } } : {}),
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function toGeminiToolChoice(
|
|
521
|
+
choice: AIToolChoice,
|
|
522
|
+
declarations: readonly AIToolDefinition[],
|
|
523
|
+
provider: string,
|
|
524
|
+
model: string,
|
|
525
|
+
) {
|
|
526
|
+
if (choice === 'none') return { mode: FunctionCallingConfigMode.NONE }
|
|
527
|
+
if (choice === 'required') return { mode: FunctionCallingConfigMode.ANY }
|
|
528
|
+
if (choice === 'auto') return { mode: FunctionCallingConfigMode.AUTO }
|
|
529
|
+
|
|
530
|
+
if (
|
|
531
|
+
typeof choice.name !== 'string'
|
|
532
|
+
|| !declarations.some((declaration) => declaration.name === choice.name)
|
|
533
|
+
) {
|
|
534
|
+
throw invalidRequest(provider, model, 'Gemini named tool choice is not declared')
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
mode: FunctionCallingConfigMode.ANY,
|
|
538
|
+
allowedFunctionNames: [choice.name],
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function validateToolDeclaration(declaration: AIToolDefinition, provider: string, model: string): void {
|
|
543
|
+
if (!TOOL_NAME_PATTERN.test(declaration.name)) {
|
|
544
|
+
throw invalidRequest(provider, model, 'Gemini tool name is invalid')
|
|
545
|
+
}
|
|
546
|
+
if (
|
|
547
|
+
!isPlainRecord(declaration.parameters)
|
|
548
|
+
|| !isJsonValue(declaration.parameters)
|
|
549
|
+
|| !isSupportedAIJSONSchema(declaration.parameters)
|
|
550
|
+
) {
|
|
551
|
+
throw invalidRequest(provider, model, 'Gemini tool schema must be a JSON object')
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function validateChat(request: AIProviderChatRequest, provider: string): void {
|
|
556
|
+
if (!Array.isArray(request.messages) || request.messages.length === 0) {
|
|
557
|
+
throw invalidRequest(provider, request.model, 'Gemini chat messages must not be empty')
|
|
558
|
+
}
|
|
559
|
+
const last = request.messages.at(-1)
|
|
560
|
+
if (last?.role !== 'user' && last?.role !== 'tool') {
|
|
561
|
+
throw invalidRequest(provider, request.model, 'Gemini chat must end with a user or tool message')
|
|
562
|
+
}
|
|
563
|
+
buildToolConfig(request, provider)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function toGeminiParts(parts: readonly AIContentPart[]): Part[] {
|
|
567
|
+
return parts.map((part) => part.type === 'text'
|
|
568
|
+
? { text: part.text }
|
|
569
|
+
: { inlineData: { mimeType: part.mimeType, data: part.data } })
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function toGeminiMessages(
|
|
573
|
+
messages: readonly AIMessage[],
|
|
574
|
+
provider: string,
|
|
575
|
+
model: string,
|
|
576
|
+
): GenerateContentParameters['contents'] {
|
|
577
|
+
const toolNames = new Map<string, string>()
|
|
578
|
+
for (const message of messages) {
|
|
579
|
+
if (message.role !== 'assistant') continue
|
|
580
|
+
for (const call of message.toolCalls ?? []) toolNames.set(call.id, call.name)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const contents: Content[] = []
|
|
584
|
+
for (const message of messages) {
|
|
585
|
+
if (message.role === 'system') continue
|
|
586
|
+
if (message.role === 'user') {
|
|
587
|
+
const parts = typeof message.content === 'string'
|
|
588
|
+
? [{ text: message.content }]
|
|
589
|
+
: toGeminiParts(message.content)
|
|
590
|
+
if (parts.length === 0) throw invalidRequest(provider, model, 'Gemini user message must not be empty')
|
|
591
|
+
contents.push({ role: 'user', parts })
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (message.role === 'assistant') {
|
|
596
|
+
const parts: Part[] = []
|
|
597
|
+
if (message.content.length > 0) parts.push({ text: message.content })
|
|
598
|
+
for (const call of message.toolCalls ?? []) {
|
|
599
|
+
if (!TOOL_NAME_PATTERN.test(call.name) || !isPlainRecord(call.arguments) || !isJsonValue(call.arguments)) {
|
|
600
|
+
throw invalidRequest(provider, model, 'Gemini assistant tool call is invalid')
|
|
601
|
+
}
|
|
602
|
+
parts.push({
|
|
603
|
+
functionCall: {
|
|
604
|
+
...(call.id ? { id: call.id } : {}),
|
|
605
|
+
name: call.name,
|
|
606
|
+
args: call.arguments as Record<string, unknown>,
|
|
607
|
+
},
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
if (parts.length === 0) throw invalidRequest(provider, model, 'Gemini assistant message must not be empty')
|
|
611
|
+
contents.push({ role: 'model', parts })
|
|
612
|
+
continue
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const toolName = message.toolName ?? toolNames.get(message.toolCallId)
|
|
616
|
+
if (!toolName || !TOOL_NAME_PATTERN.test(toolName) || message.toolCallId.length === 0) {
|
|
617
|
+
throw invalidRequest(provider, model, 'Gemini tool result is invalid')
|
|
618
|
+
}
|
|
619
|
+
contents.push({
|
|
620
|
+
role: 'user',
|
|
621
|
+
parts: [{
|
|
622
|
+
functionResponse: {
|
|
623
|
+
id: message.toolCallId,
|
|
624
|
+
name: toolName,
|
|
625
|
+
response: { output: parseToolResult(message.content) },
|
|
626
|
+
},
|
|
627
|
+
}],
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
return contents
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function parseToolResult(content: string): unknown {
|
|
634
|
+
try {
|
|
635
|
+
const value: unknown = JSON.parse(content)
|
|
636
|
+
return isJsonValue(value) ? value : content
|
|
637
|
+
} catch {
|
|
638
|
+
return content
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function extractText(response: GenerateContentResponse): string {
|
|
643
|
+
const parts = response.candidates?.[0]?.content?.parts ?? []
|
|
644
|
+
return parts
|
|
645
|
+
.filter((part) => !part.thought && typeof part.text === 'string')
|
|
646
|
+
.map((part) => part.text as string)
|
|
647
|
+
.join('')
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function extractToolCalls(
|
|
651
|
+
response: GenerateContentResponse,
|
|
652
|
+
declarations: readonly AIToolDefinition[],
|
|
653
|
+
provider: string,
|
|
654
|
+
model: string,
|
|
655
|
+
indexOffset = 0,
|
|
656
|
+
): AIToolCall[] {
|
|
657
|
+
const byName = new Map(declarations.map((declaration) => [declaration.name, declaration]))
|
|
658
|
+
const nativeCalls = (response.candidates?.[0]?.content?.parts ?? [])
|
|
659
|
+
.map((part) => part.functionCall)
|
|
660
|
+
.filter((call): call is NonNullable<typeof call> => call !== undefined)
|
|
661
|
+
|
|
662
|
+
return nativeCalls.map((call, index) => {
|
|
663
|
+
if (
|
|
664
|
+
typeof call.name !== 'string'
|
|
665
|
+
|| !TOOL_NAME_PATTERN.test(call.name)
|
|
666
|
+
|| !isPlainRecord(call.args)
|
|
667
|
+
|| !isJsonValue(call.args)
|
|
668
|
+
|| (call.partialArgs?.length ?? 0) > 0
|
|
669
|
+
) {
|
|
670
|
+
throw invalidToolArguments(provider, model)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const declaration = byName.get(call.name)
|
|
674
|
+
if (!declaration || !matchesAIJSONSchema(call.args, declaration.parameters)) {
|
|
675
|
+
throw invalidToolArguments(provider, model)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
id: typeof call.id === 'string' && call.id.length > 0
|
|
680
|
+
? call.id
|
|
681
|
+
: `gemini_${call.name}_${indexOffset + index}`,
|
|
682
|
+
name: call.name,
|
|
683
|
+
arguments: call.args,
|
|
684
|
+
}
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function responseMetadata(
|
|
689
|
+
response: GenerateContentResponse,
|
|
690
|
+
provider: string,
|
|
691
|
+
requestedModel: string,
|
|
692
|
+
finishReason = normalizeFinishReason(response, false),
|
|
693
|
+
): Omit<AIResult<never>, 'output'> {
|
|
694
|
+
return {
|
|
695
|
+
provider,
|
|
696
|
+
model: nonEmpty(response.modelVersion) ?? requestedModel,
|
|
697
|
+
usage: normalizeUsage(response),
|
|
698
|
+
finishReason,
|
|
699
|
+
providerFinishReason: response.candidates?.[0]?.finishReason,
|
|
700
|
+
requestId: nonEmpty(response.responseId) ?? requestIdFromResponse(response),
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function normalizeUsage(response: GenerateContentResponse): AIUsage | null {
|
|
705
|
+
const usage = response.usageMetadata
|
|
706
|
+
if (!usage) return null
|
|
707
|
+
|
|
708
|
+
const reportedInputTokens = tokenCount(usage.promptTokenCount)
|
|
709
|
+
const reportedOutputTokens = tokenCount(usage.candidatesTokenCount)
|
|
710
|
+
const reportedTotalTokens = tokenCount(usage.totalTokenCount)
|
|
711
|
+
const cachedInputTokens = tokenCount(usage.cachedContentTokenCount)
|
|
712
|
+
const reasoningTokens = tokenCount(usage.thoughtsTokenCount)
|
|
713
|
+
const toolInputTokens = tokenCount(usage.toolUsePromptTokenCount)
|
|
714
|
+
if (
|
|
715
|
+
reportedInputTokens === undefined
|
|
716
|
+
|| reportedOutputTokens === undefined
|
|
717
|
+
|| reportedTotalTokens === undefined
|
|
718
|
+
|| (usage.cachedContentTokenCount !== undefined && cachedInputTokens === undefined)
|
|
719
|
+
|| (usage.thoughtsTokenCount !== undefined && reasoningTokens === undefined)
|
|
720
|
+
|| (usage.toolUsePromptTokenCount !== undefined && toolInputTokens === undefined)
|
|
721
|
+
) return null
|
|
722
|
+
|
|
723
|
+
// Gemini reports thought tokens and tool-result prompt tokens outside the
|
|
724
|
+
// visible candidate/prompt buckets. Fold them into the billable neutral
|
|
725
|
+
// input/output totals while retaining reasoning as an output subset.
|
|
726
|
+
const inputTokens = reportedInputTokens + (toolInputTokens ?? 0)
|
|
727
|
+
const outputTokens = reportedOutputTokens + (reasoningTokens ?? 0)
|
|
728
|
+
const totalTokens = reportedTotalTokens
|
|
729
|
+
if (
|
|
730
|
+
!Number.isSafeInteger(inputTokens)
|
|
731
|
+
|| !Number.isSafeInteger(outputTokens)
|
|
732
|
+
|| totalTokens < inputTokens + outputTokens
|
|
733
|
+
|| (cachedInputTokens ?? 0) > inputTokens
|
|
734
|
+
) return null
|
|
735
|
+
return {
|
|
736
|
+
inputTokens,
|
|
737
|
+
outputTokens,
|
|
738
|
+
totalTokens,
|
|
739
|
+
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
|
|
740
|
+
...(cachedInputTokens !== undefined
|
|
741
|
+
? { cacheMissInputTokens: Math.max(0, inputTokens - cachedInputTokens) }
|
|
742
|
+
: {}),
|
|
743
|
+
...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function normalizeFinishReason(response: GenerateContentResponse, hasToolCalls: boolean): AIFinishReason {
|
|
748
|
+
if (isPromptBlocked(response)) return 'content_filter'
|
|
749
|
+
if (hasToolCalls) return 'tool_calls'
|
|
750
|
+
switch (response.candidates?.[0]?.finishReason) {
|
|
751
|
+
case 'STOP':
|
|
752
|
+
return 'stop'
|
|
753
|
+
case 'MAX_TOKENS':
|
|
754
|
+
return 'length'
|
|
755
|
+
case 'SAFETY':
|
|
756
|
+
case 'RECITATION':
|
|
757
|
+
case 'LANGUAGE':
|
|
758
|
+
case 'BLOCKLIST':
|
|
759
|
+
case 'PROHIBITED_CONTENT':
|
|
760
|
+
case 'SPII':
|
|
761
|
+
case 'IMAGE_SAFETY':
|
|
762
|
+
case 'IMAGE_PROHIBITED_CONTENT':
|
|
763
|
+
case 'IMAGE_RECITATION':
|
|
764
|
+
return 'content_filter'
|
|
765
|
+
default:
|
|
766
|
+
return 'other'
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function isPromptBlocked(response: GenerateContentResponse): boolean {
|
|
771
|
+
const reason = response.promptFeedback?.blockReason
|
|
772
|
+
return typeof reason === 'string'
|
|
773
|
+
&& reason.length > 0
|
|
774
|
+
&& reason !== 'BLOCKED_REASON_UNSPECIFIED'
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function requestIdFromResponse(response: GenerateContentResponse | EmbedContentResponse): string | undefined {
|
|
778
|
+
const headers = response.sdkHttpResponse?.headers
|
|
779
|
+
if (!headers) return undefined
|
|
780
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
781
|
+
const normalized = name.toLowerCase()
|
|
782
|
+
if ((normalized === 'x-request-id' || normalized === 'x-goog-request-id') && value.length > 0) {
|
|
783
|
+
return value
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return undefined
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function mapGeminiError(
|
|
790
|
+
error: unknown,
|
|
791
|
+
provider: string,
|
|
792
|
+
model: string,
|
|
793
|
+
signal: AbortSignal,
|
|
794
|
+
): AIError {
|
|
795
|
+
if (error instanceof AIError) return error
|
|
796
|
+
|
|
797
|
+
if (signal.aborted) {
|
|
798
|
+
if (signal.reason instanceof AIError) return signal.reason
|
|
799
|
+
const timedOut = isNamedError(signal.reason, 'TimeoutError')
|
|
800
|
+
return new AIError({
|
|
801
|
+
code: timedOut ? 'timeout' : 'aborted',
|
|
802
|
+
message: timedOut ? 'Gemini request timed out' : 'Gemini request was aborted',
|
|
803
|
+
provider,
|
|
804
|
+
model,
|
|
805
|
+
retryable: timedOut,
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (isNamedError(error, 'AbortError')) {
|
|
810
|
+
return new AIError({ code: 'aborted', message: 'Gemini request was aborted', provider, model })
|
|
811
|
+
}
|
|
812
|
+
if (isNamedError(error, 'TimeoutError')) {
|
|
813
|
+
return new AIError({
|
|
814
|
+
code: 'timeout',
|
|
815
|
+
message: 'Gemini request timed out',
|
|
816
|
+
provider,
|
|
817
|
+
model,
|
|
818
|
+
retryable: true,
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const status = error instanceof ApiError
|
|
823
|
+
? error.status
|
|
824
|
+
: isPlainRecord(error) && typeof error.status === 'number'
|
|
825
|
+
? error.status
|
|
826
|
+
: undefined
|
|
827
|
+
if (status !== undefined) {
|
|
828
|
+
if (status === 400 || status === 404 || status === 422) {
|
|
829
|
+
return new AIError({
|
|
830
|
+
code: 'invalid_request',
|
|
831
|
+
message: 'Gemini rejected the request',
|
|
832
|
+
provider,
|
|
833
|
+
model,
|
|
834
|
+
status,
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
return aiErrorFromHttpStatus(status, { provider, model })
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return new AIError({
|
|
841
|
+
code: 'network',
|
|
842
|
+
message: 'Gemini provider request failed',
|
|
843
|
+
provider,
|
|
844
|
+
model,
|
|
845
|
+
retryable: true,
|
|
846
|
+
})
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function invalidRequest(provider: string, model: string, message: string): AIError {
|
|
850
|
+
return new AIError({ code: 'invalid_request', message, provider, ...(model ? { model } : {}) })
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function invalidToolArguments(provider: string, model: string): AIError {
|
|
854
|
+
return new AIError({
|
|
855
|
+
code: 'invalid_response',
|
|
856
|
+
message: 'Gemini returned invalid tool arguments',
|
|
857
|
+
provider,
|
|
858
|
+
model,
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function emptyResponse(provider: string, model: string): AIError {
|
|
863
|
+
return new AIError({
|
|
864
|
+
code: 'invalid_response',
|
|
865
|
+
message: 'Gemini returned an empty response',
|
|
866
|
+
provider,
|
|
867
|
+
model,
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function contentFiltered(provider: string, model: string, operation: AIOperation): AIError {
|
|
872
|
+
return new AIError({
|
|
873
|
+
code: 'content_filtered',
|
|
874
|
+
message: 'AI provider blocked the response for safety reasons',
|
|
875
|
+
provider,
|
|
876
|
+
model,
|
|
877
|
+
operation,
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function structuredOutputError(provider: string, model: string): AIError {
|
|
882
|
+
return new AIError({
|
|
883
|
+
code: 'invalid_response',
|
|
884
|
+
message: 'Gemini returned invalid structured output',
|
|
885
|
+
provider,
|
|
886
|
+
model,
|
|
887
|
+
})
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
891
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function tokenCount(value: number | undefined): number | undefined {
|
|
895
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function isNamedError(value: unknown, name: string): boolean {
|
|
899
|
+
return value instanceof Error && value.name === name
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
903
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
904
|
+
const prototype = Object.getPrototypeOf(value)
|
|
905
|
+
return prototype === Object.prototype || prototype === null
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function isJsonValue(value: unknown, seen = new WeakSet<object>()): boolean {
|
|
909
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
|
|
910
|
+
if (typeof value === 'number') return Number.isFinite(value)
|
|
911
|
+
if (typeof value !== 'object') return false
|
|
912
|
+
if (seen.has(value)) return false
|
|
913
|
+
seen.add(value)
|
|
914
|
+
const valid = Array.isArray(value)
|
|
915
|
+
? value.every((item) => isJsonValue(item, seen))
|
|
916
|
+
: isPlainRecord(value) && Object.values(value).every((item) => isJsonValue(item, seen))
|
|
917
|
+
seen.delete(value)
|
|
918
|
+
return valid
|
|
919
|
+
}
|