ag-ai 0.2.1 → 0.3.1

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 CHANGED
@@ -28,6 +28,33 @@ runtime imports only Node builtins.
28
28
  - Codex is not used. The executor is OpenCode; the image adapter is separate
29
29
  because OpenCode has no image-generation output.
30
30
 
31
+ ## Gemini Fallback
32
+
33
+ Gemini is always the primary provider when no `endpoint` is selected. A client
34
+ can opt into one explicit cluster fallback after every eligible Gemini
35
+ model/key combination is quota- or capacity-exhausted, or is still in its
36
+ cooldown window:
37
+
38
+ ```ts
39
+ const client = createAIClient({
40
+ fallback: {
41
+ endpoint: { protocol: 'ag-ai', url: clusterURL, token: clusterToken },
42
+ model: 'gpt-5.6-luna',
43
+ reasoningEffort: 'xhigh',
44
+ },
45
+ });
46
+ ```
47
+
48
+ The fallback endpoint is never inferred. It is used only after aggregate
49
+ Gemini exhaustion, not for validation, authentication, transport, or other
50
+ nonquota failures. The Gemini model id and reasoning settings are not copied
51
+ to the cluster request; `inputs`, `outputSchema`, and other request fields are
52
+ preserved while the configured fallback model and reasoning effort are used.
53
+ `onFallback` receives an explicit exhaustion event, and `onGenerated` reports
54
+ the actual selected model and provider (`google` or `cluster`). Cluster
55
+ network operations and retry waits for this fallback are individually capped
56
+ at 3000 ms; model generation time is not included in that cap.
57
+
31
58
  ## Tooling
32
59
 
33
60
  Formatting and linting use `e7-oxfmt` / `e7-oxlint` from
@@ -5,6 +5,8 @@ export type ClusterAdapterConfig = {
5
5
  timeoutMs?: number;
6
6
  pollIntervalMs?: number;
7
7
  maxPollIntervalMs?: number;
8
+ /** Maximum time for each queue/status network operation; generation is not capped by this. */
9
+ networkTimeoutMs?: number;
8
10
  };
9
11
  /** Validate and normalize the direct private LAN endpoint used by the ag-ai cluster. */
10
12
  export declare const normalizeClusterEndpoint: (endpoint: AIEndpoint) => AIEndpoint;
@@ -168,19 +168,43 @@ const responseJSON = async (response, signal) => {
168
168
  return undefined;
169
169
  }
170
170
  };
171
- const fetchJSON = async ({ fetch, url, headers, method, body, signal, }) => {
172
- const response = await awaitWithSignal(fetch(url, {
173
- method,
174
- headers,
175
- ...(body === undefined ? {} : { body }),
176
- redirect: 'manual',
177
- ...(signal === undefined ? {} : { signal }),
178
- }), signal);
179
- const payload = await responseJSON(response, signal);
180
- if (!response.ok) {
181
- throw new ClusterHTTPError(response.status, getErrorMessage(payload, `AI endpoint returned HTTP ${response.status}`));
182
- }
183
- return payload;
171
+ const fetchJSON = async ({ fetch, url, headers, method, body, signal, networkTimeoutMs, }) => {
172
+ const controller = networkTimeoutMs === undefined ? undefined : new AbortController();
173
+ const timeout = controller === undefined ? undefined : setTimeout(() => controller.abort(), networkTimeoutMs);
174
+ const onAbort = controller === undefined ? undefined : () => controller.abort();
175
+ if (controller !== undefined && onAbort !== undefined) {
176
+ signal?.addEventListener('abort', onAbort, { once: true });
177
+ }
178
+ const requestSignal = controller?.signal ?? signal;
179
+ try {
180
+ const response = await awaitWithSignal(fetch(url, {
181
+ method,
182
+ headers,
183
+ ...(body === undefined ? {} : { body }),
184
+ redirect: 'manual',
185
+ ...(requestSignal === undefined ? {} : { signal: requestSignal }),
186
+ }), requestSignal);
187
+ const payload = await responseJSON(response, requestSignal);
188
+ if (!response.ok) {
189
+ throw new ClusterHTTPError(response.status, getErrorMessage(payload, `AI endpoint returned HTTP ${response.status}`));
190
+ }
191
+ return payload;
192
+ }
193
+ catch (error) {
194
+ if (signal?.aborted)
195
+ throw createAbortError();
196
+ if (controller?.signal.aborted) {
197
+ throw new Error(`AI endpoint network request timed out after ${networkTimeoutMs}ms`);
198
+ }
199
+ throw error;
200
+ }
201
+ finally {
202
+ if (timeout !== undefined)
203
+ clearTimeout(timeout);
204
+ if (controller !== undefined && onAbort !== undefined) {
205
+ signal?.removeEventListener('abort', onAbort);
206
+ }
207
+ }
184
208
  };
185
209
  const isRetryable = (error) => {
186
210
  if (error instanceof ClusterHTTPError)
@@ -390,6 +414,7 @@ const generateCluster = async (request, config) => {
390
414
  method: 'POST',
391
415
  body: JSON.stringify(body),
392
416
  signal: operationAbort.signal,
417
+ networkTimeoutMs: config.networkTimeoutMs,
393
418
  }), dependencies.sleep, operationAbort.signal));
394
419
  jobId = getString(created, 'id');
395
420
  if (!jobId)
@@ -430,6 +455,7 @@ const generateCluster = async (request, config) => {
430
455
  headers: endpointHeaders(endpoint),
431
456
  method: 'GET',
432
457
  signal: operationAbort.signal,
458
+ networkTimeoutMs: config.networkTimeoutMs,
433
459
  }));
434
460
  }
435
461
  catch (error) {
@@ -5,6 +5,15 @@ export type GoogleAdapterConfig = {
5
5
  dependencies?: AIClientDependencies;
6
6
  createAI?: (apiKey: string) => GoogleGenAI;
7
7
  };
8
+ /** Every eligible Gemini combination was exhausted by quota/capacity or cooldown. */
9
+ export declare class GeminiPoolExhaustedError extends Error {
10
+ readonly causes: readonly unknown[];
11
+ readonly models: readonly string[];
12
+ constructor(options?: {
13
+ causes?: readonly unknown[];
14
+ models?: readonly string[];
15
+ });
16
+ }
8
17
  export declare const generateGoogle: (request: AIRequest, config?: GoogleAdapterConfig) => Promise<GenerationResult>;
9
18
  export declare const listGoogleModels: (dependencies?: AIClientDependencies) => Promise<AIModel[]>;
10
19
  export { resetModelBackoff };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resetGoogleAdapterForTests = exports.resetModelBackoff = exports.listGoogleModels = exports.generateGoogle = void 0;
3
+ exports.resetGoogleAdapterForTests = exports.resetModelBackoff = exports.listGoogleModels = exports.generateGoogle = exports.GeminiPoolExhaustedError = void 0;
4
4
  const genai_1 = require("@google/genai");
5
5
  const apikey_1 = require("../../internal/apikey");
6
6
  const log_1 = require("../../internal/log");
@@ -25,6 +25,18 @@ const NON_TEXT_MODEL_CAPABILITIES = [
25
25
  'robotics',
26
26
  'tts',
27
27
  ];
28
+ /** Every eligible Gemini combination was exhausted by quota/capacity or cooldown. */
29
+ class GeminiPoolExhaustedError extends Error {
30
+ causes;
31
+ models;
32
+ constructor(options = {}) {
33
+ super('Gemini model pool exhausted; all eligible combinations are unavailable');
34
+ this.name = 'GeminiPoolExhaustedError';
35
+ this.causes = options.causes ?? [];
36
+ this.models = options.models ?? [];
37
+ }
38
+ }
39
+ exports.GeminiPoolExhaustedError = GeminiPoolExhaustedError;
28
40
  let genAIs = new Map();
29
41
  const geminiModelsCache = new node_cache_1.TypedNodeCache({ stdTTL: 86400 });
30
42
  const geminiModelsCacheKey = 'gemini-models-v3';
@@ -212,15 +224,27 @@ const getCombinations = async (model, prefer, output, signal, config) => {
212
224
  if (models.length === 0)
213
225
  throw new Error('No text generation models are available');
214
226
  const combinations = [];
227
+ let cooldownOnly = false;
228
+ let nonCooldownBlock = false;
215
229
  for (const [key, ai] of clients) {
216
230
  for (const selectedModel of models) {
217
- const available = (0, apikey_1.getAvailableCombinations)(`gemini-${selectedModel}`, false);
231
+ const service = `gemini-${selectedModel}`;
232
+ const status = (0, quota_1.getModelKeyBackoffStatus)(selectedModel, key, config.dependencies?.now?.() ?? Date.now());
233
+ if (status === 'expired')
234
+ (0, apikey_1.clearKeyServiceBlock)(key, service);
235
+ const available = (0, apikey_1.getAvailableCombinations)(service, false);
218
236
  if (available.some((entry) => entry.key === key)) {
219
237
  combinations.push([key, ai, selectedModel]);
220
238
  }
239
+ else if (status === 'active') {
240
+ cooldownOnly = true;
241
+ }
242
+ else {
243
+ nonCooldownBlock = true;
244
+ }
221
245
  }
222
246
  }
223
- return combinations;
247
+ return { combinations, models, cooldownOnly: cooldownOnly && !nonCooldownBlock, nonCooldownBlock };
224
248
  };
225
249
  const mediaType = (mimeType) => {
226
250
  const normalized = mimeType.toLowerCase();
@@ -379,22 +403,37 @@ const generateGoogle = async (request, config = {}) => {
379
403
  setTimeout(resolve, ms);
380
404
  }));
381
405
  const result = await (0, retryOnError_1.retryOnError)(`generation:${request.ident ?? 'unknown'}`, async () => {
382
- const combinations = await getCombinations(request.model, request.prefer, request.output, controller.signal, config);
383
- if (combinations.length === 0)
406
+ const combinationSet = await getCombinations(request.model, request.prefer, request.output, controller.signal, config);
407
+ const combinations = combinationSet.combinations;
408
+ if (combinations.length === 0) {
409
+ if (combinationSet.cooldownOnly) {
410
+ throw new GeminiPoolExhaustedError({
411
+ models: combinationSet.models,
412
+ });
413
+ }
384
414
  throw new Error('No available model for this request');
415
+ }
385
416
  let lastFailure;
417
+ const exhaustionCauses = [];
418
+ const exhaustedModels = new Set();
419
+ let attemptedCombination = false;
420
+ let nonExhaustionFailure;
421
+ let skippedForNonCooldown = false;
386
422
  for (const [key, ai, selectedModel] of combinations) {
387
- if (!(0, apikey_1.getAvailableCombinations)(`gemini-${selectedModel}`, false).some((entry) => entry.key === key)) {
423
+ const service = `gemini-${selectedModel}`;
424
+ const nowMs = config.dependencies?.now?.() ?? Date.now();
425
+ const keyBackoffStatus = (0, quota_1.getModelKeyBackoffStatus)(selectedModel, key, nowMs);
426
+ if (keyBackoffStatus === 'active') {
427
+ (0, log_1.debug)(`[${selectedModel}] skipping rate-limited model/key`, {
428
+ remainingMs: (0, quota_1.modelKeyBackoffRemainingMs)(selectedModel, key, nowMs),
429
+ });
388
430
  continue;
389
431
  }
390
- // Reactive per-model backoff: a 429 skips the model until its window
391
- // elapses while every other model keeps serving.
392
- if ((0, quota_1.isModelBackedOff)(selectedModel)) {
393
- (0, log_1.debug)(`[${selectedModel}] skipping rate-limited model`, {
394
- remainingMs: (0, quota_1.modelBackoffRemainingMs)(selectedModel),
395
- });
432
+ if (!(0, apikey_1.getAvailableCombinations)(service, false).some((entry) => entry.key === key)) {
433
+ skippedForNonCooldown = true;
396
434
  continue;
397
435
  }
436
+ attemptedCombination = true;
398
437
  const selectedRequest = {
399
438
  ...requestConfig,
400
439
  model: selectedModel,
@@ -423,22 +462,39 @@ const generateGoogle = async (request, config = {}) => {
423
462
  const unavailableModel = failureStatus === 404 && message.includes(selectedModel);
424
463
  // Fallback only; per-model backoff below is keyed by rate limiting.
425
464
  if (rateLimited || overloadedModel || unavailableModel) {
426
- (0, log_1.warn)(`[${selectedModel}] generation attempt failed; trying next available combination`, {
465
+ (0, log_1.debug)(`[${selectedModel}] generation attempt failed; trying next available combination`, {
427
466
  status: failureStatus,
428
467
  });
429
468
  (0, apikey_1.blockKeyService)(key, `gemini-${selectedModel}`);
430
469
  if (rateLimited || overloadedModel) {
431
- (0, quota_1.recordModelRateLimit)(selectedModel, {
470
+ (0, quota_1.recordModelKeyRateLimit)(selectedModel, key, {
432
471
  cause: error,
433
472
  nowMs: config.dependencies?.now?.(),
434
473
  });
435
474
  }
475
+ if (unavailableModel)
476
+ nonExhaustionFailure = error;
477
+ if (rateLimited || overloadedModel)
478
+ exhaustionCauses.push(error);
479
+ if (!unavailableModel)
480
+ exhaustedModels.add(selectedModel);
436
481
  lastFailure = error;
437
482
  continue;
438
483
  }
439
484
  throw error;
440
485
  }
441
486
  }
487
+ if (nonExhaustionFailure !== undefined)
488
+ throw nonExhaustionFailure;
489
+ if (skippedForNonCooldown || combinationSet.nonCooldownBlock) {
490
+ throw new Error('No available model for this request');
491
+ }
492
+ if (!attemptedCombination || exhaustionCauses.length > 0) {
493
+ throw new GeminiPoolExhaustedError({
494
+ causes: exhaustionCauses,
495
+ models: [...exhaustedModels],
496
+ });
497
+ }
442
498
  throw lastFailure ?? new Error('No available model for this request');
443
499
  }, {
444
500
  retries: 1,
@@ -448,6 +504,7 @@ const generateGoogle = async (request, config = {}) => {
448
504
  sleepFn: async (ms) => {
449
505
  await sleep(ms);
450
506
  },
507
+ errorCheck: (error) => !(error instanceof GeminiPoolExhaustedError) && (0, retryOnError_1.isRetryableApiError)(error),
451
508
  });
452
509
  return result;
453
510
  }
@@ -1,4 +1,5 @@
1
- import type { AIClient, AIClientDefaults, AIClientDependencies, AIEndpoint, AIInput, AIModel, AIModelPreference, AIPart, AIRequest, BinaryMedia, GenerateTextOptions, GenerationResult, PromptDirectOptions, PromptImageOptions } from './types';
1
+ import type { AIClient, AIClientDefaults, AIClientDependencies, AIEndpoint, AIInput, AIModel, AIModelPreference, AIProvider, AIPart, AIRequest, BinaryMedia, GenerateTextOptions, GenerationResult, PromptDirectOptions, PromptImageOptions } from './types';
2
+ export { GeminiPoolExhaustedError } from './adapters/google';
2
3
  export declare const createAIClient: (defaults?: AIClientDefaults) => AIClient;
3
4
  export declare const generate: (request: AIRequest) => Promise<GenerationResult>;
4
5
  export declare function generateText(prompt: string, options?: GenerateTextOptions): Promise<string>;
@@ -8,4 +9,4 @@ export declare const promptImage: (options: PromptImageOptions) => Promise<strin
8
9
  export declare const models: (options?: {
9
10
  endpoint?: AIEndpoint;
10
11
  }) => Promise<AIModel[]>;
11
- export type { AIClient, AIClientDefaults, AIClientDependencies, AIEndpoint, AIInput, AIModel, AIModelPreference, AIPart, AIRequest, BinaryMedia, GenerateTextOptions, GenerationResult, PromptDirectOptions, PromptImageOptions, };
12
+ export type { AIClient, AIClientDefaults, AIClientDependencies, AIEndpoint, AIInput, AIProvider, AIModel, AIModelPreference, AIPart, AIRequest, BinaryMedia, GenerateTextOptions, GenerationResult, PromptDirectOptions, PromptImageOptions, };
@@ -1,11 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.models = exports.promptImage = exports.promptDirect = exports.generate = exports.createAIClient = void 0;
3
+ exports.models = exports.promptImage = exports.promptDirect = exports.generate = exports.createAIClient = exports.GeminiPoolExhaustedError = void 0;
4
4
  exports.generateText = generateText;
5
5
  const cluster_1 = require("./adapters/cluster");
6
6
  const google_1 = require("./adapters/google");
7
+ var google_2 = require("./adapters/google");
8
+ Object.defineProperty(exports, "GeminiPoolExhaustedError", { enumerable: true, get: function () { return google_2.GeminiPoolExhaustedError; } });
7
9
  const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
8
10
  const DOWNLOAD_TIMEOUT_MS = 30 * 1000;
11
+ const FALLBACK_NETWORK_TIMEOUT_MS = 3000;
9
12
  const defaultSleep = (milliseconds) => new Promise((resolve) => {
10
13
  setTimeout(resolve, milliseconds);
11
14
  });
@@ -112,6 +115,7 @@ const mergeRequest = (defaults, request) => ({
112
115
  outputSchema: request.outputSchema ?? defaults.outputSchema,
113
116
  webSearch: request.webSearch ?? defaults.webSearch,
114
117
  onGenerated: request.onGenerated ?? defaults.onGenerated,
118
+ onFallback: request.onFallback ?? defaults.onFallback,
115
119
  signal: request.signal ?? defaults.signal,
116
120
  timeoutMs: request.timeoutMs ?? defaults.timeoutMs,
117
121
  staggerMs: request.staggerMs ?? defaults.staggerMs,
@@ -133,16 +137,55 @@ const createAIClient = (defaults = {}) => {
133
137
  const dependencies = getDependencies(defaults);
134
138
  const generate = async (request) => {
135
139
  const merged = mergeRequest(defaults, request);
136
- const result = merged.endpoint === undefined
137
- ? await (0, google_1.generateGoogle)(merged, { dependencies })
138
- : await (0, cluster_1.generateCluster)(merged, {
140
+ let result;
141
+ let provider;
142
+ if (merged.endpoint === undefined) {
143
+ try {
144
+ result = await (0, google_1.generateGoogle)(merged, { dependencies });
145
+ provider = 'google';
146
+ }
147
+ catch (error) {
148
+ if (!(error instanceof google_1.GeminiPoolExhaustedError) || defaults.fallback === undefined) {
149
+ throw error;
150
+ }
151
+ const fallback = defaults.fallback;
152
+ const fallbackPollIntervalMs = Math.min(defaults.pollIntervalMs ?? 250, 3000);
153
+ const fallbackMaxPollIntervalMs = Math.min(Math.max(fallbackPollIntervalMs, defaults.maxPollIntervalMs ?? 2000), FALLBACK_NETWORK_TIMEOUT_MS);
154
+ const fallbackRequest = {
155
+ ...merged,
156
+ endpoint: fallback.endpoint,
157
+ model: fallback.model,
158
+ reasoningEffort: fallback.reasoningEffort,
159
+ };
160
+ const fallbackEvent = {
161
+ from: { provider: 'google' },
162
+ to: { provider: 'cluster', model: fallback.model },
163
+ reason: 'gemini-pool-exhausted',
164
+ cause: error,
165
+ };
166
+ merged.onFallback?.(fallbackEvent);
167
+ result = await (0, cluster_1.generateCluster)(fallbackRequest, {
168
+ endpoint: fallback.endpoint,
169
+ dependencies,
170
+ timeoutMs: merged.timeoutMs,
171
+ pollIntervalMs: fallbackPollIntervalMs,
172
+ maxPollIntervalMs: fallbackMaxPollIntervalMs,
173
+ networkTimeoutMs: FALLBACK_NETWORK_TIMEOUT_MS,
174
+ });
175
+ provider = 'cluster';
176
+ }
177
+ }
178
+ else {
179
+ result = await (0, cluster_1.generateCluster)(merged, {
139
180
  endpoint: merged.endpoint,
140
181
  dependencies,
141
- timeoutMs: defaults.timeoutMs,
182
+ timeoutMs: merged.timeoutMs,
142
183
  pollIntervalMs: defaults.pollIntervalMs,
143
184
  maxPollIntervalMs: defaults.maxPollIntervalMs,
144
185
  });
145
- merged.onGenerated?.({ model: result.model, generatedAt: result.generatedAt });
186
+ provider = 'cluster';
187
+ }
188
+ merged.onGenerated?.({ model: result.model, generatedAt: result.generatedAt, provider });
146
189
  return result;
147
190
  };
148
191
  async function generateText(promptOrOptions, options) {
@@ -16,7 +16,7 @@ export declare class QuotaDeferredError extends Error {
16
16
  cause?: unknown;
17
17
  });
18
18
  }
19
- /** True when any message in the cause chain signals model quota/capacity exhaustion. */
19
+ /** True when a cause chain signals model quota/capacity exhaustion. */
20
20
  export declare const isQuotaExhaustedError: (cause: unknown) => boolean;
21
21
  /**
22
22
  * Honor the provider's retry delay when present (observed 9-57s). Returns undefined
@@ -34,6 +34,21 @@ export declare const MODEL_RATE_LIMIT_BASE_DELAY_MS = 60000;
34
34
  export declare const MODEL_RATE_LIMIT_MAX_DELAY_MS: number;
35
35
  /** Small jitter so backed-off models do not resurface in lockstep. */
36
36
  export declare const MODEL_RATE_LIMIT_JITTER_MS = 1000;
37
+ export type ModelKeyBackoffStatus = 'active' | 'expired' | 'none';
38
+ /** Inspect a model/key cooldown without exposing the credential in state or logs. */
39
+ export declare const getModelKeyBackoffStatus: (model: string, apiKey: string, nowMs?: number) => ModelKeyBackoffStatus;
40
+ /** Record a quota/capacity response for one model/key combination. */
41
+ export declare const recordModelKeyRateLimit: (model: string, apiKey: string, opt?: {
42
+ cause?: unknown;
43
+ nowMs?: number;
44
+ random?: () => number;
45
+ }) => number;
46
+ /** True while one model/key combination remains inside its cooldown window. */
47
+ export declare const isModelKeyBackedOff: (model: string, apiKey: string, nowMs?: number) => boolean;
48
+ /** Remaining cooldown for one model/key combination, or 0 when eligible. */
49
+ export declare const modelKeyBackoffRemainingMs: (model: string, apiKey: string, nowMs?: number) => number;
50
+ /** Mark one model/key combination eligible again. */
51
+ export declare const clearModelKeyBackoff: (model: string, apiKey: string) => void;
37
52
  /**
38
53
  * Record a rate-limit response for a model. The model is skipped until the
39
54
  * backoff window elapses: the provider's own retry delay when present,
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
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;
3
+ exports.resetModelBackoff = exports.clearModelBackoff = exports.modelBackoffRemainingMs = exports.isModelBackedOff = exports.recordModelRateLimit = exports.clearModelKeyBackoff = exports.modelKeyBackoffRemainingMs = exports.isModelKeyBackedOff = exports.recordModelKeyRateLimit = exports.getModelKeyBackoffStatus = 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 node_crypto_1 = require("node:crypto");
4
5
  const retryOnError_1 = require("../internal/retryOnError");
5
6
  /**
6
7
  * Reactive per-model rate-limit backoff.
@@ -26,8 +27,20 @@ class QuotaDeferredError extends Error {
26
27
  }
27
28
  }
28
29
  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);
30
+ const hasRateLimitStatus = (cause, seen = new Set()) => {
31
+ if (cause === null || cause === undefined || seen.has(cause))
32
+ return false;
33
+ if (typeof cause !== 'object')
34
+ return false;
35
+ seen.add(cause);
36
+ const record = cause;
37
+ const status = record.status ?? record.statusCode ?? record.code;
38
+ if (status === 429 || status === '429')
39
+ return true;
40
+ return [record.cause, record.error, record.response].some((nested) => hasRateLimitStatus(nested, seen));
41
+ };
42
+ /** True when a cause chain signals model quota/capacity exhaustion. */
43
+ const isQuotaExhaustedError = (cause) => hasRateLimitStatus(cause) || (0, retryOnError_1.isOverloadedApiKeyError)(cause);
31
44
  exports.isQuotaExhaustedError = isQuotaExhaustedError;
32
45
  const RETRY_DELAY_PATTERNS = [
33
46
  /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/i,
@@ -95,6 +108,54 @@ exports.MODEL_RATE_LIMIT_MAX_DELAY_MS = 15 * 60_000;
95
108
  exports.MODEL_RATE_LIMIT_JITTER_MS = 1_000;
96
109
  const modelBackoffUntilMs = new Map();
97
110
  const modelBackoffHits = new Map();
111
+ const modelKeyBackoffUntilMs = new Map();
112
+ const modelKeyBackoffHits = new Map();
113
+ const modelKeyBackoffId = (model, apiKey) => `${model}:${(0, node_crypto_1.createHash)('sha256').update(apiKey).digest('hex').slice(0, 16)}`;
114
+ /** Inspect a model/key cooldown without exposing the credential in state or logs. */
115
+ const getModelKeyBackoffStatus = (model, apiKey, nowMs = Date.now()) => {
116
+ const id = modelKeyBackoffId(model, apiKey);
117
+ const until = modelKeyBackoffUntilMs.get(id);
118
+ if (until === undefined)
119
+ return 'none';
120
+ if (nowMs >= until) {
121
+ modelKeyBackoffUntilMs.delete(id);
122
+ modelKeyBackoffHits.delete(id);
123
+ return 'expired';
124
+ }
125
+ return 'active';
126
+ };
127
+ exports.getModelKeyBackoffStatus = getModelKeyBackoffStatus;
128
+ /** Record a quota/capacity response for one model/key combination. */
129
+ const recordModelKeyRateLimit = (model, apiKey, opt) => {
130
+ const now = opt?.nowMs ?? Date.now();
131
+ const id = modelKeyBackoffId(model, apiKey);
132
+ const serverDelay = opt?.cause === undefined ? undefined : (0, exports.getQuotaRetryDelayMs)(opt.cause);
133
+ const hits = (modelKeyBackoffHits.get(id) ?? 0) + 1;
134
+ modelKeyBackoffHits.set(id, hits);
135
+ const exponential = Math.min(exports.MODEL_RATE_LIMIT_BASE_DELAY_MS * 2 ** (hits - 1), exports.MODEL_RATE_LIMIT_MAX_DELAY_MS);
136
+ const jitter = Math.floor((opt?.random ?? Math.random)() * exports.MODEL_RATE_LIMIT_JITTER_MS);
137
+ const until = now + Math.max(serverDelay ?? 0, exponential) + jitter;
138
+ modelKeyBackoffUntilMs.set(id, until);
139
+ return until;
140
+ };
141
+ exports.recordModelKeyRateLimit = recordModelKeyRateLimit;
142
+ /** True while one model/key combination remains inside its cooldown window. */
143
+ const isModelKeyBackedOff = (model, apiKey, nowMs) => (0, exports.getModelKeyBackoffStatus)(model, apiKey, nowMs) === 'active';
144
+ exports.isModelKeyBackedOff = isModelKeyBackedOff;
145
+ /** Remaining cooldown for one model/key combination, or 0 when eligible. */
146
+ const modelKeyBackoffRemainingMs = (model, apiKey, nowMs = Date.now()) => {
147
+ if ((0, exports.getModelKeyBackoffStatus)(model, apiKey, nowMs) !== 'active')
148
+ return 0;
149
+ return Math.max(0, (modelKeyBackoffUntilMs.get(modelKeyBackoffId(model, apiKey)) ?? nowMs) - nowMs);
150
+ };
151
+ exports.modelKeyBackoffRemainingMs = modelKeyBackoffRemainingMs;
152
+ /** Mark one model/key combination eligible again. */
153
+ const clearModelKeyBackoff = (model, apiKey) => {
154
+ const id = modelKeyBackoffId(model, apiKey);
155
+ modelKeyBackoffUntilMs.delete(id);
156
+ modelKeyBackoffHits.delete(id);
157
+ };
158
+ exports.clearModelKeyBackoff = clearModelKeyBackoff;
98
159
  /**
99
160
  * Record a rate-limit response for a model. The model is skipped until the
100
161
  * backoff window elapses: the provider's own retry delay when present,
@@ -144,5 +205,7 @@ exports.clearModelBackoff = clearModelBackoff;
144
205
  const resetModelBackoff = () => {
145
206
  modelBackoffUntilMs.clear();
146
207
  modelBackoffHits.clear();
208
+ modelKeyBackoffUntilMs.clear();
209
+ modelKeyBackoffHits.clear();
147
210
  };
148
211
  exports.resetModelBackoff = resetModelBackoff;
@@ -1,5 +1,6 @@
1
1
  export type AIOutputType = 'text' | 'image' | 'audio' | 'video' | 'file';
2
2
  export type AIModelPreference = 'fast' | 'quality';
3
+ export type AIProvider = 'google' | 'cluster';
3
4
  export type AIInputRole = 'user' | 'assistant' | 'system' | 'developer';
4
5
  export type AITextPart = {
5
6
  type: 'text';
@@ -29,6 +30,8 @@ export type AIOutputSchema = {
29
30
  export type GenerationMetadata = {
30
31
  model: string;
31
32
  generatedAt: number;
33
+ /** Present on client provenance callbacks; raw adapters may omit it. */
34
+ provider?: AIProvider;
32
35
  };
33
36
  export type GenerationUsage = {
34
37
  inputTokens: number;
@@ -67,6 +70,7 @@ export type AIRequest = {
67
70
  outputSchema?: AIOutputSchema;
68
71
  webSearch?: boolean;
69
72
  onGenerated?: (metadata: GenerationMetadata) => void;
73
+ onFallback?: (event: AIFallbackEvent) => void;
70
74
  signal?: AbortSignal;
71
75
  timeoutMs?: number;
72
76
  /**
@@ -76,6 +80,22 @@ export type AIRequest = {
76
80
  */
77
81
  staggerMs?: number;
78
82
  };
83
+ export type AIFallback = {
84
+ endpoint: AIEndpoint;
85
+ model: string;
86
+ reasoningEffort: string;
87
+ };
88
+ export type AIFallbackEvent = {
89
+ from: {
90
+ provider: 'google';
91
+ };
92
+ to: {
93
+ provider: 'cluster';
94
+ model: string;
95
+ };
96
+ reason: 'gemini-pool-exhausted';
97
+ cause: unknown;
98
+ };
79
99
  export type BinaryMedia = {
80
100
  arraybuffer: ArrayBuffer;
81
101
  type: string;
@@ -107,6 +127,7 @@ export type AIClientDependencies = {
107
127
  };
108
128
  export type AIClientDefaults = AIRequestOptions & AIClientDependencies & {
109
129
  dependencies?: AIClientDependencies;
130
+ fallback?: AIFallback;
110
131
  pollIntervalMs?: number;
111
132
  maxPollIntervalMs?: number;
112
133
  };
@@ -3,3 +3,5 @@ export declare const getAvailableCombinations: (service: string, resetIfExhauste
3
3
  key: string;
4
4
  }[];
5
5
  export declare const blockKeyService: (apiKey: string, service: string) => void;
6
+ /** Remove only one key+service block, preserving every unrelated blocklist entry. */
7
+ export declare const clearKeyServiceBlock: (apiKey: string, service: string) => void;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.blockKeyService = exports.getAvailableCombinations = void 0;
3
+ exports.clearKeyServiceBlock = exports.blockKeyService = exports.getAvailableCombinations = void 0;
4
4
  const array_1 = require("./array");
5
5
  const log_1 = require("./log");
6
6
  const node_cache_1 = require("./node-cache");
@@ -33,7 +33,7 @@ const getAvailableCombinations = (service, resetIfExhausted = true) => {
33
33
  }
34
34
  // If all combinations are blocklisted, clear the blocklist and try again
35
35
  if (combinations.length === 0 && resetIfExhausted) {
36
- (0, log_1.warn)(`[${service}] all API key + service combinations were blocklisted; clearing blocklist and trying again.`);
36
+ (0, log_1.debug)(`[${service}] all API key + service combinations were blocklisted; clearing blocklist and trying again.`);
37
37
  blocklist.flushAll();
38
38
  for (const key of keys) {
39
39
  combinations.push({ service, key });
@@ -45,7 +45,12 @@ const getAvailableCombinations = (service, resetIfExhausted = true) => {
45
45
  exports.getAvailableCombinations = getAvailableCombinations;
46
46
  // Block a key+service combination
47
47
  const blockKeyService = (apiKey, service) => {
48
- (0, log_1.warn)(`[${service}] key+service blocklisted`);
48
+ (0, log_1.debug)(`[${service}] key+service blocklisted`);
49
49
  addToBlocklist(apiKey, service);
50
50
  };
51
51
  exports.blockKeyService = blockKeyService;
52
+ /** Remove only one key+service block, preserving every unrelated blocklist entry. */
53
+ const clearKeyServiceBlock = (apiKey, service) => {
54
+ blocklist.del(getBlocklistKey(apiKey, service));
55
+ };
56
+ exports.clearKeyServiceBlock = clearKeyServiceBlock;
@@ -40,7 +40,22 @@ const opencodeStack = (context) => {
40
40
  (0, common_1.env)('XDG_CACHE_HOME', '/data/opencode/cache'),
41
41
  // The unrestricted /provider response processes thousands of models from
42
42
  // unconfigured providers on every request, exceeding the runner's budget.
43
- (0, common_1.env)('OPENCODE_CONFIG_CONTENT', JSON.stringify({ enabled_providers: ['openai', 'google'] })),
43
+ // Each advertised model carries the reasoning effort its consumer asks
44
+ // for: sol at high for draft copy, 5.5 at high for draft images, and luna
45
+ // at max for the client's cluster fallback. OpenCode merges these entries
46
+ // with its model catalogue; it does not replace it.
47
+ (0, common_1.env)('OPENCODE_CONFIG_CONTENT', JSON.stringify({
48
+ enabled_providers: ['openai', 'google'],
49
+ provider: {
50
+ openai: {
51
+ models: {
52
+ 'gpt-5.6-sol': { options: { reasoningEffort: 'high' } },
53
+ 'gpt-5.6-luna': { options: { reasoningEffort: 'max' } },
54
+ 'gpt-5.5': { options: { reasoningEffort: 'high' } },
55
+ },
56
+ },
57
+ },
58
+ })),
44
59
  ],
45
60
  startupProbe: { ...probe, failureThreshold: 30 },
46
61
  readinessProbe: { ...(0, common_1.openCodeHealthProbe)(common_1.OPENCODE_PORT, 'models'), initialDelaySeconds: 5 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ag-ai",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Provider-neutral AI client and cluster runtime for the rpi-mega cluster.",
5
5
  "license": "ISC",
6
6
  "author": "admin@gec.dev",
@@ -191,6 +191,26 @@ const usageFromTokens = (tokens) => {
191
191
  return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
192
192
  };
193
193
 
194
+ /**
195
+ * Read the reasoning efforts a model declares in the OpenCode provider catalog.
196
+ * OpenCode stores the effort on the model's `options`, and newer builds may also
197
+ * expose an explicit list. Missing metadata falls back to the executor default
198
+ * rather than inventing capabilities.
199
+ */
200
+ const reasoningEffortsFromModel = (model) => {
201
+ const efforts = [];
202
+ const configured = asObject(model?.options)?.reasoningEffort;
203
+ if (typeof configured === 'string' && configured.length > 0) efforts.push(configured);
204
+ for (const source of [model?.supportedReasoningEfforts, model?.reasoningEfforts]) {
205
+ if (!Array.isArray(source)) continue;
206
+ for (const item of source) {
207
+ const value = typeof item === 'string' ? item : asObject(item)?.reasoningEffort;
208
+ if (typeof value === 'string' && value.length > 0) efforts.push(value);
209
+ }
210
+ }
211
+ return [...new Set(efforts)];
212
+ };
213
+
194
214
  /**
195
215
  * Create an executor backed by an OpenCode HTTP server. The server URL comes
196
216
  * from `AG_AI_OPENCODE_URL`; the basic-auth password comes from
@@ -397,12 +417,15 @@ const createOpenCodeExecutor = async ({
397
417
  const modelId = typeof model?.id === 'string' && model.id.length > 0 ? model.id : key;
398
418
  const inputModalities = ['text'];
399
419
  if (model?.capabilities?.input?.image === true) inputModalities.push('image');
420
+ const declaredEfforts = reasoningEffortsFromModel(model);
421
+ const supportedReasoningEfforts =
422
+ declaredEfforts.length > 0 ? declaredEfforts : [DEFAULT_REASONING_EFFORT];
400
423
  models.push({
401
424
  id: `${providerId}/${modelId}`,
402
425
  inputModalities,
403
426
  outputModalities: imageAdapter !== undefined ? ['text', 'image'] : ['text'],
404
- supportedReasoningEfforts: [DEFAULT_REASONING_EFFORT],
405
- defaultReasoningEffort: DEFAULT_REASONING_EFFORT,
427
+ supportedReasoningEfforts,
428
+ defaultReasoningEffort: supportedReasoningEfforts[0],
406
429
  webSearch: false,
407
430
  prefer: ['quality'],
408
431
  isDefault: defaults[providerId] === modelId,
@@ -117,6 +117,16 @@ const normalizeModelCatalog = (models) => {
117
117
  const unsupported = (what) =>
118
118
  new GenerationProtocolError(422, 'unsupported', `${what} is not supported by the selected model`);
119
119
 
120
+ /**
121
+ * Resolve an unqualified model id (for example `gpt-5.6-luna`) to the single
122
+ * catalog entry that exposes it under a provider prefix. An ambiguous or absent
123
+ * match stays unresolved so the caller gets an explicit unsupported error.
124
+ */
125
+ const uniqueSuffixModel = (catalog, requestedModel) => {
126
+ const matches = catalog.filter((candidate) => candidate.id.endsWith(`/${requestedModel}`));
127
+ return matches.length === 1 ? matches[0] : undefined;
128
+ };
129
+
120
130
  const normalizePart = (part, capabilities, total) => {
121
131
  const object = asObject(part);
122
132
  const type = object?.type;
@@ -262,7 +272,9 @@ const normalizeGenerationRequest = (value, models) => {
262
272
  catalog.find((candidate) => candidate.prefer.includes('quality'))?.id ??
263
273
  catalog[0].id;
264
274
  }
265
- const model = catalog.find((candidate) => candidate.id === requestedModel);
275
+ const model =
276
+ catalog.find((candidate) => candidate.id === requestedModel) ??
277
+ (requestedModel.includes('/') ? undefined : uniqueSuffixModel(catalog, requestedModel));
266
278
  if (!model) throw unsupported(`model ${requestedModel}`);
267
279
  const capabilities = normalizeCapabilities(model);
268
280
  const instructions =
@@ -306,7 +318,7 @@ const normalizeGenerationRequest = (value, models) => {
306
318
  instructions,
307
319
  input,
308
320
  output,
309
- model: requestedModel,
321
+ model: model.id,
310
322
  ...(prefer === undefined ? {} : { prefer }),
311
323
  reasoningEffort,
312
324
  ...(outputSchema === undefined ? {} : { outputSchema }),