@spendgraph/llms 0.2.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.
Files changed (52) hide show
  1. package/README.md +354 -0
  2. package/dist/adapters/anthropic.d.ts +2 -0
  3. package/dist/adapters/anthropic.js +101 -0
  4. package/dist/adapters/bedrock.d.ts +2 -0
  5. package/dist/adapters/bedrock.js +88 -0
  6. package/dist/adapters/gemini.d.ts +2 -0
  7. package/dist/adapters/gemini.js +87 -0
  8. package/dist/adapters/index.d.ts +5 -0
  9. package/dist/adapters/index.js +36 -0
  10. package/dist/adapters/openai.d.ts +2 -0
  11. package/dist/adapters/openai.js +71 -0
  12. package/dist/adapters/perplexity.d.ts +2 -0
  13. package/dist/adapters/perplexity.js +92 -0
  14. package/dist/adapters/responses.d.ts +2 -0
  15. package/dist/adapters/responses.js +100 -0
  16. package/dist/affordable.d.ts +44 -0
  17. package/dist/affordable.js +87 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.js +7 -0
  20. package/dist/llm/drivers.d.ts +36 -0
  21. package/dist/llm/drivers.js +121 -0
  22. package/dist/llm/index.d.ts +2 -0
  23. package/dist/llm/index.js +1 -0
  24. package/dist/llm/invoke.d.ts +3 -0
  25. package/dist/llm/invoke.js +24 -0
  26. package/dist/llm/llm.d.ts +50 -0
  27. package/dist/llm/llm.js +238 -0
  28. package/dist/llm/schema/index.d.ts +1 -0
  29. package/dist/llm/schema/index.js +1 -0
  30. package/dist/llm/schema/schema.d.ts +45 -0
  31. package/dist/llm/schema/schema.js +80 -0
  32. package/dist/llm/types.d.ts +62 -0
  33. package/dist/llm/types.js +1 -0
  34. package/dist/model.d.ts +7 -0
  35. package/dist/model.js +22 -0
  36. package/dist/read.d.ts +10 -0
  37. package/dist/read.js +18 -0
  38. package/dist/report/index.d.ts +2 -0
  39. package/dist/report/index.js +1 -0
  40. package/dist/report/ingest.d.ts +15 -0
  41. package/dist/report/ingest.js +29 -0
  42. package/dist/report/types.d.ts +30 -0
  43. package/dist/report/types.js +1 -0
  44. package/dist/request.d.ts +26 -0
  45. package/dist/request.js +32 -0
  46. package/dist/stream.d.ts +26 -0
  47. package/dist/stream.js +71 -0
  48. package/dist/types.d.ts +82 -0
  49. package/dist/types.js +1 -0
  50. package/dist/usage.d.ts +22 -0
  51. package/dist/usage.js +76 -0
  52. package/package.json +52 -0
package/README.md ADDED
@@ -0,0 +1,354 @@
1
+ # @spendgraph/llms
2
+
3
+ Call any LLM provider, get one shape back, and record what it consumed. Takes
4
+ plain objects and calls your client by duck typing, so it has zero dependencies
5
+ and never imports a provider SDK.
6
+
7
+ There are no prompts here. Prompt storage, rendering and rollouts live in
8
+ `@spendgraph/prompt`; this package is the model call and its token usage.
9
+
10
+ ```sh
11
+ npm install @spendgraph/llms
12
+ ```
13
+
14
+ ## Llm
15
+
16
+ ```ts
17
+ import { Llm } from "@spendgraph/llms";
18
+
19
+ const llm = new Llm({
20
+ client: new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }),
21
+ model: "claude-opus-5",
22
+ spendgraph: {
23
+ apiKey: process.env.SPENDGRAPH_API_KEY,
24
+ baseUrl: "https://your-spendgraph.example.com",
25
+ },
26
+ });
27
+
28
+ const reply = await llm.call([{ role: "user", content: "Summarise this." }]);
29
+
30
+ reply.output;
31
+ reply.inputTokens;
32
+ reply.outputTokens;
33
+ reply.eventId;
34
+ ```
35
+
36
+ `call` sends the messages, normalises the reply, and — when `spendgraph` is set
37
+ and `trace` is not `false` — posts the token counts to `POST /api/v1/ingest`.
38
+ Nothing about the conversation is sent, only what it consumed. Pricing stays on
39
+ the server, where the catalogue lives.
40
+
41
+ Leave `spendgraph` off and nothing is recorded; the reply is still normalised.
42
+
43
+ A recorded call that carries no tokens prices as nothing, so the first one warns
44
+ once. The usual cause is a stream whose usage was never requested.
45
+
46
+ ### Streaming
47
+
48
+ ```ts
49
+ const reply = await llm.stream(messages, { onText: (delta) => res.write(delta) });
50
+ ```
51
+
52
+ Usage arrives in the last events of a stream, so the recording happens once it
53
+ drains. On OpenAI the driver sets `stream_options: { include_usage: true }` for
54
+ you — without it the final chunk carries no usage at all.
55
+
56
+ `stream` does not drive tool loops. Passing tools throws rather than silently
57
+ streaming without them; use `call`, or `{ tools: undefined }` for this one.
58
+
59
+ ### Tools
60
+
61
+ Pass a bus and `call` becomes a loop: it sends the declarations, runs each tool
62
+ call, feeds the results back in the provider's own envelope, and sums the
63
+ tokens across every call in the loop.
64
+
65
+ ```ts
66
+ const reply = await llm.call(messages, { tools: bus });
67
+ ```
68
+
69
+ A bus is anything with `anthropic()`, `openai()` and `invoke()` — which is what
70
+ harness's `toolbus()` and its per-turn `trace` handle both return.
71
+
72
+ ### Defaults, and overriding them
73
+
74
+ Everything except `client`, `provider` and `spendgraph` is a default you set
75
+ once and override per call.
76
+
77
+ ```ts
78
+ const llm = new Llm({ client, model: "claude-opus-5", tools: bus, maxTokens: 8_000 });
79
+
80
+ await llm.call(messages);
81
+ await llm.call(messages, { model: "claude-haiku-4-5" });
82
+ await llm.call(messages, { tools: undefined });
83
+ await llm.call(messages, { trace: false });
84
+ ```
85
+
86
+ `with()` derives a second client, merging `params` and `metadata` rather than
87
+ replacing them.
88
+
89
+ | Option | Where | |
90
+ | --- | --- | --- |
91
+ | `client` | client only | your provider SDK instance |
92
+ | `provider` | client only | pricing slug, for OpenAI-compatible gateways |
93
+ | `spendgraph` | client only | `{ apiKey, baseUrl }`, or `{ via }` to send usage yourself |
94
+ | `model` | both | the id to send |
95
+ | `trace` | both | `false` calls the model and records nothing |
96
+ | `tools` | both | a bus; its presence turns `call` into a loop |
97
+ | `maxTokens` | both | defaults to 16000 on Anthropic, unset elsewhere |
98
+ | `maxSteps` | both | tool-loop ceiling, default 8 |
99
+ | `params` | both | merged into the request body, call over client |
100
+ | `metadata` | both | merged onto the usage event, call over client |
101
+ | `onText` | both | per-delta callback for `stream` |
102
+ | `eventId` | call only | supply your own so a retried job counts once |
103
+
104
+ ### A shape, not prose
105
+
106
+ `schema` makes the reply an object. The parsed value comes back on `data`.
107
+
108
+ ```ts
109
+ const reply = await llm.call(messages, {
110
+ schema: {
111
+ type: "object",
112
+ properties: { sentiment: { type: "string" }, score: { type: "number" } },
113
+ required: ["sentiment", "score"],
114
+ },
115
+ });
116
+
117
+ reply.data; // { sentiment: "warm", score: 4 }
118
+ ```
119
+
120
+ The three providers say this three different ways and it is the same request:
121
+ Anthropic has no JSON mode, so it gets a **forced tool call** — `tool_choice`
122
+ pins it, which is the difference between "you may" and "you will"; OpenAI gets a
123
+ `json_schema` with `strict: true`; Gemini gets a `responseSchema` and the mime
124
+ type, because the schema alone still returns prose.
125
+
126
+ A reply that is not the shape you asked for **throws**. Coming back with `data`
127
+ quietly missing is how an empty case gets written into your data.
128
+
129
+ Pass a bare JSON Schema, or `{ schema, name, description }` to name it. The
130
+ schema is the wire format on purpose — `@spendgraph/tools` turns a `FieldSpec[]`
131
+ into one, zod emits one, and hand-writing one is three lines. Accepting it is
132
+ what keeps this package dependency-free.
133
+
134
+ ### When a provider is having a bad day
135
+
136
+ ```ts
137
+ new Llm({
138
+ client,
139
+ model: "claude-opus-5",
140
+ fallbacks: ["claude-sonnet-5", "gpt-5"],
141
+ onFallback: ({ from, to, error }) => log.warn({ from, to }, String(error)),
142
+ });
143
+ ```
144
+
145
+ Only for failures about the provider — 429, 529, 5xx. A 400 is about your
146
+ request and is the same answer everywhere, so trying three models spends three
147
+ round trips to be told so three times. The usage event records the model that
148
+ actually answered, not the one you asked for.
149
+
150
+ ### With the harness
151
+
152
+ `LlmReply` is structurally the `TraceOutcome` that `prompt.trace()` accepts, so
153
+ the two compose in one line — harness records the rollout, so leave `trace`
154
+ off here or the same call is counted twice.
155
+
156
+ ```ts
157
+ const llm = new Llm({ client, model, trace: false });
158
+
159
+ await prompt.trace(values, ({ messages }) => llm.call(messages));
160
+ await prompt.trace(values, ({ messages, turn }) => llm.call(messages, { tools: turn }), {
161
+ tools: bus,
162
+ });
163
+ ```
164
+
165
+ ### Sending usage yourself
166
+
167
+ `via` replaces the built-in sender — buffer, batch, or push onto a queue. The
168
+ route takes 100 events per request and the built-in sender chunks to that.
169
+
170
+ ```ts
171
+ spendgraph: { via: { send: (events) => queue.push(events) } }
172
+ ```
173
+
174
+ ## Affording the ceiling
175
+
176
+ `max_tokens` is a request for headroom, not a bill — output is charged on what
177
+ is written. A provider that reserves the ceiling against a prepaid balance
178
+ refuses the whole call over headroom that was never going to be used:
179
+
180
+ ```
181
+ 402 You requested up to 4096 tokens, but can only afford 4000.
182
+ ```
183
+
184
+ `affordable` wraps any client and retries that once, at the number the provider
185
+ named:
186
+
187
+ ```ts
188
+ import { affordable, Llm } from "@spendgraph/llms";
189
+
190
+ const llm = new Llm({
191
+ client: affordable(anthropic, { floor: 1024 }),
192
+ model: "claude-sonnet-5",
193
+ maxTokens: 4096,
194
+ });
195
+ ```
196
+
197
+ It is a decorator rather than something inside `Llm` because how a client copes
198
+ with a provider's billing is the client's business. The wrapper mirrors whatever
199
+ shape it is given, so `driverFor` still recognises an Anthropic, OpenAI or
200
+ Gemini client through it, and it shrinks whichever field that provider carries
201
+ the ceiling in — `max_tokens`, `max_completion_tokens` or a nested
202
+ `generationConfig.maxOutputTokens`.
203
+
204
+ `floor` is worth setting where the reply is a forced tool call. A schema
205
+ truncated mid-arguments parses to nothing, which is a worse answer than the
206
+ refusal it replaced.
207
+
208
+ A `402` naming no number is passed through untouched: that one is an empty
209
+ account, and no ceiling will fix it.
210
+
211
+ ## Lower level
212
+
213
+ `read` and `stream` normalise a reply you fetched yourself — a client `Llm`
214
+ does not drive, a raw `fetch`, a framework callback.
215
+
216
+ ```ts
217
+ import { anthropicMessages, read, stream } from "@spendgraph/llms";
218
+
219
+ const reply = read(await client.messages.create({ ...anthropicMessages(messages), model }));
220
+
221
+ const reading = stream(source, { provider: "anthropic" });
222
+ for await (const delta of reading) res.write(delta);
223
+ const finished = await reading.reply();
224
+ ```
225
+
226
+ `reply()` is only correct once the stream drains, since usage arrives in its
227
+ last events; it drains for you if you never iterated. `collect(source, { onText })`
228
+ is the same thing without the loop.
229
+
230
+ ### Shapes
231
+
232
+ Six wire families cover the providers `model_pricing` knows:
233
+
234
+ | Shape | Speaks it |
235
+ | --- | --- |
236
+ | `anthropic` | Anthropic Messages, Bedrock Mantle, Vertex Anthropic |
237
+ | `openai` | OpenAI chat completions, and every gateway that copies it — OpenRouter, z.ai, Groq, Together, DeepSeek, Fireworks, Azure, Ollama, vLLM |
238
+ | `responses` | OpenAI Responses |
239
+ | `bedrock` | Bedrock Converse |
240
+ | `gemini` | Gemini `generateContent`, Vertex Gemini |
241
+ | `perplexity` | Perplexity Sonar |
242
+
243
+ `read` sniffs the shape, but cannot tell OpenAI-compatible gateways apart —
244
+ they are byte-identical — so pass `provider` for anything that is not OpenAI:
245
+
246
+ ```ts
247
+ read(reply, { provider: "zai" }); // zai/glm-5
248
+ read(reply, { provider: "openrouter" }); // openrouter/anthropic/claude-opus-4
249
+ ```
250
+
251
+ **Bedrock Converse never echoes the model id**, so pass `model` there or the
252
+ reply comes back with none and prices at zero:
253
+
254
+ ```ts
255
+ read(converseReply, { provider: "bedrock", model: "anthropic.claude-opus-5" });
256
+ ```
257
+
258
+ ### Perplexity Sonar
259
+
260
+ Sonar speaks OpenAI chat completions and adds `search_results`, which is enough
261
+ to tell the two apart, so a Sonar reply is sniffed without `provider`. Its
262
+ sources come back on `citations`, newest field first and the legacy `citations`
263
+ array as the fallback.
264
+
265
+ ```ts
266
+ const reply = read(sonarReply, { provider: "perplexity" });
267
+ reply.output;
268
+ reply.citations; // ["https://…", "https://…"]
269
+ ```
270
+
271
+ Point an OpenAI client at `https://api.perplexity.ai` and `Llm` drives it, as
272
+ long as you name the provider so the reply is priced as Sonar and not as GPT:
273
+
274
+ ```ts
275
+ new Llm({ client, model: "sonar-pro", provider: "perplexity" });
276
+ ```
277
+
278
+ Sonar bills citation tokens and reasoning tokens as their own line items, so
279
+ they are read and recorded separately rather than folded into output, which
280
+ would price them at the wrong rate. Deep Research is the model that charges
281
+ both.
282
+
283
+ ```ts
284
+ reply.citationTokens;
285
+ reply.reasoningTokens;
286
+ ```
287
+
288
+ They ride the usage event only when non-zero, and the server bills them at the
289
+ input and output rate — no pricing row publishes a rate of their own, so the
290
+ choice is a known rate or $0. The per-request search fee is priced from
291
+ `model_pricing.per_request_micros` and needs nothing on the event. Sonar does
292
+ not cache, so both cache counts are zero.
293
+
294
+ Read them only off the top level of `usage`. OpenAI reports its own reasoning
295
+ count under `completion_tokens_details`, where it is already inside
296
+ `completion_tokens` — recording that one would bill it twice.
297
+
298
+ ## Model ids
299
+
300
+ `model_pricing` follows litellm. `anthropic` and `openai` rows are keyed bare
301
+ (`claude-opus-5`, `gpt-5`), Bedrock keeps the lab-namespaced native id
302
+ (`anthropic.claude-3-5-haiku-20241022-v1:0`), and everything else carries its
303
+ slug (`zai/glm-5`). The rule is applied for you, and never prefixes twice.
304
+
305
+ An id matching no row is not an error — the event records and prices at zero,
306
+ and the dashboard calls it unpriced.
307
+
308
+ ## What it removes
309
+
310
+ Six ways to record a plausible-looking call that prices wrong, five of them
311
+ silent:
312
+
313
+ | Mistake | Was |
314
+ | --- | --- |
315
+ | `prompt_tokens` vs `input_tokens` | zero cost |
316
+ | model id missing its pricing slug | zero cost |
317
+ | not summing tokens across a tool loop | an agent priced as one call |
318
+ | dropping cache token counts | cache reads billed at full rate |
319
+ | `content[0].text` on a thinking model | a thinking block as the answer |
320
+ | a refusal read as an empty success | a completed record with no output |
321
+
322
+ `toolCalls` is normalised too — `{ id, name, args }`, with `args` already
323
+ parsed from whatever JSON string the provider sent. Malformed JSON reads as
324
+ `{}`, so tool validation reports the missing field rather than the turn
325
+ throwing.
326
+
327
+ ## Testing against a real provider
328
+
329
+ Every other test here uses a fake client, which proves the request body is the
330
+ one this code meant to build and nothing about whether the provider agrees. The
331
+ three structured-output shapes in particular were written from documentation.
332
+
333
+ `src/tests/live` calls the real APIs. It **skips** unless the key is set, so a
334
+ clone with no credentials still passes.
335
+
336
+ ```sh
337
+ ANTHROPIC_API_KEY=… OPENAI_API_KEY=… GEMINI_API_KEY=… npm run test:live
338
+ ```
339
+
340
+ The clients there are built on `fetch` rather than the provider SDKs, on
341
+ purpose: a smoke test exists to find out whether the body this package builds is
342
+ one the real API accepts, and an SDK in between would reshape it before it left.
343
+
344
+ It costs real money — cheap models, one-word prompts, a 64-token ceiling — and
345
+ what it checks is the part fakes cannot:
346
+
347
+ | | |
348
+ | --- | --- |
349
+ | tokens | a real reply reports non-zero input and output |
350
+ | schema | Anthropic's forced tool call, OpenAI's strict `json_schema`, Gemini's `responseSchema` |
351
+ | tools | the loop calls, feeds the result back, and answers |
352
+ | streaming | deltas arrive and usage still lands |
353
+ | gemini `maxTokens` | lands as `generationConfig.maxOutputTokens`, where it is actually read |
354
+ | history | an assistant turn survives as `model` on Gemini, rather than being replayed as the caller |
@@ -0,0 +1,2 @@
1
+ import type { Adapter } from "../types.js";
2
+ export declare const anthropic: Adapter;
@@ -0,0 +1,101 @@
1
+ import { pricingId } from "../model.js";
2
+ import { count, parseArgs, reply, setUsage } from "../usage.js";
3
+ function usageOf(raw) {
4
+ return {
5
+ inputTokens: count(raw?.input_tokens),
6
+ outputTokens: count(raw?.output_tokens),
7
+ cacheReadTokens: count(raw?.cache_read_input_tokens),
8
+ cacheWriteTokens: count(raw?.cache_creation_input_tokens),
9
+ citationTokens: 0,
10
+ reasoningTokens: 0,
11
+ };
12
+ }
13
+ function textOf(blocks) {
14
+ let text = "";
15
+ for (const block of blocks) {
16
+ if (block.type === "text" && typeof block.text === "string")
17
+ text += block.text;
18
+ }
19
+ return text;
20
+ }
21
+ function toolCallsOf(blocks) {
22
+ const calls = [];
23
+ for (const block of blocks) {
24
+ if (block.type !== "tool_use")
25
+ continue;
26
+ calls.push({
27
+ id: block.id ?? "",
28
+ name: block.name ?? "",
29
+ args: parseArgs(block.input),
30
+ });
31
+ }
32
+ return calls;
33
+ }
34
+ function refusalOf(raw) {
35
+ if (raw.stop_reason !== "refusal")
36
+ return undefined;
37
+ const { category, explanation } = raw.stop_details ?? {};
38
+ return explanation ?? `declined: ${category ?? "unspecified"}`;
39
+ }
40
+ export const anthropic = {
41
+ provider: "anthropic",
42
+ detect(value) {
43
+ const raw = value;
44
+ return Boolean(raw && Array.isArray(raw.content) && "stop_reason" in raw);
45
+ },
46
+ read(value, opts = {}) {
47
+ const raw = value;
48
+ const blocks = raw.content ?? [];
49
+ const error = refusalOf(raw);
50
+ return reply({
51
+ output: textOf(blocks),
52
+ model: opts.model ?? pricingId(opts.provider ?? "anthropic", raw.model ?? ""),
53
+ usage: usageOf(raw.usage),
54
+ toolCalls: toolCallsOf(blocks),
55
+ stopReason: raw.stop_reason,
56
+ status: error ? "failed" : "completed",
57
+ error,
58
+ });
59
+ },
60
+ chunk(event, state) {
61
+ const e = event;
62
+ switch (e.type) {
63
+ case "message_start": {
64
+ state.model = e.message?.model ?? state.model;
65
+ setUsage(state.usage, usageOf(e.message?.usage));
66
+ return "";
67
+ }
68
+ case "content_block_start": {
69
+ const block = e.content_block;
70
+ if (block?.type === "tool_use") {
71
+ state.tools.set(String(e.index), {
72
+ id: block.id ?? "",
73
+ name: block.name ?? "",
74
+ json: "",
75
+ });
76
+ }
77
+ return "";
78
+ }
79
+ case "content_block_delta": {
80
+ const delta = e.delta ?? {};
81
+ if (delta.type === "text_delta" && typeof delta.text === "string") {
82
+ state.text += delta.text;
83
+ return delta.text;
84
+ }
85
+ if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
86
+ const pending = state.tools.get(String(e.index));
87
+ if (pending)
88
+ pending.json += delta.partial_json;
89
+ }
90
+ return "";
91
+ }
92
+ case "message_delta": {
93
+ state.stopReason = e.delta?.stop_reason ?? state.stopReason;
94
+ setUsage(state.usage, usageOf(e.usage));
95
+ return "";
96
+ }
97
+ default:
98
+ return "";
99
+ }
100
+ },
101
+ };
@@ -0,0 +1,2 @@
1
+ import type { Adapter } from "../types.js";
2
+ export declare const bedrock: Adapter;
@@ -0,0 +1,88 @@
1
+ import { count, parseArgs, reply, setUsage } from "../usage.js";
2
+ function usageOf(raw) {
3
+ return {
4
+ inputTokens: count(raw?.inputTokens),
5
+ outputTokens: count(raw?.outputTokens),
6
+ cacheReadTokens: count(raw?.cacheReadInputTokens),
7
+ cacheWriteTokens: count(raw?.cacheWriteInputTokens),
8
+ citationTokens: 0,
9
+ reasoningTokens: 0,
10
+ };
11
+ }
12
+ function blocksOf(raw) {
13
+ return raw.output?.message?.content ?? [];
14
+ }
15
+ function textOf(blocks) {
16
+ let text = "";
17
+ for (const block of blocks) {
18
+ if (typeof block.text === "string")
19
+ text += block.text;
20
+ }
21
+ return text;
22
+ }
23
+ function toolCallsOf(blocks) {
24
+ const calls = [];
25
+ for (const { toolUse } of blocks) {
26
+ if (!toolUse)
27
+ continue;
28
+ calls.push({
29
+ id: toolUse.toolUseId ?? "",
30
+ name: toolUse.name ?? "",
31
+ args: parseArgs(toolUse.input),
32
+ });
33
+ }
34
+ return calls;
35
+ }
36
+ export const bedrock = {
37
+ provider: "bedrock",
38
+ detect(value) {
39
+ const raw = value;
40
+ return Boolean(raw?.output && typeof raw.output === "object" && "message" in raw.output);
41
+ },
42
+ read(value, opts = {}) {
43
+ const raw = value;
44
+ const blocks = blocksOf(raw);
45
+ const filtered = raw.stopReason === "content_filtered";
46
+ const modelId = opts.model ?? "";
47
+ return reply({
48
+ output: textOf(blocks),
49
+ model: modelId,
50
+ usage: usageOf(raw.usage),
51
+ toolCalls: toolCallsOf(blocks),
52
+ stopReason: raw.stopReason,
53
+ status: filtered ? "failed" : "completed",
54
+ error: filtered ? "declined by a content filter" : undefined,
55
+ });
56
+ },
57
+ chunk(event, state) {
58
+ const e = event;
59
+ if (e.contentBlockStart?.start?.toolUse) {
60
+ const { toolUseId, name } = e.contentBlockStart.start.toolUse;
61
+ state.tools.set(String(e.contentBlockStart.contentBlockIndex ?? state.tools.size), {
62
+ id: toolUseId ?? "",
63
+ name: name ?? "",
64
+ json: "",
65
+ });
66
+ return "";
67
+ }
68
+ if (e.contentBlockDelta?.delta) {
69
+ const { text, toolUse } = e.contentBlockDelta.delta;
70
+ if (typeof toolUse?.input === "string") {
71
+ const key = String(e.contentBlockDelta.contentBlockIndex ?? 0);
72
+ const pending = state.tools.get(key);
73
+ if (pending)
74
+ pending.json += toolUse.input;
75
+ }
76
+ if (typeof text === "string" && text) {
77
+ state.text += text;
78
+ return text;
79
+ }
80
+ return "";
81
+ }
82
+ if (e.messageStop?.stopReason)
83
+ state.stopReason = e.messageStop.stopReason;
84
+ if (e.metadata?.usage)
85
+ setUsage(state.usage, usageOf(e.metadata.usage));
86
+ return "";
87
+ },
88
+ };
@@ -0,0 +1,2 @@
1
+ import type { Adapter } from "../types.js";
2
+ export declare const gemini: Adapter;
@@ -0,0 +1,87 @@
1
+ import { pricingId } from "../model.js";
2
+ import { count, reply, setUsage } from "../usage.js";
3
+ function usageOf(raw) {
4
+ return {
5
+ inputTokens: count(raw?.promptTokenCount),
6
+ outputTokens: count(raw?.candidatesTokenCount) + count(raw?.thoughtsTokenCount),
7
+ cacheReadTokens: count(raw?.cachedContentTokenCount),
8
+ cacheWriteTokens: 0,
9
+ citationTokens: 0,
10
+ reasoningTokens: 0,
11
+ };
12
+ }
13
+ function partsOf(raw) {
14
+ return raw.candidates?.[0]?.content?.parts ?? [];
15
+ }
16
+ function textOf(parts) {
17
+ let text = "";
18
+ for (const part of parts) {
19
+ if (!part.thought && typeof part.text === "string")
20
+ text += part.text;
21
+ }
22
+ return text;
23
+ }
24
+ function toolCallsOf(parts) {
25
+ const calls = [];
26
+ for (const part of parts) {
27
+ const call = part.functionCall;
28
+ if (!call)
29
+ continue;
30
+ calls.push({
31
+ id: `${call.name ?? "call"}-${calls.length}`,
32
+ name: call.name ?? "",
33
+ args: call.args ?? {},
34
+ });
35
+ }
36
+ return calls;
37
+ }
38
+ export const gemini = {
39
+ provider: "gemini",
40
+ detect(value) {
41
+ const raw = value;
42
+ return Boolean(raw && (Array.isArray(raw.candidates) || raw.usageMetadata));
43
+ },
44
+ read(value, opts = {}) {
45
+ const raw = value;
46
+ const parts = partsOf(raw);
47
+ const blocked = raw.promptFeedback?.blockReason;
48
+ const finish = raw.candidates?.[0]?.finishReason;
49
+ const refused = Boolean(blocked) || finish === "SAFETY" || finish === "PROHIBITED_CONTENT";
50
+ return reply({
51
+ output: textOf(parts),
52
+ model: opts.model ?? pricingId(opts.provider ?? "gemini", raw.modelVersion ?? ""),
53
+ usage: usageOf(raw.usageMetadata),
54
+ toolCalls: toolCallsOf(parts),
55
+ stopReason: finish,
56
+ status: refused ? "failed" : "completed",
57
+ error: refused ? `blocked: ${blocked ?? finish}` : undefined,
58
+ });
59
+ },
60
+ chunk(event, state) {
61
+ const raw = event;
62
+ if (raw.modelVersion)
63
+ state.model = raw.modelVersion;
64
+ if (raw.usageMetadata)
65
+ setUsage(state.usage, usageOf(raw.usageMetadata));
66
+ const finish = raw.candidates?.[0]?.finishReason;
67
+ if (finish)
68
+ state.stopReason = finish;
69
+ const parts = partsOf(raw);
70
+ for (const part of parts) {
71
+ const call = part.functionCall;
72
+ if (!call)
73
+ continue;
74
+ const key = String(state.tools.size);
75
+ state.tools.set(key, {
76
+ id: `${call.name ?? "call"}-${key}`,
77
+ name: call.name ?? "",
78
+ json: JSON.stringify(call.args ?? {}),
79
+ });
80
+ }
81
+ const text = textOf(parts);
82
+ if (!text)
83
+ return "";
84
+ state.text += text;
85
+ return text;
86
+ },
87
+ };
@@ -0,0 +1,5 @@
1
+ import type { Adapter, Provider } from "../types.js";
2
+ export type Shape = "anthropic" | "openai" | "responses" | "bedrock" | "gemini" | "perplexity";
3
+ export declare function adapterFor(provider: Provider): Adapter;
4
+ export declare function adapterOfShape(shape: Shape): Adapter;
5
+ export declare function detect(value: unknown): Adapter | undefined;
@@ -0,0 +1,36 @@
1
+ import { anthropic } from "./anthropic.js";
2
+ import { bedrock } from "./bedrock.js";
3
+ import { gemini } from "./gemini.js";
4
+ import { openai } from "./openai.js";
5
+ import { perplexity } from "./perplexity.js";
6
+ import { responses } from "./responses.js";
7
+ const BY_SHAPE = {
8
+ anthropic,
9
+ openai,
10
+ responses,
11
+ bedrock,
12
+ gemini,
13
+ perplexity,
14
+ };
15
+ /** Sniffed in this order, with OpenAI last because its shape is the loosest. */
16
+ 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
+ const SHAPE_OF = {
22
+ anthropic: "anthropic",
23
+ bedrock: "bedrock",
24
+ gemini: "gemini",
25
+ vertex_ai: "gemini",
26
+ perplexity: "perplexity",
27
+ };
28
+ export function adapterFor(provider) {
29
+ return BY_SHAPE[SHAPE_OF[provider] ?? "openai"];
30
+ }
31
+ export function adapterOfShape(shape) {
32
+ return BY_SHAPE[shape];
33
+ }
34
+ export function detect(value) {
35
+ return ORDERED.find((adapter) => adapter.detect(value));
36
+ }
@@ -0,0 +1,2 @@
1
+ import type { Adapter } from "../types.js";
2
+ export declare const openai: Adapter;