@spendgraph/harness 0.1.0 → 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.
Files changed (119) hide show
  1. package/README.md +87 -160
  2. package/dist/cascade/cascade.d.ts +13 -0
  3. package/dist/cascade/cascade.js +91 -0
  4. package/dist/cascade/index.d.ts +3 -0
  5. package/dist/cascade/index.js +2 -0
  6. package/dist/cascade/tier.d.ts +31 -0
  7. package/dist/cascade/tier.js +53 -0
  8. package/dist/cascade/types.d.ts +98 -0
  9. package/dist/chain/chain.d.ts +9 -0
  10. package/dist/chain/chain.js +58 -0
  11. package/dist/chain/gate.d.ts +16 -0
  12. package/dist/chain/gate.js +33 -0
  13. package/dist/chain/index.d.ts +3 -0
  14. package/dist/chain/index.js +2 -0
  15. package/dist/chain/step.d.ts +9 -0
  16. package/dist/chain/step.js +14 -0
  17. package/dist/chain/types.d.ts +50 -0
  18. package/dist/index.d.ts +16 -9
  19. package/dist/index.js +8 -7
  20. package/dist/loop/act.d.ts +21 -0
  21. package/dist/loop/act.js +43 -0
  22. package/dist/loop/compact.d.ts +49 -0
  23. package/dist/loop/compact.js +59 -0
  24. package/dist/loop/hooks.d.ts +43 -0
  25. package/dist/loop/hooks.js +41 -0
  26. package/dist/loop/index.d.ts +6 -0
  27. package/dist/loop/index.js +5 -0
  28. package/dist/loop/loop.d.ts +13 -0
  29. package/dist/loop/loop.js +120 -0
  30. package/dist/loop/turn.d.ts +6 -0
  31. package/dist/loop/turn.js +17 -0
  32. package/dist/loop/types.d.ts +140 -0
  33. package/dist/loop/types.js +1 -0
  34. package/dist/orchestrate/index.d.ts +4 -0
  35. package/dist/orchestrate/index.js +3 -0
  36. package/dist/orchestrate/orchestrate.d.ts +12 -0
  37. package/dist/orchestrate/orchestrate.js +68 -0
  38. package/dist/orchestrate/plan.d.ts +18 -0
  39. package/dist/orchestrate/plan.js +38 -0
  40. package/dist/orchestrate/types.d.ts +75 -0
  41. package/dist/orchestrate/types.js +1 -0
  42. package/dist/orchestrate/work.d.ts +13 -0
  43. package/dist/orchestrate/work.js +35 -0
  44. package/dist/parallel/index.d.ts +4 -0
  45. package/dist/parallel/index.js +3 -0
  46. package/dist/parallel/merge.d.ts +26 -0
  47. package/dist/parallel/merge.js +52 -0
  48. package/dist/parallel/parallel.d.ts +12 -0
  49. package/dist/parallel/parallel.js +66 -0
  50. package/dist/parallel/task.d.ts +13 -0
  51. package/dist/parallel/task.js +45 -0
  52. package/dist/parallel/types.d.ts +61 -0
  53. package/dist/parallel/types.js +1 -0
  54. package/dist/refine/attempt.d.ts +22 -0
  55. package/dist/refine/attempt.js +38 -0
  56. package/dist/refine/index.d.ts +4 -0
  57. package/dist/refine/index.js +3 -0
  58. package/dist/refine/judge.d.ts +15 -0
  59. package/dist/refine/judge.js +25 -0
  60. package/dist/refine/refine.d.ts +10 -0
  61. package/dist/refine/refine.js +67 -0
  62. package/dist/refine/types.d.ts +90 -0
  63. package/dist/refine/types.js +1 -0
  64. package/dist/route/classify.d.ts +52 -0
  65. package/dist/route/classify.js +57 -0
  66. package/dist/route/dispatch.d.ts +12 -0
  67. package/dist/route/dispatch.js +33 -0
  68. package/dist/route/index.d.ts +3 -0
  69. package/dist/route/index.js +2 -0
  70. package/dist/route/route.d.ts +11 -0
  71. package/dist/route/route.js +31 -0
  72. package/dist/route/types.d.ts +53 -0
  73. package/dist/route/types.js +1 -0
  74. package/dist/stream/graph.d.ts +9 -0
  75. package/dist/stream/graph.js +16 -0
  76. package/dist/stream/index.d.ts +2 -0
  77. package/dist/stream/index.js +1 -0
  78. package/dist/stream/stream.d.ts +19 -0
  79. package/dist/stream/stream.js +65 -0
  80. package/dist/stream/types.d.ts +31 -0
  81. package/dist/stream/types.js +1 -0
  82. package/docs/README.md +41 -0
  83. package/docs/cascade.md +165 -0
  84. package/docs/chain.md +134 -0
  85. package/docs/loop.md +318 -0
  86. package/docs/orchestrate.md +128 -0
  87. package/docs/parallel.md +139 -0
  88. package/docs/refine.md +206 -0
  89. package/docs/route.md +212 -0
  90. package/package.json +18 -14
  91. package/bin/harness.mjs +0 -76
  92. package/dist/core/budget.d.ts +0 -46
  93. package/dist/core/budget.js +0 -72
  94. package/dist/core/cache.d.ts +0 -119
  95. package/dist/core/cache.js +0 -206
  96. package/dist/core/client.d.ts +0 -33
  97. package/dist/core/client.js +0 -98
  98. package/dist/core/errors.d.ts +0 -22
  99. package/dist/core/errors.js +0 -28
  100. package/dist/core/index.d.ts +0 -5
  101. package/dist/core/index.js +0 -5
  102. package/dist/core/pull.d.ts +0 -17
  103. package/dist/core/pull.js +0 -38
  104. package/dist/core/schema/serialize.d.ts +0 -12
  105. package/dist/core/schema/serialize.js +0 -42
  106. package/dist/core/schema/types.d.ts +0 -58
  107. package/dist/core/schema/validate.d.ts +0 -28
  108. package/dist/core/schema/validate.js +0 -98
  109. package/dist/harness.d.ts +0 -128
  110. package/dist/harness.js +0 -187
  111. package/dist/prompt/codegen.d.ts +0 -37
  112. package/dist/prompt/codegen.js +0 -97
  113. package/dist/prompt/render.d.ts +0 -35
  114. package/dist/prompt/render.js +0 -66
  115. package/dist/prompt/run.d.ts +0 -38
  116. package/dist/prompt/run.js +0 -96
  117. package/dist/prompt/types.d.ts +0 -156
  118. /package/dist/{core/schema → cascade}/types.js +0 -0
  119. /package/dist/{prompt → chain}/types.js +0 -0
package/README.md CHANGED
@@ -1,162 +1,89 @@
1
1
  # @spendgraph/harness
2
2
 
3
- Pull a stored prompt, render it, report what it cost. Zero dependencies.
4
-
5
- Your prompt lives in spendgraph, not in your codebase. This client fetches it,
6
- substitutes its typed fields, and records what the call cost — without sitting
7
- in your request path.
8
-
9
- ```sh
10
- npm install @spendgraph/harness
11
- ```
12
-
13
- ## The production path
14
-
15
- ```ts
16
- import { Harness } from "@spendgraph/harness";
17
-
18
- const harness = new Harness({
19
- apiKey: process.env.SPENDGRAPH_API_KEY,
20
- baseUrl: "https://your-spendgraph.example.com",
21
- });
22
-
23
- export async function handle(request) {
24
- const prompt = await harness.pull("billing-explainer-k3f9x2");
25
-
26
- const values = { question: "why was I charged twice?" };
27
- const messages = prompt.render(values);
28
-
29
- // Call the provider yourself. spendgraph is never between you and the model.
30
- const startedAt = Date.now();
31
- const completion = await yourModelCall(messages);
32
-
33
- await harness.report(prompt.id, {
34
- versionId: prompt.versionId,
35
- model: "anthropic/claude-sonnet-5",
36
- fields: prompt.serialize(values),
37
- rendered: messages,
38
- output: completion.text,
39
- inputTokens: completion.usage.inputTokens,
40
- outputTokens: completion.usage.outputTokens,
41
- cacheReadTokens: completion.usage.cacheReadTokens ?? 0,
42
- cacheWriteTokens: completion.usage.cacheWriteTokens ?? 0,
43
- latencyMs: Date.now() - startedAt,
44
- });
45
-
46
- return completion.text;
47
- }
48
- ```
49
-
50
- Call `pull` inside the handler, not at module scope. It refreshes as a side
51
- effect of being called, so a service that pulls once at boot serves that wording
52
- for the life of the process. Called per request it is a cache read, not a round
53
- trip.
54
-
55
- `report` never throws and never rejects it sits beside a request that has
56
- already been answered, and telemetry that can break the thing it measures is
57
- worse than no telemetry. Pass `onReportError` if you want to know when one is
58
- dropped.
59
-
60
- ## Handles
61
-
62
- `pull`, `run` and the rest take a prompt's uuid or its slug.
63
-
64
- A slug is derived from the prompt's name, so **renaming the prompt mints a new
65
- one and the old stops resolving**. The uuid never changes. Pull by slug for
66
- something readable; pull by id where the handle has to outlive an edit to the
67
- name.
68
-
69
- When a handle stops resolving, the client keeps serving the wording it last saw
70
- rather than failing your request — and backs off rather than retrying on every
71
- call. Pass `onPullError` to be told, because otherwise that is silent:
72
-
73
- ```ts
74
- new Harness({
75
- apiKey,
76
- baseUrl,
77
- onPullError: (err, handle) => log.warn({ err, handle }, "prompt refresh failed"),
78
- });
79
- ```
80
-
81
- ## Caching
82
-
83
- `pull` is stale-while-revalidate. A fresh entry is returned outright, a stale
84
- one is returned *now* and refreshed behind you, and only a cold miss waits on
85
- the network. So an edit in the dashboard reaches production within a TTL without
86
- any request paying for the fetch.
87
-
88
- ```ts
89
- new Harness({ apiKey, baseUrl, cache: { ttlSeconds: 300, maxSize: 100 } });
90
- ```
91
-
92
- `harness.invalidate(promptId)` drops it immediately — `promote()` does this for
93
- you, including for entries cached under the other handle.
94
-
95
- ## The batch path
96
-
97
- `run`, `sample` and `runAll` execute the prompt server-side and record the
98
- rollout in one call. That adds a hop and makes spendgraph a dependency of your
99
- uptime, which is the right trade for evaluation and the wrong one for a user
100
- waiting on a response.
101
-
102
- ```ts
103
- const result = await harness.run("billing-explainer-k3f9x2", values);
104
- const k = await harness.sample(promptId, values, { k: 5 }); // pass^k
105
- const across = await harness.runAll(promptId, values); // one per model
106
- ```
107
-
108
- Cap what a client can spend:
109
-
110
- ```ts
111
- new Harness({ apiKey, baseUrl, maxCostMicros: 5_000_000 }); // $5
112
- harness.spent(); // micro-dollars used
113
- harness.remaining(); // what is left
114
- ```
115
-
116
- Omit `maxCostMicros` and it warns once that it is unbounded. Pass `null` to say
117
- you meant it.
118
-
119
- ## Typed fields
120
-
121
- A prompt declares its placeholders — `string`, `text`, `number`, `boolean`,
122
- `enum`, `list`, `json` — and `render` validates before anything is sent, so a
123
- missing required field is an exception rather than a `{placeholder}` reaching
124
- the model.
125
-
126
- `harness codegen` turns those declarations into types:
127
-
128
- ```sh
129
- npx harness codegen --url https://your-spendgraph.example.com
130
- # usage: harness codegen [--out <file>] [--project <id>] [--module <specifier>]
131
- ```
132
-
133
- It reads `SPENDGRAPH_API_KEY` and `SPENDGRAPH_URL` from the environment and
134
- writes `src/harness.generated.d.ts`. Include that file and the values argument
135
- is checked per prompt, by id and by slug. Delete it and calls fall back to a
136
- loose `Record<string, unknown>` — the runtime validation is unchanged either
137
- way.
138
-
139
- ## Versions
140
-
141
- ```ts
142
- const { versions, currentVersionId } = await harness.versions(promptId);
143
- await harness.promote(promptId, versionId);
144
- ```
145
-
146
- Promoting changes what every `pull` serves from that moment, and invalidates the
147
- cache so the change is visible immediately rather than after a TTL.
148
-
149
- ## Datasets
150
-
151
- ```ts
152
- const { cases, counts } = await harness.cases(promptId);
153
- await harness.setCases(promptId, cases);
154
- ```
155
-
156
- `setCases` replaces the whole set. A dataset answers "what am I evaluating
157
- against" with one answer at a time, and a merge would leave no way to remove a
158
- case.
159
-
160
- ## License
161
-
162
- MIT
3
+ **Agent = Model + Harness.** The model reasons. The harness is everything around
4
+ it that turns text into work — choosing what runs next, calling tools, holding
5
+ the result together, and knowing what to do when a step fails.
6
+
7
+ This is the *outer* harness: the part you assemble, on top of whatever agent SDK
8
+ your provider ships. It is built on `@spendgraph/prompt`, `@spendgraph/tools`
9
+ and `@spendgraph/graph`, and none of it reasons — every workflow here directs a
10
+ model call you supply.
11
+
12
+ Six shapes, all built. Each is a graph you would otherwise hand-roll, with the
13
+ pricing and the rollout already attached, so a run is recorded rather than
14
+ reconstructed afterwards.
15
+
16
+ | Workflow | What it is | When it wins |
17
+ | --- | --- | --- |
18
+ | **`chain`** | steps in sequence, each on the last's output, with a gate between | a task that decomposes cleanly and reads better as stages |
19
+ | **`route`** | classify first, then dispatch to a specialist | inputs that fall into kinds a cheaper model can tell apart |
20
+ | **`parallel`** | the same call N times, or N sections at once | voting for confidence, sectioning for latency |
21
+ | **`orchestrate`** | a lead decomposes and delegates to workers | the subtasks are not known until the input is read |
22
+ | **`refine`** | generate, critique, revise until a bar is met | there is a clear criterion and a first draft rarely meets it |
23
+ | **`loop`** | tools in a loop until the model stops asking | open-ended work with a tool surface and a step ceiling |
24
+ | **`cascade`** | try the cheap model, escalate only when it will not do | most inputs are easy and a few are not, and you can tell which |
25
+
26
+ ## Inner and outer
27
+
28
+ The provider's SDK is the **inner** harness: it drives one model call, and on
29
+ Anthropic or OpenAI it will loop over tool calls for you. `@spendgraph/llms`
30
+ wraps that.
31
+
32
+ What it does not do is decide *which* call to make, what to do with the answer,
33
+ when to stop, or what any of it cost. That is the **outer** harness, and it is
34
+ the part that is written again in every project — usually inline, usually
35
+ without a ceiling, and usually recording nothing.
36
+
37
+ | | |
38
+ | --- | --- |
39
+ | Inner | one call, tool loop, streaming — `@spendgraph/llms` |
40
+ | Outer | which call, what next, when to stop, what it cost — here |
41
+
42
+ ## Why here and not in `graph`
43
+
44
+ `graph` is the mechanism: nodes, edges, a run. These are the seven arrangements of
45
+ it worth naming, each with its own failure mode — a chain needs a gate or a bad
46
+ step poisons the rest, voting needs an odd N, a loop needs a ceiling and every
47
+ one of them needs a budget. Putting them beside the mechanism would suggest the
48
+ mechanism is opinionated. It is not.
49
+
50
+ ## What every one of them owes you
51
+
52
+ - A `GraphResult`, so the run reports as a rollout without translation.
53
+ - Every step recorded, including the ones that were rejected — a refine loop that
54
+ keeps only the winner cannot say what it cost.
55
+ - A budget ceiling, because each of these spends more than one call and the
56
+ interesting ones spend an unbounded amount.
57
+ - A `stoppedBy`, because "it finished" and "it gave up" must never look the same.
58
+
59
+ ## What is missing
60
+
61
+ Every workflow here is **single-run**: it starts, it finishes, and it holds
62
+ nothing afterwards. A harness in the fuller sense also carries state across a
63
+ pause — a run that waits for a person, resumes tomorrow, or picks up where a
64
+ crash left it.
65
+
66
+ That needs somewhere to persist a run, which is a decision about the product and
67
+ not a wiring pattern. It is written up in [`PLAN.md`](./PLAN.md) rather than
68
+ quietly folded in.
69
+
70
+ ## What is next
71
+
72
+ Phase one is done: the context manager (`compact`), lifecycle hooks, tool
73
+ annotations, budget signalling and `cascade`. What is left is **durable state** —
74
+ a run that can wait for a person and be resumed — which [`PLAN.md`](./PLAN.md)
75
+ plans with the decisions and the tests it needs.
76
+
77
+ ## Documentation
78
+
79
+ Each built workflow has a page with worked examples, the cases it suits, and the
80
+ cases it does not.
81
+
82
+ - [`docs/refine.md`](./docs/refine.md) — draft, judge, revise
83
+ - [`docs/route.md`](./docs/route.md) classify, then dispatch
84
+ - [`docs/chain.md`](./docs/chain.md) steps in sequence, with a gate between
85
+ - [`docs/parallel.md`](./docs/parallel.md) sectioning for latency, voting for confidence
86
+ - [`docs/loop.md`](./docs/loop.md) tools until the model stops asking
87
+ - [`docs/orchestrate.md`](./docs/orchestrate.md) — a lead decomposes and delegates
88
+ - [`docs/cascade.md`](./docs/cascade.md) — cheap first, escalate on rejection
89
+ - [`docs/README.md`](./docs) the seven patterns, and what each owes you
@@ -0,0 +1,13 @@
1
+ import type { CascadeOptions, CascadeResult } from "./types.js";
2
+ /**
3
+ * Try the cheap model, and escalate only when its answer will not do.
4
+ *
5
+ * Not `route`, which chooses before it has seen an answer. Not `refine`, which
6
+ * revises with the same model. This is the one lever that spends less by
7
+ * default and more only on the inputs that turn out to need it.
8
+ *
9
+ * Whether it pays depends entirely on how often the first tier is accepted, so
10
+ * every tier tried is recorded and its tokens counted — a total that hid the
11
+ * rejected attempts would report a saving on every run.
12
+ */
13
+ export declare function cascade(values: Record<string, unknown>, opts: CascadeOptions): Promise<CascadeResult>;
@@ -0,0 +1,91 @@
1
+ import { attempt, fallback, tierStep, tokensOf } from "./tier.js";
2
+ function checkTiers(tiers) {
3
+ if (tiers.length === 0)
4
+ throw new Error("cascade needs at least one tier.");
5
+ const seen = new Set();
6
+ for (const tier of tiers) {
7
+ if (!tier.name.trim())
8
+ throw new Error("Every cascade tier needs a name.");
9
+ if (seen.has(tier.name))
10
+ throw new Error(`Two cascade tiers are called "${tier.name}".`);
11
+ seen.add(tier.name);
12
+ }
13
+ }
14
+ /**
15
+ * Try the cheap model, and escalate only when its answer will not do.
16
+ *
17
+ * Not `route`, which chooses before it has seen an answer. Not `refine`, which
18
+ * revises with the same model. This is the one lever that spends less by
19
+ * default and more only on the inputs that turn out to need it.
20
+ *
21
+ * Whether it pays depends entirely on how often the first tier is accepted, so
22
+ * every tier tried is recorded and its tokens counted — a total that hid the
23
+ * rejected attempts would report a saving on every run.
24
+ */
25
+ export async function cascade(values, opts) {
26
+ const startedAt = Date.now();
27
+ checkTiers(opts.tiers);
28
+ const steps = [];
29
+ const history = [];
30
+ let spent = 0;
31
+ let accepted;
32
+ let stoppedBy = "exhausted";
33
+ for (const [index, tier] of opts.tiers.entries()) {
34
+ if (opts.maxTokens !== undefined && spent >= opts.maxTokens) {
35
+ stoppedBy = "tokens";
36
+ break;
37
+ }
38
+ const { answer, error } = await attempt(tier.run, values, (text, stage) => opts.emit?.(text, stage ?? tier.name));
39
+ const passed = answer !== null && !error && (await opts.accept(answer, index));
40
+ const record = {
41
+ index,
42
+ name: tier.name,
43
+ answer,
44
+ accepted: passed,
45
+ ...(passed ? {} : { escalatedBy: error ? "failed" : "rejected" }),
46
+ error,
47
+ tokens: tokensOf(answer),
48
+ };
49
+ steps.push(tierStep(steps.length, record));
50
+ spent += record.tokens;
51
+ history.push(record);
52
+ opts.onTier?.(record);
53
+ if (passed) {
54
+ accepted = record;
55
+ stoppedBy = "accepted";
56
+ break;
57
+ }
58
+ }
59
+ const winner = accepted ?? fallback(history);
60
+ const totals = history.reduce((sum, record) => ({
61
+ inputTokens: sum.inputTokens + (record.answer?.inputTokens ?? 0),
62
+ outputTokens: sum.outputTokens + (record.answer?.outputTokens ?? 0),
63
+ }), { inputTokens: 0, outputTokens: 0 });
64
+ const shared = {
65
+ steps,
66
+ outputs: { history },
67
+ latencyMs: Date.now() - startedAt,
68
+ ...totals,
69
+ accepted: accepted !== undefined,
70
+ escalated: history.filter((r) => r !== winner).map((r) => r.name),
71
+ history,
72
+ };
73
+ if (!winner) {
74
+ return {
75
+ ...shared,
76
+ status: "failed",
77
+ output: "",
78
+ error: history.at(-1)?.error ?? "No tier produced an answer.",
79
+ tier: null,
80
+ stoppedBy: stoppedBy === "tokens" ? "tokens" : "failed",
81
+ };
82
+ }
83
+ return {
84
+ ...shared,
85
+ status: "completed",
86
+ output: winner.answer?.output ?? "",
87
+ tier: winner.name,
88
+ stoppedBy,
89
+ ...(winner.answer?.data === undefined ? {} : { data: winner.answer.data }),
90
+ };
91
+ }
@@ -0,0 +1,3 @@
1
+ export { cascade } from "./cascade.js";
2
+ export { attempt, fallback, tierStep, tokensOf } from "./tier.js";
3
+ export type { Answer, CascadeOptions, CascadeResult, Escalation, Tier, TierRecord, } from "./types.js";
@@ -0,0 +1,2 @@
1
+ export { cascade } from "./cascade.js";
2
+ export { attempt, fallback, tierStep, tokensOf } from "./tier.js";
@@ -0,0 +1,31 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { Emit } from "../stream/index.js";
3
+ import type { Answer, TierRecord } from "./types.js";
4
+ /** What one tier consumed, both halves, whether or not its answer was used. */
5
+ export declare function tokensOf(answer: Answer | null): number;
6
+ /**
7
+ * One tier as a step, named for the tier rather than its position.
8
+ *
9
+ * A rollout that read `tier_0` would need the config beside it to mean anything,
10
+ * and the config is the thing most likely to have changed since.
11
+ */
12
+ export declare function tierStep(index: number, record: TierRecord): RolloutStep;
13
+ /**
14
+ * Runs one tier, turning a throw into an escalation.
15
+ *
16
+ * An overloaded cheap model is the case this workflow exists for, so a tier that
17
+ * falls over must hand on rather than end the run. The next rung is the whole
18
+ * point of having one.
19
+ */
20
+ export declare function attempt(run: (values: Record<string, unknown>, emit: Emit) => Promise<Answer> | Answer, values: Record<string, unknown>, emit: Emit): Promise<{
21
+ answer: Answer | null;
22
+ error?: string;
23
+ }>;
24
+ /**
25
+ * The tier to fall back to when nothing was accepted.
26
+ *
27
+ * The last that produced anything, because the tiers are ordered by capability
28
+ * and the most capable answer is the closest thing to a best effort. A tier that
29
+ * threw produced nothing, so it cannot be the fallback however late it ran.
30
+ */
31
+ export declare function fallback(history: TierRecord[]): TierRecord | undefined;
@@ -0,0 +1,53 @@
1
+ /** What one tier consumed, both halves, whether or not its answer was used. */
2
+ export function tokensOf(answer) {
3
+ if (!answer)
4
+ return 0;
5
+ return (answer.inputTokens ?? 0) + (answer.outputTokens ?? 0);
6
+ }
7
+ /**
8
+ * One tier as a step, named for the tier rather than its position.
9
+ *
10
+ * A rollout that read `tier_0` would need the config beside it to mean anything,
11
+ * and the config is the thing most likely to have changed since.
12
+ */
13
+ export function tierStep(index, record) {
14
+ return {
15
+ index,
16
+ source: record.name,
17
+ model: record.answer?.model,
18
+ output: record.answer?.output ?? "",
19
+ status: record.escalatedBy === "failed" ? "failed" : "completed",
20
+ error: record.error,
21
+ inputTokens: record.answer?.inputTokens,
22
+ outputTokens: record.answer?.outputTokens,
23
+ };
24
+ }
25
+ /**
26
+ * Runs one tier, turning a throw into an escalation.
27
+ *
28
+ * An overloaded cheap model is the case this workflow exists for, so a tier that
29
+ * falls over must hand on rather than end the run. The next rung is the whole
30
+ * point of having one.
31
+ */
32
+ export async function attempt(run, values, emit) {
33
+ try {
34
+ const answer = await run(values, emit);
35
+ if (answer.status === "failed") {
36
+ return { answer, error: answer.error ?? "The tier failed." };
37
+ }
38
+ return { answer };
39
+ }
40
+ catch (cause) {
41
+ return { answer: null, error: cause instanceof Error ? cause.message : String(cause) };
42
+ }
43
+ }
44
+ /**
45
+ * The tier to fall back to when nothing was accepted.
46
+ *
47
+ * The last that produced anything, because the tiers are ordered by capability
48
+ * and the most capable answer is the closest thing to a best effort. A tier that
49
+ * threw produced nothing, so it cannot be the fallback however late it ran.
50
+ */
51
+ export function fallback(history) {
52
+ return [...history].reverse().find((record) => record.answer !== null);
53
+ }
@@ -0,0 +1,98 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { Emit } from "../stream/index.js";
3
+ /** What one tier produced. Structurally the `LlmReply` a provider read gives back. */
4
+ export interface Answer {
5
+ output: string;
6
+ model: string;
7
+ status?: "completed" | "failed";
8
+ error?: string;
9
+ inputTokens?: number;
10
+ outputTokens?: number;
11
+ /** A parsed reply, where the tier asked for a shape. What `accept` usually reads. */
12
+ data?: unknown;
13
+ }
14
+ /** One rung: a name for the record, and the call it makes. */
15
+ export interface Tier {
16
+ /** How it appears in `result.tier`, `escalated` and the steps. Unique. */
17
+ name: string;
18
+ run(values: Record<string, unknown>, emit: Emit): Promise<Answer> | Answer;
19
+ }
20
+ /** Why a tier did not answer. */
21
+ export type Escalation = "rejected" | "failed";
22
+ /** What one tier did, whether it answered or not. */
23
+ export interface TierRecord {
24
+ index: number;
25
+ name: string;
26
+ /** What it produced, or null when the call threw. */
27
+ answer: Answer | null;
28
+ accepted: boolean;
29
+ /**
30
+ * Absent when it answered.
31
+ *
32
+ * `rejected` is `accept` saying no; `failed` is the call falling over. They
33
+ * want opposite fixes — a bar set too high against a model that is down — and
34
+ * a record that collapsed them could not tell you which you have.
35
+ */
36
+ escalatedBy?: Escalation;
37
+ error?: string;
38
+ tokens: number;
39
+ }
40
+ export interface CascadeOptions {
41
+ /**
42
+ * Rungs in order, cheapest first.
43
+ *
44
+ * Ordered by capability rather than price where the two disagree: the last is
45
+ * what a run falls back to, so it should be the one most likely to cope.
46
+ */
47
+ tiers: Tier[];
48
+ /**
49
+ * Whether that answer will do.
50
+ *
51
+ * Required. Without it there is no cascade — only a list of models and no
52
+ * reason to stop at any of them.
53
+ */
54
+ accept(answer: Answer, index: number): Promise<boolean> | boolean;
55
+ /**
56
+ * Where the tokens go while this runs.
57
+ *
58
+ * Passed down to whatever makes the model call; omit it and nothing is
59
+ * emitted. `streamed()` supplies one.
60
+ */
61
+ emit?: Emit;
62
+ /**
63
+ * Stop once the tiers have consumed this many tokens.
64
+ *
65
+ * Checked before each tier, so the ceiling is never crossed rather than merely
66
+ * noticed. Tokens rather than money because this package cannot price a call.
67
+ */
68
+ maxTokens?: number;
69
+ /** Called after each tier, so a long escalation is not silent. */
70
+ onTier?: (record: TierRecord) => void;
71
+ }
72
+ /**
73
+ * What the cascade produced, in the shape of a rollout.
74
+ *
75
+ * `inputTokens` and `outputTokens` cover **every** tier tried, not the winner
76
+ * alone. The only question a cascade has to answer is whether it paid, and a
77
+ * total that hid the rejected attempts would always say yes.
78
+ */
79
+ export interface CascadeResult {
80
+ status: "completed" | "failed";
81
+ output: string;
82
+ error?: string;
83
+ steps: RolloutStep[];
84
+ outputs: Record<string, unknown>;
85
+ latencyMs: number;
86
+ inputTokens: number;
87
+ outputTokens: number;
88
+ /** True when a tier passed `accept`. False when the answer is a fallback. */
89
+ accepted: boolean;
90
+ /** Whose answer this is: the one accepted, or the last that produced one. */
91
+ tier: string | null;
92
+ /** Tiers tried that did not supply the answer, in the order they were tried. */
93
+ escalated: string[];
94
+ stoppedBy: "accepted" | "exhausted" | "tokens" | "failed";
95
+ /** The winning tier's parsed reply, where it had one. */
96
+ data?: unknown;
97
+ history: TierRecord[];
98
+ }
@@ -0,0 +1,9 @@
1
+ import type { ChainOptions, ChainResult } from "./types.js";
2
+ /**
3
+ * Steps in sequence, each on the last one's output, with a gate between.
4
+ *
5
+ * Compiled to a graph, which already stops at the node that failed and records
6
+ * every node that ran before it — which is the whole reason to gate a chain
7
+ * rather than let it run to the end and hand back something plausible.
8
+ */
9
+ export declare function chain(values: Record<string, unknown>, opts: ChainOptions): Promise<ChainResult>;
@@ -0,0 +1,58 @@
1
+ import { edge, end, graph } from "@spendgraph/graph";
2
+ import { runGraph } from "../stream/graph.js";
3
+ import { gateName, gateNode } from "./gate.js";
4
+ import { stepNode } from "./step.js";
5
+ function assertNamed(steps) {
6
+ if (steps.length === 0)
7
+ throw new Error("A chain needs at least one step.");
8
+ const seen = new Set();
9
+ for (const step of steps) {
10
+ if (seen.has(step.name))
11
+ throw new Error(`Two steps are called "${step.name}".`);
12
+ seen.add(step.name);
13
+ }
14
+ }
15
+ /** Every node in order: a stage, then its gate when it has one. */
16
+ function nodesFor(steps) {
17
+ const nodes = [];
18
+ const order = [];
19
+ let previous = null;
20
+ for (const step of steps) {
21
+ nodes.push(stepNode(step, previous));
22
+ order.push(step.name);
23
+ if (step.gate) {
24
+ nodes.push(gateNode(step));
25
+ order.push(gateName(step.name));
26
+ }
27
+ previous = step.name;
28
+ }
29
+ return { nodes, order };
30
+ }
31
+ /**
32
+ * Steps in sequence, each on the last one's output, with a gate between.
33
+ *
34
+ * Compiled to a graph, which already stops at the node that failed and records
35
+ * every node that ran before it — which is the whole reason to gate a chain
36
+ * rather than let it run to the end and hand back something plausible.
37
+ */
38
+ export async function chain(values, opts) {
39
+ assertNamed(opts.steps);
40
+ const { nodes, order } = nodesFor(opts.steps);
41
+ const flow = graph({
42
+ entry: order[0],
43
+ maxSteps: opts.maxSteps,
44
+ nodes,
45
+ edges: [
46
+ ...order.slice(0, -1).map((from, at) => edge(from, order[at + 1])),
47
+ end(order.at(-1)),
48
+ ],
49
+ }, { now: opts.now });
50
+ const result = await runGraph(flow, values, opts.emit);
51
+ const stopped = result.status === "failed" ? result.steps.at(-1) : undefined;
52
+ return {
53
+ ...result,
54
+ ran: result.steps.filter((s) => !s.source.startsWith("gate_")).map((s) => s.source),
55
+ stoppedAt: stopped ? stopped.source.replace(/^gate_/, "") : null,
56
+ reason: stopped?.error,
57
+ };
58
+ }
@@ -0,0 +1,16 @@
1
+ import type { Step } from "./types.js";
2
+ /** The gate node's name for a stage, and the key its verdict is stored under. */
3
+ export declare function gateName(step: string): string;
4
+ export declare class GateRefused extends Error {
5
+ readonly step: string;
6
+ readonly reason: string;
7
+ constructor(step: string, reason: string);
8
+ }
9
+ /**
10
+ * A gate as its own node, so a refusal is a step of its own.
11
+ *
12
+ * Folding the check into the stage would lose the output it refused — and the
13
+ * rejected output is the evidence for why the gate fired at all. This way the
14
+ * stage records what it produced and the gate records what was wrong with it.
15
+ */
16
+ export declare function gateNode(step: Step): import("@spendgraph/graph").Node<import("@spendgraph/graph").ArgsOf<readonly import("@spendgraph/graph").ArgSpec[]>>;