@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.
- package/README.md +354 -0
- package/dist/adapters/anthropic.d.ts +2 -0
- package/dist/adapters/anthropic.js +101 -0
- package/dist/adapters/bedrock.d.ts +2 -0
- package/dist/adapters/bedrock.js +88 -0
- package/dist/adapters/gemini.d.ts +2 -0
- package/dist/adapters/gemini.js +87 -0
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/index.js +36 -0
- package/dist/adapters/openai.d.ts +2 -0
- package/dist/adapters/openai.js +71 -0
- package/dist/adapters/perplexity.d.ts +2 -0
- package/dist/adapters/perplexity.js +92 -0
- package/dist/adapters/responses.d.ts +2 -0
- package/dist/adapters/responses.js +100 -0
- package/dist/affordable.d.ts +44 -0
- package/dist/affordable.js +87 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +7 -0
- package/dist/llm/drivers.d.ts +36 -0
- package/dist/llm/drivers.js +121 -0
- package/dist/llm/index.d.ts +2 -0
- package/dist/llm/index.js +1 -0
- package/dist/llm/invoke.d.ts +3 -0
- package/dist/llm/invoke.js +24 -0
- package/dist/llm/llm.d.ts +50 -0
- package/dist/llm/llm.js +238 -0
- package/dist/llm/schema/index.d.ts +1 -0
- package/dist/llm/schema/index.js +1 -0
- package/dist/llm/schema/schema.d.ts +45 -0
- package/dist/llm/schema/schema.js +80 -0
- package/dist/llm/types.d.ts +62 -0
- package/dist/llm/types.js +1 -0
- package/dist/model.d.ts +7 -0
- package/dist/model.js +22 -0
- package/dist/read.d.ts +10 -0
- package/dist/read.js +18 -0
- package/dist/report/index.d.ts +2 -0
- package/dist/report/index.js +1 -0
- package/dist/report/ingest.d.ts +15 -0
- package/dist/report/ingest.js +29 -0
- package/dist/report/types.d.ts +30 -0
- package/dist/report/types.js +1 -0
- package/dist/request.d.ts +26 -0
- package/dist/request.js +32 -0
- package/dist/stream.d.ts +26 -0
- package/dist/stream.js +71 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.js +1 -0
- package/dist/usage.d.ts +22 -0
- package/dist/usage.js +76 -0
- package/package.json +52 -0
package/dist/llm/llm.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { adapterFor as adapterForProvider, adapterOfShape } from "../adapters/index.js";
|
|
2
|
+
import { pricingId } from "../model.js";
|
|
3
|
+
import { ingestFor, newEventId } from "../report/index.js";
|
|
4
|
+
import { collect } from "../stream.js";
|
|
5
|
+
import { addUsage, reply as build, emptyUsage } from "../usage.js";
|
|
6
|
+
import { driverFor } from "./drivers.js";
|
|
7
|
+
import { readStructured } from "./schema/index.js";
|
|
8
|
+
export class UnsupportedClientError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super("Could not tell how to call this client. Llm drives the Anthropic, OpenAI chat completions and Gemini SDKs; for anything else use read() or stream().");
|
|
11
|
+
this.name = "UnsupportedClientError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
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
|
+
function worthAnotherModel(err) {
|
|
23
|
+
const status = err?.status;
|
|
24
|
+
if (typeof status !== "number")
|
|
25
|
+
return true;
|
|
26
|
+
return status === 429 || status === 529 || status >= 500;
|
|
27
|
+
}
|
|
28
|
+
let warnedAboutTokens = false;
|
|
29
|
+
function warnOnEmptyUsage(reply) {
|
|
30
|
+
if (warnedAboutTokens || reply.status === "failed")
|
|
31
|
+
return;
|
|
32
|
+
if ((reply.inputTokens ?? 0) > 0 || (reply.outputTokens ?? 0) > 0)
|
|
33
|
+
return;
|
|
34
|
+
warnedAboutTokens = true;
|
|
35
|
+
console.warn("[llms] recorded a call with no tokens, which prices as nothing. The provider " +
|
|
36
|
+
"reported no usage — on a stream, ask for it (OpenAI needs stream_options.include_usage).");
|
|
37
|
+
}
|
|
38
|
+
/** A bare schema is shorthand for `{ schema }`. */
|
|
39
|
+
function asStructured(schema) {
|
|
40
|
+
if (!schema)
|
|
41
|
+
return undefined;
|
|
42
|
+
return "schema" in schema ? schema : { schema };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A provider client and its defaults. Calls the model, normalises the reply,
|
|
46
|
+
* and records what it consumed.
|
|
47
|
+
*/
|
|
48
|
+
export class Llm {
|
|
49
|
+
opts;
|
|
50
|
+
driver;
|
|
51
|
+
ingest;
|
|
52
|
+
constructor(opts) {
|
|
53
|
+
this.opts = opts;
|
|
54
|
+
const driver = driverFor(opts.client);
|
|
55
|
+
if (!driver)
|
|
56
|
+
throw new UnsupportedClientError();
|
|
57
|
+
this.driver = driver;
|
|
58
|
+
this.ingest = opts.spendgraph ? ingestFor(opts.spendgraph) : undefined;
|
|
59
|
+
}
|
|
60
|
+
get provider() {
|
|
61
|
+
return this.opts.provider ?? this.driver.shape;
|
|
62
|
+
}
|
|
63
|
+
get model() {
|
|
64
|
+
return this.opts.model;
|
|
65
|
+
}
|
|
66
|
+
/** The same client with different defaults — another model, tools, no tracing. */
|
|
67
|
+
with(overrides) {
|
|
68
|
+
return new Llm({
|
|
69
|
+
...this.opts,
|
|
70
|
+
...overrides,
|
|
71
|
+
params: { ...this.opts.params, ...overrides.params },
|
|
72
|
+
metadata: { ...this.opts.metadata, ...overrides.metadata },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Calls the model, looping over tools when a bus is attached. */
|
|
76
|
+
async call(messages, opts = {}) {
|
|
77
|
+
const settings = this.settings(opts);
|
|
78
|
+
const reply = await this.attempt(messages, settings);
|
|
79
|
+
return this.record(this.withData(reply, settings), settings, opts);
|
|
80
|
+
}
|
|
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
|
+
async attempt(messages, settings) {
|
|
89
|
+
const models = [settings.model, ...(this.opts.fallbacks ?? [])];
|
|
90
|
+
let last;
|
|
91
|
+
for (const [index, model] of models.entries()) {
|
|
92
|
+
const attempt = { ...settings, model };
|
|
93
|
+
try {
|
|
94
|
+
return attempt.tools
|
|
95
|
+
? await this.loop(messages, attempt.tools, attempt)
|
|
96
|
+
: await this.once(messages, attempt);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
last = err;
|
|
100
|
+
const next = models[index + 1];
|
|
101
|
+
if (!next || !worthAnotherModel(err))
|
|
102
|
+
throw err;
|
|
103
|
+
this.opts.onFallback?.({ from: model, to: next, error: err });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
throw last;
|
|
107
|
+
}
|
|
108
|
+
/** The same, streamed. `onText` receives each delta as it arrives. */
|
|
109
|
+
async stream(messages, opts = {}) {
|
|
110
|
+
const settings = this.settings(opts);
|
|
111
|
+
if (settings.tools) {
|
|
112
|
+
throw new Error("stream() does not drive tool loops. Use call(), or pass { tools: undefined } to stream without them.");
|
|
113
|
+
}
|
|
114
|
+
const convo = this.driver.start(messages);
|
|
115
|
+
const started = Date.now();
|
|
116
|
+
const source = await this.driver.stream(this.opts.client, convo, this.body(settings));
|
|
117
|
+
const reply = await collect(source, {
|
|
118
|
+
shape: this.driver.shape,
|
|
119
|
+
provider: this.provider,
|
|
120
|
+
onText: settings.onText,
|
|
121
|
+
});
|
|
122
|
+
return this.record({ ...reply, latencyMs: Date.now() - started }, settings, opts);
|
|
123
|
+
}
|
|
124
|
+
async once(messages, settings) {
|
|
125
|
+
const convo = this.driver.start(messages);
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
const raw = await this.driver.create(this.opts.client, convo, this.body(settings));
|
|
128
|
+
return { ...this.read(raw), latencyMs: Date.now() - started };
|
|
129
|
+
}
|
|
130
|
+
async loop(messages, tools, settings) {
|
|
131
|
+
if (!this.driver.supportsTools) {
|
|
132
|
+
throw new Error(`Llm does not drive tool loops for ${this.driver.shape} clients.`);
|
|
133
|
+
}
|
|
134
|
+
const convo = this.driver.start(messages);
|
|
135
|
+
const started = Date.now();
|
|
136
|
+
const spent = emptyUsage();
|
|
137
|
+
const body = {
|
|
138
|
+
...this.body(settings),
|
|
139
|
+
tools: this.driver.declarations(tools),
|
|
140
|
+
};
|
|
141
|
+
for (let step = 0; step < settings.maxSteps; step++) {
|
|
142
|
+
const raw = await this.driver.create(this.opts.client, convo, body);
|
|
143
|
+
const reply = this.read(raw);
|
|
144
|
+
addUsage(spent, reply.usage);
|
|
145
|
+
this.driver.pushReply(convo, raw);
|
|
146
|
+
if (reply.toolCalls.length === 0) {
|
|
147
|
+
return {
|
|
148
|
+
...build({ ...reply, usage: spent }),
|
|
149
|
+
latencyMs: Date.now() - started,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const done = [];
|
|
153
|
+
for (const call of reply.toolCalls) {
|
|
154
|
+
done.push({ call, result: await tools.invoke(call.name, call.args) });
|
|
155
|
+
}
|
|
156
|
+
this.driver.pushResults(convo, done);
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`Tool loop did not settle in ${settings.maxSteps} steps.`);
|
|
159
|
+
}
|
|
160
|
+
async record(reply, settings, opts) {
|
|
161
|
+
const target = this.opts.spendgraph;
|
|
162
|
+
if (!this.ingest || !target || settings.trace === false)
|
|
163
|
+
return reply;
|
|
164
|
+
const eventId = opts.eventId ?? newEventId();
|
|
165
|
+
warnOnEmptyUsage(reply);
|
|
166
|
+
try {
|
|
167
|
+
await this.ingest.send([
|
|
168
|
+
{
|
|
169
|
+
eventId,
|
|
170
|
+
model: reply.model,
|
|
171
|
+
inputTokens: reply.inputTokens ?? 0,
|
|
172
|
+
outputTokens: reply.outputTokens ?? 0,
|
|
173
|
+
cacheReadTokens: reply.cacheReadTokens,
|
|
174
|
+
cacheWriteTokens: reply.cacheWriteTokens,
|
|
175
|
+
...(reply.citationTokens ? { citationTokens: reply.citationTokens } : {}),
|
|
176
|
+
...(reply.reasoningTokens ? { reasoningTokens: reply.reasoningTokens } : {}),
|
|
177
|
+
metadata: { ...target.metadata, ...settings.metadata },
|
|
178
|
+
},
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
target.onError?.(err);
|
|
183
|
+
}
|
|
184
|
+
return { ...reply, eventId };
|
|
185
|
+
}
|
|
186
|
+
settings(opts) {
|
|
187
|
+
return {
|
|
188
|
+
model: opts.model ?? this.opts.model,
|
|
189
|
+
trace: opts.trace ?? this.opts.trace,
|
|
190
|
+
tools: "tools" in opts ? opts.tools : this.opts.tools,
|
|
191
|
+
maxTokens: opts.maxTokens ?? this.opts.maxTokens,
|
|
192
|
+
maxSteps: opts.maxSteps ?? this.opts.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
193
|
+
onText: opts.onText ?? this.opts.onText,
|
|
194
|
+
schema: opts.schema ?? this.opts.schema,
|
|
195
|
+
params: { ...this.opts.params, ...opts.params },
|
|
196
|
+
metadata: { ...this.opts.metadata, ...opts.metadata },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
body(settings) {
|
|
200
|
+
return {
|
|
201
|
+
model: settings.model,
|
|
202
|
+
maxTokens: settings.maxTokens,
|
|
203
|
+
structured: asStructured(settings.schema),
|
|
204
|
+
params: settings.params ?? {},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
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
|
+
withData(reply, settings) {
|
|
219
|
+
if (!settings.schema || reply.status === "failed")
|
|
220
|
+
return reply;
|
|
221
|
+
const forced = reply.toolCalls[0]?.args;
|
|
222
|
+
const data = readStructured(reply.output, forced);
|
|
223
|
+
const output = reply.output.trim() ? reply.output : JSON.stringify(data);
|
|
224
|
+
return { ...reply, data, output };
|
|
225
|
+
}
|
|
226
|
+
read(raw) {
|
|
227
|
+
const reply = this.adapterFor(raw).read(raw, {
|
|
228
|
+
provider: this.provider,
|
|
229
|
+
});
|
|
230
|
+
if (reply.model)
|
|
231
|
+
return reply;
|
|
232
|
+
return { ...reply, model: pricingId(this.provider, this.opts.model) };
|
|
233
|
+
}
|
|
234
|
+
adapterFor(raw) {
|
|
235
|
+
const byProvider = adapterForProvider(this.provider);
|
|
236
|
+
return byProvider.detect(raw) ? byProvider : adapterOfShape(this.driver.shape);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { anthropicStructured, geminiStructured, type JsonSchema, openaiStructured, readStructured, type StructuredOutput, StructuredOutputError, } from "./schema.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { anthropicStructured, geminiStructured, openaiStructured, readStructured, StructuredOutputError, } from "./schema.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON Schema object, as the providers take it.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately structural and not built here: `@spendgraph/tools` turns a
|
|
5
|
+
* `FieldSpec[]` into one, zod emits one, and hand-writing one is three lines.
|
|
6
|
+
* Accepting the wire format is what keeps this package dependency-free.
|
|
7
|
+
*/
|
|
8
|
+
export interface JsonSchema {
|
|
9
|
+
type?: string;
|
|
10
|
+
properties?: Record<string, unknown>;
|
|
11
|
+
required?: string[];
|
|
12
|
+
items?: unknown;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface StructuredOutput {
|
|
16
|
+
/** The shape the reply must take. */
|
|
17
|
+
schema: JsonSchema;
|
|
18
|
+
/** Names it for the provider. Defaults to "reply". */
|
|
19
|
+
name?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Anthropic has no JSON mode, so a forced tool call is the schema.
|
|
24
|
+
*
|
|
25
|
+
* `tool_choice` pins it, which is the difference between "you may" and "you
|
|
26
|
+
* will" — asking politely in the prompt gets prose back often enough to matter.
|
|
27
|
+
*/
|
|
28
|
+
export declare function anthropicStructured(output: StructuredOutput): Record<string, unknown>;
|
|
29
|
+
/** OpenAI takes a schema directly, and `strict` is what makes it a guarantee. */
|
|
30
|
+
export declare function openaiStructured(output: StructuredOutput): Record<string, unknown>;
|
|
31
|
+
/** Gemini needs the mime type as well; the schema alone still returns prose. */
|
|
32
|
+
export declare function geminiStructured(output: StructuredOutput): Record<string, unknown>;
|
|
33
|
+
export declare class StructuredOutputError extends Error {
|
|
34
|
+
readonly raw: string;
|
|
35
|
+
constructor(message: string, raw: string);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The object the model was asked for.
|
|
39
|
+
*
|
|
40
|
+
* A tool-forced reply already arrives parsed; a JSON-mode reply arrives as text
|
|
41
|
+
* and is parsed here. A reply that is neither throws rather than returning
|
|
42
|
+
* undefined, because a caller that asked for a shape and silently got nothing
|
|
43
|
+
* writes the empty case into their data.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readStructured(output: string, toolArgs?: Record<string, unknown>): unknown;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const DEFAULT_NAME = "reply";
|
|
2
|
+
function named(output) {
|
|
3
|
+
return output.name ?? DEFAULT_NAME;
|
|
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
|
+
export function anthropicStructured(output) {
|
|
12
|
+
return {
|
|
13
|
+
tools: [
|
|
14
|
+
{
|
|
15
|
+
name: named(output),
|
|
16
|
+
description: output.description ?? "Return the answer in this shape.",
|
|
17
|
+
input_schema: output.schema,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
tool_choice: { type: "tool", name: named(output) },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** OpenAI takes a schema directly, and `strict` is what makes it a guarantee. */
|
|
24
|
+
export function openaiStructured(output) {
|
|
25
|
+
return {
|
|
26
|
+
response_format: {
|
|
27
|
+
type: "json_schema",
|
|
28
|
+
json_schema: {
|
|
29
|
+
name: named(output),
|
|
30
|
+
strict: true,
|
|
31
|
+
schema: { additionalProperties: false, ...output.schema },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Gemini needs the mime type as well; the schema alone still returns prose. */
|
|
37
|
+
export function geminiStructured(output) {
|
|
38
|
+
return {
|
|
39
|
+
responseMimeType: "application/json",
|
|
40
|
+
responseSchema: output.schema,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export class StructuredOutputError extends Error {
|
|
44
|
+
raw;
|
|
45
|
+
constructor(message, raw) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.raw = raw;
|
|
48
|
+
this.name = "StructuredOutputError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
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
|
+
export function readStructured(output, toolArgs) {
|
|
60
|
+
if (toolArgs && Object.keys(toolArgs).length > 0)
|
|
61
|
+
return toolArgs;
|
|
62
|
+
const text = output.trim();
|
|
63
|
+
if (!text)
|
|
64
|
+
throw new StructuredOutputError("The model returned nothing to parse.", output);
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
const fenced = /```(?:json)?\s*([\s\S]+?)\s*```/.exec(text);
|
|
70
|
+
if (fenced) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(fenced[1]);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// falls through to the throw below
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new StructuredOutputError("The model did not return JSON.", output);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SpendgraphOptions, Tag } from "../report/index.js";
|
|
2
|
+
import type { Provider } from "../types.js";
|
|
3
|
+
import type { JsonSchema, StructuredOutput } from "./schema/index.js";
|
|
4
|
+
export interface ToolResult {
|
|
5
|
+
status: "completed" | "failed";
|
|
6
|
+
output: string;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
/** A tool bus — structurally what `@spendgraph/tools` `toolbus()` returns. */
|
|
10
|
+
export interface ToolBus {
|
|
11
|
+
anthropic(): unknown[];
|
|
12
|
+
openai(): unknown[];
|
|
13
|
+
invoke(name: string, args: Record<string, unknown>): Promise<ToolResult>;
|
|
14
|
+
}
|
|
15
|
+
/** Settable on the client and overridable on any single call. */
|
|
16
|
+
export interface CallDefaults {
|
|
17
|
+
model?: string;
|
|
18
|
+
/** false calls the model and records no usage. */
|
|
19
|
+
trace?: boolean;
|
|
20
|
+
tools?: ToolBus;
|
|
21
|
+
/**
|
|
22
|
+
* A shape the reply must take. The parsed object comes back on `data`.
|
|
23
|
+
*
|
|
24
|
+
* A bare JSON Schema is shorthand for `{ schema }`. Anthropic gets a forced
|
|
25
|
+
* tool call, OpenAI a strict `json_schema`, Gemini a `responseSchema` — the
|
|
26
|
+
* three ways of saying the same thing.
|
|
27
|
+
*/
|
|
28
|
+
schema?: JsonSchema | StructuredOutput;
|
|
29
|
+
maxTokens?: number;
|
|
30
|
+
maxSteps?: number;
|
|
31
|
+
/** Merged into the request body, call over client. */
|
|
32
|
+
params?: Record<string, unknown>;
|
|
33
|
+
/** Attached to the usage event, call over client. */
|
|
34
|
+
metadata?: Record<string, Tag>;
|
|
35
|
+
onText?: (delta: string) => void;
|
|
36
|
+
}
|
|
37
|
+
export interface LlmOptions extends CallDefaults {
|
|
38
|
+
/** A provider SDK client, called by duck typing and never imported. */
|
|
39
|
+
client: unknown;
|
|
40
|
+
model: string;
|
|
41
|
+
/** Pricing slug. Needed for OpenAI-compatible gateways, which share a shape. */
|
|
42
|
+
provider?: Provider;
|
|
43
|
+
/** Where usage goes. Omit and nothing is recorded. */
|
|
44
|
+
spendgraph?: SpendgraphOptions;
|
|
45
|
+
/**
|
|
46
|
+
* Models to try, in order, when the call throws.
|
|
47
|
+
*
|
|
48
|
+
* For an overloaded provider, not a bad request — a 400 fails the same way on
|
|
49
|
+
* every model and trying three of them spends three round trips saying so.
|
|
50
|
+
* `onFallback` is called before each attempt so the switch is not silent.
|
|
51
|
+
*/
|
|
52
|
+
fallbacks?: string[];
|
|
53
|
+
onFallback?: (info: {
|
|
54
|
+
from: string;
|
|
55
|
+
to: string;
|
|
56
|
+
error: unknown;
|
|
57
|
+
}) => void;
|
|
58
|
+
}
|
|
59
|
+
export interface CallOptions extends CallDefaults {
|
|
60
|
+
/** Supply your own so a retried job counts once. */
|
|
61
|
+
eventId?: string;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Provider } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The model id `model_pricing` is keyed on. An unmatched id records a call
|
|
4
|
+
* costing zero, so this is the difference between a priced call and an
|
|
5
|
+
* unpriced one.
|
|
6
|
+
*/
|
|
7
|
+
export declare function pricingId(provider: Provider, reported: string): string;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
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
|
+
export function pricingId(provider, reported) {
|
|
14
|
+
const id = reported.trim();
|
|
15
|
+
if (!id)
|
|
16
|
+
return "";
|
|
17
|
+
if (KEYED_ON_REPORTED_ID.has(provider))
|
|
18
|
+
return id;
|
|
19
|
+
if (id.startsWith(`${provider}/`))
|
|
20
|
+
return id;
|
|
21
|
+
return `${provider}/${id}`;
|
|
22
|
+
}
|
package/dist/read.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LlmReply, ReadOptions } from "./types.js";
|
|
2
|
+
export declare class UnknownReplyError extends Error {
|
|
3
|
+
constructor();
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Reads any supported provider reply into the shape `prompt.trace()` returns.
|
|
7
|
+
* Without `provider` the shape is sniffed, which cannot tell OpenAI-compatible
|
|
8
|
+
* gateways apart and so prices them as OpenAI.
|
|
9
|
+
*/
|
|
10
|
+
export declare function read(value: unknown, opts?: ReadOptions): LlmReply;
|
package/dist/read.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { adapterFor, detect } from "./adapters/index.js";
|
|
2
|
+
export class UnknownReplyError extends Error {
|
|
3
|
+
constructor() {
|
|
4
|
+
super("Could not tell which provider this reply came from. Pass { provider } to choose an adapter.");
|
|
5
|
+
this.name = "UnknownReplyError";
|
|
6
|
+
}
|
|
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
|
+
export function read(value, opts = {}) {
|
|
14
|
+
const adapter = opts.provider ? adapterFor(opts.provider) : detect(value);
|
|
15
|
+
if (!adapter)
|
|
16
|
+
throw new UnknownReplyError();
|
|
17
|
+
return adapter.read(value, opts);
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ingestFor, MAX_EVENTS, MissingCredentialsError, newEventId, } from "./ingest.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { MAX_EVENTS } from "@spendgraph/sdk";
|
|
2
|
+
import type { Ingest, SpendgraphOptions } from "./types.js";
|
|
3
|
+
/** The route takes up to this many events per request. */
|
|
4
|
+
export { MAX_EVENTS };
|
|
5
|
+
export declare function newEventId(): string;
|
|
6
|
+
export declare class MissingCredentialsError extends Error {
|
|
7
|
+
constructor();
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Where usage goes.
|
|
11
|
+
*
|
|
12
|
+
* The HTTP call belongs to `@spendgraph/sdk`, which is the one package that
|
|
13
|
+
* talks to the app — this one never opens a socket.
|
|
14
|
+
*/
|
|
15
|
+
export declare function ingestFor(opts: SpendgraphOptions): Ingest;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MAX_EVENTS, Spendgraph } from "@spendgraph/sdk";
|
|
2
|
+
/** The route takes up to this many events per request. */
|
|
3
|
+
export { MAX_EVENTS };
|
|
4
|
+
export function newEventId() {
|
|
5
|
+
return `ev_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
6
|
+
}
|
|
7
|
+
export class MissingCredentialsError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super("spendgraph needs { apiKey, baseUrl }, or { via } to send usage yourself.");
|
|
10
|
+
this.name = "MissingCredentialsError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
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
|
+
export function ingestFor(opts) {
|
|
20
|
+
if (opts.via)
|
|
21
|
+
return opts.via;
|
|
22
|
+
if (!opts.apiKey || !opts.baseUrl)
|
|
23
|
+
throw new MissingCredentialsError();
|
|
24
|
+
return new Spendgraph({
|
|
25
|
+
apiKey: opts.apiKey,
|
|
26
|
+
baseUrl: opts.baseUrl,
|
|
27
|
+
project: opts.project,
|
|
28
|
+
}).ingest;
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type Tag = string | number | boolean;
|
|
2
|
+
/** One call's consumption. The server prices it from the model id. */
|
|
3
|
+
export interface UsageEvent {
|
|
4
|
+
eventId?: string;
|
|
5
|
+
model: string;
|
|
6
|
+
inputTokens: number;
|
|
7
|
+
outputTokens: number;
|
|
8
|
+
cacheReadTokens?: number;
|
|
9
|
+
cacheWriteTokens?: number;
|
|
10
|
+
/** Billed on their own by search-grounded providers, not folded into output. */
|
|
11
|
+
citationTokens?: number;
|
|
12
|
+
reasoningTokens?: number;
|
|
13
|
+
timestamp?: string;
|
|
14
|
+
metadata?: Record<string, Tag>;
|
|
15
|
+
}
|
|
16
|
+
/** Anything that can deliver usage — the built-in sender, a queue, a buffer. */
|
|
17
|
+
export interface Ingest {
|
|
18
|
+
send(events: UsageEvent[]): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface SpendgraphOptions {
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
/** Used instead of `apiKey`/`baseUrl` — batch, queue, or send it yourself. */
|
|
24
|
+
via?: Ingest;
|
|
25
|
+
project?: string;
|
|
26
|
+
/** Attached to every event, merged with per-call metadata. */
|
|
27
|
+
metadata?: Record<string, Tag>;
|
|
28
|
+
/** Called when usage is dropped. Recording never throws. */
|
|
29
|
+
onError?: (err: unknown) => void;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PromptMessage } from "./types.js";
|
|
2
|
+
/** Anthropic and Bedrock Converse take the system prompt beside the messages. */
|
|
3
|
+
export declare function anthropicMessages(messages: PromptMessage[]): {
|
|
4
|
+
system?: string;
|
|
5
|
+
messages: {
|
|
6
|
+
role: "user" | "assistant";
|
|
7
|
+
content: string;
|
|
8
|
+
}[];
|
|
9
|
+
};
|
|
10
|
+
export declare function openaiMessages(messages: PromptMessage[]): {
|
|
11
|
+
messages: PromptMessage[];
|
|
12
|
+
};
|
|
13
|
+
/** Gemini calls the assistant "model", which is the one role name it renames. */
|
|
14
|
+
export declare function geminiMessages(messages: PromptMessage[]): {
|
|
15
|
+
systemInstruction?: {
|
|
16
|
+
parts: {
|
|
17
|
+
text: string;
|
|
18
|
+
}[];
|
|
19
|
+
};
|
|
20
|
+
contents: {
|
|
21
|
+
role: "user" | "model";
|
|
22
|
+
parts: {
|
|
23
|
+
text: string;
|
|
24
|
+
}[];
|
|
25
|
+
}[];
|
|
26
|
+
};
|
package/dist/request.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function split(messages) {
|
|
2
|
+
const preamble = [];
|
|
3
|
+
const turns = [];
|
|
4
|
+
for (const message of messages) {
|
|
5
|
+
if (message.role === "system")
|
|
6
|
+
preamble.push(message.content);
|
|
7
|
+
else
|
|
8
|
+
turns.push(message);
|
|
9
|
+
}
|
|
10
|
+
return { system: preamble.length ? preamble.join("\n\n") : undefined, turns };
|
|
11
|
+
}
|
|
12
|
+
/** Anthropic and Bedrock Converse take the system prompt beside the messages. */
|
|
13
|
+
export function anthropicMessages(messages) {
|
|
14
|
+
const { system, turns } = split(messages);
|
|
15
|
+
const turned = turns.map((m) => ({
|
|
16
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
17
|
+
content: m.content,
|
|
18
|
+
}));
|
|
19
|
+
return system ? { system, messages: turned } : { messages: turned };
|
|
20
|
+
}
|
|
21
|
+
export function openaiMessages(messages) {
|
|
22
|
+
return { messages: [...messages] };
|
|
23
|
+
}
|
|
24
|
+
/** Gemini calls the assistant "model", which is the one role name it renames. */
|
|
25
|
+
export function geminiMessages(messages) {
|
|
26
|
+
const { system, turns } = split(messages);
|
|
27
|
+
const contents = turns.map((m) => ({
|
|
28
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
29
|
+
parts: [{ text: m.content }],
|
|
30
|
+
}));
|
|
31
|
+
return system ? { systemInstruction: { parts: [{ text: system }] }, contents } : { contents };
|
|
32
|
+
}
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Shape } from "./adapters/index.js";
|
|
2
|
+
import type { LlmReply, ReadOptions } from "./types.js";
|
|
3
|
+
export interface StreamOptions extends ReadOptions {
|
|
4
|
+
shape?: Shape;
|
|
5
|
+
onText?: (delta: string) => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A provider stream as an async iterable of text deltas, with the finished
|
|
9
|
+
* reply available once it drains. Built by `stream()`.
|
|
10
|
+
*/
|
|
11
|
+
export type { LlmStream };
|
|
12
|
+
declare class LlmStream implements AsyncIterable<string> {
|
|
13
|
+
private readonly source;
|
|
14
|
+
private readonly opts;
|
|
15
|
+
private readonly state;
|
|
16
|
+
private readonly adapter;
|
|
17
|
+
private done;
|
|
18
|
+
constructor(source: AsyncIterable<unknown>, opts?: StreamOptions);
|
|
19
|
+
[Symbol.asyncIterator](): AsyncIterator<string>;
|
|
20
|
+
/** Drains any events not yet read, then assembles the reply. */
|
|
21
|
+
reply(): Promise<LlmReply>;
|
|
22
|
+
}
|
|
23
|
+
/** A stream to iterate for text deltas, then ask for the finished reply. */
|
|
24
|
+
export declare function stream(source: AsyncIterable<unknown>, opts?: StreamOptions): LlmStream;
|
|
25
|
+
/** Consumes a stream whole, calling `onText` per delta. */
|
|
26
|
+
export declare function collect(source: AsyncIterable<unknown>, opts?: StreamOptions): Promise<LlmReply>;
|