@spendgraph/llms 0.2.0 → 0.3.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/README.md +4 -0
- package/dist/adapters/index.js +0 -5
- package/dist/affordable.js +0 -29
- package/dist/llm/invoke.js +0 -1
- package/dist/llm/llm.js +0 -33
- package/dist/llm/schema/schema.js +0 -17
- package/dist/model.js +0 -11
- package/dist/read.js +0 -5
- package/dist/report/ingest.js +0 -7
- package/dist/request.js +0 -2
- package/dist/stream.js +0 -3
- package/dist/usage.js +0 -6
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -352,3 +352,7 @@ what it checks is the part fakes cannot:
|
|
|
352
352
|
| streaming | deltas arrive and usage still lands |
|
|
353
353
|
| gemini `maxTokens` | lands as `generationConfig.maxOutputTokens`, where it is actually read |
|
|
354
354
|
| history | an assistant turn survives as `model` on Gemini, rather than being replayed as the caller |
|
|
355
|
+
|
|
356
|
+
## License
|
|
357
|
+
|
|
358
|
+
MIT
|
package/dist/adapters/index.js
CHANGED
|
@@ -12,12 +12,7 @@ const BY_SHAPE = {
|
|
|
12
12
|
gemini,
|
|
13
13
|
perplexity,
|
|
14
14
|
};
|
|
15
|
-
/** Sniffed in this order, with OpenAI last because its shape is the loosest. */
|
|
16
15
|
const ORDERED = [anthropic, bedrock, responses, gemini, perplexity, openai];
|
|
17
|
-
/**
|
|
18
|
-
* Which wire shape a provider speaks. Everything absent is OpenAI-compatible,
|
|
19
|
-
* which is what most gateways and self-hosted servers expose.
|
|
20
|
-
*/
|
|
21
16
|
const SHAPE_OF = {
|
|
22
17
|
anthropic: "anthropic",
|
|
23
18
|
bedrock: "bedrock",
|
package/dist/affordable.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
/** Every field a provider carries the output ceiling in. */
|
|
2
1
|
const CEILINGS = ["max_tokens", "max_completion_tokens", "maxOutputTokens"];
|
|
3
2
|
const AFFORDS = /can only afford\s+(\d[\d,]*)/i;
|
|
4
|
-
/**
|
|
5
|
-
* What a provider says it can afford, read off a refusal.
|
|
6
|
-
*
|
|
7
|
-
* `402` alone is not enough to act on — it is also what an empty account
|
|
8
|
-
* returns, and shrinking a ceiling will not fix that. The number is the signal:
|
|
9
|
-
* a provider that names one is telling you the request would have been accepted
|
|
10
|
-
* smaller.
|
|
11
|
-
*/
|
|
12
3
|
export function affordableCeiling(err) {
|
|
13
4
|
const status = err?.status;
|
|
14
5
|
const message = err instanceof Error ? err.message : String(err ?? "");
|
|
@@ -33,26 +24,6 @@ function shrink(body, to) {
|
|
|
33
24
|
}
|
|
34
25
|
return undefined;
|
|
35
26
|
}
|
|
36
|
-
/**
|
|
37
|
-
* A provider client that lowers its ceiling rather than failing over it.
|
|
38
|
-
*
|
|
39
|
-
* `max_tokens` is a request for headroom, not a bill — output is charged on what
|
|
40
|
-
* is written. A provider that reserves the ceiling against a prepaid balance
|
|
41
|
-
* refuses the whole call over headroom that was never going to be used:
|
|
42
|
-
*
|
|
43
|
-
* ```
|
|
44
|
-
* 402 You requested up to 4096 tokens, but can only afford 4000.
|
|
45
|
-
* ```
|
|
46
|
-
*
|
|
47
|
-
* This retries that once at the number the provider named. It is a decorator
|
|
48
|
-
* rather than something inside `Llm` because how a client copes with a
|
|
49
|
-
* provider's billing is the client's business, and because it then works for
|
|
50
|
-
* every shape — the wrapper mirrors whatever it is given, so `driverFor` still
|
|
51
|
-
* recognises an Anthropic, OpenAI or Gemini client through it.
|
|
52
|
-
*
|
|
53
|
-
* A `402` that names no number is passed through untouched. That one is an
|
|
54
|
-
* empty account, and no ceiling will fix it.
|
|
55
|
-
*/
|
|
56
27
|
export function affordable(client, opts = {}) {
|
|
57
28
|
const floor = opts.floor ?? 0;
|
|
58
29
|
const retries = opts.retries ?? 1;
|
package/dist/llm/invoke.js
CHANGED
|
@@ -13,7 +13,6 @@ function walk(client, keys) {
|
|
|
13
13
|
export function has(client, path) {
|
|
14
14
|
return typeof walk(client, path)?.fn === "function";
|
|
15
15
|
}
|
|
16
|
-
/** Calls `client.a.b.c(body)` with `client.a.b` as the receiver. */
|
|
17
16
|
export async function invoke(client, path, body) {
|
|
18
17
|
const found = walk(client, path);
|
|
19
18
|
if (typeof found?.fn !== "function") {
|
package/dist/llm/llm.js
CHANGED
|
@@ -12,13 +12,6 @@ export class UnsupportedClientError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
const DEFAULT_MAX_STEPS = 8;
|
|
15
|
-
/**
|
|
16
|
-
* Worth trying another model for.
|
|
17
|
-
*
|
|
18
|
-
* Overload, rate limit and server fault are about the provider's day. A 400 or
|
|
19
|
-
* a 401 is about the request, and is the same answer everywhere — retrying it
|
|
20
|
-
* across three models spends three round trips to be told so three times.
|
|
21
|
-
*/
|
|
22
15
|
function worthAnotherModel(err) {
|
|
23
16
|
const status = err?.status;
|
|
24
17
|
if (typeof status !== "number")
|
|
@@ -35,16 +28,11 @@ function warnOnEmptyUsage(reply) {
|
|
|
35
28
|
console.warn("[llms] recorded a call with no tokens, which prices as nothing. The provider " +
|
|
36
29
|
"reported no usage — on a stream, ask for it (OpenAI needs stream_options.include_usage).");
|
|
37
30
|
}
|
|
38
|
-
/** A bare schema is shorthand for `{ schema }`. */
|
|
39
31
|
function asStructured(schema) {
|
|
40
32
|
if (!schema)
|
|
41
33
|
return undefined;
|
|
42
34
|
return "schema" in schema ? schema : { schema };
|
|
43
35
|
}
|
|
44
|
-
/**
|
|
45
|
-
* A provider client and its defaults. Calls the model, normalises the reply,
|
|
46
|
-
* and records what it consumed.
|
|
47
|
-
*/
|
|
48
36
|
export class Llm {
|
|
49
37
|
opts;
|
|
50
38
|
driver;
|
|
@@ -63,7 +51,6 @@ export class Llm {
|
|
|
63
51
|
get model() {
|
|
64
52
|
return this.opts.model;
|
|
65
53
|
}
|
|
66
|
-
/** The same client with different defaults — another model, tools, no tracing. */
|
|
67
54
|
with(overrides) {
|
|
68
55
|
return new Llm({
|
|
69
56
|
...this.opts,
|
|
@@ -72,19 +59,11 @@ export class Llm {
|
|
|
72
59
|
metadata: { ...this.opts.metadata, ...overrides.metadata },
|
|
73
60
|
});
|
|
74
61
|
}
|
|
75
|
-
/** Calls the model, looping over tools when a bus is attached. */
|
|
76
62
|
async call(messages, opts = {}) {
|
|
77
63
|
const settings = this.settings(opts);
|
|
78
64
|
const reply = await this.attempt(messages, settings);
|
|
79
65
|
return this.record(this.withData(reply, settings), settings, opts);
|
|
80
66
|
}
|
|
81
|
-
/**
|
|
82
|
-
* The call, then each fallback model in turn.
|
|
83
|
-
*
|
|
84
|
-
* The last error is thrown rather than the first: a caller reading the stack
|
|
85
|
-
* wants to know how it finished, and the first failure is the one that is
|
|
86
|
-
* already in the `onFallback` log.
|
|
87
|
-
*/
|
|
88
67
|
async attempt(messages, settings) {
|
|
89
68
|
const models = [settings.model, ...(this.opts.fallbacks ?? [])];
|
|
90
69
|
let last;
|
|
@@ -105,7 +84,6 @@ export class Llm {
|
|
|
105
84
|
}
|
|
106
85
|
throw last;
|
|
107
86
|
}
|
|
108
|
-
/** The same, streamed. `onText` receives each delta as it arrives. */
|
|
109
87
|
async stream(messages, opts = {}) {
|
|
110
88
|
const settings = this.settings(opts);
|
|
111
89
|
if (settings.tools) {
|
|
@@ -204,17 +182,6 @@ export class Llm {
|
|
|
204
182
|
params: settings.params ?? {},
|
|
205
183
|
};
|
|
206
184
|
}
|
|
207
|
-
/**
|
|
208
|
-
* Attaches the object a `schema` asked for.
|
|
209
|
-
*
|
|
210
|
-
* Anthropic answers a forced tool call, so the value is already parsed in the
|
|
211
|
-
* call's arguments; the JSON modes answer as text. A reply that satisfies
|
|
212
|
-
* neither throws rather than coming back with `data` quietly missing.
|
|
213
|
-
*
|
|
214
|
-
* A forced call leaves no text block, so `output` is filled from the object:
|
|
215
|
-
* it is what a recorded rollout stores as the reply, and blank there reads as
|
|
216
|
-
* a model that said nothing.
|
|
217
|
-
*/
|
|
218
185
|
withData(reply, settings) {
|
|
219
186
|
if (!settings.schema || reply.status === "failed")
|
|
220
187
|
return reply;
|
|
@@ -2,12 +2,6 @@ const DEFAULT_NAME = "reply";
|
|
|
2
2
|
function named(output) {
|
|
3
3
|
return output.name ?? DEFAULT_NAME;
|
|
4
4
|
}
|
|
5
|
-
/**
|
|
6
|
-
* Anthropic has no JSON mode, so a forced tool call is the schema.
|
|
7
|
-
*
|
|
8
|
-
* `tool_choice` pins it, which is the difference between "you may" and "you
|
|
9
|
-
* will" — asking politely in the prompt gets prose back often enough to matter.
|
|
10
|
-
*/
|
|
11
5
|
export function anthropicStructured(output) {
|
|
12
6
|
return {
|
|
13
7
|
tools: [
|
|
@@ -20,7 +14,6 @@ export function anthropicStructured(output) {
|
|
|
20
14
|
tool_choice: { type: "tool", name: named(output) },
|
|
21
15
|
};
|
|
22
16
|
}
|
|
23
|
-
/** OpenAI takes a schema directly, and `strict` is what makes it a guarantee. */
|
|
24
17
|
export function openaiStructured(output) {
|
|
25
18
|
return {
|
|
26
19
|
response_format: {
|
|
@@ -33,7 +26,6 @@ export function openaiStructured(output) {
|
|
|
33
26
|
},
|
|
34
27
|
};
|
|
35
28
|
}
|
|
36
|
-
/** Gemini needs the mime type as well; the schema alone still returns prose. */
|
|
37
29
|
export function geminiStructured(output) {
|
|
38
30
|
return {
|
|
39
31
|
responseMimeType: "application/json",
|
|
@@ -48,14 +40,6 @@ export class StructuredOutputError extends Error {
|
|
|
48
40
|
this.name = "StructuredOutputError";
|
|
49
41
|
}
|
|
50
42
|
}
|
|
51
|
-
/**
|
|
52
|
-
* The object the model was asked for.
|
|
53
|
-
*
|
|
54
|
-
* A tool-forced reply already arrives parsed; a JSON-mode reply arrives as text
|
|
55
|
-
* and is parsed here. A reply that is neither throws rather than returning
|
|
56
|
-
* undefined, because a caller that asked for a shape and silently got nothing
|
|
57
|
-
* writes the empty case into their data.
|
|
58
|
-
*/
|
|
59
43
|
export function readStructured(output, toolArgs) {
|
|
60
44
|
if (toolArgs && Object.keys(toolArgs).length > 0)
|
|
61
45
|
return toolArgs;
|
|
@@ -72,7 +56,6 @@ export function readStructured(output, toolArgs) {
|
|
|
72
56
|
return JSON.parse(fenced[1]);
|
|
73
57
|
}
|
|
74
58
|
catch {
|
|
75
|
-
// falls through to the throw below
|
|
76
59
|
}
|
|
77
60
|
}
|
|
78
61
|
throw new StructuredOutputError("The model did not return JSON.", output);
|
package/dist/model.js
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Providers whose `model_pricing` rows are keyed on the id the API reports.
|
|
3
|
-
* `anthropic` and `openai` because litellm treats them as the unprefixed
|
|
4
|
-
* defaults, `bedrock` because its ids are already namespaced by the lab that
|
|
5
|
-
* made the model.
|
|
6
|
-
*/
|
|
7
1
|
const KEYED_ON_REPORTED_ID = new Set(["anthropic", "openai", "bedrock"]);
|
|
8
|
-
/**
|
|
9
|
-
* The model id `model_pricing` is keyed on. An unmatched id records a call
|
|
10
|
-
* costing zero, so this is the difference between a priced call and an
|
|
11
|
-
* unpriced one.
|
|
12
|
-
*/
|
|
13
2
|
export function pricingId(provider, reported) {
|
|
14
3
|
const id = reported.trim();
|
|
15
4
|
if (!id)
|
package/dist/read.js
CHANGED
|
@@ -5,11 +5,6 @@ export class UnknownReplyError extends Error {
|
|
|
5
5
|
this.name = "UnknownReplyError";
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
|
-
/**
|
|
9
|
-
* Reads any supported provider reply into the shape `prompt.trace()` returns.
|
|
10
|
-
* Without `provider` the shape is sniffed, which cannot tell OpenAI-compatible
|
|
11
|
-
* gateways apart and so prices them as OpenAI.
|
|
12
|
-
*/
|
|
13
8
|
export function read(value, opts = {}) {
|
|
14
9
|
const adapter = opts.provider ? adapterFor(opts.provider) : detect(value);
|
|
15
10
|
if (!adapter)
|
package/dist/report/ingest.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { MAX_EVENTS, Spendgraph } from "@spendgraph/sdk";
|
|
2
|
-
/** The route takes up to this many events per request. */
|
|
3
2
|
export { MAX_EVENTS };
|
|
4
3
|
export function newEventId() {
|
|
5
4
|
return `ev_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
@@ -10,12 +9,6 @@ export class MissingCredentialsError extends Error {
|
|
|
10
9
|
this.name = "MissingCredentialsError";
|
|
11
10
|
}
|
|
12
11
|
}
|
|
13
|
-
/**
|
|
14
|
-
* Where usage goes.
|
|
15
|
-
*
|
|
16
|
-
* The HTTP call belongs to `@spendgraph/sdk`, which is the one package that
|
|
17
|
-
* talks to the app — this one never opens a socket.
|
|
18
|
-
*/
|
|
19
12
|
export function ingestFor(opts) {
|
|
20
13
|
if (opts.via)
|
|
21
14
|
return opts.via;
|
package/dist/request.js
CHANGED
|
@@ -9,7 +9,6 @@ function split(messages) {
|
|
|
9
9
|
}
|
|
10
10
|
return { system: preamble.length ? preamble.join("\n\n") : undefined, turns };
|
|
11
11
|
}
|
|
12
|
-
/** Anthropic and Bedrock Converse take the system prompt beside the messages. */
|
|
13
12
|
export function anthropicMessages(messages) {
|
|
14
13
|
const { system, turns } = split(messages);
|
|
15
14
|
const turned = turns.map((m) => ({
|
|
@@ -21,7 +20,6 @@ export function anthropicMessages(messages) {
|
|
|
21
20
|
export function openaiMessages(messages) {
|
|
22
21
|
return { messages: [...messages] };
|
|
23
22
|
}
|
|
24
|
-
/** Gemini calls the assistant "model", which is the one role name it renames. */
|
|
25
23
|
export function geminiMessages(messages) {
|
|
26
24
|
const { system, turns } = split(messages);
|
|
27
25
|
const contents = turns.map((m) => ({
|
package/dist/stream.js
CHANGED
|
@@ -52,7 +52,6 @@ class LlmStream {
|
|
|
52
52
|
}
|
|
53
53
|
this.done = true;
|
|
54
54
|
}
|
|
55
|
-
/** Drains any events not yet read, then assembles the reply. */
|
|
56
55
|
async reply() {
|
|
57
56
|
if (!this.done) {
|
|
58
57
|
for await (const _ of this)
|
|
@@ -61,11 +60,9 @@ class LlmStream {
|
|
|
61
60
|
return finish(this.state, this.opts.provider ?? this.adapter.provider, this.opts);
|
|
62
61
|
}
|
|
63
62
|
}
|
|
64
|
-
/** A stream to iterate for text deltas, then ask for the finished reply. */
|
|
65
63
|
export function stream(source, opts = {}) {
|
|
66
64
|
return new LlmStream(source, opts);
|
|
67
65
|
}
|
|
68
|
-
/** Consumes a stream whole, calling `onText` per delta. */
|
|
69
66
|
export function collect(source, opts = {}) {
|
|
70
67
|
return new LlmStream(source, opts).reply();
|
|
71
68
|
}
|
package/dist/usage.js
CHANGED
|
@@ -16,7 +16,6 @@ export function emptyUsage() {
|
|
|
16
16
|
reasoningTokens: 0,
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
-
/** A token count from any provider, coerced to a non-negative integer. */
|
|
20
19
|
export function count(value) {
|
|
21
20
|
const n = typeof value === "string" ? Number(value) : value;
|
|
22
21
|
return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
|
@@ -30,10 +29,6 @@ export function addUsage(into, more) {
|
|
|
30
29
|
into.reasoningTokens += more.reasoningTokens ?? 0;
|
|
31
30
|
return into;
|
|
32
31
|
}
|
|
33
|
-
/**
|
|
34
|
-
* Overwrites each field the provider reported, leaving the rest. Stream events
|
|
35
|
-
* restate running totals rather than sending increments.
|
|
36
|
-
*/
|
|
37
32
|
export function setUsage(into, more) {
|
|
38
33
|
for (const key of USAGE_KEYS) {
|
|
39
34
|
const value = more[key] ?? 0;
|
|
@@ -60,7 +55,6 @@ export function reply(parts) {
|
|
|
60
55
|
stopReason: parts.stopReason,
|
|
61
56
|
};
|
|
62
57
|
}
|
|
63
|
-
/** Tool arguments, which providers send as a JSON string. Malformed reads `{}`. */
|
|
64
58
|
export function parseArgs(raw) {
|
|
65
59
|
if (raw && typeof raw === "object")
|
|
66
60
|
return raw;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spendgraph/llms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Read any LLM provider reply into one shape. Records usage through @spendgraph/sdk.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"README.md"
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
|
-
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"build": "tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments",
|
|
37
37
|
"test": "npm run build && vitest run",
|
|
38
38
|
"test:live": "npm run build && vitest run src/tests/live"
|
|
39
39
|
},
|
|
@@ -47,6 +47,6 @@
|
|
|
47
47
|
"access": "public"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@spendgraph/sdk": "^0.
|
|
50
|
+
"@spendgraph/sdk": "^0.3.0"
|
|
51
51
|
}
|
|
52
52
|
}
|