npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@spendgraph/stage

v0.15.0

Published

One prompt, one schema, one priced reply.

Readme

@spendgraph/stage

One prompt, one schema, one priced reply.

npm install @spendgraph/stage
import { 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.005

Three 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 · events

run/ 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