ag-common 0.0.910 → 0.0.912

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.
@@ -45,6 +45,41 @@ const sortModelsByPreference = (models, prefer) => {
45
45
  return modelArray;
46
46
  };
47
47
  const getFetch = (dependencies) => dependencies?.fetch ?? globalThis.fetch;
48
+ /**
49
+ * Extract an HTTP status from a generation failure. The SDK surfaces ApiError
50
+ * with `status` plus a JSON body in `message`; REST failures arrive as
51
+ * Response objects or status-carrying records, so probe all shapes.
52
+ */
53
+ const generationFailureStatus = (error) => {
54
+ if (error instanceof Response)
55
+ return error.status;
56
+ if (typeof error !== "object" || error === null) {
57
+ return typeof error === "number" && Number.isInteger(error) ? error : undefined;
58
+ }
59
+ const record = error;
60
+ const candidates = [
61
+ record.status,
62
+ record.statusCode,
63
+ record.response?.status,
64
+ record.response?.statusCode,
65
+ record.error?.code,
66
+ ];
67
+ for (const candidate of candidates) {
68
+ const status = typeof candidate === "string" && /^\d+$/.test(candidate) ? Number(candidate) : candidate;
69
+ if (typeof status === "number" && Number.isInteger(status))
70
+ return status;
71
+ }
72
+ const nested = error.cause;
73
+ if (nested !== undefined && nested !== error)
74
+ return generationFailureStatus(nested);
75
+ const message = error instanceof Error ? error.message : undefined;
76
+ if (message !== undefined) {
77
+ const match = /"code"\s*:\s*(\d{3})/.exec(message) ?? /\bstatus\D{0,16}(\d{3})\b/i.exec(message);
78
+ if (match?.[1])
79
+ return Number(match[1]);
80
+ }
81
+ return undefined;
82
+ };
48
83
  const createAbortError = () => {
49
84
  const error = new Error("Google AI request aborted");
50
85
  error.name = "AbortError";
@@ -338,6 +373,11 @@ const generateGoogle = async (request, config = {}) => {
338
373
  const effectiveRequest = { ...request, signal: controller.signal };
339
374
  try {
340
375
  const requestConfig = toRequestConfig(effectiveRequest);
376
+ const staggerMs = Math.max(0, request.staggerMs ?? 0);
377
+ const sleep = config.dependencies?.sleep ??
378
+ ((ms) => new Promise((resolve) => {
379
+ setTimeout(resolve, ms);
380
+ }));
341
381
  const result = await (0, retryOnError_1.retryOnError)(`generation:${request.ident ?? "unknown"}`, async () => {
342
382
  const combinations = await getCombinations(request.model, request.prefer, request.output, controller.signal, config);
343
383
  if (combinations.length === 0)
@@ -377,18 +417,19 @@ const generateGoogle = async (request, config = {}) => {
377
417
  catch (error) {
378
418
  if (controller.signal.aborted)
379
419
  throw createAbortError();
380
- const status = error.status;
420
+ const failureStatus = generationFailureStatus(error);
381
421
  const message = error instanceof Error ? error.message : String(error);
382
- const rateLimited = status === 429 || (0, retryOnError_1.isOverloadedApiKeyError)(error);
383
- const unavailableModel = status === 404 && message.includes(selectedModel);
422
+ const rateLimited = failureStatus === 429 || (0, retryOnError_1.isOverloadedApiKeyError)(error);
423
+ const overloadedModel = failureStatus === 503 || failureStatus === 500 || failureStatus === 502;
424
+ const unavailableModel = failureStatus === 404 && message.includes(selectedModel);
384
425
  // Fallback only; per-model backoff below is keyed by rate limiting.
385
- if (rateLimited || unavailableModel) {
426
+ if (rateLimited || overloadedModel || unavailableModel) {
386
427
  (0, log_1.warn)("generation attempt failed; trying next available combination", {
387
428
  model: selectedModel,
388
- status,
429
+ status: failureStatus,
389
430
  });
390
431
  (0, apikey_1.blockKeyService)(key, `gemini-${selectedModel}`);
391
- if (rateLimited) {
432
+ if (rateLimited || overloadedModel) {
392
433
  (0, quota_1.recordModelRateLimit)(selectedModel, {
393
434
  cause: error,
394
435
  nowMs: config.dependencies?.now?.(),
@@ -401,7 +442,15 @@ const generateGoogle = async (request, config = {}) => {
401
442
  }
402
443
  }
403
444
  throw lastFailure ?? new Error("No available model for this request");
404
- }, 1, 5000, undefined, controller.signal);
445
+ }, {
446
+ retries: 1,
447
+ errorDelay: 5000,
448
+ initialDelayMs: staggerMs,
449
+ signal: controller.signal,
450
+ sleepFn: async (ms) => {
451
+ await sleep(ms);
452
+ },
453
+ });
405
454
  return result;
406
455
  }
407
456
  catch (error) {
@@ -114,6 +114,7 @@ const mergeRequest = (defaults, request) => ({
114
114
  onGenerated: request.onGenerated ?? defaults.onGenerated,
115
115
  signal: request.signal ?? defaults.signal,
116
116
  timeoutMs: request.timeoutMs ?? defaults.timeoutMs,
117
+ staggerMs: request.staggerMs ?? defaults.staggerMs,
117
118
  });
118
119
  const requestInput = async (options, dependencies, signal, timeoutMs) => {
119
120
  const images = options.images ?? [];
@@ -69,6 +69,12 @@ export type AIRequest = {
69
69
  onGenerated?: (metadata: GenerationMetadata) => void;
70
70
  signal?: AbortSignal;
71
71
  timeoutMs?: number;
72
+ /**
73
+ * Fixed pacing delay before the generation attempt, in ms. Lets bursty
74
+ * fan-out callers (e.g. a nightly job over N sites) stagger requests against
75
+ * low provider quotas without serializing the whole job. Defaults to 0.
76
+ */
77
+ staggerMs?: number;
72
78
  };
73
79
  export type BinaryMedia = {
74
80
  arraybuffer: ArrayBuffer;
@@ -1 +1,4 @@
1
1
  export * from "./apikey";
2
+ export { generate, generateText, models, promptDirect, promptImage } from "../ai/client";
3
+ export type { AIClient, AIClientDefaults, AIClientDependencies, AIEndpoint, AIInput, AIModel, AIModelPreference, AIPart, AIRequest, BinaryMedia, GenerateTextOptions, GenerationResult, PromptDirectOptions, PromptImageOptions, } from "../ai/types";
4
+ export { AI_QUOTA_EXHAUSTED_MESSAGE, QuotaDeferredError, clearModelBackoff, getQuotaRetryDelayMs, isModelBackedOff, isQuotaExhaustedError, modelBackoffRemainingMs, quotaBackoffDelayMs, recordModelRateLimit, resetModelBackoff, } from "../ai/quota";
@@ -14,4 +14,22 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.resetModelBackoff = exports.recordModelRateLimit = exports.quotaBackoffDelayMs = exports.modelBackoffRemainingMs = exports.isQuotaExhaustedError = exports.isModelBackedOff = exports.getQuotaRetryDelayMs = exports.clearModelBackoff = exports.QuotaDeferredError = exports.AI_QUOTA_EXHAUSTED_MESSAGE = exports.promptImage = exports.promptDirect = exports.models = exports.generateText = exports.generate = void 0;
17
18
  __exportStar(require("./apikey"), exports);
19
+ var client_1 = require("../ai/client");
20
+ Object.defineProperty(exports, "generate", { enumerable: true, get: function () { return client_1.generate; } });
21
+ Object.defineProperty(exports, "generateText", { enumerable: true, get: function () { return client_1.generateText; } });
22
+ Object.defineProperty(exports, "models", { enumerable: true, get: function () { return client_1.models; } });
23
+ Object.defineProperty(exports, "promptDirect", { enumerable: true, get: function () { return client_1.promptDirect; } });
24
+ Object.defineProperty(exports, "promptImage", { enumerable: true, get: function () { return client_1.promptImage; } });
25
+ var quota_1 = require("../ai/quota");
26
+ Object.defineProperty(exports, "AI_QUOTA_EXHAUSTED_MESSAGE", { enumerable: true, get: function () { return quota_1.AI_QUOTA_EXHAUSTED_MESSAGE; } });
27
+ Object.defineProperty(exports, "QuotaDeferredError", { enumerable: true, get: function () { return quota_1.QuotaDeferredError; } });
28
+ Object.defineProperty(exports, "clearModelBackoff", { enumerable: true, get: function () { return quota_1.clearModelBackoff; } });
29
+ Object.defineProperty(exports, "getQuotaRetryDelayMs", { enumerable: true, get: function () { return quota_1.getQuotaRetryDelayMs; } });
30
+ Object.defineProperty(exports, "isModelBackedOff", { enumerable: true, get: function () { return quota_1.isModelBackedOff; } });
31
+ Object.defineProperty(exports, "isQuotaExhaustedError", { enumerable: true, get: function () { return quota_1.isQuotaExhaustedError; } });
32
+ Object.defineProperty(exports, "modelBackoffRemainingMs", { enumerable: true, get: function () { return quota_1.modelBackoffRemainingMs; } });
33
+ Object.defineProperty(exports, "quotaBackoffDelayMs", { enumerable: true, get: function () { return quota_1.quotaBackoffDelayMs; } });
34
+ Object.defineProperty(exports, "recordModelRateLimit", { enumerable: true, get: function () { return quota_1.recordModelRateLimit; } });
35
+ Object.defineProperty(exports, "resetModelBackoff", { enumerable: true, get: function () { return quota_1.resetModelBackoff; } });
@@ -2,7 +2,25 @@ export declare const overloadedMessages: string[];
2
2
  export declare const retryableErrorMessages: string[];
3
3
  export declare const isOverloadedApiKeyError: (error: unknown) => boolean;
4
4
  export declare const isRetryableApiError: (error: unknown) => boolean;
5
+ export type RetryOnErrorOptions = {
6
+ /** Number of retries after the initial attempt. Defaults to 1. */
7
+ retries?: number;
8
+ /** Delay between attempts in ms. Defaults to 2000. */
9
+ errorDelay?: number;
10
+ /** Predicate deciding whether a failure is worth retrying. */
11
+ errorCheck?: (error: unknown) => boolean;
12
+ /** AbortSignal cancelling the backoff sleep and further attempts. */
13
+ signal?: AbortSignal;
14
+ /**
15
+ * Delay before each attempt after the first, in ms. It composes with
16
+ * errorDelay: the attempt waits for initialDelayMs plus errorDelay.
17
+ * Defaults to 0 (no pacing).
18
+ */
19
+ initialDelayMs?: number;
20
+ /** Sleep implementation, overridable in tests. */
21
+ sleepFn?: (ms: number) => Promise<unknown>;
22
+ };
5
23
  export declare function retryOnError<T>(
6
24
  /** so we can log retries with useful info */
7
- debugIdent: string, fn: () => Promise<T>, retries?: number, errorDelay?: number, errorCheck?: (error: unknown) => boolean, signal?: AbortSignal): Promise<T>;
25
+ debugIdent: string, fn: () => Promise<T>, retriesOrOptions?: number | RetryOnErrorOptions, errorDelay?: number, errorCheck?: (error: unknown) => boolean, signal?: AbortSignal): Promise<T>;
8
26
  export declare const sleep: (ms: number) => Promise<unknown>;
@@ -70,19 +70,51 @@ const awaitWithSignal = async (promise, signal) => {
70
70
  });
71
71
  return Promise.race([promise, abort]);
72
72
  };
73
+ const normalizeRetryOnErrorOptions = (retriesOrOptions, errorDelay, errorCheck, signal) => ({
74
+ retries: typeof retriesOrOptions === "number" ? retriesOrOptions : (retriesOrOptions?.retries ?? 1),
75
+ errorDelay: typeof retriesOrOptions === "number"
76
+ ? (errorDelay ?? 2000)
77
+ : (retriesOrOptions?.errorDelay ?? 2000),
78
+ errorCheck: typeof retriesOrOptions === "number"
79
+ ? (errorCheck ?? exports.isRetryableApiError)
80
+ : (retriesOrOptions?.errorCheck ?? exports.isRetryableApiError),
81
+ signal: typeof retriesOrOptions === "number" ? signal : (retriesOrOptions?.signal ?? signal),
82
+ initialDelayMs: typeof retriesOrOptions === "number" ? 0 : Math.max(0, retriesOrOptions?.initialDelayMs ?? 0),
83
+ sleepFn: typeof retriesOrOptions === "number" ? undefined : retriesOrOptions?.sleepFn,
84
+ });
73
85
  async function retryOnError(
74
86
  /** so we can log retries with useful info */
75
- debugIdent, fn, retries = 1, errorDelay = 2000, errorCheck = exports.isRetryableApiError, signal) {
87
+ debugIdent, fn, retriesOrOptions, errorDelay, errorCheck = exports.isRetryableApiError, signal) {
88
+ const options = normalizeRetryOnErrorOptions(retriesOrOptions, errorDelay, errorCheck, signal);
89
+ const sleepWithSignal = async (ms) => {
90
+ if (ms <= 0)
91
+ return;
92
+ if (options.sleepFn !== undefined) {
93
+ await options.sleepFn(ms);
94
+ return;
95
+ }
96
+ await awaitWithSignal((0, exports.sleep)(ms), options.signal);
97
+ };
98
+ // Pacing delay before attempts after the first: it spreads bursty fan-out
99
+ // (e.g. nightly jobs hitting a low free-tier quota) without changing the
100
+ // retry semantics for direct callers.
101
+ await sleepWithSignal(options.initialDelayMs);
76
102
  try {
77
103
  const result = await fn();
78
104
  return result;
79
105
  }
80
106
  catch (error) {
81
107
  const message = error instanceof Error ? error.message : String(error);
82
- if (retries > 0 && errorCheck(error)) {
83
- (0, log_1.info)(`Operation ${debugIdent} failed. Retrying after ${errorDelay}ms...`, message);
84
- await awaitWithSignal((0, exports.sleep)(errorDelay), signal);
85
- return retryOnError(debugIdent, fn, retries - 1, errorDelay, errorCheck, signal);
108
+ if (options.retries > 0 && options.errorCheck(error)) {
109
+ (0, log_1.info)(`Operation ${debugIdent} failed. Retrying after ${options.errorDelay}ms...`, message);
110
+ await sleepWithSignal(options.errorDelay);
111
+ return retryOnError(debugIdent, fn, {
112
+ retries: options.retries - 1,
113
+ errorDelay: options.errorDelay,
114
+ errorCheck: options.errorCheck,
115
+ signal: options.signal,
116
+ sleepFn: options.sleepFn,
117
+ });
86
118
  }
87
119
  throw error;
88
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ag-common",
3
- "version": "0.0.910",
3
+ "version": "0.0.912",
4
4
  "license": "ISC",
5
5
  "author": "admin@gec.dev",
6
6
  "repository": {