@spendgraph/sdk 0.7.0 → 0.8.1

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.
@@ -4,7 +4,7 @@ export declare const PUBLISH_TO: readonly ["md", "html", "txt", "pdf"];
4
4
  export type PublishFrom = (typeof PUBLISH_FROM)[number];
5
5
  export type PublishTo = (typeof PUBLISH_TO)[number];
6
6
  export interface PublishInput {
7
- projectId: string;
7
+ projectId?: string;
8
8
  content: string;
9
9
  from: PublishFrom;
10
10
  to: PublishTo;
@@ -19,6 +19,15 @@ export interface IngestResult {
19
19
  rejected: number;
20
20
  /** Ids nothing could price. Stored at $0 and counted, never rejected. */
21
21
  unpricedModels: string[];
22
+ /**
23
+ * What each event was priced at, by the `eventId` it was sent with.
24
+ *
25
+ * The server prices from the project's resolved offer as it writes the row,
26
+ * so this is the figure that was stored rather than a second calculation of
27
+ * it. A caller reporting usage rather than a rollout has no other way to put
28
+ * a cost on the call it just made.
29
+ */
30
+ costs: Record<string, number>;
22
31
  }
23
32
  /** The route takes up to this many events per request. */
24
33
  export declare const MAX_EVENTS = 100;
@@ -31,12 +40,22 @@ export declare const MAX_EVENTS = 100;
31
40
  export interface Sender {
32
41
  send(events: UsageEvent[]): Promise<void>;
33
42
  }
43
+ /** What one event cost, for a caller that sent exactly one. */
44
+ export type OneCost = number | undefined;
34
45
  /** Posts usage straight through, chunked to what the route accepts. */
35
46
  export declare class Ingest implements Sender {
36
47
  private readonly client;
37
48
  private readonly project?;
38
49
  constructor(client: Client, project?: string | undefined);
39
50
  send(events: UsageEvent[]): Promise<void>;
51
+ /**
52
+ * One event, and what it cost — for a caller that wants the figure back.
53
+ *
54
+ * `undefined` where the server could not price the model, which it counts at
55
+ * zero rather than rejecting. A caller can then say "unpriced" instead of
56
+ * showing a zero that looks measured.
57
+ */
58
+ priced(event: UsageEvent): Promise<OneCost>;
40
59
  /** The same write, with the server's answer — which ids went unpriced. */
41
60
  report(events: UsageEvent[]): Promise<IngestResult>;
42
61
  }
@@ -1 +1 @@
1
- const i=100;class p{client;project;constructor(c,e){this.client=c,this.project=e}async send(c){await this.report(c)}async report(c){const e={accepted:0,rejected:0,unpricedModels:[]};if(c.length===0)return e;const r=new Set,o=c.map(t=>({...t,eventId:t.eventId??crypto.randomUUID()}));for(let t=0;t<o.length;t+=100){const n=await this.client.post("/api/v1/ingest",{events:o.slice(t,t+100)},{project:this.project});e.accepted+=n?.accepted??0,e.rejected+=n?.rejected??0;for(const s of n?.unpricedModels??[])r.add(s)}return e.unpricedModels=[...r],e}}export{p as Ingest,i as MAX_EVENTS};
1
+ const i=100;class p{client;project;constructor(e,t){this.client=e,this.project=t}async send(e){await this.report(e)}async priced(e){const t=e.eventId??crypto.randomUUID();return(await this.report([{...e,eventId:t}])).costs[t]}async report(e){const t={accepted:0,rejected:0,unpricedModels:[],costs:{}};if(e.length===0)return t;const r=new Set,o=e.map(c=>({...c,eventId:c.eventId??crypto.randomUUID()}));for(let c=0;c<o.length;c+=100){const s=await this.client.post("/api/v1/ingest",{events:o.slice(c,c+100)},{project:this.project});t.accepted+=s?.accepted??0,t.rejected+=s?.rejected??0;for(const n of s?.unpricedModels??[])r.add(n);Object.assign(t.costs,s?.costs??{})}return t.unpricedModels=[...r],t}}export{p as Ingest,i as MAX_EVENTS};
@@ -21,7 +21,7 @@ export interface PromptBlock {
21
21
  body?: string;
22
22
  }
23
23
  export interface SavePromptInput {
24
- projectId: string;
24
+ projectId?: string;
25
25
  name: string;
26
26
  blocks?: PromptBlock[];
27
27
  variables?: Record<string, string>;
@@ -22,7 +22,7 @@ export interface ToolRow {
22
22
  updatedAt: string;
23
23
  }
24
24
  export interface SaveToolInput {
25
- projectId: string;
25
+ projectId?: string;
26
26
  name: string;
27
27
  description: string;
28
28
  args?: FieldSpec[];
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Typed prompt fields — what a `{placeholder}` accepts, and how it renders.
3
3
  *
4
- * Deliberately a copy of `lib/fields.ts` rather than an import. This package is
4
+ * Deliberately a copy of `lib/prompts/fields.ts` rather than an import. This package is
5
5
  * published and depends on nothing; reaching into the app for a type would make
6
6
  * every installed copy break the next time the app moved a file. The two are
7
7
  * kept in step by the wire format, which is the only contract that matters.
package/docs/api.mdx ADDED
@@ -0,0 +1,101 @@
1
+ export const meta = {
2
+ title: "HTTP API | spendgraph docs",
3
+ description: "The ingest endpoint, cache token accounting, the stats and events reads, and CSV export.",
4
+ };
5
+
6
+ # HTTP API
7
+
8
+ The SDK talks to one endpoint; everything else powers the dashboard. Errors are always `{ "error": { "code", "message" } }`.
9
+
10
+ ## POST /api/v1/ingest
11
+
12
+ Auth is the `x-api-key` header. The body carries 1 to 100 events.
13
+
14
+ ```bash
15
+ curl -X POST https://spendgraph.locusgraph.com/api/v1/ingest \
16
+ -H "content-type: application/json" \
17
+ -H "x-api-key: sg_..." \
18
+ -d '{
19
+ "events": [{
20
+ "model": "claude-sonnet-5",
21
+ "inputTokens": 3211,
22
+ "outputTokens": 842
23
+ }]
24
+ }'
25
+ # -> 202 { "accepted": 1, "rejected": 0, "unpricedModels": [] }
26
+ ```
27
+
28
+ Cost is computed at ingest from the newest price for the model and stored on the event in integer micro-USD. Unknown models are stored at $0 and flagged on the [Pricing](https://spendgraph.locusgraph.com/pricing) page. Events are never dropped.
29
+
30
+ ## Cache tokens
31
+
32
+ Cached prompts bill at their own rates: a read costs roughly a tenth of normal input, a write a little more than it, so they are reported as their own fields rather than folded into `inputTokens`.
33
+
34
+ ```json
35
+ "events": [{
36
+ "model": "claude-sonnet-5",
37
+ "inputTokens": 3211,
38
+ "outputTokens": 842,
39
+ "cacheReadTokens": 18004,
40
+ "cacheWriteTokens": 3211
41
+ }]
42
+ ```
43
+
44
+ <Callout tone="trap" title="The three counts are disjoint">
45
+ Providers report them separately, Anthropic's total input is `input + cache_read + cache_creation`, and spendgraph prices them the same way. Do **not** also add cache tokens into `inputTokens`: that bills the same tokens twice, at the wrong rate. Omitting them loses the spend entirely.
46
+ </Callout>
47
+
48
+ A model whose offer publishes no cache rate bills cache tokens at its input rate rather than at zero. An unknown discount should overcount, never undercount.
49
+
50
+ `wrap()` reads all three counts off the provider response for you. This only matters if you are calling the API directly.
51
+
52
+ ## Reads
53
+
54
+ These accept a signed-in GitHub session, and, for the endpoints below, the same `x-api-key` header as ingest. That is what makes a scheduled cost report possible without driving a browser.
55
+
56
+ | | |
57
+ | --- | --- |
58
+ | `GET /api/v1/stats/summary` | totals plus the previous period |
59
+ | `GET /api/v1/stats/timeseries` | spend and tokens over time |
60
+ | `GET /api/v1/stats/by-model` | grouped by model |
61
+ | `GET /api/v1/stats/by-key` | grouped by API key |
62
+ | `GET /api/v1/stats/by-tag?key=` | spend by one metadata dimension |
63
+ | `GET /api/v1/events` | raw events, newest first |
64
+ | `GET /api/v1/compare` | what a workload costs on each model |
65
+
66
+ `by-key` is how spend gets attributed to an environment or a service. Each row carries the key's `revoked` timestamp, so spend that arrived on a key you have since turned off stays visible rather than silently unattributed.
67
+
68
+ `by-tag` takes one dimension at a time on purpose. Omit `key` and the costliest is used, with the rest listed in `keys`. Ranking `env` and `feature` together would double count: they are two cuts of the same spend.
69
+
70
+ <Callout tone="warn" title="A key is pinned to its project">
71
+ `project=` is implied and can be omitted. Passing a *different* project returns `403 project_not_accessible` rather than quietly answering with the key's own project, a report that iterates projects with one key should fail loudly, not repeat one project's figures under every heading.
72
+ </Callout>
73
+
74
+ ## Filtering and exporting events
75
+
76
+ A parameter you cannot guess is a parameter you do not have, so here is the whole set `/api/v1/events` accepts. All optional, all combine freely.
77
+
78
+ | | |
79
+ | --- | --- |
80
+ | `sort=cost` | most expensive first, instead of newest |
81
+ | `model=` `key=` `project=` | narrow to one model, key, or project |
82
+ | `q=` | substring match across the model name and metadata JSON |
83
+ | `from=` `to=` | ISO timestamps |
84
+ | `format=csv` | the same rows as a spreadsheet |
85
+ | `cursor=` `limit=` | paging |
86
+
87
+ Paging works in either sort order, but a cursor is only valid for the sort it came from; switching restarts from the first page.
88
+
89
+ In CSV, each metadata key becomes its own `meta.<key>` column, so cost by feature is a pivot rather than a JSON parse.
90
+
91
+ <Callout tone="trap" title="A truncated export is a wrong answer that looks right">
92
+ CSV is capped at 5,000 rows. Check `x-spendgraph-truncated` before totalling a column: `x-spendgraph-rows` and `x-spendgraph-next-cursor` carry on from where it stopped.
93
+ </Callout>
94
+
95
+ ## Everything else
96
+
97
+ `GET /api/v1/pricing` · `PUT /api/v1/pricing/:model` · `POST /api/v1/pricing/sync` · `GET|POST /api/v1/projects` · `GET|POST /api/v1/keys` · `DELETE /api/v1/keys/:id`
98
+
99
+ Prices sync from LiteLLM's community file every day at 06:00 UTC, and **Sync prices** on the [Pricing](https://spendgraph.locusgraph.com/pricing) page runs it on demand. A price you set by hand always wins over the sync, so a negotiated rate survives the next refresh.
100
+
101
+ Keys, projects, pricing and credentials are gated on a dashboard session and have no API-key path, deliberately: credentials holds provider keys, and keys mints API keys.
@@ -0,0 +1,50 @@
1
+ export const meta = {
2
+ title: "Budgets and alerts | spendgraph docs",
3
+ description: "Set a monthly budget, get a Slack-shaped webhook at 80% and 100%, and understand what a budget deliberately does not do.",
4
+ };
5
+
6
+ # Budgets and alerts
7
+
8
+ <Callout tone="trap" title="A budget alerts. It never blocks.">
9
+ Ingest keeps accepting events and your provider keeps serving requests after the budget is passed. Spendgraph watches spend, it does not stand between you and the model. Nothing here caps anything.
10
+ </Callout>
11
+
12
+ That is worth being blunt about, because "budget" implies a limit in most tools. If you need a hard cap, it has to live in front of the provider, not here.
13
+
14
+ ## Setting one
15
+
16
+ ```bash
17
+ # set or clear a budget (null clears; webhook is optional)
18
+ PUT /api/v1/projects/{id}/budget
19
+ { "monthlyBudgetMicros": 40000000,
20
+ "alertWebhookUrl": "https://hooks.slack.com/..." }
21
+
22
+ # read it back, with spend so far this month
23
+ GET /api/v1/projects/{id}/budget
24
+ -> { "budget": { "monthlyBudgetMicros": 40000000, "alertWebhookUrl": "..." },
25
+ "monthToDateMicros": 28887771 }
26
+ ```
27
+
28
+ Money is integer micro-USD everywhere: `40000000` is $40.
29
+
30
+ ## When alerts fire
31
+
32
+ Thresholds are **80%** and **100%** of month-to-date spend, and each fires at most once per project per calendar month. A single run that jumps past both posts only the higher one.
33
+
34
+ The check runs once a day on the cron, so spend can pass a threshold and sit there for up to a day before you hear about it. Budgets are a monthly-planning tool, not a pager.
35
+
36
+ The `(project, period, threshold)` row is what makes "at most once" true, and it is written **after** a successful POST, so a webhook that is down retries on the next run instead of silently swallowing the alert.
37
+
38
+ ## Reading what fired
39
+
40
+ ```bash
41
+ GET /api/v1/alerts # newest first, limit 1-200, default 50
42
+ -> { "alerts": [{ "projectName": "support-bot", "period": "2026-08",
43
+ "thresholdPct": 80, "sentAt": "..." }] }
44
+ ```
45
+
46
+ ## The webhook payload
47
+
48
+ Slack-shaped, `{ "text": "..." }`, so any Slack-compatible incoming webhook works unchanged.
49
+
50
+ The line names the project, spend against budget, the day of the month, and where the current pace lands by month end. That last part is the point: **80% on the 3rd and 80% on the 28th are not the same news**, and an alert that does not say which one it is makes you go and look.
package/docs/client.mdx CHANGED
@@ -1,12 +1,12 @@
1
1
  export const meta = {
2
- title: "The client spendgraph docs",
2
+ title: "The client: spendgraph docs",
3
3
  description:
4
4
  "Spendgraph is the whole API in one client: prompts, tools, stats, events, keys and projects. One capital letter apart from the meter, and a different thing entirely.",
5
5
  };
6
6
 
7
7
  # The client
8
8
 
9
- `SpendGraph` meters what you spend. `Spendgraph` reads and writes everything else prompts, tools, stats, events, keys, projects.
9
+ `SpendGraph` meters what you spend. `Spendgraph` reads and writes everything else: prompts, tools, stats, events, keys, projects.
10
10
 
11
11
  ```ts
12
12
  import { Spendgraph } from "@spendgraph/sdk";
@@ -21,7 +21,7 @@ const summary = await sg.stats.summary({ from, to });
21
21
  ```
22
22
 
23
23
  <Callout tone="trap" title="Two classes, one capital letter apart">
24
- `SpendGraph` is the meter `wrap`, `track`, `flush`. `Spendgraph` is the API client. Both are exported from the package root, autocomplete offers both, and picking the wrong one gives you an object with none of the methods you expected. The meter is the one with the capital G.
24
+ `SpendGraph` is the meter: `wrap`, `track`, `flush`. `Spendgraph` is the API client. Both are exported from the package root, autocomplete offers both, and picking the wrong one gives you an object with none of the methods you expected. The meter is the one with the capital G.
25
25
  </Callout>
26
26
 
27
27
  ## Why go through it
@@ -48,7 +48,7 @@ new Spendgraph({ session: cookie, baseUrl }); // the second
48
48
  new Spendgraph({ token: "sgc_…", baseUrl }); // a terminal, via cli.exchange
49
49
  ```
50
50
 
51
- An `sgc_` token stands in for a person's session anywhere a session is accepted, and unlike a cookie it can be revoked see `cli.revoke`.
51
+ An `sgc_` token stands in for a person's session anywhere a session is accepted, and unlike a cookie it can be revoked. See `cli.revoke`.
52
52
 
53
53
  ## A key is already pinned
54
54
 
@@ -61,7 +61,7 @@ Naming a *different* project in a body comes back `403 project_not_accessible` r
61
61
  | | |
62
62
  | --- | --- |
63
63
  | `sg.stats` | `summary` · `timeseries` · `byModel` · `byKey` · `byTag` |
64
- | `sg.events` | `list` raw events, filtered, paged, or as CSV |
64
+ | `sg.events` | `list`: raw events, filtered, paged, or as CSV |
65
65
  | `sg.ingest` | reporting usage by hand, up to 100 events a call |
66
66
  | `sg.prompts` | `list` · `create` · `update` · `promote` · `rollouts` · `report` |
67
67
  | `sg.tools` | `list` · `get` · `create` · `update` · `archive` |
@@ -74,7 +74,7 @@ Naming a *different* project in a body comes back `403 project_not_accessible` r
74
74
 
75
75
  ## The escape hatch
76
76
 
77
- `sg.http` is the transport underneath. Reach for it only for a route the class does not cover yet you keep the auth, retries and error handling, and you give up the types.
77
+ `sg.http` is the transport underneath. Reach for it only for a route the class does not cover yet: you keep the auth, retries and error handling, and you give up the types.
78
78
 
79
79
  ```ts
80
80
  await sg.http.get("/api/v1/something-new");
@@ -82,4 +82,4 @@ await sg.http.get("/api/v1/something-new");
82
82
 
83
83
  ## Next
84
84
 
85
- What happens when a call fails, and what retries on its own: [Errors and retries](/docs/sdk/errors).
85
+ What happens when a call fails, and what retries on its own: [Errors and retries](/spendgraph/sdk/errors).
package/docs/errors.mdx CHANGED
@@ -1,5 +1,5 @@
1
1
  export const meta = {
2
- title: "Errors and retries spendgraph docs",
2
+ title: "Errors and retries: spendgraph docs",
3
3
  description:
4
4
  "One error type carrying the status, the code, and how many attempts it took. The client retries what is worth retrying and nothing else; the meter never throws at all.",
5
5
  };
@@ -59,7 +59,7 @@ const sg = new Spendgraph({ apiKey, baseUrl, attempts: 5, maxWaitMs: 2000 });
59
59
 
60
60
  ## `retry-after` wins, but not unbounded
61
61
 
62
- When the server sends `retry-after`, the client waits that long rather than guessing the server knows when its window reopens and your backoff does not.
62
+ When the server sends `retry-after`, the client waits that long rather than guessing, the server knows when its window reopens and your backoff does not.
63
63
 
64
64
  The two caps do different jobs, and the difference is worth keeping straight. `maxWaitMs` bounds **our own** backoff and is small. `maxRetryAfterMs` bounds **the server's** instruction and is much larger, because clamping a `retry-after: 600` down to five seconds would retry straight back into the same closed window and burn every remaining attempt for nothing.
65
65
 
@@ -71,9 +71,9 @@ Backoff is spread with jitter so a fleet of clients does not wake in lockstep an
71
71
 
72
72
  ## Why the meter is different
73
73
 
74
- The SDK's metering half batches events 5 seconds or 20 events, whichever lands first and if the server is unreachable it drops them.
74
+ The SDK's metering half batches events, 5 seconds or 20 events, whichever lands first, and if the server is unreachable it drops them.
75
75
 
76
- That is a trade, stated plainly: a metering library that takes your app down when the meter is having a bad day has failed at something more important than metering. [`flush()`](/docs/sdk/tracking) is how you decide when the risk of losing a batch matters more than the latency of waiting for it.
76
+ That is a trade, stated plainly: a metering library that takes your app down when the meter is having a bad day has failed at something more important than metering. [`flush()`](/spendgraph/sdk/tracking) is how you decide when the risk of losing a batch matters more than the latency of waiting for it.
77
77
 
78
78
  ## Unpriced models are not errors
79
79
 
@@ -0,0 +1,74 @@
1
+ export const meta = {
2
+ title: "Integrations | spendgraph docs",
3
+ description: "LangChain, LangGraph and the Vercel AI SDK, each in one line. Built in, no extra dependencies.",
4
+ };
5
+
6
+ # Integrations
7
+
8
+ Every integration ships inside the SDK: subpath imports, zero extra dependencies, version-agnostic because they read shapes rather than packages, and fail-open like everything else.
9
+
10
+ ## LangChain
11
+
12
+ One callback handler covers every model call in a chain.
13
+
14
+ ```ts
15
+ import { spendGraphHandler } from "@spendgraph/sdk/langchain";
16
+
17
+ const handler = spendGraphHandler(meter, { metadata: { feature: "rag" } });
18
+
19
+ await chain.invoke(input, { callbacks: [handler] });
20
+ ```
21
+
22
+ Or attach it once to the model, which covers everything that model ever does:
23
+
24
+ ```ts
25
+ const llm = new ChatAnthropic({
26
+ model: "claude-sonnet-5",
27
+ callbacks: [handler],
28
+ });
29
+ ```
30
+
31
+ ## LangGraph
32
+
33
+ The same handler. LangGraph forwards callbacks to every node, so one line tracks every model call inside the graph: agents and tools included.
34
+
35
+ ```ts
36
+ const graph = workflow.compile();
37
+ await graph.invoke(state, { callbacks: [spendGraphHandler(meter)] });
38
+ ```
39
+
40
+ ## Vercel AI SDK
41
+
42
+ There is no client to wrap here, you call functions, not methods, so you hand it the result instead. Both v4 and v5 usage shapes are handled.
43
+
44
+ ```ts
45
+ import { trackAiResult } from "@spendgraph/sdk/ai";
46
+
47
+ const result = await generateText({ model: anthropic("claude-sonnet-5"), prompt });
48
+ trackAiResult(meter, result);
49
+ ```
50
+
51
+ Streaming usage is only known at the end, so report it from `onFinish`:
52
+
53
+ ```ts
54
+ streamText({ model, prompt, onFinish: (r) => trackAiResult(meter, r) });
55
+ ```
56
+
57
+ A third argument takes the same `metadata` as the other integrations, and lets you name the model outright:
58
+
59
+ ```ts
60
+ trackAiResult(meter, result, {
61
+ metadata: { feature: "summarise", tenant: user.orgId },
62
+ model: "claude-sonnet-5",
63
+ });
64
+ ```
65
+
66
+ Pass `model` when you know it. The id is otherwise read off the result, and a shape that does not carry one is tracked as `unknown`: the token counts are kept rather than dropped, but nothing prices an id that is not in the catalog, so those calls show up as `unpriced`.
67
+
68
+ <Callout tone="trap" title="Flush in route handlers">
69
+ The AI SDK is mostly used on serverless runtimes, which freeze the moment the response is sent. `await meter.flush()` before returning, or the buffered event goes with them.
70
+ </Callout>
71
+
72
+ ## Everything else
73
+
74
+ Anthropic and OpenAI clients use [`meter.wrap()`](/spendgraph/sdk/tracking). Any other framework or language reports with [`meter.track()`](/spendgraph/sdk/tracking) or a plain HTTP POST to [`/api/v1/ingest`](/spendgraph/sdk/api).
@@ -0,0 +1,58 @@
1
+ export const meta = {
2
+ title: "Quickstart | spendgraph docs",
3
+ description: "Wire spendgraph into an app in two minutes: mint a key, wrap your provider client, watch spend appear.",
4
+ };
5
+
6
+ # Quickstart
7
+
8
+ Two minutes, and the only code you write is the line that wraps your client.
9
+
10
+ ## 1. Get a key
11
+
12
+ Create a project under [Projects](https://spendgraph.locusgraph.com/projects), then mint an API key under [API keys](https://spendgraph.locusgraph.com/keys), both live under **Setup** in the dashboard sidebar.
13
+
14
+ <Callout tone="warn" title="Shown once">
15
+ The plaintext key appears on screen a single time and is never recoverable. Put it in your secret store before you close the tab.
16
+ </Callout>
17
+
18
+ ## 2. Install
19
+
20
+ ```bash
21
+ npm install @spendgraph/sdk
22
+ ```
23
+
24
+ ## 3. Wrap your client
25
+
26
+ ```ts
27
+ import Anthropic from "@anthropic-ai/sdk";
28
+ import { SpendGraph } from "@spendgraph/sdk";
29
+
30
+ const meter = new SpendGraph({
31
+ apiKey: process.env.SPENDGRAPH_API_KEY, // sg_…
32
+ baseUrl: "https://spendgraph.locusgraph.com",
33
+ });
34
+
35
+ const anthropic = meter.wrap(new Anthropic());
36
+
37
+ const msg = await anthropic.messages.create({
38
+ model: "claude-sonnet-5",
39
+ max_tokens: 1024,
40
+ messages: [{ role: "user", content: "Hello" }],
41
+ });
42
+ ```
43
+
44
+ That is the whole integration. Use the client exactly as before: usage is read off each reply and reported for you. Spend appears on the dashboard within seconds.
45
+
46
+ ## What it will not do to you
47
+
48
+ The SDK is fire-and-forget by design. It never throws into your code, never blocks a request, batches events (5 seconds or 20 events, whichever comes first), and **drops data rather than crash your app** if the server is unreachable.
49
+
50
+ That last one is a deliberate trade. A metering library that takes your app down when the meter is having a bad day has failed at something more important than metering.
51
+
52
+ <Callout tone="trap" title="`wrap()` reads usage off the reply, so a stream has to ask for it">
53
+ A streamed call that did not request usage reports no tokens, and no tokens prices at $0.00, a hole in the dashboard that looks like a saving. If you stream, make sure the request asks for usage; `@spendgraph/llms` does it for you.
54
+ </Callout>
55
+
56
+ ## Next
57
+
58
+ Anthropic and OpenAI clients work with `wrap()`. Anything else, Gemini, Bedrock, a framework, another language, reports through [`track()`](/spendgraph/sdk/tracking) or a plain HTTP POST. If it can tell you its token counts, spendgraph can price it.
package/docs/tracking.mdx CHANGED
@@ -1,5 +1,5 @@
1
1
  export const meta = {
2
- title: "Tracking spendgraph docs",
2
+ title: "Tracking: spendgraph docs",
3
3
  description:
4
4
  "The three SDK functions: wrap a client, track an event by hand, and flush before a serverless runtime freezes.",
5
5
  };
@@ -10,7 +10,7 @@ Three functions. Most apps only ever call the first.
10
10
 
11
11
  ## meter.wrap(client)
12
12
 
13
- A proxy over an Anthropic or OpenAI client. After each call resolves, the provider's own usage block `usage.input_tokens` or `usage.prompt_tokens` is read and reported. No per-call code, and no wrapper object to thread through your app.
13
+ A proxy over an Anthropic or OpenAI client. After each call resolves, the provider's own usage block, `usage.input_tokens` or `usage.prompt_tokens`, is read and reported. No per-call code, and no wrapper object to thread through your app.
14
14
 
15
15
  ```ts
16
16
  const anthropic = meter.wrap(new Anthropic());
@@ -33,7 +33,7 @@ meter.track({
33
33
  });
34
34
  ```
35
35
 
36
- `metadata` is what the **Cost by tenant** and **Cost by feature** breakdowns group on. Two keys you will want on day one are `feature` and either `tenant` or `env` without them, spend is one undifferentiated number and the dashboard can only tell you that it went up.
36
+ `metadata` is what the **Cost by tenant** and **Cost by feature** breakdowns group on. Two keys you will want on day one are `feature` and either `tenant` or `env`, without them, spend is one undifferentiated number and the dashboard can only tell you that it went up.
37
37
 
38
38
  `eventId` makes a retry safe. Send the same id twice and the second is discarded server-side rather than double-counted.
39
39
 
@@ -42,7 +42,7 @@ meter.track({
42
42
  Sends everything buffered right now.
43
43
 
44
44
  <Callout tone="trap" title="Serverless runtimes freeze the moment you respond">
45
- Vercel functions, Lambda, and Cloudflare Workers stop executing as soon as the response is sent and the batched events go with them. `await meter.flush()` before returning from a route handler, or you will lose the tail of every request.
45
+ Vercel functions, Lambda, and Cloudflare Workers stop executing as soon as the response is sent, and the batched events go with them. `await meter.flush()` before returning from a route handler, or you will lose the tail of every request.
46
46
  </Callout>
47
47
 
48
48
  ```ts
@@ -53,7 +53,7 @@ export async function POST(req: Request) {
53
53
  }
54
54
  ```
55
55
 
56
- On a long-lived server you can ignore it the batch timer handles things.
56
+ On a long-lived server you can ignore it, the batch timer handles things.
57
57
 
58
58
  ## Unknown models
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Track LLM input/output tokens and cost. Three functions, zero dependencies, fail-open.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -46,6 +46,7 @@
46
46
  "README.md"
47
47
  ],
48
48
  "devDependencies": {
49
+ "@spendgraph/config": "0.8.1",
49
50
  "typescript": "^5"
50
51
  },
51
52
  "engines": {
@@ -56,6 +57,7 @@
56
57
  },
57
58
  "scripts": {
58
59
  "build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments && node ../../scripts/minify.mjs dist",
59
- "test": "vitest run"
60
+ "test": "vitest run",
61
+ "typecheck": "tsc -p tsconfig.tests.json"
60
62
  }
61
63
  }