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 +14 -0
- package/dist/k8s/opencode.js +14 -9
- package/package.json +1 -1
- package/runtime/bridge.mjs +69 -9
- package/runtime/executor/opencode.mjs +30 -5
- package/runtime/model-tiers.json +12 -0
- package/runtime/protocol.mjs +57 -16
- package/runtime/server.mjs +3 -1
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
|
package/dist/k8s/opencode.js
CHANGED
|
@@ -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
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
package/runtime/bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
674
|
-
signal: cancellation.signal,
|
|
675
|
-
|
|
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
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
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
|
}
|
package/runtime/protocol.mjs
CHANGED
|
@@ -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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
?
|
|
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
|
};
|
package/runtime/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
) {
|