@edgehero/pi-dispatch 4.3.0 → 5.0.0
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/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/mcp-preflight.mjs
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { allowlistEntryFor, egressRefusalFor, packageRunnerHosts } from "./egress-allowlist.mjs";
|
|
37
37
|
import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, forgeMcpEgressUrl, forgeMcpServer, gitlabUrlProblem, jobMcpDocument, MCP_SERVER_NAME_RE, readOverlayMcp } from "./mcp-config.mjs";
|
|
38
|
-
import { MCP_SERVERS_MAX, mcpServerToolPattern } from "./triggers.mjs";
|
|
38
|
+
import { MCP_SERVERS_MAX, RESERVED_ENV_NAMES, mcpServerToolPattern } from "./triggers.mjs";
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Errnos a read of the overlay may pass on a second try. Restated from model-catalog.mjs `OVERLAY_TRANSIENT_READ_CODES`
|
|
@@ -78,6 +78,23 @@ export function jobProviders(job, { defaultProvider = null, allowedModels = null
|
|
|
78
78
|
return [...new Set([job?.provider ?? defaultProvider, ...listed].filter((p) => typeof p === "string" && p !== ""))];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The names a job's own container holds for itself, which no server may be handed: what `makeMcpCheck`'s `reserved` answers,
|
|
83
|
+
* one function for the worker's pickup and the console's preview. `readModels` reads the overlay models.json. A read that may
|
|
84
|
+
* pass on a retry is thrown to the check, which retries the job; any other failure adds no overlay names, and the model gates,
|
|
85
|
+
* which run first, refuse every job such a file breaks.
|
|
86
|
+
*/
|
|
87
|
+
export function reservedJobNames(job, { readModels, defaultProvider = null, allowedModels = null, everyProvider = [], candidatesOf = () => [] }) {
|
|
88
|
+
let overlayModels = null;
|
|
89
|
+
try {
|
|
90
|
+
overlayModels = readModels();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (MCP_TRANSIENT_READ_CODES.has(error?.code)) throw error;
|
|
93
|
+
}
|
|
94
|
+
const providers = jobProviders(job, { defaultProvider, allowedModels, everyProvider });
|
|
95
|
+
return new Set([...RESERVED_ENV_NAMES, ...providerKeyNames({ providers, candidatesOf, overlayModels })]);
|
|
96
|
+
}
|
|
97
|
+
|
|
81
98
|
/**
|
|
82
99
|
* The check, bound to this deployment. `reserved(job)` is the set of variable names the job's own container holds for
|
|
83
100
|
* itself; `hostEnv` is the worker's environment, which decides whether a PI_FORWARD_ENV name reaches the job at all;
|
|
@@ -221,9 +238,10 @@ function exposuresOf(server) {
|
|
|
221
238
|
* `autoEnableCodemode` is not false) and tool_search when some server has a deferred one; the resource tools take their
|
|
222
239
|
* server's own exposure, and a `hidden` server's resources are never offered. Judged only for what the trigger's
|
|
223
240
|
* exclusions take away: a server the overlay leaves no way in is the overlay's, not this trigger's to be refused for.
|
|
224
|
-
* Returns `{ name, why }` for
|
|
241
|
+
* Returns a `{ name, why }` for each server the exclusions cut off, in the list's order. The console says each one it did
|
|
242
|
+
* not already cut (`trigger-environment-plans.mjs`); a pickup refuses on the first, `mcpRouteProblem`.
|
|
225
243
|
*/
|
|
226
|
-
export function
|
|
244
|
+
export function mcpRouteCuts(servers, names, excluded, { autoEnableCodemode = true } = {}) {
|
|
227
245
|
const routes = (removed) => {
|
|
228
246
|
const all = names.map((name) => exposuresOf(servers[name]));
|
|
229
247
|
const has = (kind) => all.some(({ own, tools }) => own === kind || tools.includes(kind));
|
|
@@ -242,8 +260,12 @@ export function mcpRouteProblem(servers, names, excluded, { autoEnableCodemode =
|
|
|
242
260
|
};
|
|
243
261
|
const before = routes(new Set());
|
|
244
262
|
const after = routes(excluded);
|
|
245
|
-
|
|
246
|
-
|
|
263
|
+
return names.filter((_, k) => before[k] && !after[k]).map((name) => ({ name, why: "run.excludeTools removes every tool that reaches it (its own, the discovery tools that reach them, and read_mcp_resource)" }));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The first server `mcpRouteCuts` finds, or null: what a pickup refuses on. */
|
|
267
|
+
export function mcpRouteProblem(servers, names, excluded, options) {
|
|
268
|
+
return mcpRouteCuts(servers, names, excluded, options)[0] ?? null;
|
|
247
269
|
}
|
|
248
270
|
|
|
249
271
|
/**
|
|
@@ -260,3 +282,12 @@ export const MCP_REFUSAL_COMMENT = Object.freeze({
|
|
|
260
282
|
"mcp-egress-unreachable": "the egress policy does not let a job reach that server",
|
|
261
283
|
"mcp-package-runner": "the egress policy does not let a job reach the registry that server is fetched from",
|
|
262
284
|
});
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The whole public comment of an MCP refusal before starting: the server's NAME when the check named one (operator
|
|
288
|
+
* configuration, the same class as the image ref on the image refusals) and the reason's clause above. One function
|
|
289
|
+
* for the processor, which posts it, and the reason catalog (issue #710), which reads the nameless form as its sentence.
|
|
290
|
+
*/
|
|
291
|
+
export function mcpRefusalComment(refused, name) {
|
|
292
|
+
return `Refused: this trigger names MCP server${name ? ` "${name}"` : "s"} in \`run.mcp\`, and ${MCP_REFUSAL_COMMENT[refused] ?? "it cannot be used"}. The job would have run without the servers its flow was written against; the worker's log says why (\`refused_mcp\`). Not run.`;
|
|
293
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A job's model attempt key (`INT-MODEL-COOLDOWN-KEY`): `dispatch:model-attempt:<sha256(queueName:jobId)>`, the model the
|
|
3
|
+
* job's last attempt ran on and whether its retry may switch away from it. The processor writes it before each attempt
|
|
4
|
+
* (model-failover.mjs `writeModelAttempt`); this module names it, ends it and keeps it. Light on purpose, `node:crypto`
|
|
5
|
+
* and the retention leaf only, because `cancel-state.mjs`, which the admin imports, deletes it too, and the admin's retry
|
|
6
|
+
* keeps it.
|
|
7
|
+
*
|
|
8
|
+
* ITS LIFE FOLLOWS THE JOB'S (issue #812), the rule the approval requests keep (`run:approval:request:*`):
|
|
9
|
+
* - while a retry may still come it has no expiry. A retry can wait for any time (a paused queue, a backoff, a hold),
|
|
10
|
+
* so an expiry set when the key is written could take the evidence away from a retry that still comes;
|
|
11
|
+
* - once the queue says the job finished (the worker's `completed` event, or `failed` with `finishedOn` set, start.mjs)
|
|
12
|
+
* it gets `MODEL_ATTEMPT_FINISHED_TTL_MS`, a day past a forge job's retention, so it outlives the finished job's own
|
|
13
|
+
* hash, from which a console retry can still bring the job back. Never when a run record is written: the record is
|
|
14
|
+
* written before the queue acknowledges the job, and a job whose acknowledgement is lost can still run again;
|
|
15
|
+
* - when a cancel or the host-lost sweep removes the job, it is deleted;
|
|
16
|
+
* - when the console retries a failed job, the expiry is taken off again (`keepModelAttempt`), as the job is back
|
|
17
|
+
* where a retry may come for any time.
|
|
18
|
+
* A retry's write is a plain `SET`, which clears the expiry again.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { MODEL_ATTEMPT_FINISHED_TTL_MS } from "./retention.mjs";
|
|
22
|
+
|
|
23
|
+
/** The key of the attempt kept under `id` (`modelAttemptIdOf`). */
|
|
24
|
+
export const modelAttemptKey = (id) => `dispatch:model-attempt:${createHash("sha256").update(id).digest("hex")}`;
|
|
25
|
+
|
|
26
|
+
/** The id a job's attempt is kept under: its queue's name and its job id, as the processor names it. */
|
|
27
|
+
export const modelAttemptIdOf = (queueName, jobId) => `${queueName}:${jobId}`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Give a finished job's attempt its expiry. `PEXPIRE` sets nothing on a key that is absent, so a job that never chose a
|
|
31
|
+
* model (no `fallbackModels`) creates nothing here. Rejects as the client does; the caller logs it.
|
|
32
|
+
*/
|
|
33
|
+
export async function finishModelAttempt(redis, id) {
|
|
34
|
+
await redis.pexpire(modelAttemptKey(id), MODEL_ATTEMPT_FINISHED_TTL_MS);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Send one `command` on the key `keyOf()` names, bounded by `timeoutMs` and best-effort: it never throws (a client
|
|
39
|
+
* without the command, a key that cannot be named, a throw, a rejection and a reply that never comes all leave the key as
|
|
40
|
+
* it was), and the bound's timer is cleared when the command answers first, so a CLI that sets its exit code rather than
|
|
41
|
+
* exiting is not held open by it. The one body behind every bounded write below and cancel-state.mjs's defer record.
|
|
42
|
+
*/
|
|
43
|
+
async function bestEffort(redis, command, keyOf, timeoutMs) {
|
|
44
|
+
if (typeof redis?.[command] !== "function") return;
|
|
45
|
+
let timer;
|
|
46
|
+
try {
|
|
47
|
+
await Promise.race([
|
|
48
|
+
Promise.resolve().then(() => redis[command](keyOf())),
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
51
|
+
}),
|
|
52
|
+
]);
|
|
53
|
+
} catch {
|
|
54
|
+
// Left as it was: see above.
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Delete `key`, bounded and best-effort (`bestEffort`): cancel-state.mjs deletes a removed job's defer record with it. */
|
|
61
|
+
export function dropKey(redis, key, timeoutMs = 2_000) {
|
|
62
|
+
return bestEffort(redis, "del", () => key, timeoutMs);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Delete a removed job's attempt, bounded and best-effort (`bestEffort`). A delete that fails leaves the key as the job
|
|
67
|
+
* left it, without expiry.
|
|
68
|
+
*/
|
|
69
|
+
export function dropModelAttempt(redis, id, timeoutMs = 2_000) {
|
|
70
|
+
return bestEffort(redis, "del", () => modelAttemptKey(id), timeoutMs);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Take a retried job's attempt's expiry off again, bounded and best-effort (`bestEffort`): the console's retry of a
|
|
75
|
+
* failed job (admin job-actions.mjs) calls it once BullMQ has put the job back. The failure gave the key
|
|
76
|
+
* `MODEL_ATTEMPT_FINISHED_TTL_MS`, and only the processor's next selection writes it again without one; the retried job
|
|
77
|
+
* can wait longer than what is left of it before that write (a paused queue or pause window, a busy scope, a hold for a
|
|
78
|
+
* newer release), and would then find no evidence and be refused `model-failover-no-candidate`. `PERSIST` on an absent
|
|
79
|
+
* key creates nothing, so a job without fallbacks, or one whose key already expired, is left as it was; so is a key a
|
|
80
|
+
* `PERSIST` that fails could not reach, which keeps its expiry.
|
|
81
|
+
*/
|
|
82
|
+
export function keepModelAttempt(redis, id, timeoutMs = 2_000) {
|
|
83
|
+
return bestEffort(redis, "persist", () => modelAttemptKey(id), timeoutMs);
|
|
84
|
+
}
|
package/src/model-catalog.mjs
CHANGED
|
@@ -297,3 +297,86 @@ export function declaredFallbacks(provider, id, overlay = null) {
|
|
|
297
297
|
}
|
|
298
298
|
return found ?? [];
|
|
299
299
|
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The runner's own input overhead (`BOUND_OVERHEAD_TOKENS`, image/runner/src/usage-meter.mjs), which its bound adds to
|
|
303
|
+
* every call. Copied, because the worker package does not ship the runner; `fleet-dollars.test.mjs` holds the two equal.
|
|
304
|
+
*/
|
|
305
|
+
export const FIRST_CALL_OVERHEAD_TOKENS = 8192;
|
|
306
|
+
/**
|
|
307
|
+
* The least a job's FIRST call carries before its task, in bytes: at the pin, pi's default system prompt, which names pi's install
|
|
308
|
+
* path, and the schemas of its four default tools serialise to about 5 KB, and a short task's first request is 10,667 bytes. 4,096
|
|
309
|
+
* is below both, so the bound below stays a lower bound for a job that adds nothing. `worker/test/first-call-bytes.pinned.test.mjs`
|
|
310
|
+
* computes that against the pinned pi, and holds the order.
|
|
311
|
+
*/
|
|
312
|
+
export const FIRST_CALL_MIN_CONTEXT_BYTES = 4096;
|
|
313
|
+
/**
|
|
314
|
+
* 10,667 bytes, a short task's first request. A cap at the floor above admits a request nobody sends: the same default prompt
|
|
315
|
+
* and tools alone come to about 5 KB, and a one-line task adds little. So this, not the floor, is the least a person can be told to set. A repository's context files, which go in the system prompt before any task
|
|
316
|
+
* does, and a longer task, need more still.
|
|
317
|
+
*/
|
|
318
|
+
export const FIRST_CALL_SHORT_TASK_BYTES = 10_667;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A LOWER BOUND, in integer micro-dollars, on what the runner's cost guard (`callCostBound`) reserves for a job's
|
|
322
|
+
* first call on `model`, or null when this cannot say. The guard refuses a call when its bound would pass the cap, so
|
|
323
|
+
* a per-job cap below this number can never admit a call on the model: with the main model, the job makes no call at
|
|
324
|
+
* all (the default model's first call has been bounded at $1.03 under a $1 cap).
|
|
325
|
+
*
|
|
326
|
+
* The guard's bound is (request bytes + 8,192) x the dearest input rate + the output limit x the dearest output rate,
|
|
327
|
+
* times a service-tier multiplier, over the model's table, its tiers and its fallbacks. This takes the parts of that
|
|
328
|
+
* which cannot be smaller: the model's own table (not its tiers or fallbacks, which only raise the bound), the output
|
|
329
|
+
* limit `model.maxTokens` (pi sends none of its own, so the guard uses the model's), an input of the overhead plus
|
|
330
|
+
* `FIRST_CALL_MIN_CONTEXT_BYTES`, and the runner's own service-tier multiplier (`serviceTierMultiplier`). The input
|
|
331
|
+
* rate is the guard's: the highest of input, cache read and cache write (not the 1h write, which needs long
|
|
332
|
+
* retention). Null for a table or limit it cannot read.
|
|
333
|
+
*/
|
|
334
|
+
export function firstCallFloorMicros(model) {
|
|
335
|
+
return firstCallBoundMicros(model, FIRST_CALL_MIN_CONTEXT_BYTES);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The bound `firstCallFloorMicros` describes, for a request of `contextBytes` serialised, or null when this cannot say. At
|
|
340
|
+
* `FIRST_CALL_SHORT_TASK_BYTES` it is what a short task's first call needs from the guard. Held equal to the runner's own bound
|
|
341
|
+
* at that size, for every builtin chat model, by `fleet-dollars.test.mjs`.
|
|
342
|
+
*/
|
|
343
|
+
export function firstCallBoundMicros(model, contextBytes) {
|
|
344
|
+
const cost = model?.cost;
|
|
345
|
+
const usable = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
346
|
+
if (!cost || !["input", "output", "cacheRead", "cacheWrite"].every((field) => usable(cost[field]))) return null;
|
|
347
|
+
const maxTokens = model.maxTokens;
|
|
348
|
+
if (!(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0)) return null;
|
|
349
|
+
const inputRate = Math.max(cost.input, cost.cacheRead, cost.cacheWrite);
|
|
350
|
+
return Math.ceil(((FIRST_CALL_OVERHEAD_TOKENS + contextBytes) * inputRate + maxTokens * cost.output) * serviceTierMultiplier(model));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The runner's service-tier multiplier (`callCostBound` step 7, image/runner/src/usage-meter.mjs), copied like the
|
|
355
|
+
* overhead above: pi multiplies a settled cost by the tier the RESPONSE reports on these two apis, which can be the
|
|
356
|
+
* account's default rather than the request's, so the runner always bounds at the dearest tier. Leaving it out
|
|
357
|
+
* (PR #551's review) let doctor stay silent on a cap the runner refuses every call under: openai/gpt-5.5 under $5.
|
|
358
|
+
* `fleet-dollars.test.mjs` derives the runner's multiplier from its own bound for every priced api and holds this
|
|
359
|
+
* equal to it.
|
|
360
|
+
*/
|
|
361
|
+
export function serviceTierMultiplier(model) {
|
|
362
|
+
if (model?.api !== "openai-responses" && model?.api !== "openai-codex-responses") return 1;
|
|
363
|
+
return model.id === "gpt-5.5" ? 2.5 : 2;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The model object the floor above is computed from: the builtin catalog's (`builtinModel`), or a
|
|
368
|
+
* CUSTOM model the overlay `models.json` defines (its own `cost` and `maxTokens`). Null, so the model is not judged,
|
|
369
|
+
* when the overlay redefines or overrides a builtin model: pi composes the two, and this does not copy that rule.
|
|
370
|
+
*/
|
|
371
|
+
export function boundModelOf({ provider, id }, { builtinModel, overlay = null }) {
|
|
372
|
+
const providers = overlay?.providers;
|
|
373
|
+
const entry = providers !== null && typeof providers === "object" && !Array.isArray(providers) && Object.hasOwn(providers, provider) ? providers[provider] : null;
|
|
374
|
+
const builtin = builtinModel(provider, id);
|
|
375
|
+
if (entry !== null && typeof entry === "object") {
|
|
376
|
+
const overrides = entry.modelOverrides;
|
|
377
|
+
if (overrides !== null && typeof overrides === "object" && Object.hasOwn(overrides, id)) return null;
|
|
378
|
+
const definition = Array.isArray(entry.models) ? entry.models.find((m) => m !== null && typeof m === "object" && m.id === id) : undefined;
|
|
379
|
+
if (definition !== undefined) return builtin ? null : definition;
|
|
380
|
+
}
|
|
381
|
+
return builtin;
|
|
382
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Read-only configured cooldown observations, shared by failover and console. No keyspace enumeration. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { providerFailureOf } from "./model-failover-protocol.mjs";
|
|
4
|
+
|
|
5
|
+
export const MODEL_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1000;
|
|
6
|
+
export const MODEL_RECHECK_MS = 60_000;
|
|
7
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
8
|
+
export const modelCooldownKey = (ref) => `dispatch:model-cooldown:${digest(ref)}`;
|
|
9
|
+
|
|
10
|
+
export async function readModelCooldown(redis, ref, { now = () => Date.now() } = {}) {
|
|
11
|
+
const raw = await redis.get(modelCooldownKey(ref));
|
|
12
|
+
if (raw === null || raw === undefined) return null;
|
|
13
|
+
let row;
|
|
14
|
+
try { row = JSON.parse(raw); } catch { throw new Error("invalid model cooldown"); }
|
|
15
|
+
if (!providerFailureOf(row) || !Number.isSafeInteger(row.until) || row.until < 0 || row.until > 253_402_300_799_999 || row.kind === "context-overflow") throw new Error("invalid model cooldown");
|
|
16
|
+
return row.until > now() ? { ref, kind: row.kind, until: row.until } : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function readModelCooldowns(redis, refs, options = {}) {
|
|
20
|
+
const rows = await Promise.all([...new Set(refs)].map((ref) => readModelCooldown(redis, ref, options)));
|
|
21
|
+
return rows.filter(Boolean);
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const PROVIDER_FAILURE_KINDS = Object.freeze(["rate-limited", "quota-exhausted", "overloaded", "context-overflow"]);
|
|
2
|
+
export const MODEL_WHY = Object.freeze(["primary", "fallback-cooldown", "fallback-window", "fallback-unavailable", "fallback-context"]);
|
|
3
|
+
|
|
4
|
+
export function providerFailureOf(value) {
|
|
5
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !PROVIDER_FAILURE_KINDS.includes(value.kind)) return null;
|
|
6
|
+
if (value.resetAt !== undefined && (!Number.isSafeInteger(value.resetAt) || value.resetAt < 0 || value.resetAt > 253_402_300_799_999)) return null;
|
|
7
|
+
if (value.subscriptionCode !== undefined && !["usage_limit_reached", "usage_not_included", "rate_limit_exceeded", "quota_exhausted"].includes(value.subscriptionCode)) return null;
|
|
8
|
+
if (value.subscriptionCode !== undefined && !["openai", "anthropic"].includes(value.subscriptionProvider)) return null;
|
|
9
|
+
if (value.subscriptionProvider !== undefined && !["openai", "anthropic"].includes(value.subscriptionProvider)) return null;
|
|
10
|
+
return { kind: value.kind, ...(value.resetAt !== undefined ? { resetAt: value.resetAt } : {}), ...(value.subscriptionCode ? { subscriptionCode: value.subscriptionCode } : {}), ...(value.subscriptionProvider ? { subscriptionProvider: value.subscriptionProvider } : {}) };
|
|
11
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { resolveBackendName } from "./backend-registry.mjs";
|
|
2
|
+
import { MODEL_WHY, providerFailureOf } from "./model-failover-protocol.mjs";
|
|
3
|
+
import { failoverListUsable, modelListProblem, modelOnList, splitModelEntry } from "./model-ref.mjs";
|
|
4
|
+
import { modelDollarRows } from "./scoped-limits.mjs";
|
|
5
|
+
import { dayKey, weekKey, monthKey } from "./budget.mjs";
|
|
6
|
+
import { builtinModel, checkModelsKnown } from "./model-catalog.mjs";
|
|
7
|
+
import { egressRefusalFor } from "./egress-allowlist.mjs";
|
|
8
|
+
import { LOCAL_FOLDER_HOLDS_SUBSCRIPTION_AUTH } from "./private-store-boundary.mjs";
|
|
9
|
+
|
|
10
|
+
export { MODEL_WHY, PROVIDER_FAILURE_KINDS, providerFailureOf } from "./model-failover-protocol.mjs";
|
|
11
|
+
|
|
12
|
+
export function failoverEgressRequired(job, { egress = false, defaultBackend = "local" } = {}) {
|
|
13
|
+
return egress || ["openshell", "openshell-bind"].includes(resolveBackendName(job, defaultBackend));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
import { MODEL_COOLDOWN_MAX_MS, MODEL_RECHECK_MS, modelCooldownKey, readModelCooldown, readModelCooldowns } from "./model-cooldowns.mjs";
|
|
17
|
+
import { FAILOVER_PRICED_APIS, failoverModelView, failoverPriceEligible } from "./model-status.mjs";
|
|
18
|
+
export { MODEL_COOLDOWN_MAX_MS, MODEL_RECHECK_MS, modelCooldownKey, readModelCooldown, readModelCooldowns };
|
|
19
|
+
export { FAILOVER_PRICED_APIS, failoverModelView };
|
|
20
|
+
// The key and its life live in model-attempts.mjs (issue #812); re-exported, so an import of it from here keeps working.
|
|
21
|
+
import { modelAttemptKey } from "./model-attempts.mjs";
|
|
22
|
+
export { modelAttemptKey };
|
|
23
|
+
|
|
24
|
+
export async function markModelCooldown(redis, ref, failure, { now = () => Date.now() } = {}) {
|
|
25
|
+
const signal = providerFailureOf(failure);
|
|
26
|
+
if (!signal || signal.kind === "context-overflow") return null;
|
|
27
|
+
const at = now();
|
|
28
|
+
const delay = signal.resetAt === undefined ? (signal.kind === "quota-exhausted" ? MODEL_COOLDOWN_MAX_MS : MODEL_RECHECK_MS) : Math.max(1000, signal.resetAt - at);
|
|
29
|
+
const ttl = Math.min(MODEL_COOLDOWN_MAX_MS, delay);
|
|
30
|
+
const row = { kind: signal.kind, until: at + ttl };
|
|
31
|
+
// Concurrent observations must never shorten an already published provider reset. One atomic script also
|
|
32
|
+
// prevents two workers from both reading an old reset and then overwriting the longer one in reverse order.
|
|
33
|
+
await redis.eval(`local old = redis.call('GET', KEYS[1]); if old then local ok, row = pcall(cjson.decode, old); if ok and type(row['until']) == 'number' and row['until'] > tonumber(ARGV[1]) then return 0 end end; redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3]); return 1`, 1, modelCooldownKey(ref), row.until, JSON.stringify(row), ttl);
|
|
34
|
+
return { ref, ...row };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readModelAttempt(redis, id) {
|
|
38
|
+
const raw = await redis.get(modelAttemptKey(id));
|
|
39
|
+
if (raw === null || raw === undefined) return null;
|
|
40
|
+
let row;
|
|
41
|
+
try { row = JSON.parse(raw); } catch { throw new Error("invalid model attempt"); }
|
|
42
|
+
if (!splitModelEntry(row?.ref) || typeof row.safe !== "boolean" || (row.safe && !providerFailureOf(row.failure))) throw new Error("invalid model attempt");
|
|
43
|
+
return { ref: row.ref, safe: row.safe && providerFailureOf(row.failure) !== null, failure: providerFailureOf(row.failure), ...(MODEL_WHY.includes(row.modelWhy) ? { modelWhy: row.modelWhy } : {}) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function writeModelAttempt(redis, id, { ref, safe = false, failure = null, modelWhy = null }) {
|
|
47
|
+
if (!splitModelEntry(ref)) throw new Error("invalid model attempt ref");
|
|
48
|
+
await redis.set(modelAttemptKey(id), JSON.stringify({ ref, safe: safe === true, failure: providerFailureOf(failure), ...(MODEL_WHY.includes(modelWhy) ? { modelWhy } : {}) }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function checkFailoverCandidate(job, { models = null, endpoints = [], credential, egress = false, allowlist = [], lookup = builtinModel } = {}) {
|
|
52
|
+
// The second ref makes the existing declared-provider-fallback check enforce this attempt's singleton too.
|
|
53
|
+
const known = checkModelsKnown([{ provider: job.provider, id: job.model, main: true }, { provider: job.provider, id: job.model }], { readOverlay: () => models });
|
|
54
|
+
if (known.unavailable) throw new Error("model catalog unavailable");
|
|
55
|
+
if (known.unknown) return "model-unknown";
|
|
56
|
+
if (known.fallbackUnlisted) return "model-not-allowed";
|
|
57
|
+
const view = failoverModelView(`${job.provider}/${job.model}`, { models, lookup });
|
|
58
|
+
if (!failoverPriceEligible(view)) return "model-unpriced";
|
|
59
|
+
if (credential) {
|
|
60
|
+
const checked = await credential(job, { modelEndpoints: { models, endpoints } });
|
|
61
|
+
if (checked.unavailable) throw new Error("provider credential unavailable");
|
|
62
|
+
if (!checked.ok) return "provider-unconfigured";
|
|
63
|
+
}
|
|
64
|
+
if (egress && egressRefusalFor(view.baseUrl, { allowlist, endpoints }) !== null) return "model-host-unlisted";
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function modelWindowHasRoom(redis, ref, job, limits, at) {
|
|
69
|
+
if (job.maxCostMicros === 0) return true;
|
|
70
|
+
for (const row of modelDollarRows(limits, [ref])) {
|
|
71
|
+
// reserveDollars remains the atomic authority. This read chooses a candidate without acquiring money;
|
|
72
|
+
// its later reservation may lose a race, which must refuse rather than run with an uncovered window.
|
|
73
|
+
if (!Number.isSafeInteger(job.maxCostMicros)) return false;
|
|
74
|
+
for (const [window, key] of [["day", dayKey(new Date(at), row.keyPrefix)], ["week", weekKey(new Date(at), row.keyPrefix)], ["month", monthKey(new Date(at), row.keyPrefix)]]) {
|
|
75
|
+
const cap = row.caps?.[window];
|
|
76
|
+
if (cap === null || cap === undefined) continue;
|
|
77
|
+
const raw = await redis.get(key);
|
|
78
|
+
const spent = raw === null || raw === undefined ? 0 : Number(raw);
|
|
79
|
+
if (!Number.isSafeInteger(spent) || spent < 0) throw new Error("invalid model window");
|
|
80
|
+
if (spent + job.maxCostMicros > cap) return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Only opt-in jobs enter here. Every candidate has a singleton executable policy beneath the reviewed ceiling. */
|
|
87
|
+
export async function selectFailoverJob(job, { redis, id, retry = false, recoveryModel = null, retryNeverStarted = false, models = null, limits = [], now = () => Date.now(), checkCandidate, subscriptionCheck = null, reservationFor = (candidate) => candidate.maxCostMicros, acquireEndpoints = async () => true, modelView = (ref) => failoverModelView(ref, { models }), log = () => {} } = {}) {
|
|
88
|
+
if (!failoverListUsable(job)) return { refused: "model-failover-no-candidate" };
|
|
89
|
+
const primary = `${job.provider}/${job.model}`;
|
|
90
|
+
const previous = retry ? await readModelAttempt(redis, id) : null;
|
|
91
|
+
if (retry && !previous && !retryNeverStarted && !recoveryModel) return { refused: "model-failover-no-candidate" };
|
|
92
|
+
const locked = recoveryModel ?? (retry && previous?.safe !== true ? previous?.ref ?? primary : null);
|
|
93
|
+
const needsLarger = !locked && previous?.failure?.kind === "context-overflow";
|
|
94
|
+
const largerThan = needsLarger ? modelView(previous.ref)?.contextWindow : null;
|
|
95
|
+
let why = previous?.failure?.kind === "context-overflow" ? "fallback-context" : "fallback-unavailable";
|
|
96
|
+
let wait = null;
|
|
97
|
+
const primaryAuth = subscriptionCheck ? await subscriptionCheck({ ...job, models: [primary] }) : null;
|
|
98
|
+
// Changing models cannot make a local mount stop exposing the host's refresh store.
|
|
99
|
+
if (primaryAuth?.reason === LOCAL_FOLDER_HOLDS_SUBSCRIPTION_AUTH) return { refused: LOCAL_FOLDER_HOLDS_SUBSCRIPTION_AUTH };
|
|
100
|
+
let subscriptionPrimary = primaryAuth?.managed === true || Array.isArray(primaryAuth?.managed) && primaryAuth.managed.length > 0;
|
|
101
|
+
for (const ref of [...new Set(locked ? [locked] : [primary, ...job.fallbackModels])]) {
|
|
102
|
+
const split = splitModelEntry(ref);
|
|
103
|
+
if (!split || !modelOnList(job.models, split.provider, split.model)) { log("model_candidate_skipped", { model: split ? ref : null, reason: "model-not-allowed" }); continue; }
|
|
104
|
+
const candidate = { ...job, provider: split.provider, model: split.model, models: [ref] };
|
|
105
|
+
if (subscriptionCheck) {
|
|
106
|
+
const subscription = await subscriptionCheck(candidate);
|
|
107
|
+
if (subscription.unavailable) throw new Error("subscription state unavailable");
|
|
108
|
+
if (ref === primary && (subscription.managed === true || Array.isArray(subscription.managed) && subscription.managed.length > 0)) subscriptionPrimary = true;
|
|
109
|
+
if (subscriptionPrimary && subscription.managed !== true && !(Array.isArray(subscription.managed) && subscription.managed.length > 0)) {
|
|
110
|
+
log("model_candidate_skipped", { model: ref, reason: "auth-mixed-billing" });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!subscription.ok) {
|
|
114
|
+
if (subscription.wait) wait ??= subscription.wait;
|
|
115
|
+
log("model_candidate_skipped", { model: ref, reason: subscription.wait ?? subscription.reason ?? "auth-unavailable" });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const bad = await checkCandidate(candidate);
|
|
120
|
+
if (bad) { log("model_candidate_skipped", { model: ref, reason: bad }); continue; }
|
|
121
|
+
if (needsLarger && (!Number.isFinite(largerThan) || !(modelView(ref)?.contextWindow > largerThan))) { log("model_candidate_skipped", { model: ref, reason: "context-too-small" }); continue; }
|
|
122
|
+
if (!locked) {
|
|
123
|
+
const cooldown = await readModelCooldown(redis, ref, { now });
|
|
124
|
+
const failedHere = previous?.safe === true && previous.ref === ref && previous.failure?.kind !== "context-overflow";
|
|
125
|
+
if (cooldown || failedHere) { if (ref === primary) why = "fallback-cooldown"; if (!failedHere) wait ??= "model-cooldown"; log("model_candidate_skipped", { model: ref, reason: failedHere ? "previous-attempt" : "cooldown" }); continue; }
|
|
126
|
+
}
|
|
127
|
+
const reservationJob = { ...candidate, maxCostMicros: reservationFor(candidate) };
|
|
128
|
+
if (modelDollarRows(limits, [ref]).length > 0 && !Number.isSafeInteger(reservationJob.maxCostMicros)) { log("model_candidate_skipped", { model: ref, reason: "model-cost-cap-missing" }); continue; }
|
|
129
|
+
if (!(await modelWindowHasRoom(redis, ref, reservationJob, limits, now()))) { if (ref === primary) why = "fallback-window"; wait ??= "model-window"; log("model_candidate_skipped", { model: ref, reason: "model-window" }); continue; }
|
|
130
|
+
if (!(await acquireEndpoints(candidate))) { wait ??= "endpoint"; log("model_candidate_skipped", { model: ref, reason: "endpoint-busy" }); continue; }
|
|
131
|
+
return { job: { ...candidate, modelWhy: ref === primary ? "primary" : locked ? previous?.modelWhy ?? "fallback-unavailable" : why }, ref };
|
|
132
|
+
}
|
|
133
|
+
return wait ? { wait, until: now() + MODEL_RECHECK_MS } : { refused: "model-failover-no-candidate" };
|
|
134
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Model policy observations use pickup's resolver and never acquire a slot, reserve or start work. */
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { projectOf } from "./projects.mjs";
|
|
5
|
+
import { splitModelEntry } from "./model-ref.mjs";
|
|
6
|
+
import { resolveModelRoles } from "./model-roles.mjs";
|
|
7
|
+
|
|
8
|
+
/** The same role resolver as pickup, expanded over every project a broad forge trigger can reach. */
|
|
9
|
+
export function resolveConfiguredModelRuns({ runs = [], deployment, envList = null, modelRoles = {}, projects = [], resolveFolder = realpathSync, conservativeLocal = false }) {
|
|
10
|
+
const resolved = [];
|
|
11
|
+
const checks = [];
|
|
12
|
+
for (const run of runs) {
|
|
13
|
+
if (Object.keys(modelRoles).length === 0 && !run.model?.startsWith("role:")) { resolved.push(run); continue; }
|
|
14
|
+
const list = run.models ?? envList;
|
|
15
|
+
const job = { ...run, provider: run.provider ?? deployment.provider, model: run.model ?? deployment.model, ...(list ? { models: list } : {}) };
|
|
16
|
+
// Forge events without a fully qualified repository can reach any matching project. Azure
|
|
17
|
+
// run.repository alone is only a repository name, not enough to infer a project-qualified scope.
|
|
18
|
+
let projectUnknown = false;
|
|
19
|
+
if (job.kind === "local" && typeof job.folder === "string") {
|
|
20
|
+
try { job.folder = resolveFolder(job.folder); } catch { projectUnknown = conservativeLocal; }
|
|
21
|
+
}
|
|
22
|
+
const fixed = run.kind === "local" || typeof run.repo === "string";
|
|
23
|
+
const possible = projectUnknown ? [null, ...projects.filter(project => project.members?.some(member => typeof member === "string" && isAbsolute(member))).map(project => project.id)] : fixed ? [projectOf(job, projects)] : [null, ...projects.filter((project) => project.members?.some((member) => typeof member === "string" && member.startsWith(`${run.kind}:`))).map((project) => project.id)];
|
|
24
|
+
for (const project of new Set(possible)) {
|
|
25
|
+
const label = project === null ? run.label : `${run.label} (project ${project})`;
|
|
26
|
+
try {
|
|
27
|
+
const selected = resolveModelRoles(job, modelRoles, project, { authoredProvider: Object.hasOwn(run, "authoredProvider") ? run.authoredProvider : run.provider });
|
|
28
|
+
resolved.push({ ...selected, label, project, ...(projectUnknown ? { projectUnknown: true } : {}), roleScope: selected.modelRole && Object.hasOwn(modelRoles?.[project] ?? {}, selected.modelRole) ? project : "default" });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
checks.push({ ok: false, warn: true, sourceIndex: run.sourceIndex ?? null, project, ...(projectUnknown ? { projectUnknown: true } : {}), reason: error.modelSelectionReason ?? "model-roles-invalid", label: `${label}: reviewed model roles refuse this job (${error.modelSelectionReason ?? "model-roles-invalid"})`, fix: "review triggers.json modelRoles and this trigger's provider/model/allowed list; project roles override defaults" });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { runs: resolved, checks };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The configured failover models, never arbitrary keyspace members. Non-failover jobs add no read. */
|
|
38
|
+
export function cooldownModelRefs(runs, deployment, { modelRoles = {}, projects = [], envList = null } = {}) {
|
|
39
|
+
const refs = new Set();
|
|
40
|
+
for (const run of resolveConfiguredModelRuns({ runs, deployment, envList, modelRoles, projects }).runs) {
|
|
41
|
+
if (!Array.isArray(run.fallbackModels) || run.fallbackModels.length === 0) continue;
|
|
42
|
+
for (const ref of [`${run.provider ?? deployment.provider}/${run.model ?? deployment.model}`, ...run.fallbackModels]) {
|
|
43
|
+
if (splitModelEntry(ref)) refs.add(ref);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...refs];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const RADIUS_COST_CAP_LABEL = "Radius (pi-messages) has no verified local cost bound, including zero-rate routes and custom gateways, so the runner refuses every call under a per-job cost cap as unboundable";
|
|
50
|
+
export const RADIUS_COST_CAP_FIX = "use a provider with a locally bounded API for capped jobs; raising the cap or changing catalog rates cannot bound Radius: docs/costs.md";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Closed proposal metadata. A generic successful doctor check never gains publishable prose. */
|
|
2
|
+
import { splitModelEntry, providerIdProblem } from "./model-ref.mjs";
|
|
3
|
+
import { MODEL_ROLE_RE } from "./model-roles.mjs";
|
|
4
|
+
import { isProjectId } from "./project-id.mjs";
|
|
5
|
+
export const MODEL_PROPOSAL_KINDS = Object.freeze(["retirement", "newer-sibling", "cheaper-route", "catalog-checked", "unavailable"]);
|
|
6
|
+
export function modelProposalOf(value) {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !MODEL_PROPOSAL_KINDS.includes(value.kind)) return null;
|
|
8
|
+
const out = { kind: value.kind };
|
|
9
|
+
for (const key of ["model", "candidate"]) if (value[key] !== undefined) {
|
|
10
|
+
if (!splitModelEntry(value[key])) return null;
|
|
11
|
+
out[key] = value[key];
|
|
12
|
+
}
|
|
13
|
+
if (value.scope !== undefined) { if (!isProjectId(value.scope)) return null; out.scope = value.scope; }
|
|
14
|
+
if (value.role !== undefined) { if (!MODEL_ROLE_RE.test(value.role) || ["inherit", "constructor", "prototype"].includes(value.role)) return null; out.role = value.role; }
|
|
15
|
+
if (value.provider !== undefined) { if (providerIdProblem(value.provider) !== null) return null; out.provider = value.provider; }
|
|
16
|
+
if (["retirement", "newer-sibling"].includes(out.kind) && (!out.model || !out.scope || !out.role)) return null;
|
|
17
|
+
if (["newer-sibling", "cheaper-route"].includes(out.kind) && !out.candidate) return null;
|
|
18
|
+
if (out.kind === "cheaper-route" && !out.model) return null;
|
|
19
|
+
if (out.kind === "catalog-checked" && !out.provider) return null;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Reviewed suggestions only (#717). Data enters here; no routing or settings writes leave it. */
|
|
2
|
+
|
|
3
|
+
const REF = /^(?=.{1,256}$)[~@]?[A-Za-z0-9][A-Za-z0-9._:/@_-]*$/;
|
|
4
|
+
export function catalogDiff(before, after, providers) {
|
|
5
|
+
const index = (rows) => {
|
|
6
|
+
const out = new Map();
|
|
7
|
+
for (const row of rows) {
|
|
8
|
+
if (!row || typeof row.provider !== "string" || typeof row.id !== "string" || !REF.test(row.provider) || !REF.test(row.id) || !providers.includes(row.provider) || row.type !== "chat") continue;
|
|
9
|
+
const key = `${row.provider}/${row.id}`;
|
|
10
|
+
if (out.has(key)) throw new Error("ambiguous model catalog");
|
|
11
|
+
out.set(key, row);
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
};
|
|
15
|
+
const old = index(before), next = index(after);
|
|
16
|
+
const changed = [];
|
|
17
|
+
for (const key of [...new Set([...old.keys(), ...next.keys()])].sort()) {
|
|
18
|
+
const a = old.get(key), b = next.get(key);
|
|
19
|
+
const canonical = (value) => Array.isArray(value) ? value.map(canonical) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) : value;
|
|
20
|
+
const rates = (row) => JSON.stringify(canonical(row?.cost ?? null));
|
|
21
|
+
if (!a || !b || rates(a) !== rates(b)) changed.push({ ref: key, kind: !a ? "added" : !b ? "removed" : "repriced", before: a ?? null, after: b ?? null });
|
|
22
|
+
}
|
|
23
|
+
return changed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Same worst-call bound as enforcement, for an explicitly disclosed empty-text fixture, not a job forecast. */
|
|
27
|
+
export function proposalCallBound(model, outputTokens = 4096, callCostBound) {
|
|
28
|
+
const micros = callCostBound("streamSimple", model, { messages: [] }, { maxTokens: outputTokens }, {});
|
|
29
|
+
return Number.isFinite(micros) ? micros / 1e6 : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderCatalogProposal(changes, { providers, capsUsd, outputTokens = 4096 }, callCostBound) {
|
|
33
|
+
const lines = ["## Model catalog proposals", "", `Public report scope: ${providers.join(", ")}. Reference caps: ${capsUsd.map((n) => `$${n}`).join(", ")}; these are public report inputs, not deployment caps.`, "", `First-call bounds use the runner's guard with an empty-text context and maxTokens=${outputTokens}; actual job prompts, images, thinking and options can increase the bound. Models remain unchanged until a reviewed edit.`, ""];
|
|
34
|
+
if (!changes.length) lines.push("No added, removed or repriced chat models in this scope.");
|
|
35
|
+
for (const change of changes) {
|
|
36
|
+
const bound = change.after ? proposalCallBound(change.after, outputTokens, callCostBound) : null;
|
|
37
|
+
const price = bound === null ? "not boundable" : `$${bound.toFixed(6)}`;
|
|
38
|
+
const beforeBound = change.before ? proposalCallBound(change.before, outputTokens, callCostBound) : null;
|
|
39
|
+
const previous = change.before ? `; previous reference bound: ${beforeBound === null ? "not boundable" : `$${beforeBound.toFixed(6)}`}` : "";
|
|
40
|
+
lines.push(`- ${change.kind}: \`${change.ref}\`; reference first-call bound: ${change.after ? price : "removed"}${previous}${bound === null ? "" : `; ${capsUsd.map((cap) => `${bound <= cap ? "fits" : "exceeds"} $${cap}`).join(", ")}`}.`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Provider fields verified against their Models API references, 2026-10-29. No name-based sibling guesses. */
|
|
46
|
+
export function lifecycleModels(provider, response) {
|
|
47
|
+
if (!Array.isArray(response?.data) || response.data.length > 1000) throw new Error("invalid lifecycle catalog");
|
|
48
|
+
return response.data.map((row) => {
|
|
49
|
+
if (!row || typeof row.id !== "string" || !REF.test(row.id)) throw new Error("invalid lifecycle model");
|
|
50
|
+
const date = provider === "anthropic" ? row.retires_at : row.shutdown_date;
|
|
51
|
+
if (date != null && (typeof date !== "string" || !Number.isFinite(Date.parse(date)))) throw new Error("invalid retirement date");
|
|
52
|
+
if (provider === "anthropic" && !["active", "deprecated", "retired"].includes(row.lifecycle)) throw new Error("invalid lifecycle stage");
|
|
53
|
+
return { id: row.id, retiresAt: date ?? null, deprecated: provider === "anthropic" ? row.lifecycle !== "active" : date != null, retired: provider === "anthropic" && row.lifecycle === "retired", line: typeof row.line === "string" ? row.line : null, createdAt: row.created_at ?? null };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function roleProposalChecks({ roles, catalogs = {}, replacements = {}, now, noticeDays = 90 }) {
|
|
58
|
+
const checks = [];
|
|
59
|
+
for (const [scope, mapping] of Object.entries(roles)) for (const [role, value] of Object.entries(mapping)) {
|
|
60
|
+
const ref = typeof value === "string" ? value : value?.model;
|
|
61
|
+
if (typeof ref !== "string") continue;
|
|
62
|
+
const slash = ref.indexOf("/"), provider = ref.slice(0, slash), id = ref.slice(slash + 1);
|
|
63
|
+
const rows = catalogs[provider] ?? [];
|
|
64
|
+
const model = rows.find((m) => m.id === id);
|
|
65
|
+
if (!model) continue;
|
|
66
|
+
const retirement = model.retiresAt === null ? null : Date.parse(model.retiresAt);
|
|
67
|
+
if (model.deprecated || retirement !== null && retirement - now <= noticeDays * 86400000) checks.push({ proposal: { kind: "retirement", scope, role, model: ref, ...(replacements[ref] ? { candidate: replacements[ref] } : {}) }, ok: false, warn: true, label: `Model role ${scope}/${role}: ${ref} is ${model.retired ? "retired" : "deprecated"}${model.retiresAt ? `; retirement ${model.retiresAt}` : ""}`, fix: replacements[ref] ? `review provider replacement ${replacements[ref]} with a capped canary, then edit modelRoles` : "review the provider's deprecation notice and run a capped canary before editing modelRoles" });
|
|
68
|
+
const siblings = model.line ? rows.filter((m) => m.line === model.line && !m.deprecated && Number.isFinite(Date.parse(m.createdAt)) && Date.parse(m.createdAt) > Date.parse(model.createdAt)) : [];
|
|
69
|
+
if (siblings.length) {
|
|
70
|
+
const sibling = siblings.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0].id;
|
|
71
|
+
checks.push({ proposal: { kind: "newer-sibling", scope, role, model: ref, candidate: `${provider}/${sibling}` }, ok: true, label: `Model role ${scope}/${role}: newer provider-declared sibling ${sibling}; review with a capped canary` });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return checks;
|
|
75
|
+
}
|