@spendgraph/stage
v0.15.0
Published
One prompt, one schema, one priced reply.
Maintainers
Readme
@spendgraph/stage
One prompt, one schema, one priced reply.
npm install @spendgraph/stageimport { runStage } from "@spendgraph/stage";
const outcome = await runStage(prompts, llm, "ctr-sum-a91", SCHEMA, {
question: "What is 812 × 0.2611?",
});
outcome.data.answer; // a number, or this line never ran
outcome.rolloutId;
outcome.usage.inputTokens;prompt owns the wording and records the rollout. llms makes the call and
reports the usage. Neither imports the other, on purpose. This is the join: ask
a stored or built prompt for an object, keep asking until the reply carries it,
and hand back what came out alongside what it cost.
in slug "ctr-sum-a91"
schema { answer: number, working: string }
question "What is 812 × 0.2611?"
│
▼
╔═══════════════╗
║ stage ║
╚═══════════════╝
│
attempt 1 ─────────────┤
pull the prompt ──► prompt rollout ro_71a
render it, send it ──► llms
model replies "about 212" ✗ prose, not an object
│
attempt 2 ─────────────┤
pull the prompt ──► prompt rollout ro_71b
render it, send it ──► llms
model replies {…} ✓ matches the schema
│
▼
out data { answer: 212.0132, working: "812 × 0.2611" }
usage 1,240 in · 96 out
cost ro_71a $0.004 ← the failed try, still billed
ro_71b $0.005Three responsibilities, and no more
Give back the object, or throw. A caller never parses a string or guesses at
a field. data.answer is a number or there is an exception, which is what lets
a graph branch on a field without praying it exists.
Retry a reply of the wrong shape, never a wrong answer. Attempt 1 above failed because the model wrote prose, not because 212 was incorrect. Judging answers belongs to whatever called this.
Say what it cost, including the attempts that failed. Both tries are rollouts and both are priced.
Why the retry wraps the whole call
It would be smaller to retry inside llms, around the model call alone. It
would also be wrong here: prompt.call records a rollout per call, so three
attempts inside one rollout means two of them vanish from the bill. Retrying the
outside means a reply that came back as prose is a priced row someone can see.
That is the one design decision in this package worth arguing about, and it is why the retry lives here rather than a layer down.
Why it is not part of prompt or llms
The return value needs both:
| | |
| --- | --- |
| rolloutId, model, pricing | from prompt |
| data, usage, latencyMs | from llms |
llms imports one thing in its whole source, @spendgraph/sdk, and the only
mention of prompt anywhere in it is a comment saying its Message is
structurally the same shape. prompt keeps the same distance in return. A
join cannot live inside either of the things it joins without collapsing that.
Stored or built
Both come back the same way, because pullPrompt and buildCustomPrompt both
return a Prompt.
| | |
| --- | --- |
| runStage(client, llm, slug, schema, input, opts) | wording stored on spendgraph, pulled by slug |
| runBuilt(prompt, llm, schema, input, opts) | wording in your own repo, shipped with the code that reads it |
A built prompt has no row and no version, so its spend is recorded as usage rather than a rollout. Nothing downstream has to know which it was.
Layout
src/
run/ runStage · runBuilt · StageReplyError
retry/ keep asking while the reply is the wrong shape
events/ started · finished · failed
usage/ the six token counts, and summing them
types/ StageOptions · StageOutcome · StageTools
docs/ overview · running · retrying · cost · eventsrun/ is the only part most callers touch. The rest is what it needs to do its
job honestly: a retry that is visible on the bill, events at the stage boundary
rather than the call boundary, and a usage type that counts cache and reasoning
tokens rather than pretending there are two.
What it deliberately does not do
Render your context. Which facts belong in a <context> block — a clock, a
locale, a task, a history — is a product decision, not a library one. Hand this
a question and it sends a question.
Judge the answer. A reply that matches the schema and is wrong is a successful stage. Verification is somebody else's stage.
Choose what runs next. That is @spendgraph/graph, which branches on the
fields this guarantees exist.
Events
Emitted at the stage boundary, which is not the same as the call boundary: one stage can be several calls once the retry is counted.
stage:started { slug, model }
stage:finished { slug, model, rolloutId, latencyMs }
stage:failed { slug, attempt, error }
tool:started { slug, tool }
tool:finished { slug, tool, status, latencyMs }A run that is silent between stages has nothing to show a person, which is why
these exist at all rather than being inferred from timings. The tool: pair is
there because most of a stage that calls tools is spent inside them, where the
stage pair cannot see; the bus is wrapped only when emit is passed.
Docs
| | | | --- | --- | | Overview | The join between prompt and llms | | Running a stage | Stored wording, or wording in your repo | | Retrying | Why a failed try is a row on the bill | | Cost and usage | Six token counts, and a price that arrives late | | Events | started, finished and every failed attempt |
Status
Extracted from packages/workflows/src/noesis/core, where it has run every
stage of a four-thousand-line workflow in production. The code is proven; this
package is the move, not a rewrite.
License
Apache-2.0
