ag-ai 0.4.2 → 0.4.4

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
@@ -62,6 +62,20 @@ pool is cooling down waits for the soonest model to recover and retries, at
62
62
  most `MAX_COOLDOWN_WAITS` times and never past the request timeout; with a
63
63
  fallback it falls back immediately instead.
64
64
 
65
+ ## Runner Quota Fallback
66
+
67
+ The cluster runner resolves each `prefer` tier through `runtime/model-tiers.json`. When the
68
+ tier's model fails with a quota error (HTTP 429, or a ChatGPT "usage limit"), the job walks
69
+ the text models of the tier's `fallback` provider in the OpenCode catalog: the tier's family
70
+ first (pro for `quality`, flash for `fast`), then by output price as a quality proxy. Image,
71
+ audio, embedding, alias (`-latest`) and models without tool calling are skipped.
72
+
73
+ A model that fails with 429 is skipped for the provider's requested wait, otherwise for 60 s
74
+ doubling to 15 min; a 404 skips it for an hour. Cooldowns live in the runner's memory. When
75
+ every model is cooling down, the one that recovers soonest is tried. Requests naming an
76
+ explicit model never fall back. Completed results and failure reports carry the model that
77
+ actually ran.
78
+
65
79
  ## Tooling
66
80
 
67
81
  Formatting and linting use `e7-oxfmt` / `e7-oxlint` from
@@ -1,8 +1,15 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.opencodeStack = void 0;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = __importDefault(require("node:path"));
4
9
  const ag_rpi_mega_1 = require("ag-rpi-mega");
5
10
  const common_1 = require("./common");
11
+ /** The runtime's tier table is the single source of truth for which model and effort each tier runs. */
12
+ const MODEL_TIERS = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(__dirname, '..', '..', 'runtime', 'model-tiers.json'), 'utf8'));
6
13
  /** The published OpenCode CLI is installed into a writable emptyDir at pod start. */
7
14
  const OPENCODE_PACKAGE = 'opencode-ai@1.18.31';
8
15
  const OPENCODE_PREFIX = '/opt/opencode';
@@ -49,19 +56,17 @@ const opencodeStack = (context) => {
49
56
  (0, common_1.env)('XDG_CACHE_HOME', '/data/opencode/cache'),
50
57
  // The unrestricted /provider response processes thousands of models from
51
58
  // unconfigured providers on every request, exceeding the runner's budget.
52
- // Each advertised model carries the reasoning effort its consumer asks
53
- // for: sol at high for draft copy, 5.5 at high for draft images, and luna
54
- // at max for the client's cluster fallback. OpenCode merges these entries
55
- // with its model catalogue; it does not replace it.
59
+ // Each advertised model carries the reasoning effort of the tier that
60
+ // selects it. OpenCode merges these entries with its model catalogue; it
61
+ // does not replace it.
56
62
  (0, common_1.env)('OPENCODE_CONFIG_CONTENT', JSON.stringify({
57
63
  enabled_providers: ['openai', 'google'],
58
64
  provider: {
59
65
  openai: {
60
- models: {
61
- 'gpt-5.6-sol': { options: { reasoningEffort: 'high' } },
62
- 'gpt-5.6-luna': { options: { reasoningEffort: 'max' } },
63
- 'gpt-5.5': { options: { reasoningEffort: 'high' } },
64
- },
66
+ models: Object.fromEntries(Object.values(MODEL_TIERS).map(({ model, reasoningEffort }) => [
67
+ model,
68
+ { options: { reasoningEffort } },
69
+ ])),
65
70
  },
66
71
  },
67
72
  })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ag-ai",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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",
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
 
10
10
  import { createOpenCodeExecutor, DEFAULT_OPENCODE_URL } from './executor/opencode.mjs';
11
11
  import { createImageAdapter } from './image/adapter.mjs';
12
- import { normalizeGenerationRequest } from './protocol.mjs';
12
+ import { fallbackModels, normalizeGenerationRequest, requestOnModel } from './protocol.mjs';
13
13
  import { createStatusReporter, createWorkSignal, startPushChannel } from './push.mjs';
14
14
  import { createLocalRunner, startGenerationServer } from './server.mjs';
15
15
  import { GenerationStore } from './store.mjs';
@@ -31,6 +31,9 @@ const HEALTH_INTERVAL_MS = 30_000;
31
31
  const CLAIM_INTERVAL_MS = 5_000;
32
32
  const REQUEST_TIMEOUT_MS = 15_000;
33
33
  const TURN_TIMEOUT_MS = 30 * 60 * 1000;
34
+ const QUOTA_COOLDOWN_BASE_MS = 60_000;
35
+ const QUOTA_COOLDOWN_MAX_MS = 15 * 60_000;
36
+ const UNAVAILABLE_COOLDOWN_MS = 60 * 60_000;
34
37
  const SHUTDOWN_TIMEOUT_MS = 10_000;
35
38
  const MAX_HTTP_JSON_BYTES = 2 * 1024 * 1024;
36
39
  const MAX_HTTP_INPUT_BYTES = 64 * 1024 * 1024;
@@ -394,12 +397,13 @@ class RunnerClient {
394
397
  });
395
398
  }
396
399
 
397
- fail(jobId, leaseToken, error, outcome) {
400
+ fail(jobId, leaseToken, error, outcome, model) {
398
401
  return this.request('POST', `jobs/${encodeURIComponent(jobId)}/fail`, {
399
402
  leaseToken,
400
403
  body: {
401
404
  error: error.slice(0, 10_000),
402
405
  outcome,
406
+ ...(model === undefined ? {} : { model }),
403
407
  },
404
408
  });
405
409
  }
@@ -575,6 +579,32 @@ const parseStructuredOutput = (text, outputSchema) => {
575
579
  }
576
580
  };
577
581
 
582
+ /** Models to try in order: those not cooling down, or else the one that recovers soonest. */
583
+ const modelsToTry = (candidates, cooldowns, now) => {
584
+ const ready = candidates.filter((id) => (cooldowns.get(id)?.until ?? 0) <= now);
585
+ if (ready.length > 0) return ready;
586
+ return [
587
+ candidates.reduce((soonest, id) =>
588
+ cooldowns.get(id).until < cooldowns.get(soonest).until ? id : soonest,
589
+ ),
590
+ ];
591
+ };
592
+
593
+ /**
594
+ * Skip a model for the provider's requested wait, or back off from a minute to 15 after
595
+ * repeated quota failures; an unavailable model is skipped for an hour. In memory only:
596
+ * a restarted runner retries every model once.
597
+ */
598
+ const coolDown = (cooldowns, id, error, now) => {
599
+ const hits = (cooldowns.get(id)?.hits ?? 0) + 1;
600
+ const delay =
601
+ error.status === 404
602
+ ? UNAVAILABLE_COOLDOWN_MS
603
+ : (error.retryAfterMs ??
604
+ Math.min(QUOTA_COOLDOWN_BASE_MS * 2 ** (hits - 1), QUOTA_COOLDOWN_MAX_MS));
605
+ cooldowns.set(id, { hits, until: now + delay });
606
+ };
607
+
578
608
  const safeErrorOutcome = (error) =>
579
609
  error instanceof JobError
580
610
  ? error.outcome
@@ -641,6 +671,7 @@ const processJob = async (runner, job, config, state, source, analytics) => {
641
671
  }, LEASE_HEARTBEAT_INTERVAL_MS);
642
672
 
643
673
  let turnTimer;
674
+ let model;
644
675
  try {
645
676
  if (job.leaseExpiresAt <= Date.now()) {
646
677
  throw new JobError('claimed job lease has already expired', 'unknown');
@@ -648,7 +679,8 @@ const processJob = async (runner, job, config, state, source, analytics) => {
648
679
  const inputEnvelope = await runner.input(job.id, job.leaseToken);
649
680
  void analytics.emit('job', source, 'input-loaded');
650
681
  const models = state.health.models ?? [];
651
- const normalizedInput = normalizeGenerationRequest(inputEnvelope, models);
682
+ let normalizedInput = normalizeGenerationRequest(inputEnvelope, models);
683
+ model = normalizedInput.model;
652
684
  void analytics.emit('job', source, 'input-validated');
653
685
 
654
686
  const jobDirectoryName = `runner-${job.id}`;
@@ -670,10 +702,38 @@ const processJob = async (runner, job, config, state, source, analytics) => {
670
702
 
671
703
  turnTimer = setTimeout(() => cancellation.cancel('turn timeout'), TURN_TIMEOUT_MS);
672
704
  void analytics.emit('job', source, 'turn-started');
673
- const result = await state.executor.execute(normalizedInput, {
674
- signal: cancellation.signal,
675
- directory: mainJobDirectory,
676
- });
705
+ const execute = (request) =>
706
+ state.executor.execute(request, { signal: cancellation.signal, directory: mainJobDirectory });
707
+ const cooldowns = (state.cooldowns ??= new Map());
708
+ const candidates = [normalizedInput.model, ...fallbackModels(normalizedInput, models)];
709
+ let result;
710
+ let lastError;
711
+ for (const candidate of modelsToTry(candidates, cooldowns, Date.now())) {
712
+ let request;
713
+ try {
714
+ request =
715
+ candidate === normalizedInput.model
716
+ ? normalizedInput
717
+ : requestOnModel(normalizedInput, candidate, models);
718
+ } catch {
719
+ continue; // This model cannot accept the request's inputs or outputs.
720
+ }
721
+ model = candidate;
722
+ try {
723
+ result = await execute(request);
724
+ cooldowns.delete(candidate);
725
+ normalizedInput = request;
726
+ break;
727
+ } catch (error) {
728
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- cancelled is set by the turn timer and lease checks.
729
+ if (cancellation.cancelled || (error?.status !== 429 && error?.status !== 404)) throw error;
730
+ coolDown(cooldowns, candidate, error, Date.now());
731
+ console.error(`[runner] job ${job.id}: ${candidate} is out of quota or unavailable`);
732
+ void analytics.emit('job', source, 'turn-fallback');
733
+ lastError = error;
734
+ }
735
+ }
736
+ if (result === undefined) throw lastError ?? new JobError('no model can run this request');
677
737
  void analytics.emit('job', source, 'turn-completed');
678
738
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- leaseLost is set by the lease timer.
679
739
  if (leaseLost) {
@@ -726,7 +786,7 @@ const processJob = async (runner, job, config, state, source, analytics) => {
726
786
  const responseBytes = Buffer.from(
727
787
  JSON.stringify({
728
788
  output,
729
- model: normalizedInput.model,
789
+ model,
730
790
  generatedAt: Number.isInteger(result.generatedAt) ? result.generatedAt : Date.now(),
731
791
  ...(result.usage === undefined ? {} : { usage: result.usage }),
732
792
  }),
@@ -764,7 +824,7 @@ const processJob = async (runner, job, config, state, source, analytics) => {
764
824
  void analytics.emit('job', source, 'failed', outcome);
765
825
  const diagnostic = redact(error, [job.leaseToken]);
766
826
  try {
767
- await runner.fail(job.id, job.leaseToken, diagnostic, outcome);
827
+ await runner.fail(job.id, job.leaseToken, diagnostic, outcome, model);
768
828
  void analytics.emit('job', source, 'failure-recorded', outcome);
769
829
  } catch (failError) {
770
830
  if (!(failError instanceof RunnerHttpError && failError.status === 409)) {
@@ -17,13 +17,22 @@ const MAX_TEXT_BYTES = 2 * 1024 * 1024;
17
17
  */
18
18
 
19
19
  class ExecutorError extends Error {
20
- constructor(message, { status } = {}) {
20
+ constructor(message, { status, retryAfterMs } = {}) {
21
21
  super(message);
22
22
  this.name = 'ExecutorError';
23
23
  if (status !== undefined) this.status = status;
24
+ if (retryAfterMs !== undefined) this.retryAfterMs = retryAfterMs;
24
25
  }
25
26
  }
26
27
 
28
+ /** The wait a provider quota message asks for, such as "Please retry in 2h5m12.03s". */
29
+ const retryAfterMs = (message) => {
30
+ const match = /retry in (?:(\d+)h)?(?:(\d+)m(?!s))?(?:(\d+(?:\.\d+)?)s)?/i.exec(message);
31
+ const seconds =
32
+ (Number(match?.[1] ?? 0) * 60 + Number(match?.[2] ?? 0)) * 60 + Number(match?.[3] ?? 0);
33
+ return seconds > 0 ? Math.ceil(seconds * 1000) : undefined;
34
+ };
35
+
27
36
  const asObject = (value) => (value && typeof value === 'object' ? value : null);
28
37
 
29
38
  const boundedString = (value, maximum, name) => {
@@ -385,10 +394,16 @@ const createOpenCodeExecutor = async ({
385
394
  );
386
395
  const info = asObject(response?.info);
387
396
  if (info?.error) {
388
- const message = asObject(info.error)?.data;
389
- throw new ExecutorError(
390
- `OpenCode turn failed: ${asObject(message)?.message ?? info.error.name ?? 'unknown error'}`,
391
- );
397
+ const data = asObject(asObject(info.error)?.data);
398
+ const message = data?.message ?? info.error.name ?? 'unknown error';
399
+ // A ChatGPT plan reports exhaustion as a usage limit, not always with its 429 status.
400
+ const quota = /usage limit|exceeded your current quota/i.test(String(message));
401
+ const status = quota ? 429 : data?.statusCode;
402
+ const retryAfter = retryAfterMs(String(message));
403
+ throw new ExecutorError(`OpenCode turn failed: ${message}`, {
404
+ ...(Number.isInteger(status) ? { status } : {}),
405
+ ...(retryAfter === undefined ? {} : { retryAfterMs: retryAfter }),
406
+ });
392
407
  }
393
408
  const structured = structuredFromResponse(info, response?.parts);
394
409
  text =
@@ -437,6 +452,7 @@ const createOpenCodeExecutor = async ({
437
452
  const modelId = typeof model?.id === 'string' && model.id.length > 0 ? model.id : key;
438
453
  const inputModalities = ['text'];
439
454
  if (model?.capabilities?.input?.image === true) inputModalities.push('image');
455
+ const output = asObject(model?.capabilities?.output);
440
456
  const declaredEfforts = reasoningEffortsFromModel(model);
441
457
  const supportedReasoningEfforts =
442
458
  declaredEfforts.length > 0 ? declaredEfforts : [DEFAULT_REASONING_EFFORT];
@@ -450,6 +466,15 @@ const createOpenCodeExecutor = async ({
450
466
  prefer: ['quality'],
451
467
  isDefault: defaults[providerId] === modelId,
452
468
  imageGeneration: imageAdapter !== undefined,
469
+ // Catalog facts used only to rank quota fallbacks.
470
+ textOnly:
471
+ output?.text === true &&
472
+ output.image !== true &&
473
+ output.audio !== true &&
474
+ output.video !== true,
475
+ toolCall: model?.capabilities?.toolcall === true,
476
+ ...(typeof model?.cost?.output === 'number' ? { outputCost: model.cost.output } : {}),
477
+ ...(typeof model?.release_date === 'string' ? { releaseDate: model.release_date } : {}),
453
478
  });
454
479
  }
455
480
  }
@@ -0,0 +1,12 @@
1
+ {
2
+ "fast": {
3
+ "model": "gpt-6-luna",
4
+ "reasoningEffort": "medium",
5
+ "fallback": "google"
6
+ },
7
+ "quality": {
8
+ "model": "gpt-6.1-sol",
9
+ "reasoningEffort": "medium",
10
+ "fallback": "google"
11
+ }
12
+ }
@@ -3,6 +3,17 @@
3
3
  // differ.
4
4
 
5
5
  import { createHash } from 'node:crypto';
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ /**
9
+ * The only place that names a model: each `prefer` tier maps to one model and
10
+ * effort, plus the provider whose free models a tier falls back to when its own is out
11
+ * of quota.
12
+ * Callers send a tier; the model is resolved when the call is made.
13
+ */
14
+ const MODEL_TIERS = JSON.parse(
15
+ readFileSync(new URL('./model-tiers.json', import.meta.url), 'utf8'),
16
+ );
6
17
 
7
18
  const GENERATION_VERSION = 1;
8
19
  const MAX_TEXT_BYTES = 2 * 1024 * 1024;
@@ -257,21 +268,9 @@ const normalizeGenerationRequest = (value, models) => {
257
268
  if (prefer !== undefined && prefer !== 'fast' && prefer !== 'quality') {
258
269
  throw new GenerationProtocolError(400, 'invalid_request', 'prefer is invalid');
259
270
  }
260
- let requestedModel;
261
- if (request.model !== undefined) {
262
- requestedModel = boundedString(request.model, 160, 'model');
263
- } else if (prefer === 'fast') {
264
- requestedModel = catalog.find((candidate) => candidate.prefer.includes('fast'))?.id;
265
- requestedModel ??= catalog.find((candidate) => /(?:mini|luna)$/i.test(candidate.id))?.id;
266
- if (!requestedModel) throw unsupported('fast model preference');
267
- } else {
268
- requestedModel =
269
- catalog.find((candidate) => candidate.isDefault && candidate.prefer.includes('quality'))
270
- ?.id ??
271
- catalog.find((candidate) => candidate.isDefault)?.id ??
272
- catalog.find((candidate) => candidate.prefer.includes('quality'))?.id ??
273
- catalog[0].id;
274
- }
271
+ const tier = MODEL_TIERS[prefer ?? 'quality'];
272
+ const requestedModel =
273
+ request.model === undefined ? tier.model : boundedString(request.model, 160, 'model');
275
274
  const model =
276
275
  catalog.find((candidate) => candidate.id === requestedModel) ??
277
276
  (requestedModel.includes('/') ? undefined : uniqueSuffixModel(catalog, requestedModel));
@@ -290,7 +289,9 @@ const normalizeGenerationRequest = (value, models) => {
290
289
  const output = normalizeOutput(request.output, capabilities);
291
290
  const reasoningEffort =
292
291
  request.reasoningEffort === undefined
293
- ? (capabilities.defaultReasoningEffort ?? capabilities.supportedReasoningEfforts[0])
292
+ ? request.model === undefined
293
+ ? tier.reasoningEffort
294
+ : (capabilities.defaultReasoningEffort ?? capabilities.supportedReasoningEfforts[0])
294
295
  : boundedString(request.reasoningEffort, 32, 'reasoningEffort');
295
296
  if (!reasoningEffort) {
296
297
  throw new GenerationProtocolError(
@@ -326,6 +327,43 @@ const normalizeGenerationRequest = (value, models) => {
326
327
  };
327
328
  };
328
329
 
330
+ // Catalog variants that are not general text models, or alias one that is listed already.
331
+ const FALLBACK_EXCLUDED = ['embedding', 'computer-use', 'customtools', 'latest', 'deep-research'];
332
+
333
+ /**
334
+ * Text models of the tier's fallback provider, from highest to lowest quality, for a
335
+ * request running its tier's own model; an explicit model never falls back. Output price
336
+ * ranks quality, after the tier's family (pro for quality, flash for fast) as before.
337
+ */
338
+ const fallbackModels = (request, models) => {
339
+ const tier = MODEL_TIERS[request.prefer ?? 'quality'];
340
+ if (!tier.fallback) return [];
341
+ if (request.model !== tier.model && !request.model.endsWith(`/${tier.model}`)) return [];
342
+ const family = request.prefer === 'fast' ? 'flash' : 'pro';
343
+ return (Array.isArray(models) ? models : [])
344
+ .filter(
345
+ (model) =>
346
+ typeof model?.id === 'string' &&
347
+ model.id.startsWith(`${tier.fallback}/`) &&
348
+ model.textOnly === true &&
349
+ model.toolCall === true &&
350
+ !FALLBACK_EXCLUDED.some((word) => model.id.includes(word)),
351
+ )
352
+ .sort(
353
+ (a, b) =>
354
+ Number(b.id.includes(family)) - Number(a.id.includes(family)) ||
355
+ (b.outputCost ?? 0) - (a.outputCost ?? 0) ||
356
+ String(b.releaseDate ?? '').localeCompare(String(a.releaseDate ?? '')),
357
+ )
358
+ .map((model) => model.id);
359
+ };
360
+
361
+ /** The same normalized request on another model, with that model's own default effort. */
362
+ const requestOnModel = (request, model, models) => {
363
+ const { reasoningEffort: _reasoningEffort, ...rest } = request;
364
+ return normalizeGenerationRequest({ ...rest, model }, models);
365
+ };
366
+
329
367
  const requestDigest = (request) =>
330
368
  createHash('sha256').update(JSON.stringify(request)).digest('hex');
331
369
  const idempotencyDigest = (key) => createHash('sha256').update(key).digest('hex');
@@ -358,13 +396,16 @@ const publicModelCatalog = (models, { ready = true } = {}) => ({
358
396
 
359
397
  export {
360
398
  GENERATION_VERSION,
399
+ MODEL_TIERS,
361
400
  MAX_INPUT_ATTACHMENT_BYTES,
362
401
  MAX_INPUT_TOTAL_BYTES,
363
402
  GenerationProtocolError,
403
+ fallbackModels,
364
404
  idempotencyDigest,
365
405
  normalizeGenerationRequest,
366
406
  normalizeIdempotencyKey,
367
407
  normalizeModelCatalog,
368
408
  publicModelCatalog,
369
409
  requestDigest,
410
+ requestOnModel,
370
411
  };
@@ -229,7 +229,9 @@ const genericResult = (record, responseBytes, imageBytes) => {
229
229
  !Number.isInteger(response.generatedAt) ||
230
230
  response.generatedAt < 0 ||
231
231
  typeof response.model !== 'string' ||
232
- response.model !== record.model ||
232
+ // A tier's quota fallback may run a different model than the one recorded.
233
+ response.model.length === 0 ||
234
+ response.model.length > 160 ||
233
235
  !Array.isArray(response.output) ||
234
236
  response.output.length > 64
235
237
  ) {