@spendgraph/sdk 0.1.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.d.ts +37 -0
- package/dist/client.js +36 -0
- package/dist/core/client/client.d.ts +30 -0
- package/dist/core/client/client.js +128 -0
- package/dist/core/client/errors.d.ts +25 -0
- package/dist/core/client/errors.js +17 -0
- package/dist/core/client/index.d.ts +2 -0
- package/dist/core/client/index.js +2 -0
- package/dist/core/types.d.ts +28 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +9 -124
- package/dist/index.js +5 -421
- package/dist/langchain.js +3 -17
- package/dist/resources/alerts.d.ts +18 -0
- package/dist/resources/alerts.js +9 -0
- package/dist/resources/credentials.d.ts +22 -0
- package/dist/resources/credentials.js +15 -0
- package/dist/resources/events.d.ts +41 -0
- package/dist/resources/events.js +18 -0
- package/dist/resources/index.d.ts +21 -0
- package/dist/resources/index.js +12 -0
- package/dist/resources/ingest.d.ts +42 -0
- package/dist/resources/ingest.js +27 -0
- package/dist/resources/keys.d.ts +34 -0
- package/dist/resources/keys.js +15 -0
- package/dist/resources/playground.d.ts +7 -0
- package/dist/resources/playground.js +9 -0
- package/dist/resources/pricing.d.ts +49 -0
- package/dist/resources/pricing.js +36 -0
- package/dist/resources/projects.d.ts +54 -0
- package/dist/resources/projects.js +49 -0
- package/dist/resources/prompts-admin.d.ts +23 -0
- package/dist/resources/prompts-admin.js +21 -0
- package/dist/resources/prompts.d.ts +124 -0
- package/dist/resources/prompts.js +49 -0
- package/dist/resources/stats.d.ts +65 -0
- package/dist/resources/stats.js +21 -0
- package/dist/resources/tools.d.ts +66 -0
- package/dist/resources/tools.js +21 -0
- package/dist/rollout/index.d.ts +1 -0
- package/dist/rollout/index.js +1 -0
- package/dist/rollout/rollout.d.ts +75 -0
- package/dist/rollout/rollout.js +1 -0
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +2 -0
- package/dist/schema/serialize/index.d.ts +1 -0
- package/dist/schema/serialize/index.js +1 -0
- package/dist/schema/serialize/serialize.d.ts +12 -0
- package/dist/schema/serialize/serialize.js +31 -0
- package/dist/schema/types/index.d.ts +1 -0
- package/dist/schema/types/index.js +1 -0
- package/dist/schema/types/types.d.ts +58 -0
- package/dist/schema/types/types.js +1 -0
- package/dist/schema/validate/index.d.ts +1 -0
- package/dist/schema/validate/index.js +1 -0
- package/dist/schema/validate/validate.d.ts +28 -0
- package/dist/schema/validate/validate.js +92 -0
- package/dist/track/index.d.ts +2 -0
- package/dist/track/index.js +1 -0
- package/dist/track/track.d.ts +209 -0
- package/dist/track/track.js +331 -0
- package/package.json +3 -2
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { FieldSpec } from "../schema/index.js";
|
|
2
|
+
import type { Client } from "../core/client/index.js";
|
|
3
|
+
import type { Scoped } from "./prompts.js";
|
|
4
|
+
/** What calling a tool does outside the process. Null means nobody has said. */
|
|
5
|
+
export type ToolEffect = "readonly" | "idempotent" | "destructive";
|
|
6
|
+
/**
|
|
7
|
+
* A tool's declaration, as stored.
|
|
8
|
+
*
|
|
9
|
+
* The handler is not here and never will be: this is what the model is told,
|
|
10
|
+
* and the function it maps to lives in the caller's codebase.
|
|
11
|
+
*/
|
|
12
|
+
export interface ToolRow {
|
|
13
|
+
id: string;
|
|
14
|
+
projectId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
args: FieldSpec[];
|
|
18
|
+
effect: ToolEffect | null;
|
|
19
|
+
pinned: boolean;
|
|
20
|
+
archivedAt: string | null;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
}
|
|
24
|
+
export interface SaveToolInput {
|
|
25
|
+
projectId: string;
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
args?: FieldSpec[];
|
|
29
|
+
effect?: ToolEffect | null;
|
|
30
|
+
pinned?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Declared tools.
|
|
34
|
+
*
|
|
35
|
+
* `get` and `list` take an API key, because binding a handler happens in a
|
|
36
|
+
* running service. Writing one is a dashboard action and needs a session — the
|
|
37
|
+
* same split prompts have.
|
|
38
|
+
*/
|
|
39
|
+
export declare class Tools {
|
|
40
|
+
private readonly client;
|
|
41
|
+
constructor(client: Client);
|
|
42
|
+
list(query?: Scoped & {
|
|
43
|
+
limit?: number;
|
|
44
|
+
cursor?: string;
|
|
45
|
+
archived?: "only" | "active" | "all";
|
|
46
|
+
}): Promise<{
|
|
47
|
+
tools: ToolRow[];
|
|
48
|
+
nextCursor: string | null;
|
|
49
|
+
}>;
|
|
50
|
+
/** One tool, addressed by uuid or by the name the model calls. */
|
|
51
|
+
get(name: string, query?: Scoped): Promise<{
|
|
52
|
+
tool: ToolRow;
|
|
53
|
+
}>;
|
|
54
|
+
create(body: SaveToolInput): Promise<{
|
|
55
|
+
tool: ToolRow;
|
|
56
|
+
}>;
|
|
57
|
+
update(id: string, body: SaveToolInput, query?: Scoped): Promise<{
|
|
58
|
+
tool: ToolRow;
|
|
59
|
+
}>;
|
|
60
|
+
/** Out of the list, not destroyed. `archived: false` brings it back. */
|
|
61
|
+
archive(id: string, archived: boolean, query?: Scoped): Promise<{
|
|
62
|
+
id: string;
|
|
63
|
+
archivedAt: string | null;
|
|
64
|
+
unchanged?: boolean;
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class Tools {
|
|
2
|
+
client;
|
|
3
|
+
constructor(client) {
|
|
4
|
+
this.client = client;
|
|
5
|
+
}
|
|
6
|
+
list(query = {}) {
|
|
7
|
+
return this.client.get("/api/v1/tools", { ...query });
|
|
8
|
+
}
|
|
9
|
+
get(name, query = {}) {
|
|
10
|
+
return this.client.get(`/api/v1/tools/${encodeURIComponent(name)}`, { ...query });
|
|
11
|
+
}
|
|
12
|
+
create(body) {
|
|
13
|
+
return this.client.post("/api/v1/tools", body);
|
|
14
|
+
}
|
|
15
|
+
update(id, body, query = {}) {
|
|
16
|
+
return this.client.patch(`/api/v1/tools/${encodeURIComponent(id)}`, body, { ...query });
|
|
17
|
+
}
|
|
18
|
+
archive(id, archived, query = {}) {
|
|
19
|
+
return this.client.post(`/api/v1/tools/${encodeURIComponent(id)}/archive`, { archived }, { ...query });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { RolloutInput, RolloutRecord, RolloutStep } from "./rollout.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { RenderedMessage as Message } from "../resources/prompts.js";
|
|
2
|
+
/**
|
|
3
|
+
* The record every capability writes.
|
|
4
|
+
*
|
|
5
|
+
* A prompt run is a rollout with one step. A graph run is a rollout with
|
|
6
|
+
* several. The `steps` column exists in `prompt_rollouts` from the first
|
|
7
|
+
* migration for exactly that reason, so the shape lives here rather than under
|
|
8
|
+
* `prompt/` — a second copy is how the two capabilities end up disagreeing
|
|
9
|
+
* about what a rollout is.
|
|
10
|
+
*/
|
|
11
|
+
/** One unit of work inside a rollout. A prompt run has one; an agent run many. */
|
|
12
|
+
export interface RolloutStep {
|
|
13
|
+
/** Position in the run, so a failure can be attributed to a step. */
|
|
14
|
+
index: number;
|
|
15
|
+
/** What produced it — a prompt id, a tool name, a node name. */
|
|
16
|
+
source: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
input?: unknown;
|
|
19
|
+
output?: string;
|
|
20
|
+
status?: "completed" | "failed";
|
|
21
|
+
error?: string;
|
|
22
|
+
inputTokens?: number;
|
|
23
|
+
outputTokens?: number;
|
|
24
|
+
latencyMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/** What a caller hands back after running something itself. */
|
|
27
|
+
export interface RolloutInput {
|
|
28
|
+
/** Dedupe key. Generated if omitted; supply your own to make a retry safe. */
|
|
29
|
+
rolloutId?: string;
|
|
30
|
+
versionId?: string | null;
|
|
31
|
+
model: string;
|
|
32
|
+
fields: Record<string, string>;
|
|
33
|
+
rendered: Message[];
|
|
34
|
+
output: string;
|
|
35
|
+
status?: "completed" | "failed";
|
|
36
|
+
error?: string;
|
|
37
|
+
inputTokens?: number;
|
|
38
|
+
outputTokens?: number;
|
|
39
|
+
cacheReadTokens?: number;
|
|
40
|
+
cacheWriteTokens?: number;
|
|
41
|
+
citationTokens?: number;
|
|
42
|
+
reasoningTokens?: number;
|
|
43
|
+
latencyMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* The steps a multi-step run took. Absent for a single prompt call, which is
|
|
46
|
+
* the same thing with one step and nothing worth listing.
|
|
47
|
+
*/
|
|
48
|
+
steps?: RolloutStep[];
|
|
49
|
+
/**
|
|
50
|
+
* The tools this run was offered, whether or not it called them.
|
|
51
|
+
*
|
|
52
|
+
* Without it a rollout cannot answer the first question anyone asks of a
|
|
53
|
+
* tool-using agent that got something wrong: was the tool it needed even in
|
|
54
|
+
* front of it? "Chose not to call `refund`" and "was never shown `refund`"
|
|
55
|
+
* produce identical records and want opposite fixes. Selection makes this
|
|
56
|
+
* sharper, not softer, because the offered set now varies per turn.
|
|
57
|
+
*/
|
|
58
|
+
offeredTools?: string[];
|
|
59
|
+
/** Set only for evaluation rollouts. Production traffic leaves it unset. */
|
|
60
|
+
caseId?: string;
|
|
61
|
+
seed?: number;
|
|
62
|
+
candidateId?: string;
|
|
63
|
+
parentId?: string;
|
|
64
|
+
generation?: number;
|
|
65
|
+
}
|
|
66
|
+
/** What the server returns once it has priced and stored one. */
|
|
67
|
+
export interface RolloutRecord {
|
|
68
|
+
rolloutId: string;
|
|
69
|
+
status: "completed" | "failed";
|
|
70
|
+
output: string;
|
|
71
|
+
costMicros: number;
|
|
72
|
+
latencyMs: number;
|
|
73
|
+
inputTokens: number;
|
|
74
|
+
outputTokens: number;
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { serializeFields } from "./serialize.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { serializeFields } from "./serialize.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FieldSpec } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validated values as the flat string map the renderer substitutes.
|
|
4
|
+
*
|
|
5
|
+
* Throws rather than rendering something approximate. Headless there is no
|
|
6
|
+
* human reading the reply, so a missing required field would otherwise buy a
|
|
7
|
+
* paid-for response with a hole in it that nothing downstream can detect.
|
|
8
|
+
*
|
|
9
|
+
* An optional field left unset is omitted, so its `{name}` stays visible — the
|
|
10
|
+
* same behaviour the prompt builder has always had.
|
|
11
|
+
*/
|
|
12
|
+
export declare function serializeFields(values: Record<string, unknown>, spec: FieldSpec[]): Record<string, string>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { FieldValidationError, isMissing, toBoolean, toNumber, validateFields, } from "../validate/index.js";
|
|
2
|
+
function renderOne(field, value) {
|
|
3
|
+
switch (field.type) {
|
|
4
|
+
case "number":
|
|
5
|
+
return String(toNumber(value));
|
|
6
|
+
case "boolean":
|
|
7
|
+
return toBoolean(value) ? (field.trueText ?? "true") : (field.falseText ?? "false");
|
|
8
|
+
case "list":
|
|
9
|
+
return value.map(String).join(field.separator ?? ", ");
|
|
10
|
+
case "json":
|
|
11
|
+
return JSON.stringify(value, null, 2);
|
|
12
|
+
default:
|
|
13
|
+
return String(value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function serializeFields(values, spec) {
|
|
17
|
+
const errors = validateFields(values, spec);
|
|
18
|
+
if (errors.length)
|
|
19
|
+
throw new FieldValidationError(errors);
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const field of spec) {
|
|
22
|
+
const value = values[field.name];
|
|
23
|
+
if (isMissing(value)) {
|
|
24
|
+
if (field.default !== undefined)
|
|
25
|
+
out[field.name] = field.default;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
out[field.name] = renderOne(field, value);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { FieldSpec, FieldType } from "./types.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed prompt fields — what a `{placeholder}` accepts, and how it renders.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately a copy of `lib/fields.ts` rather than an import. This package is
|
|
5
|
+
* published and depends on nothing; reaching into the app for a type would make
|
|
6
|
+
* every installed copy break the next time the app moved a file. The two are
|
|
7
|
+
* kept in step by the wire format, which is the only contract that matters.
|
|
8
|
+
*
|
|
9
|
+
* A field type is two things, and the second is the one that gets forgotten: an
|
|
10
|
+
* input contract, and a rendering rule. Everything ends up interpolated into a
|
|
11
|
+
* string, so `{max_words}` given `50` has to become `"50"` and `{tags}` given
|
|
12
|
+
* `["a","b"]` has to become something the prompt author chose rather than
|
|
13
|
+
* whatever the first implementation happened to do.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not called `Variable`: a tool's arguments and an agent node's
|
|
16
|
+
* inputs are the same problem, and naming this after prompts guarantees it gets
|
|
17
|
+
* written twice more with three different meanings for `required`.
|
|
18
|
+
*/
|
|
19
|
+
export type FieldType = "string" | "text" | "number" | "boolean" | "enum" | "list" | "json";
|
|
20
|
+
export interface FieldSpec {
|
|
21
|
+
/** Matches the `{placeholder}` in the blocks or question. */
|
|
22
|
+
name: string;
|
|
23
|
+
type: FieldType;
|
|
24
|
+
/** A missing required field throws before anything is sent. */
|
|
25
|
+
required: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Substituted verbatim when the field is absent — already-rendered text, not
|
|
28
|
+
* a value to coerce. A `list` default is the joined string, a `json` default
|
|
29
|
+
* is the printed JSON. Authored once by a human rather than typed per run, so
|
|
30
|
+
* asking for it in final form is simpler than parsing it back per type.
|
|
31
|
+
*/
|
|
32
|
+
default?: string;
|
|
33
|
+
/**
|
|
34
|
+
* What the field means. Shown in the UI, and given to the optimizer's
|
|
35
|
+
* reflector — knowing what a field is *for* is the difference between a
|
|
36
|
+
* useful prompt mutation and a guess.
|
|
37
|
+
*/
|
|
38
|
+
description?: string;
|
|
39
|
+
/** `enum` — the permitted values. */
|
|
40
|
+
options?: string[];
|
|
41
|
+
/** `list` — what joins the items. Defaults to ", ". */
|
|
42
|
+
separator?: string;
|
|
43
|
+
/** `number` — inclusive bounds. */
|
|
44
|
+
min?: number;
|
|
45
|
+
max?: number;
|
|
46
|
+
/** `string` | `text` — rejected above this length. */
|
|
47
|
+
maxLength?: number;
|
|
48
|
+
/** `boolean` — words to render instead of "true"/"false". */
|
|
49
|
+
trueText?: string;
|
|
50
|
+
falseText?: string;
|
|
51
|
+
/**
|
|
52
|
+
* This is the field a dataset varies.
|
|
53
|
+
*
|
|
54
|
+
* A prompt declaring `{question}` plus 200 stored values for it *is* a
|
|
55
|
+
* 200-case dataset; this flag is what says which field those values fill.
|
|
56
|
+
*/
|
|
57
|
+
datasetKey?: boolean;
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type FieldError, FieldValidationError, isMissing, toBoolean, toNumber, validateFields, } from "./validate.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FieldValidationError, isMissing, toBoolean, toNumber, validateFields, } from "./validate.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FieldSpec } from "../types/index.js";
|
|
2
|
+
export type FieldError = {
|
|
3
|
+
field: string;
|
|
4
|
+
message: string;
|
|
5
|
+
};
|
|
6
|
+
export declare class FieldValidationError extends Error {
|
|
7
|
+
readonly errors: FieldError[];
|
|
8
|
+
constructor(errors: FieldError[]);
|
|
9
|
+
}
|
|
10
|
+
/** Absent means absent. An empty string is a value someone chose to send. */
|
|
11
|
+
export declare function isMissing(value: unknown): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Coercions shared with the renderer.
|
|
14
|
+
*
|
|
15
|
+
* Shared deliberately: if the checker and the renderer disagreed, a value could
|
|
16
|
+
* pass validation and then render as something else — a request you paid for
|
|
17
|
+
* and cannot explain.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toNumber(value: unknown): number;
|
|
20
|
+
export declare function toBoolean(value: unknown): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Everything wrong with these values, rather than the first thing.
|
|
23
|
+
*
|
|
24
|
+
* Values with no matching field are ignored, not reported: a stored prompt keeps
|
|
25
|
+
* values for placeholders that were later edited out, and treating a stale key
|
|
26
|
+
* as an error would fail a prompt that renders perfectly well.
|
|
27
|
+
*/
|
|
28
|
+
export declare function validateFields(values: Record<string, unknown>, spec: FieldSpec[]): FieldError[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export class FieldValidationError extends Error {
|
|
2
|
+
errors;
|
|
3
|
+
constructor(errors) {
|
|
4
|
+
super(errors.map((e) => `${e.field}: ${e.message}`).join("; "));
|
|
5
|
+
this.name = "FieldValidationError";
|
|
6
|
+
this.errors = errors;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isMissing(value) {
|
|
10
|
+
return value === undefined || value === null;
|
|
11
|
+
}
|
|
12
|
+
export function toNumber(value) {
|
|
13
|
+
return typeof value === "number" ? value : Number(String(value).trim());
|
|
14
|
+
}
|
|
15
|
+
export function toBoolean(value) {
|
|
16
|
+
return typeof value === "boolean" ? value : String(value).toLowerCase() === "true";
|
|
17
|
+
}
|
|
18
|
+
function checkText(field, value) {
|
|
19
|
+
const text = String(value);
|
|
20
|
+
return field.maxLength !== undefined && text.length > field.maxLength
|
|
21
|
+
? `is ${text.length} characters, over the limit of ${field.maxLength}`
|
|
22
|
+
: null;
|
|
23
|
+
}
|
|
24
|
+
function checkNumber(field, value) {
|
|
25
|
+
const n = toNumber(value);
|
|
26
|
+
if (!Number.isFinite(n))
|
|
27
|
+
return `expects a number, got ${JSON.stringify(value)}`;
|
|
28
|
+
if (field.min !== undefined && n < field.min)
|
|
29
|
+
return `is ${n}, below the minimum of ${field.min}`;
|
|
30
|
+
if (field.max !== undefined && n > field.max)
|
|
31
|
+
return `is ${n}, above the maximum of ${field.max}`;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function checkBoolean(value) {
|
|
35
|
+
if (typeof value === "boolean")
|
|
36
|
+
return null;
|
|
37
|
+
const s = String(value).toLowerCase();
|
|
38
|
+
return s === "true" || s === "false"
|
|
39
|
+
? null
|
|
40
|
+
: `expects true or false, got ${JSON.stringify(value)}`;
|
|
41
|
+
}
|
|
42
|
+
function checkEnum(field, value) {
|
|
43
|
+
const options = field.options ?? [];
|
|
44
|
+
if (options.length === 0)
|
|
45
|
+
return "is an enum with no options declared";
|
|
46
|
+
return options.includes(String(value))
|
|
47
|
+
? null
|
|
48
|
+
: `must be one of ${options.join(" | ")}, got ${JSON.stringify(value)}`;
|
|
49
|
+
}
|
|
50
|
+
function checkJson(value) {
|
|
51
|
+
try {
|
|
52
|
+
JSON.stringify(value);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return `is not serialisable: ${err.message}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function checkOne(field, value) {
|
|
60
|
+
switch (field.type) {
|
|
61
|
+
case "string":
|
|
62
|
+
case "text":
|
|
63
|
+
return checkText(field, value);
|
|
64
|
+
case "number":
|
|
65
|
+
return checkNumber(field, value);
|
|
66
|
+
case "boolean":
|
|
67
|
+
return checkBoolean(value);
|
|
68
|
+
case "enum":
|
|
69
|
+
return checkEnum(field, value);
|
|
70
|
+
case "list":
|
|
71
|
+
return Array.isArray(value) ? null : `expects an array, got ${typeof value}`;
|
|
72
|
+
case "json":
|
|
73
|
+
return checkJson(value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function validateFields(values, spec) {
|
|
77
|
+
const errors = [];
|
|
78
|
+
for (const field of spec) {
|
|
79
|
+
const value = values[field.name];
|
|
80
|
+
if (isMissing(value)) {
|
|
81
|
+
if (field.default !== undefined)
|
|
82
|
+
continue;
|
|
83
|
+
if (field.required)
|
|
84
|
+
errors.push({ field: field.name, message: "is required" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const problem = checkOne(field, value);
|
|
88
|
+
if (problem)
|
|
89
|
+
errors.push({ field: field.name, message: problem });
|
|
90
|
+
}
|
|
91
|
+
return errors;
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SpendGraph } from "./track.js";
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
export interface SpendGraphOptions {
|
|
2
|
+
/** spendgraph API key (sg_…). Without it the SDK is a no-op. */
|
|
3
|
+
apiKey: string | undefined;
|
|
4
|
+
/** Base URL of your deployed spendgraph app, e.g. https://costs.yourco.com */
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
/** Flush at most this often (ms). Default 5000. */
|
|
7
|
+
flushIntervalMs?: number;
|
|
8
|
+
/** Flush when the buffer reaches this size. Default 20. */
|
|
9
|
+
maxBatch?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface TrackEvent {
|
|
12
|
+
model: string;
|
|
13
|
+
/** Uncached input tokens — cache tokens go in the fields below. */
|
|
14
|
+
inputTokens: number;
|
|
15
|
+
outputTokens: number;
|
|
16
|
+
/** Prompt-cache read (hit) tokens, billed at the provider's cache-read rate. */
|
|
17
|
+
cacheReadTokens?: number;
|
|
18
|
+
/** Prompt-cache write (creation) tokens. */
|
|
19
|
+
cacheWriteTokens?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Billed on their own by search-grounded providers — Perplexity Sonar Deep
|
|
22
|
+
* Research charges both at rates that are neither input nor output. Leave
|
|
23
|
+
* them unset for every other provider; OpenAI's `reasoning_tokens` are
|
|
24
|
+
* already counted inside `completion_tokens` and would be billed twice.
|
|
25
|
+
*/
|
|
26
|
+
citationTokens?: number;
|
|
27
|
+
reasoningTokens?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Client-side id: retried sends dedupe server-side.
|
|
30
|
+
* Must be 8–64 characters — shorter ids are rejected with a 422.
|
|
31
|
+
*/
|
|
32
|
+
eventId?: string;
|
|
33
|
+
/** ISO timestamp; defaults to server receive time. */
|
|
34
|
+
timestamp?: string;
|
|
35
|
+
metadata?: Record<string, string | number | boolean>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fire-and-forget token tracking. track() never throws and never blocks;
|
|
39
|
+
* events batch in memory and flush every 5s or 20 events. Network failures
|
|
40
|
+
* retry once, then drop with one console.warn — tracking can never break
|
|
41
|
+
* the host app.
|
|
42
|
+
*/
|
|
43
|
+
export declare class SpendGraph {
|
|
44
|
+
private readonly opts;
|
|
45
|
+
private queue;
|
|
46
|
+
private timer;
|
|
47
|
+
private warned;
|
|
48
|
+
/** Events lost in the current run of failures, reset by a successful flush. */
|
|
49
|
+
private dropped;
|
|
50
|
+
/** Unpriced model ids already reported, so each is named at most once. */
|
|
51
|
+
private readonly unpricedSeen;
|
|
52
|
+
private readonly interval;
|
|
53
|
+
private readonly maxBatch;
|
|
54
|
+
/** When the pending flush was scheduled, for the suspend check in track(). */
|
|
55
|
+
private timerAt;
|
|
56
|
+
private suspendWarned;
|
|
57
|
+
private noKeyWarned;
|
|
58
|
+
private streamUsageWarned;
|
|
59
|
+
/** The flush currently on the wire, so a later flush() can wait for it. */
|
|
60
|
+
private inFlight;
|
|
61
|
+
constructor(opts: SpendGraphOptions);
|
|
62
|
+
/**
|
|
63
|
+
* The package's client, given no retries of its own.
|
|
64
|
+
*
|
|
65
|
+
* The policy in `send` stays this class's: a flush is awaited inside
|
|
66
|
+
* serverless handlers, where blocking out a minute-long rate-limit window is
|
|
67
|
+
* worse than losing the batch, and the client's backoff would do exactly that.
|
|
68
|
+
*/
|
|
69
|
+
private clientFor;
|
|
70
|
+
/** Record one LLM call. Synchronous, returns void, never throws. */
|
|
71
|
+
track(event: TrackEvent): void;
|
|
72
|
+
/**
|
|
73
|
+
* Says once that tracking is off.
|
|
74
|
+
*
|
|
75
|
+
* Running without a key stays a no-op on purpose — it keeps spendgraph out of
|
|
76
|
+
* tests and local runs without branching at every call site. But an unset
|
|
77
|
+
* SPENDGRAPH_API_KEY is indistinguishable from that choice, and it is the
|
|
78
|
+
* likeliest reason a fresh integration records nothing at all: the code is
|
|
79
|
+
* wired up correctly, the dashboard is empty, and nothing anywhere says why.
|
|
80
|
+
* Every other failure in this class warns once; this was the one that stayed
|
|
81
|
+
* quiet, which made it the hardest to find.
|
|
82
|
+
*/
|
|
83
|
+
private warnNoKey;
|
|
84
|
+
/**
|
|
85
|
+
* Notices that the runtime froze with events still buffered.
|
|
86
|
+
*
|
|
87
|
+
* Serverless platforms suspend a function once it returns rather than
|
|
88
|
+
* exiting it, so neither the flush timer nor `beforeExit` ever runs and the
|
|
89
|
+
* queue is lost without a sound — the failure Langfuse documents for Lambda
|
|
90
|
+
* and Vercel. There is no reliable flag for "am I serverless", but there is
|
|
91
|
+
* direct evidence: a pending timer whose deadline passed long ago did not
|
|
92
|
+
* fire, which only happens if the runtime stopped executing between calls.
|
|
93
|
+
*
|
|
94
|
+
* Seeing that, send the stragglers now (they survive into this invocation)
|
|
95
|
+
* and say once what the fix is. Correct callers await flush(), which clears
|
|
96
|
+
* the timer, so this never fires for them.
|
|
97
|
+
*/
|
|
98
|
+
private detectSuspendedRuntime;
|
|
99
|
+
/**
|
|
100
|
+
* Send everything buffered now. Call at the end of serverless handlers.
|
|
101
|
+
*
|
|
102
|
+
* Serialised against a flush already running, because this method is not the
|
|
103
|
+
* only thing that starts one: track() flushes by itself the moment the batch
|
|
104
|
+
* is full, and so do the timer, the suspend check and the exit hook. Those
|
|
105
|
+
* empty the queue synchronously, so an awaited flush() used to find nothing
|
|
106
|
+
* left to send and hand back an already-resolved promise while the real
|
|
107
|
+
* batch was still on the wire — and the next thing a serverless handler does
|
|
108
|
+
* after awaiting is return, freezing the runtime and losing exactly the
|
|
109
|
+
* events the await was there to save.
|
|
110
|
+
*
|
|
111
|
+
* The drain still starts synchronously — `drain()` runs as far as its first
|
|
112
|
+
* await before returning a promise, so the queue is taken during this call
|
|
113
|
+
* and not a microtask later. That ordering is load-bearing: the suspend
|
|
114
|
+
* check and the exit hook both fire a flush and then expect the buffer to be
|
|
115
|
+
* gone, and deferring it broke them.
|
|
116
|
+
*/
|
|
117
|
+
flush(): Promise<void>;
|
|
118
|
+
/** One pass: stop the timer, take the queue, put it on the wire. */
|
|
119
|
+
private drain;
|
|
120
|
+
/**
|
|
121
|
+
* Wrap an Anthropic or OpenAI client. Use the wrapped client exactly as
|
|
122
|
+
* before — token usage is read off each response and tracked automatically.
|
|
123
|
+
*
|
|
124
|
+
* Streaming is covered too:
|
|
125
|
+
* - helper streams (`anthropic.messages.stream()`,
|
|
126
|
+
* `openai.beta.chat.completions.stream()`) are tracked via their
|
|
127
|
+
* final-message promise — the stream you get back is untouched;
|
|
128
|
+
* - raw streams (`create({ stream: true })`) are tee'd: you receive one
|
|
129
|
+
* branch, usage is accumulated off the other. For OpenAI raw streams,
|
|
130
|
+
* pass `stream_options: { include_usage: true }` or there is no usage
|
|
131
|
+
* to read and the call goes untracked.
|
|
132
|
+
*/
|
|
133
|
+
wrap<T extends object>(client: T): T;
|
|
134
|
+
/**
|
|
135
|
+
* The recursive wrapper behind wrap().
|
|
136
|
+
*
|
|
137
|
+
* Two things here are deliberate and were both wrong before.
|
|
138
|
+
*
|
|
139
|
+
* `Reflect.get` is called without a receiver, so a getter runs with the real
|
|
140
|
+
* client as `this`. Passing the proxy — the obvious reading of the Proxy
|
|
141
|
+
* docs — makes any getter that touches a `#private` field throw
|
|
142
|
+
* "Cannot read private member", and the provider SDKs this is built to wrap
|
|
143
|
+
* use private fields. That turned a tracking wrapper into a crash on a
|
|
144
|
+
* client that works perfectly well unwrapped, in an SDK whose whole contract
|
|
145
|
+
* is to fail open.
|
|
146
|
+
*
|
|
147
|
+
* And each wrapper is kept, so `client.messages` and `client.messages.create`
|
|
148
|
+
* are the same object and the same function every time they are read.
|
|
149
|
+
* Rebuilding them per access allocated a proxy and a closure on every call
|
|
150
|
+
* and quietly broke any caller that holds onto a method or compares
|
|
151
|
+
* identity. The cache is per proxy and keyed by property, so a method always
|
|
152
|
+
* applies to the object it was read from, and it re-wraps if the underlying
|
|
153
|
+
* value is ever replaced.
|
|
154
|
+
*/
|
|
155
|
+
private proxy;
|
|
156
|
+
private observeResult;
|
|
157
|
+
private interceptStream;
|
|
158
|
+
/** Accumulate usage off a tee'd SSE branch (Anthropic events / OpenAI chunks). */
|
|
159
|
+
private consumeStream;
|
|
160
|
+
/**
|
|
161
|
+
* Says once that a streamed call went untracked.
|
|
162
|
+
*
|
|
163
|
+
* A raw OpenAI stream carries no usage unless the caller asks for it, so the
|
|
164
|
+
* accumulator above finishes with nothing to report and simply returns —
|
|
165
|
+
* correct, and completely silent. That is the same hole warnNoKey was added
|
|
166
|
+
* to close: the integration looks right, the dashboard stays empty for those
|
|
167
|
+
* calls, and nothing anywhere connects the two. Anthropic streams always
|
|
168
|
+
* carry usage, so in practice this names the one option that fixes it.
|
|
169
|
+
*
|
|
170
|
+
* Guarded on having seen at least one chunk, so an empty or aborted stream —
|
|
171
|
+
* where there was never anything to read — stays quiet.
|
|
172
|
+
*/
|
|
173
|
+
private warnStreamNoUsage;
|
|
174
|
+
private trackFromResponse;
|
|
175
|
+
private send;
|
|
176
|
+
/**
|
|
177
|
+
* The drop policy, which is this class's rather than the client's.
|
|
178
|
+
*
|
|
179
|
+
* 429 is the one failure the server tells you how to fix: it sends
|
|
180
|
+
* retry-after, and the window is at most a minute wide, so a batch that
|
|
181
|
+
* arrived at the end of one is deliverable a second later. Dropping it
|
|
182
|
+
* immediately threw that away and reported the least useful thing it could —
|
|
183
|
+
* "ingest returned 429" — for the one status with a documented remedy. The
|
|
184
|
+
* wait is capped because flush() is awaited inside serverless handlers, where
|
|
185
|
+
* blocking for a minute is worse than losing the batch.
|
|
186
|
+
*/
|
|
187
|
+
private onSendFailed;
|
|
188
|
+
/**
|
|
189
|
+
* Surfaces model ids the server could not price, once each.
|
|
190
|
+
*
|
|
191
|
+
* These are accepted and stored, so nothing here is an error — but they cost
|
|
192
|
+
* $0, and a dashboard reading $0 is indistinguishable from one reading
|
|
193
|
+
* "nothing happened". Naming the id in the integrator's own console is the
|
|
194
|
+
* cheapest possible moment to catch a typo or an unmapped model, and the
|
|
195
|
+
* per-id guard keeps a steady stream of the same unknown model from becoming
|
|
196
|
+
* log noise.
|
|
197
|
+
*/
|
|
198
|
+
private reportUnpriced;
|
|
199
|
+
/**
|
|
200
|
+
* Warns once per outage, with a running count of what was lost.
|
|
201
|
+
*
|
|
202
|
+
* Warning on every flush would spam a hot loop, but warning exactly once per
|
|
203
|
+
* process — the previous behaviour — hid a server-side bug that failed every
|
|
204
|
+
* full batch: one line early in a long-lived process, then silence, while the
|
|
205
|
+
* dashboard quietly undercounted. The count is what makes the silence legible
|
|
206
|
+
* when someone does go looking.
|
|
207
|
+
*/
|
|
208
|
+
private reportDropped;
|
|
209
|
+
}
|