@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 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
@@ -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",
@@ -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;
@@ -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)
@@ -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.2.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.2.0"
50
+ "@spendgraph/sdk": "^0.3.0"
51
51
  }
52
52
  }