@ultimat3/ai 27.8.1 → 27.10.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/CLAUDE.md +137 -137
- package/README.md +95 -4
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +134 -0
- package/src/agent-errors.ts +69 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +63 -0
- package/src/agent.ts +190 -250
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +40 -12
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +14 -0
- package/src/errors.ts +26 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +62 -9
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +47 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +89 -66
- package/src/models.ts +45 -1
- package/src/openai-body.ts +56 -4
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +26 -5
- package/src/openai-wire.ts +184 -69
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +71 -45
- package/src/run-meter.ts +147 -0
- package/src/sampling.ts +86 -0
- package/src/tool-arguments.ts +88 -0
- package/src/tools.ts +51 -6
- package/src/wire.ts +15 -14
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Single responsibility: PROVIDER prompt caching — the neutral request shapes (a system prompt in
|
|
2
|
+
// a stable and a per-run part, the hint that asks for the prefix to be cached) and the table that
|
|
3
|
+
// says how each wire and host is told. Not the gateway's response cache, not `llm()`'s semantic one.
|
|
4
|
+
//
|
|
5
|
+
// THE RULE, and where it comes from (both pages read 2026-10-11):
|
|
6
|
+
//
|
|
7
|
+
// Anthropic — https://platform.claude.com/docs/en/docs/build-with-claude/prompt-caching
|
|
8
|
+
// "Place `cache_control` directly on individual content blocks"; the shape is
|
|
9
|
+
// `"cache_control": { "type": "ephemeral" }` (5-minute TTL, the default).
|
|
10
|
+
// "Prompt caching references the entire prompt: `tools`, `system`, and `messages` (in that
|
|
11
|
+
// order), up to and including the block designated with `cache_control`."
|
|
12
|
+
// "You can define up to 4 cache breakpoints" — a fifth is a 400.
|
|
13
|
+
// "The system checks at most 20 positions per breakpoint, counting the breakpoint itself."
|
|
14
|
+
// "Shorter prompts cannot be cached, even if marked with `cache_control`. Any requests to cache
|
|
15
|
+
// fewer than this number of tokens will be processed without caching, and no error is
|
|
16
|
+
// returned." Minimums are per model (512 to 4,096 tokens) — `ModelSpec.cacheMinimumTokens`.
|
|
17
|
+
// "5-minute cache write tokens are 1.25 times the base input tokens price … Cache read tokens
|
|
18
|
+
// are 0.1 times the base input tokens price" (0.05x and 0.025x on named models).
|
|
19
|
+
//
|
|
20
|
+
// OpenRouter — https://openrouter.ai/docs/features/prompt-caching
|
|
21
|
+
// Anthropic models: `cache_control` on a `text` part inside a message's `content` array;
|
|
22
|
+
// "There is a limit of four explicit breakpoints."
|
|
23
|
+
// Gemini: "Gemini 2.5 series models and newer support implicit caching" with no write cost; an
|
|
24
|
+
// explicit breakpoint is billed as a write (input price plus storage). So: never mark.
|
|
25
|
+
// OpenAI: automatic, no configuration. So: never mark.
|
|
26
|
+
// Stickiness: `session_id` in the body or the `x-session-id` header, at most 256 characters;
|
|
27
|
+
// "Sticky routing is skipped when you set `provider.order`" — this package sends no `provider`
|
|
28
|
+
// object at all, and must not start to.
|
|
29
|
+
// Usage: `prompt_tokens_details.cached_tokens` and `.cache_write_tokens`.
|
|
30
|
+
// https://openrouter.ai/docs/use-cases/usage-accounting: "The `usage: { include: true }` …
|
|
31
|
+
// parameters are deprecated and have no effect" — so it is not sent.
|
|
32
|
+
//
|
|
33
|
+
// OpenAI — automatic above 1,024 tokens (the OpenRouter page's summary of it); the body field
|
|
34
|
+
// `prompt_cache_key` groups requests that share a prefix. OpenRouter reads it as a session id
|
|
35
|
+
// when neither `session_id` nor the header is given.
|
|
36
|
+
//
|
|
37
|
+
// Checked against the live service, 2026-10-11, `anthropic/claude-sonnet-5.5` on OpenRouter, a
|
|
38
|
+
// three-turn `agent()` run: a mark on a `role: 'tool'` message's text part IS honoured (turn 2
|
|
39
|
+
// read turn 1's 19,914-token prefix and wrote 72), and `prompt_tokens` INCLUDES both the read and
|
|
40
|
+
// the written tokens — which is why ./openai-wire subtracts both.
|
|
41
|
+
//
|
|
42
|
+
// Two limits of the rows below, stated rather than hidden:
|
|
43
|
+
// - the `anthropic` row matches ANY host an `anthropicProvider({ baseUrl })` is pointed at — a
|
|
44
|
+
// proxy speaking that wire is assumed to speak its `cache_control` too;
|
|
45
|
+
// - the `parts` row marks the tail only, so the next turn finds this turn's entry through the
|
|
46
|
+
// 20-position lookback alone. A turn that appends more blocks than that — more than about 9
|
|
47
|
+
// tool calls in ONE turn, each a `tool_use` and a `tool_result` — misses it and is billed as a
|
|
48
|
+
// fresh write of the whole transcript. The stable system block still reads.
|
|
49
|
+
//
|
|
50
|
+
// The table is DATA: a new host or model family is a row. Unknown endpoints get NOTHING — a field
|
|
51
|
+
// a self-hosted server has never heard of is a 400 on some of them, and a mark is never worth that.
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A system prompt in two parts. `stable` is byte-identical across runs — rules, a knowledge-base
|
|
55
|
+
* index — and is what a cache shares BETWEEN runs; `run` is whatever differs per run and comes
|
|
56
|
+
* after it, so it never invalidates the stable half. A cache is a prefix match: one changed byte in
|
|
57
|
+
* `stable` (a timestamp, an unsorted list) is a full-price write on every call.
|
|
58
|
+
*/
|
|
59
|
+
export interface SystemParts {
|
|
60
|
+
readonly stable: string;
|
|
61
|
+
readonly run?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A plain string is the whole prompt as one stable part. */
|
|
65
|
+
export type SystemPrompt = string | SystemParts;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* "Cache this request's prefix": present on a `GenerateRequest`, each provider marks the stable
|
|
69
|
+
* system part and the transcript's tail the way its wire wants — or not at all, where the host
|
|
70
|
+
* caches implicitly. Absent, nothing is marked and nothing is sent.
|
|
71
|
+
*/
|
|
72
|
+
export interface PromptCacheHint {
|
|
73
|
+
/**
|
|
74
|
+
* A key naming the runs that share a prefix, for hosts that route or shard by one (an
|
|
75
|
+
* `x-session-id` header on OpenRouter, `prompt_cache_key` on OpenAI). Never a secret, never
|
|
76
|
+
* personal data: it leaves the process in a header.
|
|
77
|
+
*/
|
|
78
|
+
readonly session?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** OpenRouter's documented ceiling on a session id ("Maximum length is 256 characters"). */
|
|
82
|
+
export const SESSION_KEY_MAX = 256;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The longest `prompt_cache_key` sent to OpenAI verbatim. OpenAI's prompt-caching guide and its
|
|
86
|
+
* SDK types (read 2026-10-11) state NO maximum, and its API reference could not be read from
|
|
87
|
+
* here; 64 is the conservative figure a reviewer recalled. A longer key is not refused — it is a
|
|
88
|
+
* grouping key, so it is replaced by a deterministic hash of itself: same key, same group.
|
|
89
|
+
*/
|
|
90
|
+
export const OPENAI_CACHE_KEY_MAX = 64;
|
|
91
|
+
|
|
92
|
+
/** The one mark this package sends: the 5-minute TTL, which is the rate `costOf` prices. */
|
|
93
|
+
export const CACHE_MARK = { type: 'ephemeral' } as const;
|
|
94
|
+
|
|
95
|
+
/** Both parts as one text, stable first — for an estimate, and for a wire with one system string. */
|
|
96
|
+
export function systemText(system: SystemPrompt | undefined): string {
|
|
97
|
+
if (system === undefined) return '';
|
|
98
|
+
if (typeof system === 'string') return system;
|
|
99
|
+
return [system.stable, system.run ?? ''].filter((part) => part !== '').join('\n\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The parts, normalised: `''` for an absent half, so a wire never sends an empty block. */
|
|
103
|
+
export function systemParts(system: SystemPrompt | undefined): Required<SystemParts> {
|
|
104
|
+
if (system === undefined) return { stable: '', run: '' };
|
|
105
|
+
if (typeof system === 'string') return { stable: system, run: '' };
|
|
106
|
+
return { stable: system.stable, run: system.run ?? '' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type CacheWire = 'anthropic' | 'openai';
|
|
110
|
+
|
|
111
|
+
export interface PromptCacheRule {
|
|
112
|
+
readonly wire: CacheWire;
|
|
113
|
+
/** A hostname and every subdomain of it; `'*'` is any endpoint on this wire. */
|
|
114
|
+
readonly host: string;
|
|
115
|
+
/** A model-id prefix; `''` is every model. */
|
|
116
|
+
readonly model: string;
|
|
117
|
+
/**
|
|
118
|
+
* `blocks`: `cache_control` on Anthropic content blocks — the stable system block and the last
|
|
119
|
+
* block of the last two messages (three of the four breakpoints; the fourth is left free).
|
|
120
|
+
* `parts`: the same mark on chat-completions content PARTS — the stable system part and the tail
|
|
121
|
+
* message's last part (two breakpoints).
|
|
122
|
+
* `none`: the host caches by itself, or is unknown.
|
|
123
|
+
*/
|
|
124
|
+
readonly marks: 'blocks' | 'parts' | 'none';
|
|
125
|
+
/** Where a `PromptCacheHint.session` goes. */
|
|
126
|
+
readonly session: 'x-session-id' | 'prompt_cache_key' | 'none';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** First match wins. */
|
|
130
|
+
export const PROMPT_CACHE_RULES: readonly PromptCacheRule[] = [
|
|
131
|
+
{ wire: 'anthropic', host: '*', model: '', marks: 'blocks', session: 'none' },
|
|
132
|
+
{
|
|
133
|
+
wire: 'openai',
|
|
134
|
+
host: 'openrouter.ai',
|
|
135
|
+
model: 'anthropic/',
|
|
136
|
+
marks: 'parts',
|
|
137
|
+
session: 'x-session-id',
|
|
138
|
+
},
|
|
139
|
+
// Gemini, OpenAI, and everything else OpenRouter fronts: implicit or automatic. An explicit mark
|
|
140
|
+
// on Gemini turns a free implicit cache into a paid write.
|
|
141
|
+
{ wire: 'openai', host: 'openrouter.ai', model: '', marks: 'none', session: 'x-session-id' },
|
|
142
|
+
{ wire: 'openai', host: 'api.openai.com', model: '', marks: 'none', session: 'prompt_cache_key' },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const NO_RULE: Pick<PromptCacheRule, 'marks' | 'session'> = { marks: 'none', session: 'none' };
|
|
146
|
+
|
|
147
|
+
/** OpenAI's own endpoint, which is what an `openAiProvider()` with no `baseUrl` talks to. */
|
|
148
|
+
const DEFAULT_OPENAI_HOST = 'api.openai.com';
|
|
149
|
+
|
|
150
|
+
/** The rule for one request. Total: a base URL that does not parse matches no host. */
|
|
151
|
+
export function promptCacheRule(
|
|
152
|
+
wire: CacheWire,
|
|
153
|
+
baseUrl: string | undefined,
|
|
154
|
+
model: string,
|
|
155
|
+
): Pick<PromptCacheRule, 'marks' | 'session'> {
|
|
156
|
+
const host = baseUrl === undefined ? DEFAULT_OPENAI_HOST : hostOf(baseUrl);
|
|
157
|
+
const found = PROMPT_CACHE_RULES.find(
|
|
158
|
+
(rule) =>
|
|
159
|
+
rule.wire === wire &&
|
|
160
|
+
(rule.host === '*' || host === rule.host || host.endsWith(`.${rule.host}`)) &&
|
|
161
|
+
model.startsWith(rule.model),
|
|
162
|
+
);
|
|
163
|
+
return found ?? NO_RULE;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hostOf(baseUrl: string): string {
|
|
167
|
+
return URL.canParse(baseUrl) ? new URL(baseUrl).hostname.toLowerCase() : '';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a declaration writes: `'auto'` marks every request of the call, `'off'` marks none, and the
|
|
172
|
+
* object form is `'auto'` plus a session key — a constant, or derived per call (per org, say).
|
|
173
|
+
*/
|
|
174
|
+
export type PromptCacheOption<A> =
|
|
175
|
+
| 'auto'
|
|
176
|
+
| 'off'
|
|
177
|
+
| { readonly session: string | ((args: A) => string) };
|
|
178
|
+
|
|
179
|
+
/** The hint one call sends on every request, or `undefined` for none. */
|
|
180
|
+
export function promptCacheHint<A>(
|
|
181
|
+
option: PromptCacheOption<A> | undefined,
|
|
182
|
+
fallback: 'auto' | 'off',
|
|
183
|
+
args: A,
|
|
184
|
+
): PromptCacheHint | undefined {
|
|
185
|
+
const chosen = option ?? fallback;
|
|
186
|
+
if (chosen === 'off') return undefined;
|
|
187
|
+
if (chosen === 'auto') return {};
|
|
188
|
+
return { session: typeof chosen.session === 'string' ? chosen.session : chosen.session(args) };
|
|
189
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
|
-
// The `Provider` interface
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
1
|
+
// The `Provider` interface and `AnthropicProvider` (the real Messages API, streaming and not) —
|
|
2
|
+
// `EchoProvider`, the double, is ./echo-provider's, and the money arithmetic is ./cost's
|
|
3
|
+
// (`costOf` is re-exported here, where every caller has always imported it from). The REQUEST half only: ./models owns the catalogue and the per-model rules,
|
|
4
4
|
// ./wire owns the response half.
|
|
5
5
|
|
|
6
6
|
import type { Money } from '@ultimat3/money';
|
|
7
7
|
import type { AiMediaBlock } from './content-blocks';
|
|
8
8
|
import { assertMediaContent, blockText, mediaTokenEstimate } from './content-blocks';
|
|
9
|
+
import { costOf, exactCostOf } from './cost';
|
|
9
10
|
import { detailOf, withoutKey } from './error-body';
|
|
10
11
|
import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
|
|
11
12
|
import type { AiFetch } from './fetch-seam';
|
|
12
13
|
import { resolveModel } from './model-resolve';
|
|
13
14
|
import type { Effort, ModelId, ThinkingMode } from './models';
|
|
14
15
|
import { modelSpec, reasoningBody } from './models';
|
|
16
|
+
import type { PromptCacheHint, SystemPrompt } from './prompt-cache';
|
|
17
|
+
import { promptCacheRule, systemText } from './prompt-cache';
|
|
18
|
+
import { anthropicPrompt } from './prompt-cache-blocks';
|
|
19
|
+
import type { SamplingWire } from './sampling';
|
|
20
|
+
import { samplingBody } from './sampling';
|
|
15
21
|
import { readSse } from './sse';
|
|
22
|
+
import { assertToolArguments } from './tool-arguments';
|
|
16
23
|
import type { LlmTool, LlmToolCall } from './tools';
|
|
17
24
|
import {
|
|
18
25
|
asToolInput,
|
|
@@ -65,7 +72,14 @@ export function messageText(message: AiMessage): string {
|
|
|
65
72
|
|
|
66
73
|
export interface GenerateRequest {
|
|
67
74
|
readonly model?: ModelId;
|
|
68
|
-
|
|
75
|
+
/** One string, or a stable part and a per-run part (`./prompt-cache`) — two blocks on the wire. */
|
|
76
|
+
readonly system?: SystemPrompt;
|
|
77
|
+
/**
|
|
78
|
+
* Ask the provider to cache this request's prefix: the stable system part and the transcript's
|
|
79
|
+
* tail, marked per wire and host by `PROMPT_CACHE_RULES`. Absent sends no mark. Not part of
|
|
80
|
+
* `promptCacheKey` — it changes what a call costs, never what it answers.
|
|
81
|
+
*/
|
|
82
|
+
readonly promptCache?: PromptCacheHint;
|
|
69
83
|
readonly messages: readonly AiMessage[];
|
|
70
84
|
/** Enforced output ceiling. The model never sees it, so it can be cut off mid-answer. */
|
|
71
85
|
readonly maxTokens: number;
|
|
@@ -73,6 +87,10 @@ export interface GenerateRequest {
|
|
|
73
87
|
readonly thinking?: ThinkingMode;
|
|
74
88
|
readonly tools?: readonly LlmTool[];
|
|
75
89
|
readonly stopSequences?: readonly string[];
|
|
90
|
+
/** Sent only to a model whose row takes it (`ModelSpec.sampling`, `./sampling`). 0 to 2. */
|
|
91
|
+
readonly temperature?: number;
|
|
92
|
+
/** As `temperature`; chat-completions only — the Messages API has no seed. */
|
|
93
|
+
readonly seed?: number;
|
|
76
94
|
/**
|
|
77
95
|
* The caller's abort signal, forwarded to the socket by every provider in this package.
|
|
78
96
|
*
|
|
@@ -137,8 +155,14 @@ export interface GenerateResult {
|
|
|
137
155
|
/** Required, not optional: a provider that forgets it turns a refusal into a silent empty answer. */
|
|
138
156
|
readonly stopDetails: StopDetails | undefined;
|
|
139
157
|
readonly usage: TokenUsage;
|
|
140
|
-
/** Cost of this call
|
|
158
|
+
/** Cost of this call in the currency's minor units, rounded up once (`costOf`). */
|
|
141
159
|
readonly cost: Money;
|
|
160
|
+
/**
|
|
161
|
+
* The same cost EXACTLY, as `Money` at a finer `scale` (`exactCostOf`) — what a ledger sums, so
|
|
162
|
+
* a run of cheap calls is rounded once at the end and not once per call. Optional: a `Provider`
|
|
163
|
+
* an app wrote may not state it, and its `cost` is summed in its place.
|
|
164
|
+
*/
|
|
165
|
+
readonly costExact?: Money;
|
|
142
166
|
}
|
|
143
167
|
|
|
144
168
|
/** One streamed increment. `done` carries the assembled result. */
|
|
@@ -148,39 +172,22 @@ export type StreamChunk =
|
|
|
148
172
|
| { readonly type: 'tool-call'; readonly call: LlmToolCall }
|
|
149
173
|
| { readonly type: 'done'; readonly result: GenerateResult };
|
|
150
174
|
|
|
175
|
+
export { costOf } from './cost';
|
|
176
|
+
|
|
151
177
|
export interface Provider {
|
|
152
178
|
readonly name: string;
|
|
153
179
|
/** Models this provider can serve. The gateway routes by membership. */
|
|
154
180
|
readonly models: readonly ModelId[];
|
|
155
181
|
generate(request: GenerateRequest): Promise<GenerateResult>;
|
|
156
182
|
stream(request: GenerateRequest): AsyncIterable<StreamChunk>;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// so no call is priced — and no budget reserved — against a number nobody chose.
|
|
166
|
-
const spec = modelSpec(model);
|
|
167
|
-
const input = spec.inputPerMillion.minor;
|
|
168
|
-
// In TWENTIETHS of a minor unit, so the standard multipliers stay integral on any whole price:
|
|
169
|
-
// a cache read is 0.1x input (2/20) and a 5-minute write 1.25x (25/20) unless the row states
|
|
170
|
-
// its own — Opus 5.5 reads at 0.05x and Fable 5.1 at 0.025x, which a fixed 0.1x overstated.
|
|
171
|
-
const read =
|
|
172
|
-
spec.cacheReadPerMillion === undefined ? input * 2 : spec.cacheReadPerMillion.minor * 20;
|
|
173
|
-
const write =
|
|
174
|
-
spec.cacheWritePerMillion === undefined ? input * 25 : spec.cacheWritePerMillion.minor * 20;
|
|
175
|
-
const inputUnits =
|
|
176
|
-
usage.inputTokens * input * 20 + usage.cacheReadTokens * read + usage.cacheWriteTokens * write;
|
|
177
|
-
const inputMinor = divideCeil(inputUnits, 20_000_000);
|
|
178
|
-
const outputMinor = divideCeil(usage.outputTokens * spec.outputPerMillion.minor, 1_000_000);
|
|
179
|
-
return { minor: inputMinor + outputMinor, currency: spec.inputPerMillion.currency };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function divideCeil(numerator: number, denominator: number): number {
|
|
183
|
-
return Math.ceil(numerator / denominator);
|
|
183
|
+
/**
|
|
184
|
+
* Whether a request carrying `promptCache` for this model would be sent with an explicit cache
|
|
185
|
+
* mark — billed as a cache WRITE. Absent is no: the gateway prices the write only where one
|
|
186
|
+
* can happen.
|
|
187
|
+
*/
|
|
188
|
+
marksPromptCache?(model: ModelId): boolean;
|
|
189
|
+
/** Which wire format this provider speaks, when it is one of the two built in (`./sampling`). */
|
|
190
|
+
readonly wire?: SamplingWire;
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
/** Total tokens a usage record accounts for — what a budget is debited by. */
|
|
@@ -228,12 +235,14 @@ export function anthropicProvider(input: AnthropicProviderInput): AnthropicProvi
|
|
|
228
235
|
/**
|
|
229
236
|
* The real Messages API shape. The request-surface rules are encoded rather than documented,
|
|
230
237
|
* because getting them wrong is a 400:
|
|
231
|
-
* - `
|
|
238
|
+
* - `top_p`/`top_k` are never sent, and `temperature` only to a model whose row takes it
|
|
239
|
+
* (`ModelSpec.sampling`): a reasoning model rejects all three.
|
|
232
240
|
* - `thinking.budget_tokens` is REJECTED. Use `output_config.effort`.
|
|
233
241
|
* - `effort` and adaptive thinking are per-model, and ./models owns which model takes which.
|
|
234
242
|
*/
|
|
235
243
|
export class AnthropicProvider implements Provider {
|
|
236
244
|
readonly name = 'anthropic';
|
|
245
|
+
readonly wire = 'anthropic' as const;
|
|
237
246
|
/** The app's list, never the registry's: a model served elsewhere must not be routed here. */
|
|
238
247
|
readonly models: readonly ModelId[];
|
|
239
248
|
private readonly config: AnthropicProviderInput;
|
|
@@ -316,25 +325,37 @@ export class AnthropicProvider implements Provider {
|
|
|
316
325
|
'the stream ended before message_stop, or with a tool call still open — the answer is truncated',
|
|
317
326
|
});
|
|
318
327
|
}
|
|
319
|
-
const state = message.state();
|
|
328
|
+
const { malformed, ...state } = message.state();
|
|
329
|
+
const cost = costOf(model, state.usage);
|
|
330
|
+
// After usage is known, so a finished turn with unreadable arguments fails WITH its bill.
|
|
331
|
+
assertToolArguments({ provider: this.name, model, ...state, malformed, cost });
|
|
320
332
|
yield {
|
|
321
333
|
type: 'done',
|
|
322
|
-
result: { model, ...state, cost:
|
|
334
|
+
result: { model, ...state, cost, costExact: exactCostOf(model, state.usage) },
|
|
323
335
|
};
|
|
324
336
|
}
|
|
325
337
|
|
|
338
|
+
marksPromptCache(model: ModelId): boolean {
|
|
339
|
+
return promptCacheRule('anthropic', this.config.baseUrl, model).marks === 'blocks';
|
|
340
|
+
}
|
|
341
|
+
|
|
326
342
|
/** The request body. Pure and side-effect free so a test can assert it directly. */
|
|
327
343
|
body(request: GenerateRequest): Record<string, unknown> {
|
|
328
344
|
const model = this.modelOf(request);
|
|
329
345
|
// The blocks pass through untouched below — they ARE this wire's shapes — so this is the screen.
|
|
330
346
|
assertMediaContent(request.messages, { provider: this.name, model });
|
|
347
|
+
const prompt = anthropicPrompt(
|
|
348
|
+
request,
|
|
349
|
+
request.promptCache !== undefined && this.marksPromptCache(model),
|
|
350
|
+
);
|
|
331
351
|
const body: Record<string, unknown> = {
|
|
332
352
|
model,
|
|
333
353
|
max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
|
|
334
|
-
messages:
|
|
354
|
+
messages: prompt.messages,
|
|
335
355
|
...reasoningBody(model, request.effort, request.thinking),
|
|
356
|
+
...samplingBody('anthropic', model, request),
|
|
336
357
|
};
|
|
337
|
-
if (
|
|
358
|
+
if (prompt.system !== undefined) body['system'] = prompt.system;
|
|
338
359
|
if (request.tools !== undefined && request.tools.length > 0) body['tools'] = request.tools;
|
|
339
360
|
if (request.stopSequences !== undefined) body['stop_sequences'] = request.stopSequences;
|
|
340
361
|
return body;
|
|
@@ -421,6 +442,7 @@ export function parseMessage(model: ModelId, raw: Record<string, unknown>): Gene
|
|
|
421
442
|
stopDetails,
|
|
422
443
|
usage,
|
|
423
444
|
cost: costOf(model, usage),
|
|
445
|
+
costExact: exactCostOf(model, usage),
|
|
424
446
|
};
|
|
425
447
|
}
|
|
426
448
|
|
|
@@ -439,7 +461,7 @@ const completionCeiling = (request: GenerateRequest): number =>
|
|
|
439
461
|
/** The prompt half alone — what the provider bills at the input rate. */
|
|
440
462
|
export function estimateInputTokens(request: GenerateRequest): number {
|
|
441
463
|
const body = request.messages.map(messageText).join(' ');
|
|
442
|
-
const text = estimateTextTokens(body) + estimateTextTokens(request.system
|
|
464
|
+
const text = estimateTextTokens(body) + estimateTextTokens(systemText(request.system));
|
|
443
465
|
return text + mediaTokenEstimate(request.messages);
|
|
444
466
|
}
|
|
445
467
|
|
|
@@ -448,13 +470,17 @@ export function estimateInputTokens(request: GenerateRequest): number {
|
|
|
448
470
|
* `maxTokens` at the output rate. Deliberately pessimistic — a ceiling checked against an
|
|
449
471
|
* optimistic estimate is a ceiling one long completion walks through.
|
|
450
472
|
*/
|
|
451
|
-
export function estimateCost(request: GenerateRequest): Money {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
473
|
+
export function estimateCost(request: GenerateRequest, marked = false): Money {
|
|
474
|
+
const model = resolveModel('provider', request.model);
|
|
475
|
+
const prompt = estimateInputTokens(request);
|
|
476
|
+
const usage = { outputTokens: completionCeiling(request), cacheReadTokens: 0 };
|
|
477
|
+
const asInput = costOf(model, { ...usage, inputTokens: prompt, cacheWriteTokens: 0 });
|
|
478
|
+
// A request a provider WILL mark (`marked`: the gateway asks the providers serving the model) can
|
|
479
|
+
// be billed as a cache write end to end. The DEARER of the two: a row whose write is cheaper
|
|
480
|
+
// than input must not lower the ceiling's estimate below what an unmarked call costs.
|
|
481
|
+
if (!marked || request.promptCache === undefined) return asInput;
|
|
482
|
+
const asWrite = costOf(model, { ...usage, inputTokens: 0, cacheWriteTokens: prompt });
|
|
483
|
+
return asWrite.minor > asInput.minor ? asWrite : asInput;
|
|
458
484
|
}
|
|
459
485
|
|
|
460
486
|
export function estimateTextTokens(text: string): number {
|
package/src/run-meter.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Single responsibility: one run's meter — every provider call an `llm()` or an `agent()` makes
|
|
2
|
+
// goes through it, so the run always knows what it was billed, and a failure can say so.
|
|
3
|
+
//
|
|
4
|
+
// The ledger is not the place to read this from: a ledger is shared (a job's scope, an org's
|
|
5
|
+
// window), and the question a failed job asks is about THIS run.
|
|
6
|
+
|
|
7
|
+
import { asyncContext, isUltimateError } from '@ultimat3/core';
|
|
8
|
+
import type { AgentRunUsage } from './agent-usage';
|
|
9
|
+
import { addUsage } from './agent-usage';
|
|
10
|
+
import type { Billed } from './billed';
|
|
11
|
+
import { BILLED_META, billedCall, billedOf } from './billed';
|
|
12
|
+
import { addMoney } from './cost';
|
|
13
|
+
import type { GenerateResult } from './provider';
|
|
14
|
+
import { ZERO_USAGE } from './wire';
|
|
15
|
+
|
|
16
|
+
export interface RunMeter {
|
|
17
|
+
/**
|
|
18
|
+
* Await one provider call. Until it reports usage it is UNSETTLED: an abort, a deadline or a
|
|
19
|
+
* transport failure leaves it that way, and its bill cannot be known from here.
|
|
20
|
+
*/
|
|
21
|
+
call(work: Promise<GenerateResult>): Promise<GenerateResult>;
|
|
22
|
+
/** The run so far, re-priced from the summed usage — `undefined` before the first answer. */
|
|
23
|
+
total(): AgentRunUsage | undefined;
|
|
24
|
+
/** The run's bill, or `undefined` when no provider call was ever started. */
|
|
25
|
+
billed(): Billed | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* What runs UNDERNEATH this one were billed — a tool that itself calls `llm()` or an `agent()`.
|
|
28
|
+
* Their failures are read by the model as tool results and never thrown here, so this is the
|
|
29
|
+
* only way that spend reaches the run that caused it.
|
|
30
|
+
*/
|
|
31
|
+
below(): Billed | undefined;
|
|
32
|
+
/** Run `fn` with this meter as the one nested runs report to. */
|
|
33
|
+
within<T>(fn: () => T): T;
|
|
34
|
+
/**
|
|
35
|
+
* `error`, carrying the run's bill on `meta.billed`. Returned for a `throw`, never thrown here.
|
|
36
|
+
* Only an `UltimateError` has a `meta` to carry it; anything else is returned untouched (the
|
|
37
|
+
* ledger still holds the bill).
|
|
38
|
+
*/
|
|
39
|
+
fail(error: unknown): unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The meter of the run in flight, so a run started underneath it can report upward.
|
|
43
|
+
const meters = asyncContext<{ report(bill: Billed): void }>('an AI run meter');
|
|
44
|
+
|
|
45
|
+
/** Errors a nested meter already reported upward: their `meta.billed` must not be added again. */
|
|
46
|
+
const reported = new WeakSet<object>();
|
|
47
|
+
|
|
48
|
+
export function runMeter(): RunMeter {
|
|
49
|
+
const parent = meters.get();
|
|
50
|
+
let nested: Billed | undefined;
|
|
51
|
+
const report = (bill: Billed): void => {
|
|
52
|
+
nested = nested === undefined ? bill : sum(nested, bill);
|
|
53
|
+
parent?.report(bill);
|
|
54
|
+
};
|
|
55
|
+
let own: AgentRunUsage | undefined;
|
|
56
|
+
let calls = 0;
|
|
57
|
+
let unsettled = 0;
|
|
58
|
+
// Bills that arrived ON an error and were not priced by this meter: a provider's
|
|
59
|
+
// `X_LLM_TOOL_ARGUMENTS_INVALID`, or a nested run's failure passing through.
|
|
60
|
+
let carried: Billed | undefined;
|
|
61
|
+
const counted = new WeakSet<object>();
|
|
62
|
+
|
|
63
|
+
const absorb = (error: unknown): void => {
|
|
64
|
+
const billed = billedOf(error);
|
|
65
|
+
if (billed === undefined || typeof error !== 'object' || error === null) return;
|
|
66
|
+
if (counted.has(error) || reported.has(error)) return;
|
|
67
|
+
counted.add(error);
|
|
68
|
+
carried = carried === undefined ? billed : sum(carried, billed);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const billed = (): Billed | undefined => {
|
|
72
|
+
const direct = directBill();
|
|
73
|
+
if (direct === undefined) return nested;
|
|
74
|
+
return nested === undefined ? direct : sum(direct, nested);
|
|
75
|
+
};
|
|
76
|
+
const directBill = (): Billed | undefined => {
|
|
77
|
+
if (calls === 0 && unsettled === 0 && carried === undefined) return undefined;
|
|
78
|
+
const mine: Billed = {
|
|
79
|
+
usage: own?.usage ?? ZERO_USAGE,
|
|
80
|
+
cost: own?.cost ?? carried?.cost ?? { minor: 0, currency: 'USD' },
|
|
81
|
+
calls,
|
|
82
|
+
unsettled,
|
|
83
|
+
};
|
|
84
|
+
if (carried === undefined) return mine;
|
|
85
|
+
return own === undefined
|
|
86
|
+
? { ...carried, unsettled: carried.unsettled + unsettled }
|
|
87
|
+
: sum(mine, carried);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
async call(work) {
|
|
92
|
+
unsettled += 1;
|
|
93
|
+
try {
|
|
94
|
+
const result = await work;
|
|
95
|
+
unsettled -= 1;
|
|
96
|
+
calls += 1;
|
|
97
|
+
own = addUsage(own, result);
|
|
98
|
+
parent?.report(billedCall(result.usage, result.costExact ?? result.cost));
|
|
99
|
+
return result;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// The provider answered and billed, then the answer could not be used: settled after all.
|
|
102
|
+
const bill = billedOf(error);
|
|
103
|
+
if (bill !== undefined) {
|
|
104
|
+
unsettled -= 1;
|
|
105
|
+
absorb(error);
|
|
106
|
+
parent?.report(bill);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
total: () => own,
|
|
112
|
+
billed,
|
|
113
|
+
below: () => nested,
|
|
114
|
+
within: (fn) => meters.run({ report }, fn),
|
|
115
|
+
fail(error) {
|
|
116
|
+
absorb(error);
|
|
117
|
+
// Everything this run was billed already reached the parent call by call.
|
|
118
|
+
if (parent !== undefined && typeof error === 'object' && error !== null) reported.add(error);
|
|
119
|
+
const bill = billed();
|
|
120
|
+
if (bill === undefined || !isUltimateError(error)) return error;
|
|
121
|
+
// An error is thrown once, by the run it failed, and `meta` is a plain own property the
|
|
122
|
+
// constructor assigned. Written in place rather than wrapped: a wrapper would be a second
|
|
123
|
+
// error with a different stack, and every reader upstream matches on the ORIGINAL's code.
|
|
124
|
+
try {
|
|
125
|
+
Reflect.set(error, 'meta', { ...error.meta, [BILLED_META]: bill });
|
|
126
|
+
} catch {
|
|
127
|
+
// A frozen error keeps the meta it was thrown with.
|
|
128
|
+
}
|
|
129
|
+
return error;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Two bills as one. A cost in another currency cannot be added and is left out of the sum. */
|
|
135
|
+
function sum(a: Billed, b: Billed): Billed {
|
|
136
|
+
return {
|
|
137
|
+
usage: {
|
|
138
|
+
inputTokens: a.usage.inputTokens + b.usage.inputTokens,
|
|
139
|
+
outputTokens: a.usage.outputTokens + b.usage.outputTokens,
|
|
140
|
+
cacheReadTokens: a.usage.cacheReadTokens + b.usage.cacheReadTokens,
|
|
141
|
+
cacheWriteTokens: a.usage.cacheWriteTokens + b.usage.cacheWriteTokens,
|
|
142
|
+
},
|
|
143
|
+
cost: a.cost.currency === b.cost.currency ? addMoney(a.cost, b.cost) : a.cost,
|
|
144
|
+
calls: a.calls + b.calls,
|
|
145
|
+
unsettled: a.unsettled + b.unsettled,
|
|
146
|
+
};
|
|
147
|
+
}
|
package/src/sampling.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Single responsibility: the two determinism knobs a request may carry — `temperature` and `seed`
|
|
2
|
+
// — screened once where they are declared, and written onto a wire body only for a model whose
|
|
3
|
+
// row says it takes them.
|
|
4
|
+
//
|
|
5
|
+
// A row decides, never an `if` in a body builder (the rule `reasoningBody` follows): a reasoning
|
|
6
|
+
// model in either family answers a sampling parameter with a 400, and which models those are is
|
|
7
|
+
// the app's catalogue. So `ModelSpec.sampling` is the switch, absent means NOT SENT — every row
|
|
8
|
+
// that predates it keeps sending exactly what it sent — and an asked-for knob the row does not
|
|
9
|
+
// take is left out, not refused: a verifier that asks for `temperature: 0` still runs on a model
|
|
10
|
+
// that has no such control, and `ignoredSampling` is what a span says about it.
|
|
11
|
+
|
|
12
|
+
import { AiRequestInvalidError } from './errors';
|
|
13
|
+
import type { ModelId } from './models';
|
|
14
|
+
import { modelSpec } from './models';
|
|
15
|
+
|
|
16
|
+
export interface Sampling {
|
|
17
|
+
/** 0 to 2. `0` is the most repeatable a model offers; it is not a guarantee of determinism. */
|
|
18
|
+
readonly temperature?: number;
|
|
19
|
+
/** A whole number. Honoured by chat-completions endpoints that support it; best effort there. */
|
|
20
|
+
readonly seed?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Which knobs each wire HAS. The Messages API has no `seed` at all. */
|
|
24
|
+
const WIRE_KNOBS = {
|
|
25
|
+
openai: ['temperature', 'seed'],
|
|
26
|
+
anthropic: ['temperature'],
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export type SamplingWire = keyof typeof WIRE_KNOBS;
|
|
30
|
+
|
|
31
|
+
/** A declaration's knobs, screened: a `NaN` temperature or a fractional seed is a 400 mid-run. */
|
|
32
|
+
export function screenSampling(subject: string, sampling: Sampling): Sampling {
|
|
33
|
+
const { temperature, seed } = sampling;
|
|
34
|
+
if (
|
|
35
|
+
temperature !== undefined &&
|
|
36
|
+
!(Number.isFinite(temperature) && temperature >= 0 && temperature <= 2)
|
|
37
|
+
) {
|
|
38
|
+
throw new AiRequestInvalidError({
|
|
39
|
+
detail: `${subject} temperature is ${String(temperature)}; it must be a number from 0 to 2`,
|
|
40
|
+
fix: `temperature: 0 # on the ${subject}() declaration — 0 to 2, or drop the key`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (seed !== undefined && !Number.isSafeInteger(seed)) {
|
|
44
|
+
throw new AiRequestInvalidError({
|
|
45
|
+
detail: `${subject} seed is ${String(seed)}; it must be a whole number`,
|
|
46
|
+
fix: `seed: 7 # on the ${subject}() declaration — a whole number, or drop the key`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...(temperature === undefined ? {} : { temperature }),
|
|
51
|
+
...(seed === undefined ? {} : { seed }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The body fields for the knobs this request asks for AND this model, on this wire, takes. */
|
|
56
|
+
export function samplingBody(
|
|
57
|
+
wire: SamplingWire,
|
|
58
|
+
model: ModelId,
|
|
59
|
+
request: Sampling,
|
|
60
|
+
): Record<string, number> {
|
|
61
|
+
const accepts = modelSpec(model).sampling;
|
|
62
|
+
const body: Record<string, number> = {};
|
|
63
|
+
for (const knob of WIRE_KNOBS[wire]) {
|
|
64
|
+
const value = request[knob];
|
|
65
|
+
if (value !== undefined && accepts?.[knob] === true) body[knob] = value;
|
|
66
|
+
}
|
|
67
|
+
return body;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The knobs a request asked for that no wire will send to `model` — what a span reports. */
|
|
71
|
+
export function ignoredSampling(
|
|
72
|
+
model: ModelId,
|
|
73
|
+
request: Sampling,
|
|
74
|
+
/** The wire formats of the providers serving `model`, when the gateway can say. */
|
|
75
|
+
wires?: readonly SamplingWire[],
|
|
76
|
+
): readonly string[] {
|
|
77
|
+
const accepts = modelSpec(model).sampling;
|
|
78
|
+
// A knob no serving wire has a word for is dropped too — `seed` on the Messages API.
|
|
79
|
+
const sendable = (knob: 'temperature' | 'seed'): boolean =>
|
|
80
|
+
wires === undefined ||
|
|
81
|
+
wires.length === 0 ||
|
|
82
|
+
wires.some((wire) => (WIRE_KNOBS[wire] as readonly string[]).includes(knob));
|
|
83
|
+
return (['temperature', 'seed'] as const).filter(
|
|
84
|
+
(knob) => request[knob] !== undefined && (accepts?.[knob] !== true || !sendable(knob)),
|
|
85
|
+
);
|
|
86
|
+
}
|