@spendgraph/sdk 0.6.0 → 0.8.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.
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -1
- package/dist/core/concurrency.d.ts +2 -0
- package/dist/core/concurrency.js +1 -0
- package/dist/core/money.d.ts +23 -0
- package/dist/core/money.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/resources/files.d.ts +35 -0
- package/dist/resources/files.js +1 -0
- package/dist/resources/index.d.ts +2 -0
- package/dist/resources/index.js +1 -1
- package/dist/resources/ingest.d.ts +19 -0
- package/dist/resources/ingest.js +1 -1
- package/dist/resources/prompts.d.ts +14 -2
- package/dist/resources/tools.d.ts +1 -1
- package/dist/rollout/rollout.d.ts +19 -1
- package/dist/rollout/rollout.js +1 -1
- package/dist/schema/types/types.d.ts +1 -1
- package/dist/schema/validate/validate.d.ts +4 -0
- package/dist/schema/validate/validate.js +1 -1
- package/docs/client.mdx +85 -0
- package/docs/errors.mdx +82 -0
- package/docs/tracking.mdx +62 -0
- package/package.json +10 -6
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Client } from "./core/client/index.js";
|
|
2
2
|
import type { ClientOptions } from "./core/types.js";
|
|
3
|
-
import { Alerts, Cli, Credentials, Events, Ingest, Invites, Keys, Models, Playground, Pricing, Projects, Prompts, PromptsAdmin, Stats, Tools } from "./resources/index.js";
|
|
3
|
+
import { Alerts, Cli, Credentials, Events, Files, Ingest, Invites, Keys, Models, Playground, Pricing, Projects, Prompts, PromptsAdmin, Stats, Tools } from "./resources/index.js";
|
|
4
4
|
export interface SpendgraphOptions extends ClientOptions {
|
|
5
5
|
/** Scopes writes that accept one. A key is already pinned to its project. */
|
|
6
6
|
project?: string;
|
|
@@ -35,5 +35,6 @@ export declare class Spendgraph {
|
|
|
35
35
|
readonly credentials: Credentials;
|
|
36
36
|
readonly playground: Playground;
|
|
37
37
|
readonly tools: Tools;
|
|
38
|
+
readonly files: Files;
|
|
38
39
|
constructor(opts: SpendgraphOptions);
|
|
39
40
|
}
|
package/dist/client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Client as s}from"./core/client/index.js";import{Alerts as i,Cli as e,Credentials as h,Events as n,
|
|
1
|
+
import{Client as s}from"./core/client/index.js";import{Alerts as i,Cli as e,Credentials as h,Events as n,Files as p,Ingest as r,Invites as o,Keys as l,Models as w,Playground as m,Pricing as c,Projects as a,Prompts as d,PromptsAdmin as g,Stats as v,Tools as y}from"./resources/index.js";class u{http;cli;ingest;stats;events;alerts;prompts;promptsAdmin;keys;projects;invites;pricing;models;credentials;playground;tools;files;constructor(t){this.http=new s(t),this.cli=new e(this.http),this.ingest=new r(this.http,t.project),this.stats=new v(this.http),this.events=new n(this.http),this.alerts=new i(this.http),this.prompts=new d(this.http),this.promptsAdmin=new g(this.http),this.keys=new l(this.http),this.projects=new a(this.http),this.invites=new o(this.http),this.pricing=new c(this.http),this.models=new w(this.http),this.credentials=new h(this.http),this.playground=new m(this.http),this.tools=new y(this.http),this.files=new p(this.http)}}export{u as Spendgraph};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
async function h(t,e,o){const a=new Array(t.length);let r=0;const l=Array.from({length:Math.max(1,Math.min(e,t.length))},async()=>{for(;r<t.length;){const n=r++;a[n]=await o(t[n],n)}});return await Promise.all(l),a}export{h as mapLimit};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A micro-USD integer as money somebody reads.
|
|
3
|
+
*
|
|
4
|
+
* Two decimal places, because that is what a person is owed for a price. The
|
|
5
|
+
* exception is the one that keeps this honest: an amount that is not zero but
|
|
6
|
+
* would print as `$0.00` is shown at the full six places instead, with trailing
|
|
7
|
+
* zeros trimmed. A judge call costing 250 micro-USD is a real cost, and a run
|
|
8
|
+
* of ten thousand of them is a real bill; rounding each to nothing and summing
|
|
9
|
+
* is how a report comes to say a paid run was free.
|
|
10
|
+
*
|
|
11
|
+
* Formatting only. The arithmetic stays in integers, which is the whole reason
|
|
12
|
+
* costs are micro-USD.
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatMicros(micros: number, places?: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* A price in dollars as the micro-USD integer everything else here holds.
|
|
17
|
+
*
|
|
18
|
+
* The one place a float is allowed: a price arrives from a rate card, an
|
|
19
|
+
* invoice or another system as `40.00`, and it has to become an integer once,
|
|
20
|
+
* here, rather than at every call site with a different rounding. Rounded to
|
|
21
|
+
* the nearest micro — below that there is nothing to represent.
|
|
22
|
+
*/
|
|
23
|
+
export declare function microsFromUsd(usd: number): number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const c=1e6,u=6,e=(t,n)=>t.toLocaleString("en-US",{minimumFractionDigits:n,maximumFractionDigits:n});function a(t,n=2){if(!Number.isFinite(t))return"$\u2014";const o=t<0?"-":"",r=Math.abs(t)/1e6,i=e(r,n);if(t===0||/[1-9]/.test(i))return`${o}$${i}`;const s=e(r,6).replace(/0+$/,"");return`${o}$${s}`}function m(t){if(!Number.isFinite(t))throw new Error(`${t} is not an amount.`);return Math.round(t*1e6)}export{a as formatMicros,m as microsFromUsd};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { SpendgraphOptions } from "./client.js";
|
|
2
2
|
export { Spendgraph } from "./client.js";
|
|
3
3
|
export { Client, queryString, SpendgraphError } from "./core/client/index.js";
|
|
4
|
+
export { mapLimit } from "./core/concurrency.js";
|
|
5
|
+
export { formatMicros, microsFromUsd } from "./core/money.js";
|
|
4
6
|
export type { ClientOptions, Credentials as ClientCredentials, Query, Tag } from "./core/types.js";
|
|
5
7
|
export * from "./resources/index.js";
|
|
6
8
|
export type { RolloutInput, RolloutRecord, RolloutStep } from "./rollout/index.js";
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Spendgraph as e}from"./client.js";import{Client as i,queryString as p,SpendgraphError as
|
|
1
|
+
import{Spendgraph as e}from"./client.js";import{Client as i,queryString as p,SpendgraphError as m}from"./core/client/index.js";import{mapLimit as s}from"./core/concurrency.js";import{formatMicros as f,microsFromUsd as l}from"./core/money.js";export*from"./resources/index.js";import{totalTokens as g}from"./rollout/index.js";import{FieldValidationError as S,isMissing as h,serializeFields as c,toBoolean as u,toNumber as E,validateFields as M}from"./schema/index.js";import{SpendGraph as k}from"./track/index.js";export{i as Client,S as FieldValidationError,k as SpendGraph,e as Spendgraph,m as SpendgraphError,f as formatMicros,h as isMissing,s as mapLimit,l as microsFromUsd,p as queryString,c as serializeFields,u as toBoolean,E as toNumber,g as totalTokens,M as validateFields};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Client } from "../core/client/index.js";
|
|
2
|
+
export declare const PUBLISH_FROM: readonly ["text", "md", "html"];
|
|
3
|
+
export declare const PUBLISH_TO: readonly ["md", "html", "txt", "pdf"];
|
|
4
|
+
export type PublishFrom = (typeof PUBLISH_FROM)[number];
|
|
5
|
+
export type PublishTo = (typeof PUBLISH_TO)[number];
|
|
6
|
+
export interface PublishInput {
|
|
7
|
+
projectId?: string;
|
|
8
|
+
content: string;
|
|
9
|
+
from: PublishFrom;
|
|
10
|
+
to: PublishTo;
|
|
11
|
+
/** The file's name without its extension; `document` when unset. */
|
|
12
|
+
name?: string;
|
|
13
|
+
}
|
|
14
|
+
/** A file the app rendered and stored, and the public URL it answers on. */
|
|
15
|
+
export interface PublishedFile {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
type: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
key: string;
|
|
21
|
+
url: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Files the app renders from content and stores for anyone with the link.
|
|
25
|
+
*
|
|
26
|
+
* An API key publishes into the one project it is pinned to. Nothing is read
|
|
27
|
+
* back through here: a published file is public, so its URL is the way in.
|
|
28
|
+
*/
|
|
29
|
+
export declare class Files {
|
|
30
|
+
private readonly client;
|
|
31
|
+
constructor(client: Client);
|
|
32
|
+
publish(body: PublishInput): Promise<{
|
|
33
|
+
file: PublishedFile;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=["text","md","html"],i=["md","html","txt","pdf"];class l{client;constructor(t){this.client=t}publish(t){return this.client.post("/api/v1/files",t)}}export{l as Files,e as PUBLISH_FROM,i as PUBLISH_TO};
|
|
@@ -5,6 +5,8 @@ export { Cli } from "./cli.js";
|
|
|
5
5
|
export { Credentials } from "./credentials.js";
|
|
6
6
|
export type { EventsPage, EventsQuery, UsageEventRow } from "./events.js";
|
|
7
7
|
export { Events } from "./events.js";
|
|
8
|
+
export type { PublishedFile, PublishFrom, PublishInput, PublishTo } from "./files.js";
|
|
9
|
+
export { Files, PUBLISH_FROM, PUBLISH_TO } from "./files.js";
|
|
8
10
|
export type { IngestResult, Sender, UsageEvent } from "./ingest.js";
|
|
9
11
|
export { Ingest, MAX_EVENTS } from "./ingest.js";
|
|
10
12
|
export type { ApiKeyRow } from "./keys.js";
|
package/dist/resources/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Alerts as
|
|
1
|
+
import{Alerts as e}from"./alerts.js";import{Cli as m}from"./cli.js";import{Credentials as f}from"./credentials.js";import{Events as s}from"./events.js";import{Files as l,PUBLISH_FROM as n,PUBLISH_TO as P}from"./files.js";import{Ingest as I,MAX_EVENTS as S}from"./ingest.js";import{Keys as g}from"./keys.js";import{Playground as E}from"./playground.js";import{Models as T,Pricing as _}from"./pricing.js";import{Invites as v,Projects as y}from"./projects.js";import{Prompts as C}from"./prompts.js";import{PromptsAdmin as H}from"./prompts-admin.js";import{Stats as O}from"./stats.js";import{Tools as j}from"./tools.js";export{e as Alerts,m as Cli,f as Credentials,s as Events,l as Files,I as Ingest,v as Invites,g as Keys,S as MAX_EVENTS,T as Models,n as PUBLISH_FROM,P as PUBLISH_TO,E as Playground,_ as Pricing,y as Projects,C as Prompts,H as PromptsAdmin,O as Stats,j as Tools};
|
|
@@ -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
|
}
|
package/dist/resources/ingest.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const i=100;class p{client;project;constructor(
|
|
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};
|
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import type { Client } from "../core/client/index.js";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* A turn as a rollout records it.
|
|
4
|
+
*
|
|
5
|
+
* `tool` is here because a conversation that used tools is not three roles
|
|
6
|
+
* pretending to be two. Narrating a result into a `user` message kept the words
|
|
7
|
+
* and lost the fact — and it misled the one reader that cared: the summary
|
|
8
|
+
* takes the last user message as the question when a prompt names no
|
|
9
|
+
* `question` field, so a tool-using run was captioned with whatever the tool
|
|
10
|
+
* said back.
|
|
11
|
+
*/
|
|
12
|
+
export type Role = "system" | "user" | "assistant" | "tool";
|
|
3
13
|
export interface RenderedMessage {
|
|
4
14
|
role: Role;
|
|
5
15
|
content: string;
|
|
16
|
+
/** Which tool answered, on a `tool` turn. Absent on the other three. */
|
|
17
|
+
name?: string;
|
|
6
18
|
}
|
|
7
19
|
export interface PromptBlock {
|
|
8
20
|
title?: string;
|
|
9
21
|
body?: string;
|
|
10
22
|
}
|
|
11
23
|
export interface SavePromptInput {
|
|
12
|
-
projectId
|
|
24
|
+
projectId?: string;
|
|
13
25
|
name: string;
|
|
14
26
|
blocks?: PromptBlock[];
|
|
15
27
|
variables?: Record<string, string>;
|
|
@@ -21,8 +21,25 @@ export interface RolloutStep {
|
|
|
21
21
|
error?: string;
|
|
22
22
|
inputTokens?: number;
|
|
23
23
|
outputTokens?: number;
|
|
24
|
+
/**
|
|
25
|
+
* The four counts a provider bills apart from input and output.
|
|
26
|
+
*
|
|
27
|
+
* Optional and usually absent: most steps are a tool call, which has no
|
|
28
|
+
* tokens at all. A step that made a model call and left them off is saying
|
|
29
|
+
* the reply did not carry them — which is not the same as a workload that
|
|
30
|
+
* used no cache, and is why a rollup leaves the key out rather than adding a
|
|
31
|
+
* zero. The write is billed at a premium and the read at a discount, so a
|
|
32
|
+
* total built from the first two alone is wrong in both directions at once.
|
|
33
|
+
*/
|
|
34
|
+
cacheReadTokens?: number;
|
|
35
|
+
cacheWriteTokens?: number;
|
|
36
|
+
citationTokens?: number;
|
|
37
|
+
reasoningTokens?: number;
|
|
24
38
|
latencyMs?: number;
|
|
25
39
|
}
|
|
40
|
+
/** The counts beyond input and output, in the order a report reads them. */
|
|
41
|
+
declare const EXTRA: readonly ["cacheReadTokens", "cacheWriteTokens", "citationTokens", "reasoningTokens"];
|
|
42
|
+
type Extra = (typeof EXTRA)[number];
|
|
26
43
|
/**
|
|
27
44
|
* What every step in a rollout adds up to.
|
|
28
45
|
*
|
|
@@ -34,7 +51,7 @@ export interface RolloutStep {
|
|
|
34
51
|
export declare function totalTokens(steps: RolloutStep[]): {
|
|
35
52
|
inputTokens: number;
|
|
36
53
|
outputTokens: number;
|
|
37
|
-
}
|
|
54
|
+
} & Partial<Record<Extra, number>>;
|
|
38
55
|
/** What a caller hands back after running something itself. */
|
|
39
56
|
export interface RolloutInput {
|
|
40
57
|
/** Dedupe key. Generated if omitted; supply your own to make a retry safe. */
|
|
@@ -85,3 +102,4 @@ export interface RolloutRecord {
|
|
|
85
102
|
inputTokens: number;
|
|
86
103
|
outputTokens: number;
|
|
87
104
|
}
|
|
105
|
+
export {};
|
package/dist/rollout/rollout.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
function
|
|
1
|
+
const c=["cacheReadTokens","cacheWriteTokens","citationTokens","reasoningTokens"];function s(n){return typeof n!="number"||!Number.isFinite(n)||n<0?0:Math.trunc(n)}function u(n){let i=0,r=0;const o={};for(const t of n){i+=s(t.inputTokens),r+=s(t.outputTokens);for(const e of c)t[e]!==void 0&&(o[e]=(o[e]??0)+s(t[e]))}return{inputTokens:i,outputTokens:r,...o}}export{u as totalTokens};
|
|
@@ -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.
|
|
@@ -15,6 +15,10 @@ export declare function isMissing(value: unknown): boolean;
|
|
|
15
15
|
* Shared deliberately: if the checker and the renderer disagreed, a value could
|
|
16
16
|
* pass validation and then render as something else — a request you paid for
|
|
17
17
|
* and cannot explain.
|
|
18
|
+
*
|
|
19
|
+
* A numeric string passing is the point: a value off the wire arrives as text,
|
|
20
|
+
* and refusing `"40"` for a number field would refuse every form and query
|
|
21
|
+
* string there is.
|
|
18
22
|
*/
|
|
19
23
|
export declare function toNumber(value: unknown): number;
|
|
20
24
|
export declare function toBoolean(value: unknown): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
class l extends Error{errors;constructor(
|
|
1
|
+
class l extends Error{errors;constructor(r){super(r.map(e=>`${e.field}: ${e.message}`).join("; ")),this.name="FieldValidationError",this.errors=r}}function u(n){return n==null}const i=n=>Array.isArray(n);function c(n){return typeof n=="number"?n:i(n)?Number.NaN:Number(String(n).trim())}function x(n){return typeof n=="boolean"?n:i(n)?!1:String(n).toLowerCase()==="true"}function a(n,r){const e=String(r);return n.maxLength!==void 0&&e.length>n.maxLength?`is ${e.length} characters, over the limit of ${n.maxLength}`:null}function f(n,r){const e=c(r);return Number.isFinite(e)?n.min!==void 0&&e<n.min?`is ${e}, below the minimum of ${n.min}`:n.max!==void 0&&e>n.max?`is ${e}, above the maximum of ${n.max}`:null:`expects a number, got ${JSON.stringify(r)}`}function m(n){if(typeof n=="boolean")return null;if(i(n))return`expects true or false, got ${JSON.stringify(n)}`;const r=String(n).toLowerCase();return r==="true"||r==="false"?null:`expects true or false, got ${JSON.stringify(n)}`}function g(n,r){const e=n.options??[];return e.length===0?"is an enum with no options declared":e.includes(String(r))?null:`must be one of ${e.join(" | ")}, got ${JSON.stringify(r)}`}function h(n){try{return JSON.stringify(n),null}catch(r){return`is not serialisable: ${r.message}`}}function p(n,r){switch(n.type){case"string":case"text":return a(n,r);case"number":return f(n,r);case"boolean":return m(r);case"enum":return g(n,r);case"list":return Array.isArray(r)?null:`expects an array, got ${typeof r}`;case"json":return h(r)}}function d(n,r){const e=[];for(const t of r){const o=n[t.name];if(u(o)){if(t.default!==void 0)continue;t.required&&e.push({field:t.name,message:"is required"});continue}const s=p(t,o);s&&e.push({field:t.name,message:s})}return e}export{l as FieldValidationError,u as isMissing,x as toBoolean,c as toNumber,d as validateFields};
|
package/docs/client.mdx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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.
|
|
@@ -0,0 +1,62 @@
|
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spendgraph/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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,6 +29,7 @@
|
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
30
|
"import": "./dist/index.js"
|
|
31
31
|
},
|
|
32
|
+
"./docs/*": "./docs/*",
|
|
32
33
|
"./langchain": {
|
|
33
34
|
"types": "./dist/langchain.d.ts",
|
|
34
35
|
"import": "./dist/langchain.js"
|
|
@@ -41,13 +42,11 @@
|
|
|
41
42
|
},
|
|
42
43
|
"files": [
|
|
43
44
|
"dist",
|
|
45
|
+
"docs",
|
|
44
46
|
"README.md"
|
|
45
47
|
],
|
|
46
|
-
"scripts": {
|
|
47
|
-
"build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments && node ../../scripts/minify.mjs dist",
|
|
48
|
-
"test": "vitest run"
|
|
49
|
-
},
|
|
50
48
|
"devDependencies": {
|
|
49
|
+
"@spendgraph/config": "0.8.0",
|
|
51
50
|
"typescript": "^5"
|
|
52
51
|
},
|
|
53
52
|
"engines": {
|
|
@@ -55,5 +54,10 @@
|
|
|
55
54
|
},
|
|
56
55
|
"publishConfig": {
|
|
57
56
|
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments && node ../../scripts/minify.mjs dist",
|
|
60
|
+
"test": "vitest run",
|
|
61
|
+
"typecheck": "tsc -p tsconfig.tests.json"
|
|
58
62
|
}
|
|
59
|
-
}
|
|
63
|
+
}
|