ag-ai 0.4.3 → 0.4.5
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/assets/network-init.sh +1 -1
- package/dist/k8s/runner.js +32 -9
- package/package.json +1 -1
- package/runtime/bridge.mjs +69 -9
- package/runtime/executor/opencode.mjs +30 -5
- package/runtime/model-tiers.json +10 -2
- package/runtime/protocol.mjs +42 -1
- 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
|
|
@@ -13,7 +13,7 @@ iptables-restore <<'RULES'
|
|
|
13
13
|
:OUTPUT DROP [0:0]
|
|
14
14
|
-A INPUT -i lo -j ACCEPT
|
|
15
15
|
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
16
|
-
|
|
16
|
+
__GENERATION_INGRESS__
|
|
17
17
|
-A OUTPUT -o lo -j ACCEPT
|
|
18
18
|
-A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
19
19
|
__DNS_RULES__
|
package/dist/k8s/runner.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.runnerStack = void 0;
|
|
7
7
|
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_net_1 = require("node:net");
|
|
8
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
10
|
const ag_rpi_mega_1 = require("ag-rpi-mega");
|
|
10
11
|
const common_1 = require("./common");
|
|
@@ -12,12 +13,14 @@ const networkInitTemplate = (0, node_fs_1.readFileSync)(node_path_1.default.join
|
|
|
12
13
|
/** The published runtime is installed into a writable emptyDir at pod start. */
|
|
13
14
|
const AG_AI_RUNTIME_DIR = '/opt/ag-ai';
|
|
14
15
|
const AG_AI_BRIDGE = `${AG_AI_RUNTIME_DIR}/node_modules/ag-ai/runtime/bridge.mjs`;
|
|
15
|
-
const networkInitScript = ({
|
|
16
|
-
.replaceAll('
|
|
16
|
+
const networkInitScript = ({ generationCidrs, generationPort, opencodePort, }) => networkInitTemplate
|
|
17
|
+
.replaceAll('__GENERATION_INGRESS__', generationCidrs
|
|
18
|
+
.map((cidr) => `-A INPUT -s ${cidr} -p tcp --dport ${generationPort} -j ACCEPT`)
|
|
19
|
+
.join('\n'))
|
|
17
20
|
.replaceAll('__GENERATION_PORT__', String(generationPort))
|
|
18
21
|
.replaceAll('__OPENCODE_PORT__', String(opencodePort))
|
|
19
22
|
.replaceAll('__DNS_RULES__', '-A OUTPUT -p udp --dport 53 -j ACCEPT\n-A OUTPUT -p tcp --dport 53 -j ACCEPT')
|
|
20
|
-
.replaceAll('__GENERATION_REJECT__', `-A OUTPUT -d ${
|
|
23
|
+
.replaceAll('__GENERATION_REJECT__', generationCidrs.map((cidr) => `-A OUTPUT -d ${cidr} -j REJECT`).join('\n'));
|
|
21
24
|
const healthProbe = (readiness) => ({
|
|
22
25
|
exec: {
|
|
23
26
|
command: [
|
|
@@ -38,6 +41,25 @@ const runnerStack = (context) => {
|
|
|
38
41
|
const generationCidr = context.env.AG_AI_GENERATION_CIDR;
|
|
39
42
|
if (!generationCidr)
|
|
40
43
|
throw new Error('AG_AI_GENERATION_CIDR is required');
|
|
44
|
+
const clusterCidr = context.env.AG_AI_GENERATION_CLUSTER_CIDR;
|
|
45
|
+
const generationCidrs = [generationCidr, ...(clusterCidr ? [clusterCidr] : [])];
|
|
46
|
+
for (const cidr of generationCidrs) {
|
|
47
|
+
const [address, prefix, ...extra] = cidr.split('/');
|
|
48
|
+
const minimum = address.startsWith('10.')
|
|
49
|
+
? 8
|
|
50
|
+
: address.startsWith('192.168.')
|
|
51
|
+
? 16
|
|
52
|
+
: /^172\.(1[6-9]|2\d|3[01])\./.test(address)
|
|
53
|
+
? 12
|
|
54
|
+
: 33;
|
|
55
|
+
if ((0, node_net_1.isIP)(address) !== 4 ||
|
|
56
|
+
extra.length ||
|
|
57
|
+
!/^\d{1,2}$/.test(prefix) ||
|
|
58
|
+
Number(prefix) < minimum ||
|
|
59
|
+
Number(prefix) > 32) {
|
|
60
|
+
throw new Error('Generation ingress requires a valid private IPv4 CIDR');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
41
63
|
const imageProvider = context.env.AG_AI_IMAGE_PROVIDER ?? 'openai';
|
|
42
64
|
const opencodeProvider = context.env.AG_AI_OPENCODE_PROVIDER;
|
|
43
65
|
const agAiPackage = context.env.AG_AI_PACKAGE;
|
|
@@ -61,7 +83,7 @@ const runnerStack = (context) => {
|
|
|
61
83
|
preserveData: true,
|
|
62
84
|
data: {
|
|
63
85
|
'network-init.sh': networkInitScript({
|
|
64
|
-
|
|
86
|
+
generationCidrs,
|
|
65
87
|
generationPort: common_1.GENERATION_PORT,
|
|
66
88
|
opencodePort: common_1.OPENCODE_PORT,
|
|
67
89
|
}),
|
|
@@ -138,9 +160,10 @@ const runnerStack = (context) => {
|
|
|
138
160
|
changeNFSFilePerms: false,
|
|
139
161
|
});
|
|
140
162
|
const spec = deployment.spec;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
163
|
+
if (!spec)
|
|
164
|
+
throw new Error('runner deployment is missing its specification');
|
|
165
|
+
const pod = spec.template.spec;
|
|
166
|
+
if (!pod)
|
|
144
167
|
throw new Error('runner deployment is missing its pod specification');
|
|
145
168
|
// A shared store must have only one writer during rollouts.
|
|
146
169
|
spec.strategy = { type: 'Recreate' };
|
|
@@ -253,7 +276,7 @@ const runnerStack = (context) => {
|
|
|
253
276
|
spec: {
|
|
254
277
|
type: 'LoadBalancer',
|
|
255
278
|
loadBalancerIP: metalLbAddress,
|
|
256
|
-
loadBalancerSourceRanges:
|
|
279
|
+
loadBalancerSourceRanges: generationCidrs,
|
|
257
280
|
externalTrafficPolicy: 'Local',
|
|
258
281
|
allocateLoadBalancerNodePorts: false,
|
|
259
282
|
selector: { app: common_1.RUNNER_APP },
|
|
@@ -279,7 +302,7 @@ const runnerStack = (context) => {
|
|
|
279
302
|
policyTypes: ['Ingress'],
|
|
280
303
|
ingress: [
|
|
281
304
|
{
|
|
282
|
-
from:
|
|
305
|
+
from: generationCidrs.map((cidr) => ({ ipBlock: { cidr } })),
|
|
283
306
|
ports: [{ protocol: 'TCP', port: common_1.GENERATION_PORT }],
|
|
284
307
|
},
|
|
285
308
|
],
|
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/model-tiers.json
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"fast": {
|
|
3
|
-
|
|
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
|
+
}
|
|
4
12
|
}
|
package/runtime/protocol.mjs
CHANGED
|
@@ -7,7 +7,9 @@ import { readFileSync } from 'node:fs';
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The only place that names a model: each `prefer` tier maps to one model and
|
|
10
|
-
* effort
|
|
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.
|
|
11
13
|
*/
|
|
12
14
|
const MODEL_TIERS = JSON.parse(
|
|
13
15
|
readFileSync(new URL('./model-tiers.json', import.meta.url), 'utf8'),
|
|
@@ -325,6 +327,43 @@ const normalizeGenerationRequest = (value, models) => {
|
|
|
325
327
|
};
|
|
326
328
|
};
|
|
327
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
|
+
|
|
328
367
|
const requestDigest = (request) =>
|
|
329
368
|
createHash('sha256').update(JSON.stringify(request)).digest('hex');
|
|
330
369
|
const idempotencyDigest = (key) => createHash('sha256').update(key).digest('hex');
|
|
@@ -361,10 +400,12 @@ export {
|
|
|
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
|
) {
|