@foldrun/core 0.2.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
package/src/approvals.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Deciding a run parked at an approval gate.
|
|
2
|
+
//
|
|
3
|
+
// The run record is the queue: the orchestrator polls it, so flipping a step
|
|
4
|
+
// from awaiting-approval to pending (approve) or failed (reject) is the whole
|
|
5
|
+
// decision. Nothing in memory has to survive a restart.
|
|
6
|
+
//
|
|
7
|
+
// Two doors lead here — the dashboard's approve button and the link in a
|
|
8
|
+
// notification email — and they must do exactly the same thing to the record,
|
|
9
|
+
// or a run approved from a phone would resume differently from one approved
|
|
10
|
+
// at a desk. So the decision lives in core and the routes only say who made
|
|
11
|
+
// it.
|
|
12
|
+
|
|
13
|
+
import { readRun, writeRun, type RunRecord } from "./store.ts";
|
|
14
|
+
import { platform } from "./platform.ts";
|
|
15
|
+
|
|
16
|
+
export interface ApprovalDecision {
|
|
17
|
+
decision: "approve" | "reject";
|
|
18
|
+
/** Guidance for the step being approved — rides the record into its prompt. */
|
|
19
|
+
note?: string;
|
|
20
|
+
/** Why a rejection, for the trace. Falls back to `note`, since a form has one box. */
|
|
21
|
+
reason?: string;
|
|
22
|
+
/** Decide only this step index; otherwise every step that is waiting. */
|
|
23
|
+
step?: number;
|
|
24
|
+
/** Who decided, as it reads after the verb: "by a human", "via emailed link". */
|
|
25
|
+
by: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An error the HTTP layer can map straight to a status — see errorResponse. */
|
|
29
|
+
class ApprovalError extends Error {
|
|
30
|
+
status: number;
|
|
31
|
+
constructor(message: string, status: number) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.status = status;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Apply a decision to every step the run is waiting on, and line the run up
|
|
39
|
+
* again if a worker had parked it. Returns the record as written and which
|
|
40
|
+
* step indexes were decided.
|
|
41
|
+
*
|
|
42
|
+
* Throws with `status` 404 when the run does not exist and 409 when nothing
|
|
43
|
+
* is waiting — an emailed link is clicked twice, or after someone else
|
|
44
|
+
* already answered, and the second click must not be reported as success.
|
|
45
|
+
*/
|
|
46
|
+
export async function decideApproval(
|
|
47
|
+
tenant: string,
|
|
48
|
+
workspace: string,
|
|
49
|
+
runId: string,
|
|
50
|
+
d: ApprovalDecision,
|
|
51
|
+
): Promise<{ run: RunRecord; steps: number[] }> {
|
|
52
|
+
const run = readRun(tenant, workspace, runId);
|
|
53
|
+
if (!run) throw new ApprovalError("run not found", 404);
|
|
54
|
+
|
|
55
|
+
const waiting = run.steps
|
|
56
|
+
.map((s, i) => ({ s, i }))
|
|
57
|
+
.filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i));
|
|
58
|
+
if (waiting.length === 0) throw new ApprovalError("nothing is awaiting approval", 409);
|
|
59
|
+
|
|
60
|
+
const note = typeof d.note === "string" ? d.note.trim() : "";
|
|
61
|
+
const reason = (typeof d.reason === "string" ? d.reason.trim() : "") || note;
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
|
|
64
|
+
for (const { s } of waiting) {
|
|
65
|
+
s.status = d.decision === "approve" ? "pending" : "failed";
|
|
66
|
+
// Record the decision itself, not just its effect on status. `pending`
|
|
67
|
+
// is also what a step looks like before anyone was asked, so without
|
|
68
|
+
// this a run that was approved and then interrupted comes back asking
|
|
69
|
+
// the same person the same question.
|
|
70
|
+
if (d.decision === "approve") {
|
|
71
|
+
s.approvedAt = now;
|
|
72
|
+
// An approval can carry guidance — it rides the step record into the
|
|
73
|
+
// prompt (see runner.ts), so "yes, but…" is one gesture, not a yes
|
|
74
|
+
// followed by a chase.
|
|
75
|
+
if (note) s.approvalNote = note.slice(0, 4000);
|
|
76
|
+
}
|
|
77
|
+
s.events.push({
|
|
78
|
+
t: now,
|
|
79
|
+
type: d.decision === "approve" ? "info" : "error",
|
|
80
|
+
text:
|
|
81
|
+
d.decision === "approve"
|
|
82
|
+
? `approved ${d.by} — continuing${note ? ` (with guidance: ${note.slice(0, 200)})` : ""}`
|
|
83
|
+
: `rejected ${d.by}${reason ? `: ${reason.slice(0, 200)}` : ""}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
writeRun(tenant, workspace, run);
|
|
87
|
+
|
|
88
|
+
// A parked run has no process polling for this decision — the worker
|
|
89
|
+
// that was driving it gave its slot back at the gate. Line it up again.
|
|
90
|
+
// Runs without the marker still have their starter polling the file, and
|
|
91
|
+
// enqueueing those too would put two drivers on one record.
|
|
92
|
+
if (run.parkedAt && run.steps.every((s) => s.status !== "awaiting-approval")) {
|
|
93
|
+
await platform.enqueueResume(tenant, workspace, run.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { run, steps: waiting.map(({ i }) => i) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Release a run parked on `wait: event` with what arrived. The payload is
|
|
101
|
+
* stored on the step — not as an approval note, which is a person's
|
|
102
|
+
* guidance and capped as such — and the release itself is an approval
|
|
103
|
+
* "by an external event", so the record reads the same way whichever door
|
|
104
|
+
* a park was opened through.
|
|
105
|
+
*
|
|
106
|
+
* Only steps waiting on an event are released: a run that is also asking a
|
|
107
|
+
* person a question keeps asking.
|
|
108
|
+
*/
|
|
109
|
+
export async function deliverEvent(
|
|
110
|
+
tenant: string,
|
|
111
|
+
workspace: string,
|
|
112
|
+
runId: string,
|
|
113
|
+
payload: string,
|
|
114
|
+
): Promise<{ run: RunRecord; steps: number[] }> {
|
|
115
|
+
const run = readRun(tenant, workspace, runId);
|
|
116
|
+
if (!run) throw new ApprovalError("run not found", 404);
|
|
117
|
+
const waiting = run.steps
|
|
118
|
+
.map((s, i) => ({ s, i }))
|
|
119
|
+
.filter(({ s }) => s.status === "awaiting-approval" && s.waitFor === "event");
|
|
120
|
+
if (waiting.length === 0) throw new ApprovalError("this run is not waiting for an event", 409);
|
|
121
|
+
for (const { s } of waiting) s.eventPayload = payload.slice(0, 20_000);
|
|
122
|
+
writeRun(tenant, workspace, run);
|
|
123
|
+
let decided: number[] = [];
|
|
124
|
+
for (const { i } of waiting) {
|
|
125
|
+
const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: "by an external event" });
|
|
126
|
+
decided = decided.concat(out.steps);
|
|
127
|
+
}
|
|
128
|
+
return { run: readRun(tenant, workspace, runId) ?? run, steps: decided };
|
|
129
|
+
}
|
package/src/arrange.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Rearranging a flow's steps into groups.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions, extracted from the drag-and-drop board so they can be
|
|
4
|
+
// tested: when a drag produces an order you didn't expect, the first question
|
|
5
|
+
// is whether the rule is wrong or the target was — and that should be
|
|
6
|
+
// answerable without a browser.
|
|
7
|
+
//
|
|
8
|
+
// A `Groups` value is the arrangement: an array of groups, each holding
|
|
9
|
+
// indices into the flow's parsed steps. Steps sharing a group run in parallel.
|
|
10
|
+
|
|
11
|
+
export type Groups = number[][];
|
|
12
|
+
|
|
13
|
+
function removeStep(groups: Groups, step: number) {
|
|
14
|
+
const next = groups.map((g) => g.filter((i) => i !== step));
|
|
15
|
+
const emptiedAt = next.findIndex((g) => g.length === 0);
|
|
16
|
+
return { next: next.filter((g) => g.length > 0), emptiedAt };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Move `step` into the group that currently contains `anchor` — run in parallel. */
|
|
20
|
+
export function joinGroup(groups: Groups, step: number, anchor: number): Groups {
|
|
21
|
+
if (step === anchor) return groups;
|
|
22
|
+
const { next } = removeStep(groups, step);
|
|
23
|
+
const target = next.findIndex((g) => g.includes(anchor));
|
|
24
|
+
if (target === -1) return groups;
|
|
25
|
+
next[target] = [...next[target], step];
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Move `step` out on its own at rail position `rail` — run sequentially.
|
|
31
|
+
* Rail 0 is above every group; rail N is below group N-1.
|
|
32
|
+
*/
|
|
33
|
+
export function splitToRail(groups: Groups, step: number, rail: number): Groups {
|
|
34
|
+
const { next, emptiedAt } = removeStep(groups, step);
|
|
35
|
+
// Removing the step may have collapsed a group above the target, which
|
|
36
|
+
// shifts every rail below it up by one.
|
|
37
|
+
let at = rail;
|
|
38
|
+
if (emptiedAt !== -1 && emptiedAt < rail) at -= 1;
|
|
39
|
+
at = Math.max(0, Math.min(next.length, at));
|
|
40
|
+
next.splice(at, 0, [step]);
|
|
41
|
+
return next;
|
|
42
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// The model catalogue: what a gateway can actually run, cached.
|
|
2
|
+
//
|
|
3
|
+
// "Any model they want" is only an offer if picking a wrong one fails in one
|
|
4
|
+
// line at step start — not three minutes in, as an agent that narrates tool
|
|
5
|
+
// calls into prose because its model cannot make them. OpenRouter's models
|
|
6
|
+
// endpoint declares, per model, which parameters it supports (`tools` being
|
|
7
|
+
// the one that decides whether an agent loop is possible at all), plus
|
|
8
|
+
// context length and real prices. This module fetches that once, caches it
|
|
9
|
+
// on disk, and answers three questions from the cache:
|
|
10
|
+
//
|
|
11
|
+
// - can this model drive tools? → the run-start gate
|
|
12
|
+
// - what models exist, at what price? → `model:` autocomplete
|
|
13
|
+
// - what does a token actually cost? → metering for routed models
|
|
14
|
+
//
|
|
15
|
+
// Everything degrades to "don't know": a missing cache, an offline fetch or
|
|
16
|
+
// an unknown id yields null, and callers treat null as "no opinion" — the
|
|
17
|
+
// gate never blocks on ignorance, only on knowledge. A preset reference
|
|
18
|
+
// (`@preset/…`) is opaque by design: its settings live in the provider's
|
|
19
|
+
// dashboard, so the catalogue has no opinion about it either.
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { dataRoot } from "./paths.ts";
|
|
24
|
+
import { resolveModel, resolveTier, MODEL_TIERS, EFFORT_LEVELS, type Effort, type Tier, type FlowInfo } from "./store.ts";
|
|
25
|
+
import type { FlowWarning } from "./flow-lint.ts";
|
|
26
|
+
|
|
27
|
+
export interface CatalogModel {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
contextLength: number | null;
|
|
31
|
+
/** USD per input token, as the gateway quotes it (not per MTok). */
|
|
32
|
+
promptPrice: number | null;
|
|
33
|
+
/** USD per output token. */
|
|
34
|
+
completionPrice: number | null;
|
|
35
|
+
/** Can this model make tool calls? Null when the gateway doesn't say. */
|
|
36
|
+
tools: boolean | null;
|
|
37
|
+
/** Does it take a reasoning-depth parameter? (What `effort:` needs.) */
|
|
38
|
+
reasoning: boolean | null;
|
|
39
|
+
/** The effort levels this model accepts, when the gateway names them —
|
|
40
|
+
* OpenRouter's level names are ours (low…max, plus minimal/none below
|
|
41
|
+
* our scale), so no translation table sits between the two. */
|
|
42
|
+
efforts: string[] | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Catalog {
|
|
46
|
+
/** The base_url this catalogue was fetched from. */
|
|
47
|
+
baseUrl: string;
|
|
48
|
+
fetchedAt: string;
|
|
49
|
+
models: CatalogModel[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A day. Model lists move weekly, not hourly, and a stale price in an
|
|
53
|
+
* autocomplete hint is a smaller failure than a fetch on every keystroke. */
|
|
54
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
const cacheFile = (baseUrl: string) =>
|
|
57
|
+
path.join(
|
|
58
|
+
dataRoot(),
|
|
59
|
+
"catalog",
|
|
60
|
+
// One file per endpoint, named legibly: catalog/openrouter.ai.json.
|
|
61
|
+
`${new URL(baseUrl).hostname.replace(/[^a-z0-9.-]/gi, "_")}.json`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
function parseModels(payload: unknown): CatalogModel[] {
|
|
65
|
+
const data = (payload as { data?: unknown[] })?.data;
|
|
66
|
+
if (!Array.isArray(data)) return [];
|
|
67
|
+
const out: CatalogModel[] = [];
|
|
68
|
+
for (const raw of data) {
|
|
69
|
+
if (!raw || typeof raw !== "object") continue;
|
|
70
|
+
const m = raw as Record<string, unknown>;
|
|
71
|
+
if (typeof m.id !== "string") continue;
|
|
72
|
+
const supported = Array.isArray(m.supported_parameters)
|
|
73
|
+
? m.supported_parameters.map(String)
|
|
74
|
+
: null;
|
|
75
|
+
const pricing = (m.pricing ?? {}) as Record<string, unknown>;
|
|
76
|
+
const price = (k: string) => {
|
|
77
|
+
const n = Number(pricing[k]);
|
|
78
|
+
return Number.isFinite(n) ? n : null;
|
|
79
|
+
};
|
|
80
|
+
out.push({
|
|
81
|
+
id: m.id,
|
|
82
|
+
name: typeof m.name === "string" ? m.name : m.id,
|
|
83
|
+
contextLength: Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : null,
|
|
84
|
+
promptPrice: price("prompt"),
|
|
85
|
+
completionPrice: price("completion"),
|
|
86
|
+
tools: supported ? supported.includes("tools") : null,
|
|
87
|
+
reasoning: supported ? supported.includes("reasoning") : null,
|
|
88
|
+
efforts: Array.isArray((m.reasoning as Record<string, unknown> | undefined)?.supported_efforts)
|
|
89
|
+
? ((m.reasoning as Record<string, unknown>).supported_efforts as unknown[]).map(String)
|
|
90
|
+
: null,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The cache as it stands, however old — null only when nothing was ever
|
|
97
|
+
* fetched. Reading never touches the network. */
|
|
98
|
+
export function readCatalog(baseUrl: string): Catalog | null {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(fs.readFileSync(cacheFile(baseUrl), "utf8")) as Catalog;
|
|
101
|
+
return Array.isArray(parsed.models) ? parsed : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The cache, refreshed over the network when it is older than the TTL.
|
|
109
|
+
* A failed fetch keeps the stale copy — offline is a reason to be out of
|
|
110
|
+
* date, not a reason to forget everything — and returns null only when
|
|
111
|
+
* there is nothing at all to fall back on.
|
|
112
|
+
*/
|
|
113
|
+
export async function loadCatalog(baseUrl: string): Promise<Catalog | null> {
|
|
114
|
+
const cached = readCatalog(baseUrl);
|
|
115
|
+
if (cached && Date.now() - Date.parse(cached.fetchedAt) < TTL_MS) return cached;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/models`, {
|
|
119
|
+
signal: AbortSignal.timeout(10_000),
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) throw new Error(`${res.status}`);
|
|
122
|
+
const models = parseModels(await res.json());
|
|
123
|
+
if (!models.length) throw new Error("empty model list");
|
|
124
|
+
const catalog: Catalog = { baseUrl, fetchedAt: new Date().toISOString(), models };
|
|
125
|
+
fs.mkdirSync(path.dirname(cacheFile(baseUrl)), { recursive: true });
|
|
126
|
+
// Atomic like every other store write: a crashed refresh must not leave
|
|
127
|
+
// half a JSON file where the whole catalogue used to be.
|
|
128
|
+
const tmp = cacheFile(baseUrl) + ".tmp";
|
|
129
|
+
fs.writeFileSync(tmp, JSON.stringify(catalog));
|
|
130
|
+
fs.renameSync(tmp, cacheFile(baseUrl));
|
|
131
|
+
return catalog;
|
|
132
|
+
} catch {
|
|
133
|
+
return cached; // stale beats nothing; null when there is no nothing
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A preset reference — settings live in the gateway's dashboard, opaque to
|
|
138
|
+
* us on purpose. The catalogue offers no opinion on one. */
|
|
139
|
+
export const isPresetRef = (model: string) => model.startsWith("@preset/");
|
|
140
|
+
|
|
141
|
+
export function findModel(catalog: Catalog | null, id: string): CatalogModel | null {
|
|
142
|
+
if (!catalog || isPresetRef(id)) return null;
|
|
143
|
+
return catalog.models.find((m) => m.id === id) ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ModelVerdict {
|
|
147
|
+
ok: boolean;
|
|
148
|
+
/** One line, naming the cause and the nearest fix. Only set when !ok. */
|
|
149
|
+
reason?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The run-start gate. Only ever fails on *knowledge*: an id the catalogue
|
|
154
|
+
* has, whose declared capabilities rule the step out. Unknown ids, preset
|
|
155
|
+
* refs, tier names that resolved elsewhere, and an absent catalogue all
|
|
156
|
+
* pass — the model call itself is the authority of last resort, and a gate
|
|
157
|
+
* that blocks on ignorance would make offline development impossible.
|
|
158
|
+
*/
|
|
159
|
+
export function checkModel(
|
|
160
|
+
catalog: Catalog | null,
|
|
161
|
+
modelId: string,
|
|
162
|
+
needs: { tools: boolean },
|
|
163
|
+
): ModelVerdict {
|
|
164
|
+
const m = findModel(catalog, modelId);
|
|
165
|
+
if (!m) return { ok: true };
|
|
166
|
+
if (needs.tools && m.tools === false) {
|
|
167
|
+
const alternative = nearestWithTools(catalog!, m);
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason:
|
|
171
|
+
`model ${m.id} cannot call tools, and this agent declares some — the run would ` +
|
|
172
|
+
`narrate tool use as text instead of doing it.` +
|
|
173
|
+
(alternative ? ` Nearest model that can: ${alternative.id}.` : ""),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Same family first (everything before the slash), then anything with
|
|
180
|
+
* tools — "use the provider's sibling" is advice people actually take. */
|
|
181
|
+
function nearestWithTools(catalog: Catalog, from: CatalogModel): CatalogModel | null {
|
|
182
|
+
const vendor = from.id.split("/")[0];
|
|
183
|
+
const candidates = catalog.models.filter((m) => m.tools === true);
|
|
184
|
+
return candidates.find((m) => m.id.startsWith(vendor + "/")) ?? candidates[0] ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** True when `effort:` will be silently ignored by this model — worth a
|
|
188
|
+
* trace line, never a failure: the step still runs, just without the depth
|
|
189
|
+
* the file asked for. */
|
|
190
|
+
export function effortIgnored(catalog: Catalog | null, modelId: string): boolean {
|
|
191
|
+
const m = findModel(catalog, modelId);
|
|
192
|
+
return m !== null && m.reasoning === false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The effort that will actually reach this model, and the sentence that
|
|
197
|
+
* explains any difference. Three outcomes:
|
|
198
|
+
*
|
|
199
|
+
* - the model takes this level → unchanged, silent
|
|
200
|
+
* - it reasons, but not at this level → nearest supported level, said aloud
|
|
201
|
+
* - it does not reason at all → null, said aloud
|
|
202
|
+
*
|
|
203
|
+
* "Nearest" prefers the highest supported level at or below the request —
|
|
204
|
+
* someone who wrote `max` wanted the ceiling, and the ceiling here is
|
|
205
|
+
* `xhigh` — falling back to the lowest above it. Unknown models and absent
|
|
206
|
+
* catalogues pass the request through untouched: no knowledge, no opinion,
|
|
207
|
+
* same as the gate.
|
|
208
|
+
*/
|
|
209
|
+
export function clampEffort(
|
|
210
|
+
catalog: Catalog | null,
|
|
211
|
+
modelId: string,
|
|
212
|
+
effort: Effort | null,
|
|
213
|
+
): { effort: Effort | null; note: string | null } {
|
|
214
|
+
if (!effort) return { effort: null, note: null };
|
|
215
|
+
const m = findModel(catalog, modelId);
|
|
216
|
+
if (!m) return { effort, note: null };
|
|
217
|
+
if (m.reasoning === false && !m.efforts?.length) {
|
|
218
|
+
return {
|
|
219
|
+
effort: null,
|
|
220
|
+
note: `model ${m.id} takes no reasoning parameter — effort: ${effort} will have no effect`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (!m.efforts?.length) return { effort, note: null }; // reasons, levels unnamed
|
|
224
|
+
const supported = EFFORT_LEVELS.filter((l) => m.efforts!.includes(l));
|
|
225
|
+
if (!supported.length || supported.includes(effort)) return { effort, note: null };
|
|
226
|
+
const want = EFFORT_LEVELS.indexOf(effort);
|
|
227
|
+
const below = [...supported].reverse().find((l) => EFFORT_LEVELS.indexOf(l) < want);
|
|
228
|
+
const clamped = below ?? supported[0];
|
|
229
|
+
return {
|
|
230
|
+
effort: clamped,
|
|
231
|
+
note:
|
|
232
|
+
`model ${m.id} supports effort ${supported.join(", ")} — ` +
|
|
233
|
+
`effort: ${effort} runs as ${clamped}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What one step actually cost through this gateway, or null when the
|
|
239
|
+
* catalogue can't say. The SDK's own costUsd is priced off Anthropic's
|
|
240
|
+
* table, which is wrong for every routed model — so when the catalogue
|
|
241
|
+
* knows better, its number wins.
|
|
242
|
+
*/
|
|
243
|
+
export function catalogCost(
|
|
244
|
+
catalog: Catalog | null,
|
|
245
|
+
modelId: string,
|
|
246
|
+
usage: { inputTokens: number; outputTokens: number },
|
|
247
|
+
): number | null {
|
|
248
|
+
const m = findModel(catalog, modelId);
|
|
249
|
+
if (!m || m.promptPrice == null || m.completionPrice == null) return null;
|
|
250
|
+
return usage.inputTokens * m.promptPrice + usage.outputTokens * m.completionPrice;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Catalogue entries as completion items: price per MTok, context, and the
|
|
254
|
+
* one capability that decides agent-worthiness. Tool-capable models first —
|
|
255
|
+
* on an agent platform they are almost always what the author wants. */
|
|
256
|
+
export function catalogCompletions(catalog: Catalog | null): { id: string; hint: string }[] {
|
|
257
|
+
if (!catalog) return [];
|
|
258
|
+
const perMTok = (v: number | null) => (v == null ? "?" : `$${(v * 1_000_000).toFixed(2)}`);
|
|
259
|
+
const ctx = (n: number | null) => (n == null ? "" : n >= 1000 ? ` · ${Math.round(n / 1000)}k ctx` : ` · ${n} ctx`);
|
|
260
|
+
return [...catalog.models]
|
|
261
|
+
.sort((a, b) => Number(b.tools === true) - Number(a.tools === true) || a.id.localeCompare(b.id))
|
|
262
|
+
.map((m) => ({
|
|
263
|
+
id: m.id,
|
|
264
|
+
hint: `${perMTok(m.promptPrice)}/${perMTok(m.completionPrice)} MTok${ctx(m.contextLength)}${m.tools === false ? " · no tools" : ""}`,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// -------------------------------------------------------------- edit-time
|
|
269
|
+
|
|
270
|
+
/** What the lint needs to know about each agent: its own model, and whether
|
|
271
|
+
* it declares any tools at all. */
|
|
272
|
+
export interface AgentModelInfo {
|
|
273
|
+
name: string;
|
|
274
|
+
model: string;
|
|
275
|
+
usesTools: boolean;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The run-start gate's veto, raised while the file is still open. Same
|
|
280
|
+
* knowledge, earlier moment: a step whose resolved model the catalogue says
|
|
281
|
+
* cannot call tools — run by an agent that declares some — gets a warning in
|
|
282
|
+
* the editor instead of a failed step tomorrow morning. Advisory like every
|
|
283
|
+
* flow lint; the run-start gate stays the enforcement.
|
|
284
|
+
*/
|
|
285
|
+
export function lintFlowModels(
|
|
286
|
+
flow: FlowInfo,
|
|
287
|
+
agents: AgentModelInfo[],
|
|
288
|
+
catalog: Catalog | null,
|
|
289
|
+
providerModels: Partial<Record<Tier, string>> = {},
|
|
290
|
+
): FlowWarning[] {
|
|
291
|
+
if (!catalog) return [];
|
|
292
|
+
const warnings: FlowWarning[] = [];
|
|
293
|
+
const byName = new Map(agents.map((a) => [a.name, a]));
|
|
294
|
+
|
|
295
|
+
// The same remap the runner applies on the wire: a tier the provider
|
|
296
|
+
// block renames is checked as the gateway's id.
|
|
297
|
+
const wire = (value: string): string => {
|
|
298
|
+
const resolved = resolveModel(value);
|
|
299
|
+
const tier = (Object.entries(MODEL_TIERS) as [Tier, string][]).find(
|
|
300
|
+
([, id]) => id === resolved,
|
|
301
|
+
)?.[0] ?? resolveTier(value);
|
|
302
|
+
return (tier && providerModels[tier]) || resolved;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
flow.steps.forEach((step, i) => {
|
|
306
|
+
if (step.subflow) return; // the subflow's own lint covers its steps
|
|
307
|
+
const agent = byName.get(step.agent);
|
|
308
|
+
if (!agent?.usesTools) return;
|
|
309
|
+
const model = wire(step.model ?? flow.model ?? agent.model);
|
|
310
|
+
const verdict = checkModel(catalog, model, { tools: true });
|
|
311
|
+
if (!verdict.ok) {
|
|
312
|
+
warnings.push({
|
|
313
|
+
step: i,
|
|
314
|
+
line: step.line,
|
|
315
|
+
message: `"${step.agent}" would run on ${model}, which cannot call tools`,
|
|
316
|
+
detail: verdict.reason!,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
return warnings;
|
|
321
|
+
}
|