@spendgraph/sdk 0.8.0 → 0.8.2
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/package.json +2 -4
- package/docs/client.mdx +0 -85
- package/docs/errors.mdx +0 -82
- package/docs/tracking.mdx +0 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spendgraph/sdk",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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": {
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
30
|
"import": "./dist/index.js"
|
|
31
31
|
},
|
|
32
|
-
"./docs/*": "./docs/*",
|
|
33
32
|
"./langchain": {
|
|
34
33
|
"types": "./dist/langchain.d.ts",
|
|
35
34
|
"import": "./dist/langchain.js"
|
|
@@ -42,11 +41,10 @@
|
|
|
42
41
|
},
|
|
43
42
|
"files": [
|
|
44
43
|
"dist",
|
|
45
|
-
"docs",
|
|
46
44
|
"README.md"
|
|
47
45
|
],
|
|
48
46
|
"devDependencies": {
|
|
49
|
-
"@spendgraph/config": "0.8.
|
|
47
|
+
"@spendgraph/config": "0.8.2",
|
|
50
48
|
"typescript": "^5"
|
|
51
49
|
},
|
|
52
50
|
"engines": {
|
package/docs/client.mdx
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
export const meta = {
|
|
2
|
-
title: "The client — spendgraph docs",
|
|
3
|
-
description:
|
|
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
|
-
};
|
|
6
|
-
|
|
7
|
-
# The client
|
|
8
|
-
|
|
9
|
-
`SpendGraph` meters what you spend. `Spendgraph` reads and writes everything else — prompts, tools, stats, events, keys, projects.
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
import { Spendgraph } from "@spendgraph/sdk";
|
|
13
|
-
|
|
14
|
-
const sg = new Spendgraph({
|
|
15
|
-
apiKey: process.env.SPENDGRAPH_API_KEY,
|
|
16
|
-
baseUrl: "https://spendgraph.locusgraph.com",
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const { prompts } = await sg.prompts.list();
|
|
20
|
-
const summary = await sg.stats.summary({ from, to });
|
|
21
|
-
```
|
|
22
|
-
|
|
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.
|
|
25
|
-
</Callout>
|
|
26
|
-
|
|
27
|
-
## Why go through it
|
|
28
|
-
|
|
29
|
-
Every other spendgraph package talks to the app through this client rather than opening a socket of its own. Base URL, auth, retries, backoff and error shapes are decided once, here, instead of five times slightly differently.
|
|
30
|
-
|
|
31
|
-
If you are adding an endpoint, it gets a method on a resource module before it gets a caller anywhere else.
|
|
32
|
-
|
|
33
|
-
## Two halves, and the line between them
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
sg_… API key ──▶ usage · stats · events · prompts · tools
|
|
37
|
-
read and write, in one pinned project
|
|
38
|
-
|
|
39
|
-
session / sgc_… ──▶ keys · projects · pricing · credentials
|
|
40
|
-
gated on a signed-in user, server-side
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
That split is deliberate and there is no API-key path across it. `credentials` holds provider keys and `keys` mints API keys, so a leaked ingest key that could reach either would be a much worse leak than it is.
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
new Spendgraph({ apiKey: "sg_…", baseUrl }); // the first half
|
|
47
|
-
new Spendgraph({ session: cookie, baseUrl }); // the second
|
|
48
|
-
new Spendgraph({ token: "sgc_…", baseUrl }); // a terminal, via cli.exchange
|
|
49
|
-
```
|
|
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`.
|
|
52
|
-
|
|
53
|
-
## A key is already pinned
|
|
54
|
-
|
|
55
|
-
An API key names no project of its own, so the pin is the whole of its authority. `project` on the constructor scopes writes that accept one; it cannot widen anything.
|
|
56
|
-
|
|
57
|
-
Naming a *different* project in a body comes back `403 project_not_accessible` rather than quietly landing in the key's own project. A write that goes somewhere you did not ask for, under a `200` that says it worked, is worse than a refusal.
|
|
58
|
-
|
|
59
|
-
## The resources
|
|
60
|
-
|
|
61
|
-
| | |
|
|
62
|
-
| --- | --- |
|
|
63
|
-
| `sg.stats` | `summary` · `timeseries` · `byModel` · `byKey` · `byTag` |
|
|
64
|
-
| `sg.events` | `list` — raw events, filtered, paged, or as CSV |
|
|
65
|
-
| `sg.ingest` | reporting usage by hand, up to 100 events a call |
|
|
66
|
-
| `sg.prompts` | `list` · `create` · `update` · `promote` · `rollouts` · `report` |
|
|
67
|
-
| `sg.tools` | `list` · `get` · `create` · `update` · `archive` |
|
|
68
|
-
| `sg.alerts` | what budget alerts have fired |
|
|
69
|
-
| `sg.keys` · `sg.projects` · `sg.invites` | the dashboard half |
|
|
70
|
-
| `sg.pricing` · `sg.models` | the price catalogue |
|
|
71
|
-
| `sg.credentials` | provider keys, session-gated |
|
|
72
|
-
| `sg.playground` | what the dashboard's playground calls |
|
|
73
|
-
| `sg.cli` | how a terminal obtains and revokes a token |
|
|
74
|
-
|
|
75
|
-
## The escape hatch
|
|
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.
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
await sg.http.get("/api/v1/something-new");
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
## Next
|
|
84
|
-
|
|
85
|
-
What happens when a call fails, and what retries on its own: [Errors and retries](/docs/sdk/errors).
|
package/docs/errors.mdx
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
export const meta = {
|
|
2
|
-
title: "Errors and retries — spendgraph docs",
|
|
3
|
-
description:
|
|
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
|
-
};
|
|
6
|
-
|
|
7
|
-
# Errors and retries
|
|
8
|
-
|
|
9
|
-
The two classes fail in opposite directions, on purpose.
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
SpendGraph (the meter) ──▶ never throws · drops data rather than
|
|
13
|
-
take your app down with it
|
|
14
|
-
|
|
15
|
-
Spendgraph (the client) ──▶ throws SpendgraphError · you asked for
|
|
16
|
-
an answer, so a failure is one
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
A metering call is a side effect and losing one costs you a row on a chart. A read you awaited is the thing your code is about, and swallowing its failure would hand you an empty array that looks like an empty account.
|
|
20
|
-
|
|
21
|
-
## SpendgraphError
|
|
22
|
-
|
|
23
|
-
```ts
|
|
24
|
-
import { SpendgraphError } from "@spendgraph/sdk";
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
await sg.stats.summary({ from, to });
|
|
28
|
-
} catch (err) {
|
|
29
|
-
if (err instanceof SpendgraphError) {
|
|
30
|
-
err.status; // 401, 429, 500 — 0 if the connection never landed
|
|
31
|
-
err.code; // the API's machine-readable code
|
|
32
|
-
err.attempts; // so a log line can say "gave up after 3"
|
|
33
|
-
err.retryAfterMs; // what the server asked for, or null
|
|
34
|
-
err.retryable; // whether trying again could ever help
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
One error type for everything the API can refuse. A caller retrying blind is what that prevents: a 429 is worth waiting out, a 401 never will be, and a message alone cannot tell them apart.
|
|
40
|
-
|
|
41
|
-
## What retries itself
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
429 · 5xx · no connection ──▶ retried, with backoff
|
|
45
|
-
400 · 401 · 403 · 404 · 422 ──▶ thrown at once
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Retrying a 401 spends the same attempt budget on the same answer. `retryable` encodes that, and the client acts on it before you ever see the error.
|
|
49
|
-
|
|
50
|
-
| | |
|
|
51
|
-
| --- | --- |
|
|
52
|
-
| `attempts` | total tries including the first. Default **3** |
|
|
53
|
-
| `maxWaitMs` | longest single backoff. Default **5000** |
|
|
54
|
-
| `maxRetryAfterMs` | ceiling on a `retry-after` the server sent. Default **60000** |
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
const sg = new Spendgraph({ apiKey, baseUrl, attempts: 5, maxWaitMs: 2000 });
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## `retry-after` wins, but not unbounded
|
|
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.
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
Backoff is spread with jitter so a fleet of clients does not wake in lockstep and rebuild the spike that rate-limited them.
|
|
67
|
-
|
|
68
|
-
<Callout tone="trap" title="Status 0 is not a verdict">
|
|
69
|
-
A connection that never reached the server has no status code, and treating that as "not retryable" drops spend on every blip. It is retried; a 422 never is, because the same body will be refused the same way and the budget is spent on nothing.
|
|
70
|
-
</Callout>
|
|
71
|
-
|
|
72
|
-
## Why the meter is different
|
|
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.
|
|
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.
|
|
77
|
-
|
|
78
|
-
## Unpriced models are not errors
|
|
79
|
-
|
|
80
|
-
A model id with no price is accepted, stored at $0, and returned in `unpricedModels` on the ingest response. The SDK logs each unknown id once.
|
|
81
|
-
|
|
82
|
-
Token counts are never thrown away for want of a price, and a typo in a model name surfaces while you are still wiring things up rather than as an inexplicably empty dashboard a fortnight later.
|
package/docs/tracking.mdx
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
export const meta = {
|
|
2
|
-
title: "Tracking — spendgraph docs",
|
|
3
|
-
description:
|
|
4
|
-
"The three SDK functions: wrap a client, track an event by hand, and flush before a serverless runtime freezes.",
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
# Tracking
|
|
8
|
-
|
|
9
|
-
Three functions. Most apps only ever call the first.
|
|
10
|
-
|
|
11
|
-
## meter.wrap(client)
|
|
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.
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
const anthropic = meter.wrap(new Anthropic());
|
|
17
|
-
const openai = meter.wrap(new OpenAI());
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
It also reads cache counts off the reply, which is the main reason to prefer it over hand-rolling `track()` against a provider that supports caching.
|
|
21
|
-
|
|
22
|
-
## meter.track(event)
|
|
23
|
-
|
|
24
|
-
Manual reporting, for any provider or framework:
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
meter.track({
|
|
28
|
-
model: "gemini-2.5-pro",
|
|
29
|
-
inputTokens: usage.promptTokenCount,
|
|
30
|
-
outputTokens: usage.candidatesTokenCount,
|
|
31
|
-
metadata: { feature: "search" }, // optional, ≤1KB
|
|
32
|
-
eventId: crypto.randomUUID(), // optional: retries dedupe server-side
|
|
33
|
-
});
|
|
34
|
-
```
|
|
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.
|
|
37
|
-
|
|
38
|
-
`eventId` makes a retry safe. Send the same id twice and the second is discarded server-side rather than double-counted.
|
|
39
|
-
|
|
40
|
-
## meter.flush()
|
|
41
|
-
|
|
42
|
-
Sends everything buffered right now.
|
|
43
|
-
|
|
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.
|
|
46
|
-
</Callout>
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
export async function POST(req: Request) {
|
|
50
|
-
const reply = await anthropic.messages.create(/* … */);
|
|
51
|
-
await meter.flush();
|
|
52
|
-
return Response.json(reply);
|
|
53
|
-
}
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
On a long-lived server you can ignore it — the batch timer handles things.
|
|
57
|
-
|
|
58
|
-
## Unknown models
|
|
59
|
-
|
|
60
|
-
A model id spendgraph has no price for is still stored, at $0, and flagged on the [Pricing](/pricing) page. Token counts are never thrown away for want of a price.
|
|
61
|
-
|
|
62
|
-
The ingest response also returns any such id in `unpricedModels`, and the SDK logs each one once. That is deliberate: a typo in a model name should surface while you are still wiring the integration, not as an inexplicably empty dashboard a fortnight later.
|