ag-common 0.0.908 → 0.0.910

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Reactive per-model rate-limit backoff.
3
+ *
4
+ * Strategy: never pace proactively and never hardcode provider limits. Every
5
+ * discovered model stays eligible until it returns a rate-limit response; that
6
+ * model is then skipped for a backoff window while rotation across the
7
+ * remaining models continues untouched.
8
+ */
9
+ /** Stable message for deferred work. Must stay app-agnostic so error tracking groups it. */
10
+ export declare const AI_QUOTA_EXHAUSTED_MESSAGE = "AI model pool exhausted; job deferred with backoff";
11
+ /** Grouped error for quota-deferred queue rows. Cause chain preserves the original failure. */
12
+ export declare class QuotaDeferredError extends Error {
13
+ readonly deferredIds: string[];
14
+ readonly quotaCause?: unknown;
15
+ constructor(deferredIds: string[], options?: {
16
+ cause?: unknown;
17
+ });
18
+ }
19
+ /** True when any message in the cause chain signals model quota/capacity exhaustion. */
20
+ export declare const isQuotaExhaustedError: (cause: unknown) => boolean;
21
+ /**
22
+ * Honor the provider's retry delay when present (observed 9-57s). Returns undefined
23
+ * when the failure carries no usable delay, letting callers fall back to backoff.
24
+ */
25
+ export declare const getQuotaRetryDelayMs: (cause: unknown) => number | undefined;
26
+ /**
27
+ * Queue backoff for a deferred row: exponential hours (1h, 2h, 4h ... capped at
28
+ * 24h) or the provider's own retry delay, whichever is longer, plus jitter.
29
+ */
30
+ export declare const quotaBackoffDelayMs: (attempt: number, cause?: unknown, random?: () => number) => number;
31
+ /** First backoff rung after a rate-limit response; doubles per consecutive hit. */
32
+ export declare const MODEL_RATE_LIMIT_BASE_DELAY_MS = 60000;
33
+ /** Upper bound for per-model backoff. */
34
+ export declare const MODEL_RATE_LIMIT_MAX_DELAY_MS: number;
35
+ /** Small jitter so backed-off models do not resurface in lockstep. */
36
+ export declare const MODEL_RATE_LIMIT_JITTER_MS = 1000;
37
+ /**
38
+ * Record a rate-limit response for a model. The model is skipped until the
39
+ * backoff window elapses: the provider's own retry delay when present,
40
+ * otherwise an exponential window that doubles per consecutive hit.
41
+ * Returns the timestamp (ms) when the model becomes eligible again.
42
+ */
43
+ export declare const recordModelRateLimit: (model: string, opt?: {
44
+ cause?: unknown;
45
+ nowMs?: number;
46
+ random?: () => number;
47
+ }) => number;
48
+ /** True while a rate-limited model is still inside its backoff window. */
49
+ export declare const isModelBackedOff: (model: string, nowMs?: number) => boolean;
50
+ /** Remaining backoff for a model in ms, or 0 when it is eligible. */
51
+ export declare const modelBackoffRemainingMs: (model: string, nowMs?: number) => number;
52
+ /** Mark a model eligible again, e.g. after it serves a request successfully. */
53
+ export declare const clearModelBackoff: (model: string) => void;
54
+ /** Test seam: clear all per-model backoff state. */
55
+ export declare const resetModelBackoff: () => void;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resetModelBackoff = exports.clearModelBackoff = exports.modelBackoffRemainingMs = exports.isModelBackedOff = exports.recordModelRateLimit = exports.MODEL_RATE_LIMIT_JITTER_MS = exports.MODEL_RATE_LIMIT_MAX_DELAY_MS = exports.MODEL_RATE_LIMIT_BASE_DELAY_MS = exports.quotaBackoffDelayMs = exports.getQuotaRetryDelayMs = exports.isQuotaExhaustedError = exports.QuotaDeferredError = exports.AI_QUOTA_EXHAUSTED_MESSAGE = void 0;
4
+ const retryOnError_1 = require("../retryOnError");
5
+ /**
6
+ * Reactive per-model rate-limit backoff.
7
+ *
8
+ * Strategy: never pace proactively and never hardcode provider limits. Every
9
+ * discovered model stays eligible until it returns a rate-limit response; that
10
+ * model is then skipped for a backoff window while rotation across the
11
+ * remaining models continues untouched.
12
+ */
13
+ /** Stable message for deferred work. Must stay app-agnostic so error tracking groups it. */
14
+ exports.AI_QUOTA_EXHAUSTED_MESSAGE = "AI model pool exhausted; job deferred with backoff";
15
+ /** Grouped error for quota-deferred queue rows. Cause chain preserves the original failure. */
16
+ class QuotaDeferredError extends Error {
17
+ deferredIds;
18
+ quotaCause;
19
+ constructor(deferredIds, options) {
20
+ super(`${exports.AI_QUOTA_EXHAUSTED_MESSAGE}: deferred ${deferredIds.length} job(s)`);
21
+ this.name = "QuotaDeferredError";
22
+ this.deferredIds = deferredIds;
23
+ if (options?.cause !== undefined) {
24
+ this.quotaCause = options.cause;
25
+ }
26
+ }
27
+ }
28
+ exports.QuotaDeferredError = QuotaDeferredError;
29
+ /** True when any message in the cause chain signals model quota/capacity exhaustion. */
30
+ const isQuotaExhaustedError = (cause) => (0, retryOnError_1.isOverloadedApiKeyError)(cause);
31
+ exports.isQuotaExhaustedError = isQuotaExhaustedError;
32
+ const RETRY_DELAY_PATTERNS = [
33
+ /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/i,
34
+ /retry in (\d+(?:\.\d+)?)\s*s/i,
35
+ ];
36
+ const collectCauseText = (cause, seen = new Set()) => {
37
+ if (cause === null || cause === undefined || seen.has(cause)) {
38
+ return "";
39
+ }
40
+ if (typeof cause === "string") {
41
+ return cause;
42
+ }
43
+ seen.add(cause);
44
+ if (cause instanceof Error) {
45
+ const nested = cause.cause;
46
+ const nestedText = nested === undefined ? "" : `\n${collectCauseText(nested, seen)}`;
47
+ return `${cause.message}${nestedText}`;
48
+ }
49
+ if (typeof cause === "object") {
50
+ try {
51
+ return JSON.stringify(cause);
52
+ }
53
+ catch {
54
+ return Object.prototype.toString.call(cause);
55
+ }
56
+ }
57
+ return Object.prototype.toString.call(cause);
58
+ };
59
+ /**
60
+ * Honor the provider's retry delay when present (observed 9-57s). Returns undefined
61
+ * when the failure carries no usable delay, letting callers fall back to backoff.
62
+ */
63
+ const getQuotaRetryDelayMs = (cause) => {
64
+ const combined = collectCauseText(cause);
65
+ for (const pattern of RETRY_DELAY_PATTERNS) {
66
+ const match = combined.match(pattern);
67
+ if (match?.[1]) {
68
+ const seconds = Number(match[1]);
69
+ if (Number.isFinite(seconds) && seconds > 0) {
70
+ return Math.min(Math.ceil(seconds * 1000), 15 * 60 * 1000);
71
+ }
72
+ }
73
+ }
74
+ return undefined;
75
+ };
76
+ exports.getQuotaRetryDelayMs = getQuotaRetryDelayMs;
77
+ /**
78
+ * Queue backoff for a deferred row: exponential hours (1h, 2h, 4h ... capped at
79
+ * 24h) or the provider's own retry delay, whichever is longer, plus jitter.
80
+ */
81
+ const quotaBackoffDelayMs = (attempt, cause, random = Math.random) => {
82
+ // Cap the exponential component at 16h so attempt 5+ lands on the 16h rung
83
+ // with up to 1h jitter, still strictly under the 24h ceiling.
84
+ const clamped = Math.min(Math.max(Math.floor(attempt) || 1, 1), 5);
85
+ const exponential = Math.min(3_600_000 * 2 ** (clamped - 1), 16 * 3_600_000);
86
+ const serverDelay = cause === undefined ? undefined : (0, exports.getQuotaRetryDelayMs)(cause);
87
+ return Math.max(serverDelay ?? 0, exponential) + Math.floor(random() * 60_000);
88
+ };
89
+ exports.quotaBackoffDelayMs = quotaBackoffDelayMs;
90
+ /** First backoff rung after a rate-limit response; doubles per consecutive hit. */
91
+ exports.MODEL_RATE_LIMIT_BASE_DELAY_MS = 60_000;
92
+ /** Upper bound for per-model backoff. */
93
+ exports.MODEL_RATE_LIMIT_MAX_DELAY_MS = 15 * 60_000;
94
+ /** Small jitter so backed-off models do not resurface in lockstep. */
95
+ exports.MODEL_RATE_LIMIT_JITTER_MS = 1_000;
96
+ const modelBackoffUntilMs = new Map();
97
+ const modelBackoffHits = new Map();
98
+ /**
99
+ * Record a rate-limit response for a model. The model is skipped until the
100
+ * backoff window elapses: the provider's own retry delay when present,
101
+ * otherwise an exponential window that doubles per consecutive hit.
102
+ * Returns the timestamp (ms) when the model becomes eligible again.
103
+ */
104
+ const recordModelRateLimit = (model, opt) => {
105
+ const now = opt?.nowMs ?? Date.now();
106
+ const serverDelay = opt?.cause === undefined ? undefined : (0, exports.getQuotaRetryDelayMs)(opt.cause);
107
+ const hits = (modelBackoffHits.get(model) ?? 0) + 1;
108
+ modelBackoffHits.set(model, hits);
109
+ const exponential = Math.min(exports.MODEL_RATE_LIMIT_BASE_DELAY_MS * 2 ** (hits - 1), exports.MODEL_RATE_LIMIT_MAX_DELAY_MS);
110
+ const jitter = Math.floor((opt?.random ?? Math.random)() * exports.MODEL_RATE_LIMIT_JITTER_MS);
111
+ const until = now + Math.max(serverDelay ?? 0, exponential) + jitter;
112
+ modelBackoffUntilMs.set(model, until);
113
+ return until;
114
+ };
115
+ exports.recordModelRateLimit = recordModelRateLimit;
116
+ /** True while a rate-limited model is still inside its backoff window. */
117
+ const isModelBackedOff = (model, nowMs) => {
118
+ const until = modelBackoffUntilMs.get(model);
119
+ if (until === undefined)
120
+ return false;
121
+ const now = nowMs ?? Date.now();
122
+ if (now >= until) {
123
+ modelBackoffUntilMs.delete(model);
124
+ return false;
125
+ }
126
+ return true;
127
+ };
128
+ exports.isModelBackedOff = isModelBackedOff;
129
+ /** Remaining backoff for a model in ms, or 0 when it is eligible. */
130
+ const modelBackoffRemainingMs = (model, nowMs) => {
131
+ const until = modelBackoffUntilMs.get(model);
132
+ if (until === undefined)
133
+ return 0;
134
+ return Math.max(0, until - (nowMs ?? Date.now()));
135
+ };
136
+ exports.modelBackoffRemainingMs = modelBackoffRemainingMs;
137
+ /** Mark a model eligible again, e.g. after it serves a request successfully. */
138
+ const clearModelBackoff = (model) => {
139
+ modelBackoffUntilMs.delete(model);
140
+ modelBackoffHits.delete(model);
141
+ };
142
+ exports.clearModelBackoff = clearModelBackoff;
143
+ /** Test seam: clear all per-model backoff state. */
144
+ const resetModelBackoff = () => {
145
+ modelBackoffUntilMs.clear();
146
+ modelBackoffHits.clear();
147
+ };
148
+ exports.resetModelBackoff = resetModelBackoff;
@@ -0,0 +1,118 @@
1
+ export type AIOutputType = "text" | "image" | "audio" | "video" | "file";
2
+ export type AIModelPreference = "fast" | "quality";
3
+ export type AIInputRole = "user" | "assistant" | "system" | "developer";
4
+ export type AITextPart = {
5
+ type: "text";
6
+ text: string;
7
+ };
8
+ export type AIMediaPart = {
9
+ type: Exclude<AIOutputType, "text">;
10
+ mimeType: string;
11
+ data: string;
12
+ name?: string;
13
+ };
14
+ export type AIPart = AITextPart | AIMediaPart;
15
+ export type AIInput = {
16
+ role: AIInputRole;
17
+ content: AIPart[];
18
+ };
19
+ /** A provider endpoint. Codex endpoints must be direct private LAN IPv4 URLs. */
20
+ export type AIEndpoint = {
21
+ protocol: "codex";
22
+ url: string;
23
+ token: string;
24
+ };
25
+ export type AIOutputSchema = {
26
+ name: string;
27
+ schema: Record<string, unknown>;
28
+ };
29
+ export type GenerationMetadata = {
30
+ model: string;
31
+ generatedAt: number;
32
+ };
33
+ export type GenerationUsage = {
34
+ inputTokens: number;
35
+ outputTokens: number;
36
+ totalTokens: number;
37
+ };
38
+ export type GenerationResult = GenerationMetadata & {
39
+ output: AIPart[];
40
+ usage?: GenerationUsage;
41
+ };
42
+ /**
43
+ * Provider-neutral model metadata. Providers may omit capabilities they do not
44
+ * publish; the model id is always preserved exactly as returned by the provider.
45
+ */
46
+ export type AIModel = {
47
+ id: string;
48
+ inputModalities?: AIOutputType[];
49
+ outputModalities?: AIOutputType[];
50
+ supportedReasoningEfforts?: string[];
51
+ defaultReasoningEffort?: string;
52
+ prefer?: AIModelPreference[];
53
+ webSearch?: boolean;
54
+ isDefault?: boolean;
55
+ ready?: boolean;
56
+ };
57
+ export type AIRequest = {
58
+ instructions?: string;
59
+ input: AIInput[];
60
+ output?: AIOutputType[];
61
+ ident?: string;
62
+ endpoint?: AIEndpoint;
63
+ model?: string;
64
+ prefer?: "fast" | "quality";
65
+ reasoningEffort?: string;
66
+ maxOutputTokens?: number;
67
+ outputSchema?: AIOutputSchema;
68
+ webSearch?: boolean;
69
+ onGenerated?: (metadata: GenerationMetadata) => void;
70
+ signal?: AbortSignal;
71
+ timeoutMs?: number;
72
+ };
73
+ export type BinaryMedia = {
74
+ arraybuffer: ArrayBuffer;
75
+ type: string;
76
+ name?: string;
77
+ };
78
+ export type AIRequestOptions = Omit<AIRequest, "input" | "output"> & {
79
+ output?: AIOutputType[];
80
+ };
81
+ export type GenerateTextOptions = AIRequestOptions & {
82
+ prompt?: string;
83
+ input?: AIInput[];
84
+ images?: BinaryMedia[];
85
+ urls?: string[];
86
+ };
87
+ export type PromptDirectOptions = AIRequestOptions & {
88
+ prompt: string;
89
+ images?: BinaryMedia[];
90
+ };
91
+ export type PromptImageOptions = AIRequestOptions & {
92
+ prompt: string;
93
+ urls?: string[];
94
+ images?: BinaryMedia[];
95
+ };
96
+ export type AIClientDependencies = {
97
+ fetch?: typeof globalThis.fetch;
98
+ sleep?: (milliseconds: number) => Promise<void>;
99
+ now?: () => number;
100
+ createIdempotencyKey?: () => string;
101
+ };
102
+ export type AIClientDefaults = AIRequestOptions & AIClientDependencies & {
103
+ dependencies?: AIClientDependencies;
104
+ pollIntervalMs?: number;
105
+ maxPollIntervalMs?: number;
106
+ };
107
+ export type AIClient = {
108
+ generate: (request: AIRequest) => Promise<GenerationResult>;
109
+ generateText: {
110
+ (prompt: string, options?: GenerateTextOptions): Promise<string>;
111
+ (options: GenerateTextOptions): Promise<string>;
112
+ };
113
+ promptDirect: (options: PromptDirectOptions) => Promise<string>;
114
+ promptImage: (options: PromptImageOptions) => Promise<string>;
115
+ models: (options?: {
116
+ endpoint?: AIEndpoint;
117
+ }) => Promise<AIModel[]>;
118
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,2 +1 @@
1
1
  export * from "./apikey";
2
- export * from "./gemini";
@@ -15,4 +15,3 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./apikey"), exports);
18
- __exportStar(require("./gemini"), exports);
@@ -1,4 +1,5 @@
1
1
  export * from "./acm";
2
+ export * from "./ai";
2
3
  export * from "./api";
3
4
  export * from "./apigw";
4
5
  export * from "./aws";
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./acm"), exports);
18
+ __exportStar(require("./ai"), exports);
18
19
  __exportStar(require("./api"), exports);
19
20
  __exportStar(require("./apigw"), exports);
20
21
  __exportStar(require("./aws"), exports);
@@ -1,8 +1,8 @@
1
1
  export declare const overloadedMessages: string[];
2
2
  export declare const retryableErrorMessages: string[];
3
- export declare const isOverloadedApiKeyError: (error: Error) => boolean;
4
- export declare const isRetryableApiError: (error: Error) => boolean;
3
+ export declare const isOverloadedApiKeyError: (error: unknown) => boolean;
4
+ export declare const isRetryableApiError: (error: unknown) => boolean;
5
5
  export declare function retryOnError<T>(
6
6
  /** so we can log retries with useful info */
7
- debugIdent: string, fn: () => Promise<T>, retries?: number, errorDelay?: number, errorCheck?: (error: Error) => boolean): Promise<T>;
7
+ debugIdent: string, fn: () => Promise<T>, retries?: number, errorDelay?: number, errorCheck?: (error: unknown) => boolean, signal?: AbortSignal): Promise<T>;
8
8
  export declare const sleep: (ms: number) => Promise<unknown>;
@@ -3,7 +3,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sleep = exports.isRetryableApiError = exports.isOverloadedApiKeyError = exports.retryableErrorMessages = exports.overloadedMessages = void 0;
4
4
  exports.retryOnError = retryOnError;
5
5
  const log_1 = require("../../common/helpers/log");
6
- exports.overloadedMessages = ["429", "current usage", "current quota", "overloaded"];
6
+ exports.overloadedMessages = [
7
+ "429",
8
+ "current usage",
9
+ "current quota",
10
+ "overloaded",
11
+ "quota exceeded",
12
+ "quota failure",
13
+ "rate limited",
14
+ "too many requests",
15
+ "generate_content_free_tier_requests",
16
+ "generaterequestsperday",
17
+ "high demand",
18
+ "model pool exhausted",
19
+ "no available model",
20
+ ];
7
21
  exports.retryableErrorMessages = [
8
22
  ...exports.overloadedMessages,
9
23
  "500",
@@ -12,27 +26,63 @@ exports.retryableErrorMessages = [
12
26
  "invalid_type",
13
27
  "expected",
14
28
  ];
29
+ const errorText = (error) => {
30
+ if (typeof error === "string") {
31
+ return error;
32
+ }
33
+ if (error instanceof Error) {
34
+ const nested = error.cause;
35
+ const nestedText = nested === undefined ? "" : ` ${errorText(nested)}`;
36
+ return `${error.message}${nestedText}`;
37
+ }
38
+ if (typeof error === "object" && error !== null) {
39
+ try {
40
+ return JSON.stringify(error);
41
+ }
42
+ catch {
43
+ return Object.prototype.toString.call(error);
44
+ }
45
+ }
46
+ return String(error);
47
+ };
15
48
  const errorContainsAnyMessage = (error, messages) => {
16
- const message = error.message.toLowerCase();
49
+ const message = errorText(error).toLowerCase();
17
50
  return messages.some((m) => message.includes(m.toLowerCase()));
18
51
  };
19
52
  const isOverloadedApiKeyError = (error) => errorContainsAnyMessage(error, exports.overloadedMessages);
20
53
  exports.isOverloadedApiKeyError = isOverloadedApiKeyError;
21
54
  const isRetryableApiError = (error) => errorContainsAnyMessage(error, exports.retryableErrorMessages);
22
55
  exports.isRetryableApiError = isRetryableApiError;
56
+ const createAbortError = () => {
57
+ const error = new Error("Operation aborted");
58
+ error.name = "AbortError";
59
+ return error;
60
+ };
61
+ const awaitWithSignal = async (promise, signal) => {
62
+ if (signal === undefined)
63
+ return promise;
64
+ if (signal.aborted)
65
+ throw createAbortError();
66
+ const abort = new Promise((_, reject) => {
67
+ const onAbort = () => reject(createAbortError());
68
+ signal.addEventListener("abort", onAbort, { once: true });
69
+ void promise.then(() => signal.removeEventListener("abort", onAbort), () => signal.removeEventListener("abort", onAbort));
70
+ });
71
+ return Promise.race([promise, abort]);
72
+ };
23
73
  async function retryOnError(
24
74
  /** so we can log retries with useful info */
25
- debugIdent, fn, retries = 1, errorDelay = 2000, errorCheck = exports.isRetryableApiError) {
75
+ debugIdent, fn, retries = 1, errorDelay = 2000, errorCheck = exports.isRetryableApiError, signal) {
26
76
  try {
27
- return fn();
77
+ const result = await fn();
78
+ return result;
28
79
  }
29
80
  catch (error) {
30
- const e = error;
31
- const em = e.message;
32
- if (retries > 0 && errorCheck(e)) {
33
- (0, log_1.info)(`Operation ${debugIdent} failed. Retrying after ${errorDelay}ms...`, em);
34
- await (0, exports.sleep)(errorDelay);
35
- return retryOnError(debugIdent, fn, retries - 1, errorDelay, errorCheck);
81
+ 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);
36
86
  }
37
87
  throw error;
38
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ag-common",
3
- "version": "0.0.908",
3
+ "version": "0.0.910",
4
4
  "license": "ISC",
5
5
  "author": "admin@gec.dev",
6
6
  "repository": {
@@ -1,26 +0,0 @@
1
- export type ModelPreference = "quality" | "fast" | undefined;
2
- /** Provenance of a successful response, with generation time in Unix milliseconds. */
3
- export type GenerationMetadata = {
4
- model: string;
5
- generatedAt: number;
6
- };
7
- export declare const isTextGenerationModel: (name: string) => boolean;
8
- export declare const geminiPromptImage: ({ prompt, urls, ident, prefer, onGenerated, }: {
9
- prompt: string;
10
- urls?: string[];
11
- ident: string | undefined;
12
- prefer?: ModelPreference;
13
- onGenerated?: (metadata: GenerationMetadata) => void;
14
- }) => Promise<string>;
15
- export declare const geminiPromptDirect: ({ prompt, images, ident, prefer, groundedSearch, onGenerated, }: {
16
- prompt: string;
17
- images?: {
18
- arraybuffer: ArrayBuffer;
19
- type: string;
20
- }[];
21
- ident: string | undefined;
22
- prefer?: ModelPreference;
23
- groundedSearch?: boolean;
24
- onGenerated?: (metadata: GenerationMetadata) => void;
25
- }) => Promise<string>;
26
- export declare const resolveGroundedUrl: (url: string) => Promise<string>;