@spendgraph/sdk 0.2.0 → 0.2.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.
- package/dist/ai.js +0 -12
- package/dist/client.js +0 -12
- package/dist/core/client/client.js +0 -15
- package/dist/core/client/errors.js +0 -14
- package/dist/langchain.js +0 -20
- package/dist/resources/alerts.js +0 -1
- package/dist/resources/credentials.js +0 -7
- package/dist/resources/events.js +0 -2
- package/dist/resources/ingest.js +0 -3
- package/dist/resources/keys.js +0 -5
- package/dist/resources/playground.js +0 -1
- package/dist/resources/pricing.js +0 -6
- package/dist/resources/projects.js +0 -2
- package/dist/resources/prompts-admin.js +0 -5
- package/dist/resources/prompts.js +0 -18
- package/dist/resources/stats.js +0 -8
- package/dist/resources/tools.js +0 -9
- package/dist/schema/serialize/serialize.js +0 -11
- package/dist/schema/validate/validate.js +1 -17
- package/dist/track/track.js +1 -183
- package/package.json +2 -2
package/dist/ai.js
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Track a Vercel AI SDK result:
|
|
3
|
-
*
|
|
4
|
-
* const result = await generateText({ model: anthropic("claude-sonnet-5"), ... });
|
|
5
|
-
* trackAiResult(meter, result);
|
|
6
|
-
*
|
|
7
|
-
* streamText({ model, prompt, onFinish: (r) => trackAiResult(meter, r) });
|
|
8
|
-
*
|
|
9
|
-
* Fail-open: if the shape is unrecognized, nothing is tracked.
|
|
10
|
-
*/
|
|
11
1
|
export function trackAiResult(meter, result, options = {}) {
|
|
12
2
|
try {
|
|
13
3
|
const usage = result?.totalUsage ?? result?.usage;
|
|
@@ -15,7 +5,6 @@ export function trackAiResult(meter, result, options = {}) {
|
|
|
15
5
|
const outputTokens = usage?.outputTokens ?? usage?.completionTokens;
|
|
16
6
|
if (typeof inputTokens !== "number" || typeof outputTokens !== "number")
|
|
17
7
|
return;
|
|
18
|
-
// v5 inputTokens includes cached tokens — split them out
|
|
19
8
|
const cacheReadTokens = usage?.cachedInputTokens ?? 0;
|
|
20
9
|
inputTokens = Math.max(0, inputTokens - cacheReadTokens);
|
|
21
10
|
const model = options.model ??
|
|
@@ -32,6 +21,5 @@ export function trackAiResult(meter, result, options = {}) {
|
|
|
32
21
|
});
|
|
33
22
|
}
|
|
34
23
|
catch {
|
|
35
|
-
/* fail-open */
|
|
36
24
|
}
|
|
37
25
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import { Client } from "./core/client/index.js";
|
|
2
2
|
import { Alerts, Credentials, Events, Ingest, Invites, Keys, Models, Playground, Pricing, Projects, Prompts, PromptsAdmin, Stats, Tools, } from "./resources/index.js";
|
|
3
|
-
/**
|
|
4
|
-
* The whole spendgraph API, one client.
|
|
5
|
-
*
|
|
6
|
-
* Every other package in this repo goes through this rather than calling the
|
|
7
|
-
* app directly, so base URL, auth, retries and error shapes are decided once.
|
|
8
|
-
*
|
|
9
|
-
* An `apiKey` reaches usage, stats and prompts. The dashboard half — keys,
|
|
10
|
-
* projects, pricing, credentials — is gated on a signed-in user server-side and
|
|
11
|
-
* needs `session`; there is no API-key path to it, which is what stops a leaked
|
|
12
|
-
* ingest key from minting more keys or reading a provider secret.
|
|
13
|
-
*/
|
|
14
3
|
export class Spendgraph {
|
|
15
|
-
/** The transport. Reach for it only for a route this class does not cover. */
|
|
16
4
|
http;
|
|
17
5
|
ingest;
|
|
18
6
|
stats;
|
|
@@ -2,12 +2,6 @@ import { SpendgraphError } from "./errors.js";
|
|
|
2
2
|
const DEFAULT_ATTEMPTS = 3;
|
|
3
3
|
const DEFAULT_MAX_WAIT_MS = 5_000;
|
|
4
4
|
const BACKOFF_BASE_MS = 250;
|
|
5
|
-
/**
|
|
6
|
-
* `retry-after` in milliseconds, or null when the server did not say.
|
|
7
|
-
*
|
|
8
|
-
* Whole seconds per the HTTP spec. Honouring it beats guessing — a backoff
|
|
9
|
-
* shorter than the window just burns another attempt.
|
|
10
|
-
*/
|
|
11
5
|
function retryAfterMs(res) {
|
|
12
6
|
const raw = res.headers.get("retry-after");
|
|
13
7
|
if (!raw)
|
|
@@ -15,7 +9,6 @@ function retryAfterMs(res) {
|
|
|
15
9
|
const seconds = Number(raw);
|
|
16
10
|
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
|
|
17
11
|
}
|
|
18
|
-
/** Drops empty parameters rather than sending the string "undefined". */
|
|
19
12
|
export function queryString(query = {}) {
|
|
20
13
|
const params = new URLSearchParams();
|
|
21
14
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -26,12 +19,6 @@ export function queryString(query = {}) {
|
|
|
26
19
|
const encoded = params.toString();
|
|
27
20
|
return encoded ? `?${encoded}` : "";
|
|
28
21
|
}
|
|
29
|
-
/**
|
|
30
|
-
* The one thing in this package that talks to spendgraph.
|
|
31
|
-
*
|
|
32
|
-
* Every resource module is given this and returns data, which is what makes
|
|
33
|
-
* the rest testable with no server.
|
|
34
|
-
*/
|
|
35
22
|
export class Client {
|
|
36
23
|
baseUrl;
|
|
37
24
|
doFetch;
|
|
@@ -51,7 +38,6 @@ export class Client {
|
|
|
51
38
|
...(opts.session ? { cookie: opts.session } : {}),
|
|
52
39
|
};
|
|
53
40
|
}
|
|
54
|
-
/** True once anything is set that the server might accept. */
|
|
55
41
|
get authenticated() {
|
|
56
42
|
return Object.keys(this.auth).length > 0;
|
|
57
43
|
}
|
|
@@ -110,7 +96,6 @@ export class Client {
|
|
|
110
96
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
111
97
|
});
|
|
112
98
|
}
|
|
113
|
-
/** A 204 and an empty body are success, not a JSON parse failure. */
|
|
114
99
|
async decode(res) {
|
|
115
100
|
if (res.status === 204)
|
|
116
101
|
return undefined;
|
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One error type for everything the API can refuse, carrying the status.
|
|
3
|
-
*
|
|
4
|
-
* A caller retrying blind is the failure this prevents: 429 is worth waiting
|
|
5
|
-
* out, 401 never will be, and a message alone cannot tell them apart.
|
|
6
|
-
*/
|
|
7
1
|
export class SpendgraphError extends Error {
|
|
8
2
|
status;
|
|
9
|
-
/** The API's machine-readable code, when it sent one. */
|
|
10
3
|
code;
|
|
11
|
-
/** How many attempts were made, so a log line says "gave up after 4". */
|
|
12
4
|
attempts;
|
|
13
|
-
/** The server's own `retry-after`, in ms. Null when it did not send one. */
|
|
14
5
|
retryAfterMs;
|
|
15
6
|
constructor(status, code, message, opts = {}) {
|
|
16
7
|
super(message);
|
|
@@ -20,11 +11,6 @@ export class SpendgraphError extends Error {
|
|
|
20
11
|
this.attempts = opts.attempts ?? 1;
|
|
21
12
|
this.retryAfterMs = opts.retryAfterMs ?? null;
|
|
22
13
|
}
|
|
23
|
-
/**
|
|
24
|
-
* Worth trying again: rate limits, server faults, and a connection that never
|
|
25
|
-
* got far enough to have a status. Never a bad request — retrying a 401 or a
|
|
26
|
-
* 422 just spends the same attempt budget on the same answer.
|
|
27
|
-
*/
|
|
28
14
|
get retryable() {
|
|
29
15
|
return this.status === 0 || this.status === 429 || this.status >= 500;
|
|
30
16
|
}
|
package/dist/langchain.js
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LangChain / LangGraph callback handler.
|
|
3
|
-
*
|
|
4
|
-
* const handler = spendGraphHandler(meter);
|
|
5
|
-
* await chain.invoke(input, { callbacks: [handler] }); // LangChain
|
|
6
|
-
* await graph.invoke(input, { callbacks: [handler] }); // LangGraph
|
|
7
|
-
*
|
|
8
|
-
* Or pass it in the model constructor (`new ChatAnthropic({ callbacks: [...] })`)
|
|
9
|
-
* to cover every call that model makes. Fail-open like the rest of the SDK.
|
|
10
|
-
*/
|
|
11
1
|
export function spendGraphHandler(meter, options = {}) {
|
|
12
2
|
const models = new Map();
|
|
13
3
|
const rememberModel = (llm, runId, extraParams) => {
|
|
@@ -21,7 +11,6 @@ export function spendGraphHandler(meter, options = {}) {
|
|
|
21
11
|
models.set(runId, model);
|
|
22
12
|
}
|
|
23
13
|
catch {
|
|
24
|
-
/* fail-open */
|
|
25
14
|
}
|
|
26
15
|
};
|
|
27
16
|
return {
|
|
@@ -33,11 +22,6 @@ export function spendGraphHandler(meter, options = {}) {
|
|
|
33
22
|
rememberModel(llm, runId, extraParams);
|
|
34
23
|
},
|
|
35
24
|
handleLLMEnd(output, runId) {
|
|
36
|
-
// Forget the run before anything else can bail out. The usage checks
|
|
37
|
-
// below give up on any response whose token counts cannot be read, and
|
|
38
|
-
// the delete used to sit after them — so each such call left its entry
|
|
39
|
-
// behind for good. This handler is meant to be attached to a long-lived
|
|
40
|
-
// model instance, which turns that into a map that only ever grows.
|
|
41
25
|
const startedAs = models.get(runId);
|
|
42
26
|
models.delete(runId);
|
|
43
27
|
try {
|
|
@@ -49,9 +33,6 @@ export function spendGraphHandler(meter, options = {}) {
|
|
|
49
33
|
const outputTokens = um?.output_tokens ?? au?.output_tokens ?? tu?.completionTokens;
|
|
50
34
|
if (typeof inputTokens !== "number" || typeof outputTokens !== "number")
|
|
51
35
|
return;
|
|
52
|
-
// Cache tokens: usage_metadata.input_tokens INCLUDES them (LangChain
|
|
53
|
-
// normalizes to a total) so they're split out; the raw Anthropic
|
|
54
|
-
// llmOutput.usage shape keeps them separate already.
|
|
55
36
|
let cacheReadTokens = 0;
|
|
56
37
|
let cacheWriteTokens = 0;
|
|
57
38
|
if (um?.input_token_details && typeof um.input_tokens === "number") {
|
|
@@ -79,7 +60,6 @@ export function spendGraphHandler(meter, options = {}) {
|
|
|
79
60
|
});
|
|
80
61
|
}
|
|
81
62
|
catch {
|
|
82
|
-
/* fail-open */
|
|
83
63
|
}
|
|
84
64
|
},
|
|
85
65
|
handleLLMError(_err, runId) {
|
package/dist/resources/alerts.js
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Provider API keys held by the server so it can run a prompt for you.
|
|
3
|
-
*
|
|
4
|
-
* Dashboard session only, and it stays that way: these are the secrets an
|
|
5
|
-
* `sg_` key must never be able to read.
|
|
6
|
-
*/
|
|
7
1
|
export class Credentials {
|
|
8
2
|
client;
|
|
9
3
|
constructor(client) {
|
|
@@ -12,7 +6,6 @@ export class Credentials {
|
|
|
12
6
|
list() {
|
|
13
7
|
return this.client.get("/api/v1/credentials");
|
|
14
8
|
}
|
|
15
|
-
/** Write-only from the caller's side — the server never reads one back. */
|
|
16
9
|
set(body) {
|
|
17
10
|
return this.client.put("/api/v1/credentials", body);
|
|
18
11
|
}
|
package/dist/resources/events.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** The raw call records behind the totals. */
|
|
2
1
|
export class Events {
|
|
3
2
|
client;
|
|
4
3
|
constructor(client) {
|
|
@@ -7,7 +6,6 @@ export class Events {
|
|
|
7
6
|
list(query = {}) {
|
|
8
7
|
return this.client.get("/api/v1/events", { ...query });
|
|
9
8
|
}
|
|
10
|
-
/** Walks every page, so a caller does not have to hold the cursor. */
|
|
11
9
|
async *all(query = {}) {
|
|
12
10
|
let cursor = query.cursor;
|
|
13
11
|
do {
|
package/dist/resources/ingest.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/** The route takes up to this many events per request. */
|
|
2
1
|
export const MAX_EVENTS = 100;
|
|
3
|
-
/** Posts usage straight through, chunked to what the route accepts. */
|
|
4
2
|
export class Ingest {
|
|
5
3
|
client;
|
|
6
4
|
project;
|
|
@@ -11,7 +9,6 @@ export class Ingest {
|
|
|
11
9
|
async send(events) {
|
|
12
10
|
await this.report(events);
|
|
13
11
|
}
|
|
14
|
-
/** The same write, with the server's answer — which ids went unpriced. */
|
|
15
12
|
async report(events) {
|
|
16
13
|
const total = { accepted: 0, rejected: 0, unpricedModels: [] };
|
|
17
14
|
if (events.length === 0)
|
package/dist/resources/keys.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* API keys. Dashboard session only, deliberately: a key that could mint keys
|
|
3
|
-
* would turn one leaked ingest credential into permanent access.
|
|
4
|
-
*/
|
|
5
1
|
export class Keys {
|
|
6
2
|
client;
|
|
7
3
|
constructor(client) {
|
|
@@ -10,7 +6,6 @@ export class Keys {
|
|
|
10
6
|
list(query = {}) {
|
|
11
7
|
return this.client.get("/api/v1/keys", { ...query });
|
|
12
8
|
}
|
|
13
|
-
/** The plaintext key is in this response and nowhere else, ever again. */
|
|
14
9
|
create(body) {
|
|
15
10
|
return this.client.post("/api/v1/keys", body);
|
|
16
11
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** The pricing catalogue. Dashboard session only. */
|
|
2
1
|
export class Pricing {
|
|
3
2
|
client;
|
|
4
3
|
constructor(client) {
|
|
@@ -7,15 +6,12 @@ export class Pricing {
|
|
|
7
6
|
list() {
|
|
8
7
|
return this.client.get("/api/v1/pricing");
|
|
9
8
|
}
|
|
10
|
-
/** A manual override for one model, which outranks the synced rows. */
|
|
11
9
|
override(model, body) {
|
|
12
10
|
return this.client.put(`/api/v1/pricing/${encodeURIComponent(model)}`, body);
|
|
13
11
|
}
|
|
14
|
-
/** Which models the catalogue can price, and which it cannot. */
|
|
15
12
|
coverage() {
|
|
16
13
|
return this.client.get("/api/v1/pricing/coverage");
|
|
17
14
|
}
|
|
18
|
-
/** What legacy and offer-based pricing would each charge, side by side. */
|
|
19
15
|
readiness() {
|
|
20
16
|
return this.client.get("/api/v1/pricing/readiness");
|
|
21
17
|
}
|
|
@@ -23,7 +19,6 @@ export class Pricing {
|
|
|
23
19
|
return this.client.post("/api/v1/pricing/sync", body);
|
|
24
20
|
}
|
|
25
21
|
}
|
|
26
|
-
/** The model catalogue and the offers behind it. Dashboard session only. */
|
|
27
22
|
export class Models {
|
|
28
23
|
client;
|
|
29
24
|
constructor(client) {
|
|
@@ -35,7 +30,6 @@ export class Models {
|
|
|
35
30
|
offers(query = {}) {
|
|
36
31
|
return this.client.get("/api/v1/offers", { ...query });
|
|
37
32
|
}
|
|
38
|
-
/** Prices one workload across models or serving providers. */
|
|
39
33
|
compare(query = {}) {
|
|
40
34
|
return this.client.get("/api/v1/compare", { ...query });
|
|
41
35
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Projects, their budgets, members and invites. Dashboard session only. */
|
|
2
1
|
export class Projects {
|
|
3
2
|
client;
|
|
4
3
|
constructor(client) {
|
|
@@ -36,7 +35,6 @@ export class Projects {
|
|
|
36
35
|
});
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
|
-
/** Accepting an invite is the one project call that identifies a person, not a key. */
|
|
40
38
|
export class Invites {
|
|
41
39
|
client;
|
|
42
40
|
constructor(client) {
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The prompt operations gated on a dashboard session — publishing a version,
|
|
3
|
-
* archiving a prompt, starting an optimization run, and reading run history.
|
|
4
|
-
*/
|
|
5
1
|
export class PromptsAdmin {
|
|
6
2
|
client;
|
|
7
3
|
constructor(client) {
|
|
@@ -13,7 +9,6 @@ export class PromptsAdmin {
|
|
|
13
9
|
archive(promptId, query = {}) {
|
|
14
10
|
return this.client.post(`/api/v1/prompts/${encodeURIComponent(promptId)}/archive`, {}, { ...query });
|
|
15
11
|
}
|
|
16
|
-
/** Starts an optimization run. The budget is required, never defaulted. */
|
|
17
12
|
assay(promptId, body) {
|
|
18
13
|
return this.client.post(`/api/v1/prompts/${encodeURIComponent(promptId)}/assay`, body);
|
|
19
14
|
}
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stored prompts, their versions, datasets and rollouts.
|
|
3
|
-
*
|
|
4
|
-
* This is the half of the API a job uses: every method here takes an API key.
|
|
5
|
-
* Publishing, archiving and optimizing are on the dashboard half — see
|
|
6
|
-
* `PromptsAdmin`.
|
|
7
|
-
*/
|
|
8
1
|
export class Prompts {
|
|
9
2
|
client;
|
|
10
3
|
constructor(client) {
|
|
@@ -16,7 +9,6 @@ export class Prompts {
|
|
|
16
9
|
create(body) {
|
|
17
10
|
return this.client.post("/api/v1/prompts", body);
|
|
18
11
|
}
|
|
19
|
-
/** One prompt with its current version. `runs` asks for its recent runs too. */
|
|
20
12
|
get(promptId, query = {}) {
|
|
21
13
|
return this.client.get(`/api/v1/prompts/${encodeURIComponent(promptId)}`, { ...query });
|
|
22
14
|
}
|
|
@@ -28,7 +20,6 @@ export class Prompts {
|
|
|
28
20
|
...query,
|
|
29
21
|
});
|
|
30
22
|
}
|
|
31
|
-
/** Promotes a stored version to current. Unchanged text is a no-op, not a fork. */
|
|
32
23
|
promote(promptId, versionId, query = {}) {
|
|
33
24
|
return this.client.post(`/api/v1/prompts/${encodeURIComponent(promptId)}/versions`, { versionId }, { ...query });
|
|
34
25
|
}
|
|
@@ -37,7 +28,6 @@ export class Prompts {
|
|
|
37
28
|
...query,
|
|
38
29
|
});
|
|
39
30
|
}
|
|
40
|
-
/** Replaces the dataset. Splits are derived, so a case never moves between them. */
|
|
41
31
|
putCases(promptId, cases, query = {}) {
|
|
42
32
|
return this.client.put(`/api/v1/prompts/${encodeURIComponent(promptId)}/cases`, { cases }, { ...query });
|
|
43
33
|
}
|
|
@@ -46,19 +36,11 @@ export class Prompts {
|
|
|
46
36
|
...query,
|
|
47
37
|
});
|
|
48
38
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Records a rollout the caller ran itself. Dedupes on `rolloutId`.
|
|
51
|
-
*
|
|
52
|
-
* `priced` is false where no pricing row covered the model. The stored cost
|
|
53
|
-
* is zero either way — the column is `notNull` — so this flag is the only
|
|
54
|
-
* thing telling a free call apart from an unpriced one.
|
|
55
|
-
*/
|
|
56
39
|
report(promptId, body, query = {}) {
|
|
57
40
|
return this.client.post(`/api/v1/prompts/${encodeURIComponent(promptId)}/rollouts`, body, {
|
|
58
41
|
...query,
|
|
59
42
|
});
|
|
60
43
|
}
|
|
61
|
-
/** Runs the prompt server-side, where the spec, rendering and pricing already live. */
|
|
62
44
|
run(promptId, body, query = {}) {
|
|
63
45
|
return this.client.post(`/api/v1/prompts/${encodeURIComponent(promptId)}/run`, body, {
|
|
64
46
|
...query,
|
package/dist/resources/stats.js
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Spend, read back.
|
|
3
|
-
*
|
|
4
|
-
* Every method here takes an API key, so a job can check what it spent without
|
|
5
|
-
* a dashboard session.
|
|
6
|
-
*/
|
|
7
1
|
export class Stats {
|
|
8
2
|
client;
|
|
9
3
|
constructor(client) {
|
|
10
4
|
this.client = client;
|
|
11
5
|
}
|
|
12
|
-
/** Totals for the window, beside the window before it. */
|
|
13
6
|
summary(query = {}) {
|
|
14
7
|
return this.client.get("/api/v1/stats/summary", { ...query });
|
|
15
8
|
}
|
|
@@ -22,7 +15,6 @@ export class Stats {
|
|
|
22
15
|
byKey(query = {}) {
|
|
23
16
|
return this.client.get("/api/v1/stats/by-key", { ...query });
|
|
24
17
|
}
|
|
25
|
-
/** Grouped by one metadata tag. `key` picks which tag. */
|
|
26
18
|
byTag(query = {}) {
|
|
27
19
|
return this.client.get("/api/v1/stats/by-tag", { ...query });
|
|
28
20
|
}
|
package/dist/resources/tools.js
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Declared tools.
|
|
3
|
-
*
|
|
4
|
-
* `get` and `list` take an API key, because binding a handler happens in a
|
|
5
|
-
* running service. Writing one is a dashboard action and needs a session — the
|
|
6
|
-
* same split prompts have.
|
|
7
|
-
*/
|
|
8
1
|
export class Tools {
|
|
9
2
|
client;
|
|
10
3
|
constructor(client) {
|
|
@@ -13,7 +6,6 @@ export class Tools {
|
|
|
13
6
|
list(query = {}) {
|
|
14
7
|
return this.client.get("/api/v1/tools", { ...query });
|
|
15
8
|
}
|
|
16
|
-
/** One tool, addressed by uuid or by the name the model calls. */
|
|
17
9
|
get(name, query = {}) {
|
|
18
10
|
return this.client.get(`/api/v1/tools/${encodeURIComponent(name)}`, { ...query });
|
|
19
11
|
}
|
|
@@ -23,7 +15,6 @@ export class Tools {
|
|
|
23
15
|
update(id, body, query = {}) {
|
|
24
16
|
return this.client.patch(`/api/v1/tools/${encodeURIComponent(id)}`, body, { ...query });
|
|
25
17
|
}
|
|
26
|
-
/** Out of the list, not destroyed. `archived: false` brings it back. */
|
|
27
18
|
archive(id, archived, query = {}) {
|
|
28
19
|
return this.client.post(`/api/v1/tools/${encodeURIComponent(id)}/archive`, { archived }, { ...query });
|
|
29
20
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FieldValidationError, isMissing, toBoolean, toNumber, validateFields, } from "../validate/index.js";
|
|
2
|
-
/** One declared value as the text that replaces its placeholder. */
|
|
3
2
|
function renderOne(field, value) {
|
|
4
3
|
switch (field.type) {
|
|
5
4
|
case "number":
|
|
@@ -14,16 +13,6 @@ function renderOne(field, value) {
|
|
|
14
13
|
return String(value);
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
|
-
/**
|
|
18
|
-
* Validated values as the flat string map the renderer substitutes.
|
|
19
|
-
*
|
|
20
|
-
* Throws rather than rendering something approximate. Headless there is no
|
|
21
|
-
* human reading the reply, so a missing required field would otherwise buy a
|
|
22
|
-
* paid-for response with a hole in it that nothing downstream can detect.
|
|
23
|
-
*
|
|
24
|
-
* An optional field left unset is omitted, so its `{name}` stays visible — the
|
|
25
|
-
* same behaviour the prompt builder has always had.
|
|
26
|
-
*/
|
|
27
16
|
export function serializeFields(values, spec) {
|
|
28
17
|
const errors = validateFields(values, spec);
|
|
29
18
|
if (errors.length)
|
|
@@ -6,17 +6,9 @@ export class FieldValidationError extends Error {
|
|
|
6
6
|
this.errors = errors;
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
-
/** Absent means absent. An empty string is a value someone chose to send. */
|
|
10
9
|
export function isMissing(value) {
|
|
11
10
|
return value === undefined || value === null;
|
|
12
11
|
}
|
|
13
|
-
/**
|
|
14
|
-
* Coercions shared with the renderer.
|
|
15
|
-
*
|
|
16
|
-
* Shared deliberately: if the checker and the renderer disagreed, a value could
|
|
17
|
-
* pass validation and then render as something else — a request you paid for
|
|
18
|
-
* and cannot explain.
|
|
19
|
-
*/
|
|
20
12
|
export function toNumber(value) {
|
|
21
13
|
return typeof value === "number" ? value : Number(String(value).trim());
|
|
22
14
|
}
|
|
@@ -55,7 +47,6 @@ function checkEnum(field, value) {
|
|
|
55
47
|
? null
|
|
56
48
|
: `must be one of ${options.join(" | ")}, got ${JSON.stringify(value)}`;
|
|
57
49
|
}
|
|
58
|
-
/** Catches cycles and BigInt, which otherwise reach the provider as a crash. */
|
|
59
50
|
function checkJson(value) {
|
|
60
51
|
try {
|
|
61
52
|
JSON.stringify(value);
|
|
@@ -82,13 +73,6 @@ function checkOne(field, value) {
|
|
|
82
73
|
return checkJson(value);
|
|
83
74
|
}
|
|
84
75
|
}
|
|
85
|
-
/**
|
|
86
|
-
* Everything wrong with these values, rather than the first thing.
|
|
87
|
-
*
|
|
88
|
-
* Values with no matching field are ignored, not reported: a stored prompt keeps
|
|
89
|
-
* values for placeholders that were later edited out, and treating a stale key
|
|
90
|
-
* as an error would fail a prompt that renders perfectly well.
|
|
91
|
-
*/
|
|
92
76
|
export function validateFields(values, spec) {
|
|
93
77
|
const errors = [];
|
|
94
78
|
for (const field of spec) {
|
|
@@ -98,7 +82,7 @@ export function validateFields(values, spec) {
|
|
|
98
82
|
continue;
|
|
99
83
|
if (field.required)
|
|
100
84
|
errors.push({ field: field.name, message: "is required" });
|
|
101
|
-
continue;
|
|
85
|
+
continue;
|
|
102
86
|
}
|
|
103
87
|
const problem = checkOne(field, value);
|
|
104
88
|
if (problem)
|
package/dist/track/track.js
CHANGED
|
@@ -1,25 +1,10 @@
|
|
|
1
1
|
import { Client, SpendgraphError } from "../core/client/index.js";
|
|
2
|
-
/** How long a flush may block waiting out a rate limit before giving up. */
|
|
3
2
|
const MAX_RETRY_WAIT_MS = 2_000;
|
|
4
|
-
/**
|
|
5
|
-
* Last-chance flush when the process winds down, shared by every meter.
|
|
6
|
-
*
|
|
7
|
-
* `beforeExit` is the right hook — unlike `exit` it permits async work, so the
|
|
8
|
-
* final send can actually complete. But it used to be registered per instance,
|
|
9
|
-
* so an app holding more than ten meters tripped Node's
|
|
10
|
-
* MaxListenersExceededWarning and kept every one of them alive forever.
|
|
11
|
-
*
|
|
12
|
-
* One listener, and meters are held weakly so a discarded one can still be
|
|
13
|
-
* collected. Deliberately no SIGTERM handler: installing one removes Node's
|
|
14
|
-
* default signal behaviour from the host application, and a telemetry library
|
|
15
|
-
* has no business changing how its host shuts down.
|
|
16
|
-
*/
|
|
17
3
|
const liveMeters = new Set();
|
|
18
4
|
let exitHookInstalled = false;
|
|
19
5
|
function registerForExitFlush(meter) {
|
|
20
6
|
if (typeof process === "undefined" || typeof process.on !== "function")
|
|
21
7
|
return;
|
|
22
|
-
// WeakRef is everywhere the SDK runs, but fall back rather than assume
|
|
23
8
|
const ref = typeof WeakRef === "function"
|
|
24
9
|
? new WeakRef(meter)
|
|
25
10
|
: { deref: () => meter };
|
|
@@ -33,33 +18,23 @@ function registerForExitFlush(meter) {
|
|
|
33
18
|
if (m)
|
|
34
19
|
void m.flush();
|
|
35
20
|
else
|
|
36
|
-
liveMeters.delete(r);
|
|
21
|
+
liveMeters.delete(r);
|
|
37
22
|
}
|
|
38
23
|
});
|
|
39
24
|
}
|
|
40
|
-
/**
|
|
41
|
-
* Fire-and-forget token tracking. track() never throws and never blocks;
|
|
42
|
-
* events batch in memory and flush every 5s or 20 events. Network failures
|
|
43
|
-
* retry once, then drop with one console.warn — tracking can never break
|
|
44
|
-
* the host app.
|
|
45
|
-
*/
|
|
46
25
|
export class SpendGraph {
|
|
47
26
|
opts;
|
|
48
27
|
queue = [];
|
|
49
28
|
timer = null;
|
|
50
29
|
warned = false;
|
|
51
|
-
/** Events lost in the current run of failures, reset by a successful flush. */
|
|
52
30
|
dropped = 0;
|
|
53
|
-
/** Unpriced model ids already reported, so each is named at most once. */
|
|
54
31
|
unpricedSeen = new Set();
|
|
55
32
|
interval;
|
|
56
33
|
maxBatch;
|
|
57
|
-
/** When the pending flush was scheduled, for the suspend check in track(). */
|
|
58
34
|
timerAt = 0;
|
|
59
35
|
suspendWarned = false;
|
|
60
36
|
noKeyWarned = false;
|
|
61
37
|
streamUsageWarned = false;
|
|
62
|
-
/** The flush currently on the wire, so a later flush() can wait for it. */
|
|
63
38
|
inFlight = null;
|
|
64
39
|
constructor(opts) {
|
|
65
40
|
this.opts = opts;
|
|
@@ -67,17 +42,9 @@ export class SpendGraph {
|
|
|
67
42
|
this.maxBatch = opts.maxBatch ?? 20;
|
|
68
43
|
registerForExitFlush(this);
|
|
69
44
|
}
|
|
70
|
-
/**
|
|
71
|
-
* The package's client, given no retries of its own.
|
|
72
|
-
*
|
|
73
|
-
* The policy in `send` stays this class's: a flush is awaited inside
|
|
74
|
-
* serverless handlers, where blocking out a minute-long rate-limit window is
|
|
75
|
-
* worse than losing the batch, and the client's backoff would do exactly that.
|
|
76
|
-
*/
|
|
77
45
|
clientFor(apiKey) {
|
|
78
46
|
return new Client({ apiKey, baseUrl: this.opts.baseUrl, attempts: 1 });
|
|
79
47
|
}
|
|
80
|
-
/** Record one LLM call. Synchronous, returns void, never throws. */
|
|
81
48
|
track(event) {
|
|
82
49
|
try {
|
|
83
50
|
if (!this.opts.apiKey) {
|
|
@@ -92,25 +59,12 @@ export class SpendGraph {
|
|
|
92
59
|
else if (!this.timer) {
|
|
93
60
|
this.timer = setTimeout(() => void this.flush(), this.interval);
|
|
94
61
|
this.timerAt = Date.now();
|
|
95
|
-
// don't hold the process open just to flush telemetry
|
|
96
62
|
this.timer.unref?.();
|
|
97
63
|
}
|
|
98
64
|
}
|
|
99
65
|
catch {
|
|
100
|
-
/* fail-open */
|
|
101
66
|
}
|
|
102
67
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Says once that tracking is off.
|
|
105
|
-
*
|
|
106
|
-
* Running without a key stays a no-op on purpose — it keeps spendgraph out of
|
|
107
|
-
* tests and local runs without branching at every call site. But an unset
|
|
108
|
-
* SPENDGRAPH_API_KEY is indistinguishable from that choice, and it is the
|
|
109
|
-
* likeliest reason a fresh integration records nothing at all: the code is
|
|
110
|
-
* wired up correctly, the dashboard is empty, and nothing anywhere says why.
|
|
111
|
-
* Every other failure in this class warns once; this was the one that stayed
|
|
112
|
-
* quiet, which made it the hardest to find.
|
|
113
|
-
*/
|
|
114
68
|
warnNoKey() {
|
|
115
69
|
if (this.noKeyWarned)
|
|
116
70
|
return;
|
|
@@ -119,24 +73,9 @@ export class SpendGraph {
|
|
|
119
73
|
"(usually from SPENDGRAPH_API_KEY) to start tracking, or ignore this if " +
|
|
120
74
|
"tracking is meant to be off here.");
|
|
121
75
|
}
|
|
122
|
-
/**
|
|
123
|
-
* Notices that the runtime froze with events still buffered.
|
|
124
|
-
*
|
|
125
|
-
* Serverless platforms suspend a function once it returns rather than
|
|
126
|
-
* exiting it, so neither the flush timer nor `beforeExit` ever runs and the
|
|
127
|
-
* queue is lost without a sound — the failure Langfuse documents for Lambda
|
|
128
|
-
* and Vercel. There is no reliable flag for "am I serverless", but there is
|
|
129
|
-
* direct evidence: a pending timer whose deadline passed long ago did not
|
|
130
|
-
* fire, which only happens if the runtime stopped executing between calls.
|
|
131
|
-
*
|
|
132
|
-
* Seeing that, send the stragglers now (they survive into this invocation)
|
|
133
|
-
* and say once what the fix is. Correct callers await flush(), which clears
|
|
134
|
-
* the timer, so this never fires for them.
|
|
135
|
-
*/
|
|
136
76
|
detectSuspendedRuntime() {
|
|
137
77
|
if (!this.timer)
|
|
138
78
|
return;
|
|
139
|
-
// 2x the interval, so ordinary event-loop lag is never mistaken for a freeze
|
|
140
79
|
if (Date.now() - this.timerAt <= this.interval * 2)
|
|
141
80
|
return;
|
|
142
81
|
if (!this.suspendWarned) {
|
|
@@ -147,42 +86,19 @@ export class SpendGraph {
|
|
|
147
86
|
}
|
|
148
87
|
void this.flush();
|
|
149
88
|
}
|
|
150
|
-
/**
|
|
151
|
-
* Send everything buffered now. Call at the end of serverless handlers.
|
|
152
|
-
*
|
|
153
|
-
* Serialised against a flush already running, because this method is not the
|
|
154
|
-
* only thing that starts one: track() flushes by itself the moment the batch
|
|
155
|
-
* is full, and so do the timer, the suspend check and the exit hook. Those
|
|
156
|
-
* empty the queue synchronously, so an awaited flush() used to find nothing
|
|
157
|
-
* left to send and hand back an already-resolved promise while the real
|
|
158
|
-
* batch was still on the wire — and the next thing a serverless handler does
|
|
159
|
-
* after awaiting is return, freezing the runtime and losing exactly the
|
|
160
|
-
* events the await was there to save.
|
|
161
|
-
*
|
|
162
|
-
* The drain still starts synchronously — `drain()` runs as far as its first
|
|
163
|
-
* await before returning a promise, so the queue is taken during this call
|
|
164
|
-
* and not a microtask later. That ordering is load-bearing: the suspend
|
|
165
|
-
* check and the exit hook both fire a flush and then expect the buffer to be
|
|
166
|
-
* gone, and deferring it broke them.
|
|
167
|
-
*/
|
|
168
89
|
async flush() {
|
|
169
90
|
const prev = this.inFlight;
|
|
170
91
|
const mine = this.drain();
|
|
171
|
-
// Both, not one after the other: they carry disjoint events, since this
|
|
172
|
-
// call already took everything the earlier one left behind.
|
|
173
92
|
const run = Promise.allSettled([prev, mine]).then(() => undefined);
|
|
174
93
|
this.inFlight = run;
|
|
175
94
|
try {
|
|
176
95
|
await run;
|
|
177
96
|
}
|
|
178
97
|
finally {
|
|
179
|
-
// Only the newest link clears the slot. An older one settling last must
|
|
180
|
-
// not strand a flush that has already queued up behind it.
|
|
181
98
|
if (this.inFlight === run)
|
|
182
99
|
this.inFlight = null;
|
|
183
100
|
}
|
|
184
101
|
}
|
|
185
|
-
/** One pass: stop the timer, take the queue, put it on the wire. */
|
|
186
102
|
async drain() {
|
|
187
103
|
if (this.timer) {
|
|
188
104
|
clearTimeout(this.timer);
|
|
@@ -196,43 +112,9 @@ export class SpendGraph {
|
|
|
196
112
|
await this.send(events.slice(i, i + 100), apiKey);
|
|
197
113
|
}
|
|
198
114
|
}
|
|
199
|
-
/**
|
|
200
|
-
* Wrap an Anthropic or OpenAI client. Use the wrapped client exactly as
|
|
201
|
-
* before — token usage is read off each response and tracked automatically.
|
|
202
|
-
*
|
|
203
|
-
* Streaming is covered too:
|
|
204
|
-
* - helper streams (`anthropic.messages.stream()`,
|
|
205
|
-
* `openai.beta.chat.completions.stream()`) are tracked via their
|
|
206
|
-
* final-message promise — the stream you get back is untouched;
|
|
207
|
-
* - raw streams (`create({ stream: true })`) are tee'd: you receive one
|
|
208
|
-
* branch, usage is accumulated off the other. For OpenAI raw streams,
|
|
209
|
-
* pass `stream_options: { include_usage: true }` or there is no usage
|
|
210
|
-
* to read and the call goes untracked.
|
|
211
|
-
*/
|
|
212
115
|
wrap(client) {
|
|
213
116
|
return this.proxy(client);
|
|
214
117
|
}
|
|
215
|
-
/**
|
|
216
|
-
* The recursive wrapper behind wrap().
|
|
217
|
-
*
|
|
218
|
-
* Two things here are deliberate and were both wrong before.
|
|
219
|
-
*
|
|
220
|
-
* `Reflect.get` is called without a receiver, so a getter runs with the real
|
|
221
|
-
* client as `this`. Passing the proxy — the obvious reading of the Proxy
|
|
222
|
-
* docs — makes any getter that touches a `#private` field throw
|
|
223
|
-
* "Cannot read private member", and the provider SDKs this is built to wrap
|
|
224
|
-
* use private fields. That turned a tracking wrapper into a crash on a
|
|
225
|
-
* client that works perfectly well unwrapped, in an SDK whose whole contract
|
|
226
|
-
* is to fail open.
|
|
227
|
-
*
|
|
228
|
-
* And each wrapper is kept, so `client.messages` and `client.messages.create`
|
|
229
|
-
* are the same object and the same function every time they are read.
|
|
230
|
-
* Rebuilding them per access allocated a proxy and a closure on every call
|
|
231
|
-
* and quietly broke any caller that holds onto a method or compares
|
|
232
|
-
* identity. The cache is per proxy and keyed by property, so a method always
|
|
233
|
-
* applies to the object it was read from, and it re-wraps if the underlying
|
|
234
|
-
* value is ever replaced.
|
|
235
|
-
*/
|
|
236
118
|
proxy(target) {
|
|
237
119
|
const cache = new Map();
|
|
238
120
|
return new Proxy(target, {
|
|
@@ -254,9 +136,6 @@ export class SpendGraph {
|
|
|
254
136
|
}
|
|
255
137
|
observeResult(result, args) {
|
|
256
138
|
try {
|
|
257
|
-
// Helper streams return synchronously and expose a final-message
|
|
258
|
-
// promise (Anthropic MessageStream / OpenAI ChatCompletionStream).
|
|
259
|
-
// Awaiting it does not consume the caller's iterator.
|
|
260
139
|
const helper = result;
|
|
261
140
|
const final = typeof helper?.finalMessage === "function"
|
|
262
141
|
? helper.finalMessage()
|
|
@@ -268,20 +147,14 @@ export class SpendGraph {
|
|
|
268
147
|
return result;
|
|
269
148
|
}
|
|
270
149
|
if (result instanceof Promise) {
|
|
271
|
-
// Raw streaming (create({stream: true})) resolves to an SSE stream;
|
|
272
|
-
// swap in a tee'd branch so we can read usage without consuming the
|
|
273
|
-
// caller's. Only then do we replace the promise — non-streaming
|
|
274
|
-
// calls keep the SDK's original promise (withResponse() etc.).
|
|
275
150
|
const wantsStream = !!args[0]?.stream;
|
|
276
151
|
if (wantsStream) {
|
|
277
152
|
return result.then((v) => this.interceptStream(v));
|
|
278
153
|
}
|
|
279
|
-
// provider errors are the caller's to handle
|
|
280
154
|
result.then((v) => this.trackFromResponse(v), () => { });
|
|
281
155
|
}
|
|
282
156
|
}
|
|
283
157
|
catch {
|
|
284
|
-
/* fail-open */
|
|
285
158
|
}
|
|
286
159
|
return result;
|
|
287
160
|
}
|
|
@@ -296,11 +169,9 @@ export class SpendGraph {
|
|
|
296
169
|
this.trackFromResponse(v);
|
|
297
170
|
}
|
|
298
171
|
catch {
|
|
299
|
-
/* fail-open */
|
|
300
172
|
}
|
|
301
173
|
return v;
|
|
302
174
|
}
|
|
303
|
-
/** Accumulate usage off a tee'd SSE branch (Anthropic events / OpenAI chunks). */
|
|
304
175
|
async consumeStream(iter) {
|
|
305
176
|
try {
|
|
306
177
|
let model;
|
|
@@ -315,7 +186,6 @@ export class SpendGraph {
|
|
|
315
186
|
sawChunk = true;
|
|
316
187
|
const ev = raw;
|
|
317
188
|
if (ev?.type === "message_start" && ev.message) {
|
|
318
|
-
// Anthropic: input + cache usage arrive up front
|
|
319
189
|
model = ev.message.model ?? model;
|
|
320
190
|
const u = ev.message.usage ?? {};
|
|
321
191
|
inputTokens = u.input_tokens ?? inputTokens;
|
|
@@ -323,12 +193,9 @@ export class SpendGraph {
|
|
|
323
193
|
cacheWriteTokens = u.cache_creation_input_tokens ?? 0;
|
|
324
194
|
}
|
|
325
195
|
else if (ev?.type === "message_delta" && ev.usage) {
|
|
326
|
-
// Anthropic: cumulative output count
|
|
327
196
|
outputTokens = ev.usage.output_tokens ?? outputTokens;
|
|
328
197
|
}
|
|
329
198
|
else if (ev?.object === "chat.completion.chunk") {
|
|
330
|
-
// OpenAI: usage only on the final chunk, and only with
|
|
331
|
-
// stream_options: { include_usage: true }
|
|
332
199
|
model = ev.model ?? model;
|
|
333
200
|
if (ev.usage) {
|
|
334
201
|
const u = ev.usage;
|
|
@@ -357,22 +224,8 @@ export class SpendGraph {
|
|
|
357
224
|
}
|
|
358
225
|
}
|
|
359
226
|
catch {
|
|
360
|
-
/* fail-open — never disturb the caller's branch */
|
|
361
227
|
}
|
|
362
228
|
}
|
|
363
|
-
/**
|
|
364
|
-
* Says once that a streamed call went untracked.
|
|
365
|
-
*
|
|
366
|
-
* A raw OpenAI stream carries no usage unless the caller asks for it, so the
|
|
367
|
-
* accumulator above finishes with nothing to report and simply returns —
|
|
368
|
-
* correct, and completely silent. That is the same hole warnNoKey was added
|
|
369
|
-
* to close: the integration looks right, the dashboard stays empty for those
|
|
370
|
-
* calls, and nothing anywhere connects the two. Anthropic streams always
|
|
371
|
-
* carry usage, so in practice this names the one option that fixes it.
|
|
372
|
-
*
|
|
373
|
-
* Guarded on having seen at least one chunk, so an empty or aborted stream —
|
|
374
|
-
* where there was never anything to read — stays quiet.
|
|
375
|
-
*/
|
|
376
229
|
warnStreamNoUsage() {
|
|
377
230
|
if (this.streamUsageWarned)
|
|
378
231
|
return;
|
|
@@ -391,7 +244,6 @@ export class SpendGraph {
|
|
|
391
244
|
return;
|
|
392
245
|
const u = r.usage;
|
|
393
246
|
if (typeof u.input_tokens === "number" && typeof u.output_tokens === "number") {
|
|
394
|
-
// Anthropic: cache tokens are separate fields already
|
|
395
247
|
this.track({
|
|
396
248
|
model: r.model,
|
|
397
249
|
inputTokens: u.input_tokens,
|
|
@@ -402,7 +254,6 @@ export class SpendGraph {
|
|
|
402
254
|
return;
|
|
403
255
|
}
|
|
404
256
|
if (typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number") {
|
|
405
|
-
// OpenAI: cached tokens are included in prompt_tokens — split them out
|
|
406
257
|
const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
|
|
407
258
|
this.track({
|
|
408
259
|
model: r.model,
|
|
@@ -415,7 +266,6 @@ export class SpendGraph {
|
|
|
415
266
|
}
|
|
416
267
|
}
|
|
417
268
|
catch {
|
|
418
|
-
/* fail-open */
|
|
419
269
|
}
|
|
420
270
|
}
|
|
421
271
|
async send(events, apiKey, attempt = 0) {
|
|
@@ -426,23 +276,10 @@ export class SpendGraph {
|
|
|
426
276
|
catch (err) {
|
|
427
277
|
return this.onSendFailed(err, events, apiKey, attempt);
|
|
428
278
|
}
|
|
429
|
-
// A run of failures is over; a later one is news again rather than more
|
|
430
|
-
// of the same, so the warning is allowed to fire once more.
|
|
431
279
|
this.warned = false;
|
|
432
280
|
this.dropped = 0;
|
|
433
281
|
this.reportUnpriced(body);
|
|
434
282
|
}
|
|
435
|
-
/**
|
|
436
|
-
* The drop policy, which is this class's rather than the client's.
|
|
437
|
-
*
|
|
438
|
-
* 429 is the one failure the server tells you how to fix: it sends
|
|
439
|
-
* retry-after, and the window is at most a minute wide, so a batch that
|
|
440
|
-
* arrived at the end of one is deliverable a second later. Dropping it
|
|
441
|
-
* immediately threw that away and reported the least useful thing it could —
|
|
442
|
-
* "ingest returned 429" — for the one status with a documented remedy. The
|
|
443
|
-
* wait is capped because flush() is awaited inside serverless handlers, where
|
|
444
|
-
* blocking for a minute is worse than losing the batch.
|
|
445
|
-
*/
|
|
446
283
|
async onSendFailed(err, events, apiKey, attempt) {
|
|
447
284
|
const failure = err instanceof SpendgraphError ? err : null;
|
|
448
285
|
if (!failure || failure.status === 0) {
|
|
@@ -467,16 +304,6 @@ export class SpendGraph {
|
|
|
467
304
|
}
|
|
468
305
|
this.reportDropped(events.length, `ingest returned ${failure.status}`);
|
|
469
306
|
}
|
|
470
|
-
/**
|
|
471
|
-
* Surfaces model ids the server could not price, once each.
|
|
472
|
-
*
|
|
473
|
-
* These are accepted and stored, so nothing here is an error — but they cost
|
|
474
|
-
* $0, and a dashboard reading $0 is indistinguishable from one reading
|
|
475
|
-
* "nothing happened". Naming the id in the integrator's own console is the
|
|
476
|
-
* cheapest possible moment to catch a typo or an unmapped model, and the
|
|
477
|
-
* per-id guard keeps a steady stream of the same unknown model from becoming
|
|
478
|
-
* log noise.
|
|
479
|
-
*/
|
|
480
307
|
reportUnpriced(body) {
|
|
481
308
|
{
|
|
482
309
|
const parsed = body;
|
|
@@ -493,15 +320,6 @@ export class SpendGraph {
|
|
|
493
320
|
`These events are recorded but cost $0 until the model is in the catalog.`);
|
|
494
321
|
}
|
|
495
322
|
}
|
|
496
|
-
/**
|
|
497
|
-
* Warns once per outage, with a running count of what was lost.
|
|
498
|
-
*
|
|
499
|
-
* Warning on every flush would spam a hot loop, but warning exactly once per
|
|
500
|
-
* process — the previous behaviour — hid a server-side bug that failed every
|
|
501
|
-
* full batch: one line early in a long-lived process, then silence, while the
|
|
502
|
-
* dashboard quietly undercounted. The count is what makes the silence legible
|
|
503
|
-
* when someone does go looking.
|
|
504
|
-
*/
|
|
505
323
|
reportDropped(count, reason) {
|
|
506
324
|
this.dropped += count;
|
|
507
325
|
if (this.warned)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spendgraph/sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Track LLM input/output tokens and cost. Three functions, zero dependencies, fail-open.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"build": "tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments",
|
|
47
47
|
"test": "vitest run"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|