@gullabs/google 0.2.0 → 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/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { assertNever, LlmError, classifyError, redactSecrets } from '@gullabs/core';
2
- import { ZodOptional, ZodNullable, ZodDefault, ZodObject, ZodString, ZodNumber, ZodBoolean, ZodArray, ZodEnum, ZodLiteral } from 'zod';
1
+ import { LlmError, EFFORT_BUDGET, assertNever, classifyError, redactSecrets } from '@gullabs/core';
3
2
 
4
3
  // src/adapter.ts
5
4
 
6
5
  // src/client.ts
7
6
  var FLEX_DEFAULT_TIMEOUT_MS = 15e5;
7
+ var STANDARD_DEFAULT_TIMEOUT_MS = 3e5;
8
8
  var TRANSPORT_TIMEOUT_BUFFER_MS = 5e3;
9
9
  async function buildGoogleClient(auth) {
10
10
  const { GoogleGenAI } = await import('@google/genai');
@@ -18,130 +18,37 @@ async function buildGoogleClient(auth) {
18
18
  }
19
19
  };
20
20
  }
21
- function isOptionalField(schema) {
22
- if (schema instanceof ZodOptional || schema instanceof ZodDefault) return true;
23
- if (schema instanceof ZodNullable) {
24
- const unwrapped = schema.unwrap();
25
- return isOptionalField(unwrapped);
26
- }
27
- return false;
28
- }
29
- function zodToGeminiSchema(schema) {
30
- return convertSchema(schema, false);
31
- }
32
- function convertSchema(schema, nullable) {
33
- const desc = typeof schema.description === "string" ? schema.description : void 0;
34
- if (schema instanceof ZodOptional) {
35
- const unwrapped = schema.unwrap();
36
- return convertSchema(unwrapped, nullable);
37
- }
38
- if (schema instanceof ZodNullable) {
39
- const unwrapped = schema.unwrap();
40
- return convertSchema(unwrapped, true);
41
- }
42
- if (schema instanceof ZodDefault) {
43
- const innerType = schema._def.innerType;
44
- return convertSchema(innerType, nullable);
45
- }
46
- if (schema instanceof ZodObject) {
47
- const shape = schema.shape;
48
- const properties = {};
49
- const required = [];
50
- for (const [key, fieldType] of Object.entries(shape)) {
51
- const fieldSchema = convertSchema(fieldType, false);
52
- if (fieldSchema === void 0) return void 0;
53
- properties[key] = fieldSchema;
54
- if (!isOptionalField(fieldType)) {
55
- required.push(key);
56
- }
57
- }
58
- const result = {
59
- type: "object",
60
- properties,
61
- ...required.length > 0 ? { required } : {},
62
- ...desc !== void 0 ? { description: desc } : {},
63
- ...nullable ? { nullable: true } : {}
64
- };
65
- return result;
66
- }
67
- if (schema instanceof ZodString) {
68
- return {
69
- type: "string",
70
- ...desc !== void 0 ? { description: desc } : {},
71
- ...nullable ? { nullable: true } : {}
72
- };
73
- }
74
- if (schema instanceof ZodNumber) {
75
- const checks = schema._def.checks ?? [];
76
- const isInt = checks.some((c) => c.kind === "int");
77
- return {
78
- type: isInt ? "integer" : "number",
79
- ...desc !== void 0 ? { description: desc } : {},
80
- ...nullable ? { nullable: true } : {}
81
- };
82
- }
83
- if (schema instanceof ZodBoolean) {
84
- return {
85
- type: "boolean",
86
- ...desc !== void 0 ? { description: desc } : {},
87
- ...nullable ? { nullable: true } : {}
88
- };
89
- }
90
- if (schema instanceof ZodArray) {
91
- const element = schema.element;
92
- const items = convertSchema(element, false);
93
- if (items === void 0) return void 0;
94
- return {
95
- type: "array",
96
- items,
97
- ...desc !== void 0 ? { description: desc } : {},
98
- ...nullable ? { nullable: true } : {}
99
- };
100
- }
101
- if (schema instanceof ZodEnum) {
102
- return {
103
- type: "string",
104
- enum: schema.options,
105
- ...desc !== void 0 ? { description: desc } : {},
106
- ...nullable ? { nullable: true } : {}
107
- };
108
- }
109
- if (schema instanceof ZodLiteral) {
110
- const val = schema.value;
111
- if (typeof val === "string") {
112
- return {
113
- type: "string",
114
- enum: [val],
115
- ...desc !== void 0 ? { description: desc } : {},
116
- ...nullable ? { nullable: true } : {}
117
- };
118
- }
119
- if (typeof val === "number") {
120
- return {
121
- type: "number",
122
- ...desc !== void 0 ? { description: desc } : {},
123
- ...nullable ? { nullable: true } : {}
124
- };
125
- }
126
- if (typeof val === "boolean") {
127
- return {
128
- type: "boolean",
129
- ...desc !== void 0 ? { description: desc } : {},
130
- ...nullable ? { nullable: true } : {}
131
- };
132
- }
133
- return void 0;
21
+
22
+ // src/flex-fallback.ts
23
+ var CAPACITY_PATTERNS = [
24
+ /capacity/i,
25
+ /overload/i,
26
+ /overloaded/i,
27
+ /unavailable/i,
28
+ /no\s+capacity/i,
29
+ /temporar(?:y|ily)/i,
30
+ /try\s+again/i
31
+ ];
32
+ var QUOTA_PATTERNS = [
33
+ /quota/i,
34
+ /billing/i,
35
+ /billable/i,
36
+ /payment/i,
37
+ /rate\s+limit/i,
38
+ /exceeded/i,
39
+ /insufficient/i
40
+ ];
41
+ function isGeminiCapacityError(err) {
42
+ if (err.kind === "server") return err.httpStatus === 503;
43
+ if (err.kind !== "rate_limited") return false;
44
+ const message = err.message;
45
+ if (QUOTA_PATTERNS.some((pattern) => pattern.test(message))) {
46
+ return false;
134
47
  }
135
- return void 0;
48
+ return CAPACITY_PATTERNS.some((pattern) => pattern.test(message));
136
49
  }
137
50
 
138
51
  // src/adapter.ts
139
- var EFFORT_BUDGET = {
140
- none: 0,
141
- low: 1024,
142
- medium: 8192,
143
- high: 24576
144
- };
145
52
  function mapFinishReason(raw) {
146
53
  if (raw === void 0) return void 0;
147
54
  switch (raw) {
@@ -253,24 +160,45 @@ function geminiAdapter(opts) {
253
160
  if (genConfig.stopSequences !== void 0) {
254
161
  config.stopSequences = genConfig.stopSequences;
255
162
  }
256
- config.serviceTier = genConfig.serviceTier === "standard" ? "standard" : "flex";
163
+ const explicit = genConfig.serviceTier;
164
+ const supported = req.modelDescriptor?.capabilities?.serviceTiers;
165
+ if (explicit !== void 0) {
166
+ if (req.modelDescriptor !== void 0 && (supported === void 0 || !supported.includes(explicit))) {
167
+ throw new LlmError(
168
+ `serviceTier "${explicit}" is not supported for model "${model}".`,
169
+ { kind: "bad_request", retryable: false }
170
+ );
171
+ }
172
+ config.serviceTier = explicit;
173
+ } else {
174
+ if (req.modelDescriptor === void 0) {
175
+ config.serviceTier = "flex";
176
+ } else if (supported?.includes("flex") === true) {
177
+ config.serviceTier = "flex";
178
+ }
179
+ }
257
180
  const reasoning = genConfig.reasoning;
258
181
  if (reasoning !== void 0) {
259
182
  const reasoningApi = req.modelDescriptor?.capabilities?.reasoningApi;
183
+ if (reasoning.effort !== void 0 && reasoning.budgetTokens !== void 0) {
184
+ throw new LlmError(
185
+ `Provide either reasoning.effort or reasoning.budgetTokens, not both, for model "${model}".`,
186
+ { kind: "bad_request", retryable: false }
187
+ );
188
+ }
260
189
  if (reasoningApi === "budget") {
261
- const budget = reasoning.budgetTokens !== void 0 ? reasoning.budgetTokens : reasoning.effort !== void 0 ? EFFORT_BUDGET[reasoning.effort] ?? 0 : void 0;
190
+ const budget = reasoning.budgetTokens !== void 0 ? reasoning.budgetTokens : reasoning.effort !== void 0 ? EFFORT_BUDGET[reasoning.effort] : void 0;
262
191
  config.thinkingConfig = {
263
192
  ...budget !== void 0 ? { thinkingBudget: budget } : {},
264
193
  ...reasoning.includeThoughts === true ? { includeThoughts: true } : {}
265
194
  };
266
- if (reasoning.effort !== void 0 && reasoning.budgetTokens !== void 0) {
267
- warnings.push({
268
- type: "reasoning-mapping",
269
- quality: "approximate",
270
- details: "budgetTokens takes precedence over effort for thinkingBudget"
271
- });
272
- }
273
195
  } else if (reasoningApi === "level") {
196
+ if (reasoning.budgetTokens !== void 0) {
197
+ throw new LlmError(
198
+ `reasoning.budgetTokens is not supported for model "${model}" (it uses thinkingLevel, not thinkingBudget); use reasoning.effort instead.`,
199
+ { kind: "bad_request", retryable: false }
200
+ );
201
+ }
274
202
  let thinkingLevel;
275
203
  if (reasoning.effort !== void 0) {
276
204
  switch (reasoning.effort) {
@@ -290,73 +218,57 @@ function geminiAdapter(opts) {
290
218
  assertNever(reasoning.effort);
291
219
  }
292
220
  }
293
- if (reasoning.budgetTokens !== void 0) {
294
- warnings.push({
295
- type: "reasoning-mapping",
296
- quality: "approximate",
297
- details: "budgetTokens is not supported for gemini-3.x models; mapping effort to thinkingLevel instead"
298
- });
299
- }
300
221
  config.thinkingConfig = {
301
222
  ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
302
223
  ...reasoning.includeThoughts === true ? { includeThoughts: true } : {}
303
224
  };
304
225
  } else {
305
- warnings.push({
306
- type: "reasoning-mapping",
307
- quality: "unsupported",
308
- details: `thinkingConfig not mapped for model "${model}"; unknown generation`
309
- });
226
+ throw new LlmError(
227
+ `Model "${model}" does not support reasoning/thinkingConfig.`,
228
+ { kind: "bad_request", retryable: false }
229
+ );
310
230
  }
311
231
  }
312
- let outputSchemaRequested = false;
313
- if (req.outputSchema !== void 0) {
314
- outputSchemaRequested = true;
315
- config.responseMimeType = "application/json";
316
- if (req.outputSchema["~standard"].vendor === "zod") {
317
- const geminiSchema = zodToGeminiSchema(req.outputSchema);
318
- if (geminiSchema !== void 0) {
319
- config.responseSchema = geminiSchema;
320
- } else {
321
- warnings.push({
322
- type: "unsupported-setting",
323
- setting: "output.schema",
324
- details: "Could not convert Zod schema to Gemini responseSchema; proceeding with responseMimeType only. Engine will still validate."
325
- });
326
- }
327
- } else {
328
- warnings.push({
329
- type: "other",
330
- message: `Native Gemini responseSchema conversion is not available for vendor "${req.outputSchema["~standard"].vendor}"; proceeding with responseMimeType only. Engine will validate output client-side via Standard Schema.`
331
- });
232
+ const structuredOutputRequested = req.outputJsonSchema !== void 0;
233
+ if (structuredOutputRequested) {
234
+ const nativeStructuredOutput = req.modelDescriptor?.capabilities?.nativeStructuredOutput !== false;
235
+ if (nativeStructuredOutput) {
236
+ config.responseMimeType = "application/json";
237
+ config.responseSchema = req.outputJsonSchema;
332
238
  }
333
239
  }
334
240
  const googleOpts = genConfig.providerOptions?.["google"];
335
241
  if (googleOpts !== void 0 && typeof googleOpts === "object" && googleOpts !== null) {
336
242
  Object.assign(config, googleOpts);
337
243
  }
244
+ const mergedServiceTier = config.serviceTier;
245
+ if (mergedServiceTier !== void 0 && req.modelDescriptor !== void 0) {
246
+ const supportedAfterMerge = req.modelDescriptor.capabilities?.serviceTiers;
247
+ if (supportedAfterMerge === void 0 || !supportedAfterMerge.includes(mergedServiceTier)) {
248
+ throw new LlmError(
249
+ `serviceTier "${mergedServiceTier}" is not supported for model "${model}".`,
250
+ { kind: "bad_request", retryable: false }
251
+ );
252
+ }
253
+ }
338
254
  if (req.modelDescriptor?.capabilities?.sampling === "fixed") {
339
- const droppedSampling = [];
255
+ const offendingSampling = [];
340
256
  if ("temperature" in config) {
341
- delete config.temperature;
342
- droppedSampling.push("temperature");
257
+ offendingSampling.push("temperature");
343
258
  }
344
259
  if ("topP" in config) {
345
- delete config.topP;
346
- droppedSampling.push("topP");
260
+ offendingSampling.push("topP");
347
261
  }
348
262
  if ("topK" in config) {
349
- delete config.topK;
350
- droppedSampling.push("topK");
263
+ offendingSampling.push("topK");
351
264
  }
352
- if (droppedSampling.length > 0) {
353
- warnings.push({
354
- type: "unsupported-setting",
355
- setting: droppedSampling.join(", "),
356
- details: `Sampling parameter(s) [${droppedSampling.join(
265
+ if (offendingSampling.length > 0) {
266
+ throw new LlmError(
267
+ `Sampling parameters [${offendingSampling.join(
357
268
  ", "
358
- )}] from providerOptions.google were stripped: this model has fixed sampling (Gemini 3.x) and the API rejects these fields.`
359
- });
269
+ )}] are not supported for model "${model}" (fixed sampling); they were supplied via providerOptions.google.`,
270
+ { kind: "bad_request", retryable: false }
271
+ );
360
272
  }
361
273
  }
362
274
  const configAsAny = config;
@@ -367,32 +279,41 @@ function geminiAdapter(opts) {
367
279
  }
368
280
  return false;
369
281
  });
370
- if (groundingRequested && req.outputSchema !== void 0) {
282
+ if (groundingRequested && structuredOutputRequested) {
371
283
  throw new LlmError(
372
- "Grounding (googleSearch) cannot be combined with structured output (output.schema) on Gemini; choose one.",
284
+ "Grounding (googleSearch) cannot be combined with structured output (output.jsonSchema) on Gemini; choose one.",
373
285
  { kind: "bad_request", retryable: false }
374
286
  );
375
287
  }
376
- let _flexTimeoutHandle;
377
- if (config.serviceTier === "flex" && genConfig.timeoutMs === void 0) {
378
- const flexController = new AbortController();
379
- const timeoutReason = new DOMException(
380
- `Flex timeout: call exceeded ${FLEX_DEFAULT_TIMEOUT_MS}ms client-side ceiling (@google/genai #1277 belt-and-suspenders)`,
381
- "TimeoutError"
382
- );
383
- _flexTimeoutHandle = setTimeout(() => {
384
- flexController.abort(timeoutReason);
385
- }, FLEX_DEFAULT_TIMEOUT_MS);
386
- if (ctx.signal !== void 0) {
387
- config.abortSignal = AbortSignal.any([flexController.signal, ctx.signal]);
388
- } else {
389
- config.abortSignal = flexController.signal;
288
+ let tierTimeoutHandle;
289
+ const clearTierTimeout = () => {
290
+ if (tierTimeoutHandle !== void 0) {
291
+ clearTimeout(tierTimeoutHandle);
292
+ tierTimeoutHandle = void 0;
390
293
  }
391
- } else if (ctx.signal !== void 0) {
392
- config.abortSignal = ctx.signal;
393
- }
294
+ };
295
+ const applyTierTimeout = (tier) => {
296
+ clearTierTimeout();
297
+ delete config.abortSignal;
298
+ const defaultTimeoutMs = genConfig.timeoutMs === void 0 ? tier === "flex" ? FLEX_DEFAULT_TIMEOUT_MS : tier === "standard" ? STANDARD_DEFAULT_TIMEOUT_MS : void 0 : void 0;
299
+ if (defaultTimeoutMs !== void 0) {
300
+ const tierController = new AbortController();
301
+ const tierLabel = tier === "standard" ? "Standard" : "Flex";
302
+ const timeoutReason = new DOMException(
303
+ `${tierLabel} timeout: call exceeded ${defaultTimeoutMs}ms client-side ceiling (@google/genai #1277 belt-and-suspenders)`,
304
+ "TimeoutError"
305
+ );
306
+ tierTimeoutHandle = setTimeout(() => {
307
+ tierController.abort(timeoutReason);
308
+ }, defaultTimeoutMs);
309
+ config.abortSignal = ctx.signal !== void 0 ? AbortSignal.any([tierController.signal, ctx.signal]) : tierController.signal;
310
+ } else if (ctx.signal !== void 0) {
311
+ config.abortSignal = ctx.signal;
312
+ }
313
+ };
314
+ applyTierTimeout(config.serviceTier);
394
315
  const callerHttpOptions = config.httpOptions;
395
- const computedTimeoutMs = genConfig.timeoutMs !== void 0 ? genConfig.timeoutMs + TRANSPORT_TIMEOUT_BUFFER_MS : genConfig.serviceTier === "flex" ? FLEX_DEFAULT_TIMEOUT_MS : void 0;
316
+ const computedTimeoutMs = genConfig.timeoutMs !== void 0 ? genConfig.timeoutMs + TRANSPORT_TIMEOUT_BUFFER_MS : config.serviceTier === "flex" ? FLEX_DEFAULT_TIMEOUT_MS : config.serviceTier === "standard" ? STANDARD_DEFAULT_TIMEOUT_MS : void 0;
396
317
  const mergedHttpOptions = {
397
318
  ...computedTimeoutMs !== void 0 ? { timeout: computedTimeoutMs } : {},
398
319
  ...callerHttpOptions
@@ -400,32 +321,71 @@ function geminiAdapter(opts) {
400
321
  if (Object.keys(mergedHttpOptions).length > 0) {
401
322
  config.httpOptions = mergedHttpOptions;
402
323
  }
403
- const params = {
404
- model,
405
- contents,
406
- config
407
- };
408
324
  let response;
409
- try {
325
+ let servedServiceTier = config.serviceTier;
326
+ const dispatch = async () => {
327
+ const dispatchConfig = {
328
+ ...config,
329
+ ...config.httpOptions !== void 0 ? { httpOptions: { ...config.httpOptions } } : {}
330
+ };
331
+ const params = {
332
+ model,
333
+ contents,
334
+ config: dispatchConfig
335
+ };
410
336
  const buildClient = opts?._clientFactory ?? buildGoogleClient;
411
337
  const client = opts?.client !== void 0 ? opts.client : await buildClient(ctx.auth);
412
338
  ctx.logger.debug(
413
- { model, configKeys: Object.keys(config) },
339
+ {
340
+ model,
341
+ configKeys: Object.keys(dispatchConfig),
342
+ serviceTier: dispatchConfig.serviceTier
343
+ },
414
344
  "llm.adapter.dispatch"
415
345
  );
416
- response = await client.models.generateContent(params);
346
+ return client.models.generateContent(params);
347
+ };
348
+ try {
349
+ response = await dispatch();
417
350
  } catch (rawErr) {
418
351
  const classified = classifyError(rawErr);
419
- throw new LlmError(classified.message, {
352
+ const typed = new LlmError(classified.message, {
420
353
  kind: classified.kind,
421
354
  retryable: classified.retryable,
422
355
  ...classified.httpStatus !== void 0 ? { httpStatus: classified.httpStatus } : {},
423
356
  ...classified.retryAfterMs !== void 0 ? { retryAfterMs: classified.retryAfterMs } : {},
424
357
  provider: "google",
425
- cause: classified.cause ?? rawErr
358
+ cause: classified.cause ?? rawErr,
359
+ ...servedServiceTier !== void 0 ? { servedServiceTier } : {}
426
360
  });
361
+ if (config.serviceTier === "flex" && genConfig.flexFallback !== false && isGeminiCapacityError(typed)) {
362
+ config.serviceTier = "standard";
363
+ servedServiceTier = "standard";
364
+ const fallbackTimeout = genConfig.timeoutMs !== void 0 ? genConfig.timeoutMs + TRANSPORT_TIMEOUT_BUFFER_MS : STANDARD_DEFAULT_TIMEOUT_MS;
365
+ config.httpOptions = {
366
+ timeout: fallbackTimeout,
367
+ ...callerHttpOptions
368
+ };
369
+ applyTierTimeout("standard");
370
+ try {
371
+ response = await dispatch();
372
+ } catch (fallbackRawErr) {
373
+ const fallbackClassified = classifyError(fallbackRawErr);
374
+ throw new LlmError(fallbackClassified.message, {
375
+ kind: fallbackClassified.kind,
376
+ retryable: fallbackClassified.retryable,
377
+ ...fallbackClassified.httpStatus !== void 0 ? { httpStatus: fallbackClassified.httpStatus } : {},
378
+ ...fallbackClassified.retryAfterMs !== void 0 ? { retryAfterMs: fallbackClassified.retryAfterMs } : {},
379
+ provider: "google",
380
+ cause: fallbackClassified.cause ?? fallbackRawErr,
381
+ servedServiceTier: "standard"
382
+ });
383
+ }
384
+ } else {
385
+ throw typed;
386
+ }
427
387
  } finally {
428
- if (_flexTimeoutHandle !== void 0) clearTimeout(_flexTimeoutHandle);
388
+ clearTierTimeout();
429
389
  }
430
390
  const hasBlockReason = response.promptFeedback?.blockReason !== void 0;
431
391
  const hasCandidates = response.candidates !== void 0 && response.candidates.length > 0;
@@ -468,7 +428,7 @@ function geminiAdapter(opts) {
468
428
  const text = textParts.join("");
469
429
  const reasoningText = thoughtParts.length > 0 ? thoughtParts.join("") : void 0;
470
430
  let rawStructured;
471
- if (outputSchemaRequested && text.length > 0) {
431
+ if (structuredOutputRequested && text.length > 0) {
472
432
  try {
473
433
  rawStructured = JSON.parse(text);
474
434
  } catch {
@@ -480,6 +440,7 @@ function geminiAdapter(opts) {
480
440
  model,
481
441
  usage,
482
442
  warnings,
443
+ ...servedServiceTier !== void 0 ? { servedServiceTier } : {},
483
444
  ...text.length > 0 ? { text } : {},
484
445
  ...reasoningText !== void 0 ? { reasoningText } : {},
485
446
  ...rawStructured !== void 0 ? { rawStructured } : {},
@@ -905,6 +866,6 @@ var GoogleCacheStore = class {
905
866
  }
906
867
  };
907
868
 
908
- export { FLEX_DEFAULT_TIMEOUT_MS, GoogleCacheStore, GoogleFileStore, TRANSPORT_TIMEOUT_BUFFER_MS, buildGoogleClient, geminiAdapter, zodToGeminiSchema };
869
+ export { FLEX_DEFAULT_TIMEOUT_MS, GoogleCacheStore, GoogleFileStore, STANDARD_DEFAULT_TIMEOUT_MS, TRANSPORT_TIMEOUT_BUFFER_MS, buildGoogleClient, geminiAdapter, isGeminiCapacityError };
909
870
  //# sourceMappingURL=index.js.map
910
871
  //# sourceMappingURL=index.js.map