@ultimat3/ai 27.9.0 → 27.10.1
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 +104 -108
- package/README.md +21 -5
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +28 -5
- package/src/agent-errors.ts +20 -1
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +49 -13
- package/src/agent.ts +163 -193
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +37 -10
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +8 -0
- package/src/errors.ts +22 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +53 -7
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +29 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +93 -75
- package/src/models.ts +45 -1
- package/src/openai-body.ts +6 -3
- package/src/openai-provider.ts +11 -2
- package/src/openai-wire.ts +168 -62
- package/src/provider.ts +32 -34
- package/src/run-meter.ts +147 -0
- package/src/sampling.ts +113 -0
- package/src/tool-arguments.ts +88 -0
- package/src/tools.ts +51 -6
- package/src/wire.ts +15 -14
package/src/provider.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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';
|
|
@@ -15,7 +16,10 @@ import { modelSpec, reasoningBody } from './models';
|
|
|
15
16
|
import type { PromptCacheHint, SystemPrompt } from './prompt-cache';
|
|
16
17
|
import { promptCacheRule, systemText } from './prompt-cache';
|
|
17
18
|
import { anthropicPrompt } from './prompt-cache-blocks';
|
|
19
|
+
import type { SamplingWire } from './sampling';
|
|
20
|
+
import { samplingBody } from './sampling';
|
|
18
21
|
import { readSse } from './sse';
|
|
22
|
+
import { assertToolArguments } from './tool-arguments';
|
|
19
23
|
import type { LlmTool, LlmToolCall } from './tools';
|
|
20
24
|
import {
|
|
21
25
|
asToolInput,
|
|
@@ -83,6 +87,10 @@ export interface GenerateRequest {
|
|
|
83
87
|
readonly thinking?: ThinkingMode;
|
|
84
88
|
readonly tools?: readonly LlmTool[];
|
|
85
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;
|
|
86
94
|
/**
|
|
87
95
|
* The caller's abort signal, forwarded to the socket by every provider in this package.
|
|
88
96
|
*
|
|
@@ -147,8 +155,14 @@ export interface GenerateResult {
|
|
|
147
155
|
/** Required, not optional: a provider that forgets it turns a refusal into a silent empty answer. */
|
|
148
156
|
readonly stopDetails: StopDetails | undefined;
|
|
149
157
|
readonly usage: TokenUsage;
|
|
150
|
-
/** Cost of this call
|
|
158
|
+
/** Cost of this call in the currency's minor units, rounded up once (`costOf`). */
|
|
151
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;
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
/** One streamed increment. `done` carries the assembled result. */
|
|
@@ -158,6 +172,8 @@ export type StreamChunk =
|
|
|
158
172
|
| { readonly type: 'tool-call'; readonly call: LlmToolCall }
|
|
159
173
|
| { readonly type: 'done'; readonly result: GenerateResult };
|
|
160
174
|
|
|
175
|
+
export { costOf } from './cost';
|
|
176
|
+
|
|
161
177
|
export interface Provider {
|
|
162
178
|
readonly name: string;
|
|
163
179
|
/** Models this provider can serve. The gateway routes by membership. */
|
|
@@ -170,33 +186,8 @@ export interface Provider {
|
|
|
170
186
|
* can happen.
|
|
171
187
|
*/
|
|
172
188
|
marksPromptCache?(model: ModelId): boolean;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Cost of one call, in integer minor units. Rounded UP: a fraction of a cent that we round
|
|
177
|
-
* away is money the framework silently absorbs, and under-reporting spend defeats a budget.
|
|
178
|
-
*/
|
|
179
|
-
export function costOf(model: ModelId, usage: TokenUsage): Money {
|
|
180
|
-
// The one price lookup: a model the app never `registerModel`-ed is `X_AI_MODEL_UNKNOWN` here,
|
|
181
|
-
// so no call is priced — and no budget reserved — against a number nobody chose.
|
|
182
|
-
const spec = modelSpec(model);
|
|
183
|
-
const input = spec.inputPerMillion.minor;
|
|
184
|
-
// In TWENTIETHS of a minor unit, so the standard multipliers stay integral on any whole price:
|
|
185
|
-
// a cache read is 0.1x input (2/20) and a 5-minute write 1.25x (25/20) unless the row states
|
|
186
|
-
// its own — Opus 5.5 reads at 0.05x and Fable 5.1 at 0.025x, which a fixed 0.1x overstated.
|
|
187
|
-
const read =
|
|
188
|
-
spec.cacheReadPerMillion === undefined ? input * 2 : spec.cacheReadPerMillion.minor * 20;
|
|
189
|
-
const write =
|
|
190
|
-
spec.cacheWritePerMillion === undefined ? input * 25 : spec.cacheWritePerMillion.minor * 20;
|
|
191
|
-
const inputUnits =
|
|
192
|
-
usage.inputTokens * input * 20 + usage.cacheReadTokens * read + usage.cacheWriteTokens * write;
|
|
193
|
-
const inputMinor = divideCeil(inputUnits, 20_000_000);
|
|
194
|
-
const outputMinor = divideCeil(usage.outputTokens * spec.outputPerMillion.minor, 1_000_000);
|
|
195
|
-
return { minor: inputMinor + outputMinor, currency: spec.inputPerMillion.currency };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function divideCeil(numerator: number, denominator: number): number {
|
|
199
|
-
return Math.ceil(numerator / denominator);
|
|
189
|
+
/** Which wire format this provider speaks, when it is one of the two built in (`./sampling`). */
|
|
190
|
+
readonly wire?: SamplingWire;
|
|
200
191
|
}
|
|
201
192
|
|
|
202
193
|
/** Total tokens a usage record accounts for — what a budget is debited by. */
|
|
@@ -244,12 +235,14 @@ export function anthropicProvider(input: AnthropicProviderInput): AnthropicProvi
|
|
|
244
235
|
/**
|
|
245
236
|
* The real Messages API shape. The request-surface rules are encoded rather than documented,
|
|
246
237
|
* because getting them wrong is a 400:
|
|
247
|
-
* - `
|
|
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.
|
|
248
240
|
* - `thinking.budget_tokens` is REJECTED. Use `output_config.effort`.
|
|
249
241
|
* - `effort` and adaptive thinking are per-model, and ./models owns which model takes which.
|
|
250
242
|
*/
|
|
251
243
|
export class AnthropicProvider implements Provider {
|
|
252
244
|
readonly name = 'anthropic';
|
|
245
|
+
readonly wire = 'anthropic' as const;
|
|
253
246
|
/** The app's list, never the registry's: a model served elsewhere must not be routed here. */
|
|
254
247
|
readonly models: readonly ModelId[];
|
|
255
248
|
private readonly config: AnthropicProviderInput;
|
|
@@ -332,10 +325,13 @@ export class AnthropicProvider implements Provider {
|
|
|
332
325
|
'the stream ended before message_stop, or with a tool call still open — the answer is truncated',
|
|
333
326
|
});
|
|
334
327
|
}
|
|
335
|
-
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 });
|
|
336
332
|
yield {
|
|
337
333
|
type: 'done',
|
|
338
|
-
result: { model, ...state, cost:
|
|
334
|
+
result: { model, ...state, cost, costExact: exactCostOf(model, state.usage) },
|
|
339
335
|
};
|
|
340
336
|
}
|
|
341
337
|
|
|
@@ -357,6 +353,7 @@ export class AnthropicProvider implements Provider {
|
|
|
357
353
|
max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
|
|
358
354
|
messages: prompt.messages,
|
|
359
355
|
...reasoningBody(model, request.effort, request.thinking),
|
|
356
|
+
...samplingBody('anthropic', model, request),
|
|
360
357
|
};
|
|
361
358
|
if (prompt.system !== undefined) body['system'] = prompt.system;
|
|
362
359
|
if (request.tools !== undefined && request.tools.length > 0) body['tools'] = request.tools;
|
|
@@ -445,6 +442,7 @@ export function parseMessage(model: ModelId, raw: Record<string, unknown>): Gene
|
|
|
445
442
|
stopDetails,
|
|
446
443
|
usage,
|
|
447
444
|
cost: costOf(model, usage),
|
|
445
|
+
costExact: exactCostOf(model, usage),
|
|
448
446
|
};
|
|
449
447
|
}
|
|
450
448
|
|
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,113 @@
|
|
|
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 { isModelRegistered, 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
|
+
// Nothing asked, nothing ignored — and no row read: a model none asked of needs none here.
|
|
78
|
+
if (request.temperature === undefined && request.seed === undefined) return [];
|
|
79
|
+
const accepts = modelSpec(model).sampling;
|
|
80
|
+
// A knob no serving wire has a word for is dropped too — `seed` on the Messages API.
|
|
81
|
+
const sendable = (knob: 'temperature' | 'seed'): boolean =>
|
|
82
|
+
wires === undefined ||
|
|
83
|
+
wires.length === 0 ||
|
|
84
|
+
wires.some((wire) => (WIRE_KNOBS[wire] as readonly string[]).includes(knob));
|
|
85
|
+
return (['temperature', 'seed'] as const).filter(
|
|
86
|
+
(knob) => request[knob] !== undefined && (accepts?.[knob] !== true || !sendable(knob)),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The `llm.sampling.ignored` span attribute, set when a run ENDS — answered or failed — never at
|
|
92
|
+
* its start. The catalogue is the app's, and an app may register it when its gateway is first used
|
|
93
|
+
* (a gateway built lazily from the env): read at the top of `agent()` / `llm()`, before the gateway
|
|
94
|
+
* was asked anything, the row was not there yet and a REPORT refused the call `X_AI_MODEL_UNKNOWN`.
|
|
95
|
+
* A model still unregistered by then has no row to report against; the call's own failure says so.
|
|
96
|
+
*/
|
|
97
|
+
export function ignoredSamplingAttribute(
|
|
98
|
+
model: ModelId,
|
|
99
|
+
request: Sampling,
|
|
100
|
+
wires: () => readonly SamplingWire[] | undefined,
|
|
101
|
+
): Record<'llm.sampling.ignored', string> | Record<string, never> {
|
|
102
|
+
if (request.temperature === undefined && request.seed === undefined) {
|
|
103
|
+
return { 'llm.sampling.ignored': '' };
|
|
104
|
+
}
|
|
105
|
+
if (!isModelRegistered(model)) return {};
|
|
106
|
+
// A report never fails the call it describes: an installed gateway may throw from
|
|
107
|
+
// `samplingWires`, and the attribute is then left out rather than replacing the call's result.
|
|
108
|
+
try {
|
|
109
|
+
return { 'llm.sampling.ignored': ignoredSampling(model, request, wires()).join(',') };
|
|
110
|
+
} catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Single responsibility: reading a tool call's argument STRING, and deciding what an unreadable
|
|
2
|
+
// one means once the turn's stop reason is known. One copy for both wire formats, streamed or not.
|
|
3
|
+
//
|
|
4
|
+
// "The arguments are not JSON" is two different events, and neither is the provider being
|
|
5
|
+
// unavailable — which is what both wires used to report it as, under the one code this package
|
|
6
|
+
// classifies retryable, with the answer already billed:
|
|
7
|
+
// - the turn was CUT OFF mid-arguments. The stop reason says so, and the fragment is dropped: a
|
|
8
|
+
// fragment is never a call, and `llm()` / `agent()` refuse a truncated turn before reading it.
|
|
9
|
+
// - the turn FINISHED and the model wrote broken JSON. That is `X_LLM_TOOL_ARGUMENTS_INVALID`,
|
|
10
|
+
// thrown with the usage the provider reported, so the gateway records the bill instead of
|
|
11
|
+
// releasing the reservation and nothing retries a request that was answered.
|
|
12
|
+
|
|
13
|
+
import type { Money } from '@ultimat3/money';
|
|
14
|
+
import { AiProviderUnexpectedError, LlmToolArgumentsError } from './answer-errors';
|
|
15
|
+
import { billedCall } from './billed';
|
|
16
|
+
import type { ModelId } from './models';
|
|
17
|
+
import type { StopReason, TokenUsage } from './provider';
|
|
18
|
+
import { isTruncated } from './provider';
|
|
19
|
+
|
|
20
|
+
/** A tool call whose arguments could not be read. Never the text: it is the model's, unscreened. */
|
|
21
|
+
export interface MalformedToolCall {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** Characters of argument text that arrived. */
|
|
24
|
+
readonly chars: number;
|
|
25
|
+
/**
|
|
26
|
+
* The fragments carried neither an `index` nor an `id`, and more than one named a tool: two
|
|
27
|
+
* calls that cannot be told apart. Refused whole — never run as one call with merged arguments.
|
|
28
|
+
*/
|
|
29
|
+
readonly unkeyed?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The arguments as an object, or `undefined` when the string is not JSON. A blank string is `{}`:
|
|
34
|
+
* a tool that takes no arguments sends `''` or `'{}'`, and neither is a fault. JSON that is not an
|
|
35
|
+
* object is `{}` too — `input` is untrusted, and a string under `Record<string, unknown>` is a
|
|
36
|
+
* type lie every later reader indexes into.
|
|
37
|
+
*/
|
|
38
|
+
export function parseToolArguments(raw: string): Record<string, unknown> | undefined {
|
|
39
|
+
if (raw.trim() === '') return {};
|
|
40
|
+
let parsed: unknown;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
47
|
+
? (parsed as Record<string, unknown>)
|
|
48
|
+
: {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Refuse a FINISHED turn that carried unreadable arguments. Called by a provider once usage is
|
|
53
|
+
* known — on a stream that is the last frame, long after the call closed — so the error can say
|
|
54
|
+
* what the answer cost. A truncated turn passes: its fragments were already dropped.
|
|
55
|
+
*/
|
|
56
|
+
export function assertToolArguments(turn: {
|
|
57
|
+
readonly provider: string;
|
|
58
|
+
readonly model: ModelId;
|
|
59
|
+
readonly stopReason: StopReason;
|
|
60
|
+
readonly malformed: readonly MalformedToolCall[];
|
|
61
|
+
readonly usage: TokenUsage;
|
|
62
|
+
readonly cost: Money;
|
|
63
|
+
/** The raw finish reason, when the wire did not know it (`openai-wire.ts`). */
|
|
64
|
+
readonly unknownFinish?: string | undefined;
|
|
65
|
+
}): void {
|
|
66
|
+
// A reason nobody knows is not truncation. A turn that is whole was read as a stop; one that is
|
|
67
|
+
// not — arguments that do not parse — or one the endpoint itself called an `error`, is refused
|
|
68
|
+
// by name, with its bill, instead of being guessed at.
|
|
69
|
+
const unknown = turn.unknownFinish;
|
|
70
|
+
if (unknown !== undefined && (turn.malformed.length > 0 || unknown.toLowerCase() === 'error')) {
|
|
71
|
+
throw new AiProviderUnexpectedError({
|
|
72
|
+
provider: turn.provider,
|
|
73
|
+
model: turn.model,
|
|
74
|
+
reason: unknown,
|
|
75
|
+
billed: billedCall(turn.usage, turn.cost),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const first = turn.malformed[0];
|
|
79
|
+
if (first === undefined || isTruncated(turn.stopReason)) return;
|
|
80
|
+
throw new LlmToolArgumentsError({
|
|
81
|
+
provider: turn.provider,
|
|
82
|
+
model: turn.model,
|
|
83
|
+
tool: first.name,
|
|
84
|
+
chars: first.chars,
|
|
85
|
+
unkeyed: first.unkeyed === true,
|
|
86
|
+
billed: billedCall(turn.usage, turn.cost),
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { Actor } from '@ultimat3/core';
|
|
|
24
24
|
import { isMcpExposed, tryUseContext } from '@ultimat3/core';
|
|
25
25
|
import type { WireJsonSchema } from '@ultimat3/schema';
|
|
26
26
|
import { toWireSchema } from '@ultimat3/schema';
|
|
27
|
-
import { discloseFailure } from './failure-disclosure';
|
|
27
|
+
import { discloseFailure, discloseOwnFailure } from './failure-disclosure';
|
|
28
28
|
import { takeToolIdempotencyKey, withToolIdempotencyKey } from './tool-idempotency';
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -69,6 +69,14 @@ export interface ProjectableAction {
|
|
|
69
69
|
readonly description?: string;
|
|
70
70
|
readonly mcp?: { readonly expose?: boolean; readonly description?: string };
|
|
71
71
|
readonly inputJsonSchema?: JsonSchema;
|
|
72
|
+
/**
|
|
73
|
+
* The tool runs IN PROCESS — the app's own code, called by its own agent — and is not the
|
|
74
|
+
* projection of an exposed action. Its typed refusal is then shown to the model whole, as a
|
|
75
|
+
* structured error (`discloseOwnFailure`); a projected tool keeps the rule MCP follows.
|
|
76
|
+
*/
|
|
77
|
+
readonly inProcess?: boolean;
|
|
78
|
+
/** In-process only: `false` withholds its refusals as a projected tool's are. Default `true`. */
|
|
79
|
+
readonly discloseErrors?: boolean;
|
|
72
80
|
run(args: { input: unknown; actor: Actor }): Promise<unknown>;
|
|
73
81
|
}
|
|
74
82
|
|
|
@@ -147,6 +155,16 @@ export function toLlmTool(action: ProjectableAction): LlmTool {
|
|
|
147
155
|
};
|
|
148
156
|
}
|
|
149
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Whether an agent may be OFFERED this tool: an action an MCP surface exposes (`isMcpExposed`,
|
|
160
|
+
* core's one predicate — an in-app agent and an external one see the same catalogue of ACTIONS), or
|
|
161
|
+
* a tool that is this process's alone (`inProcessTool()`), which no surface but the agent sees.
|
|
162
|
+
*/
|
|
163
|
+
export const isOfferedTool = (tool: {
|
|
164
|
+
readonly mcp?: ProjectableAction['mcp'];
|
|
165
|
+
readonly inProcess?: boolean;
|
|
166
|
+
}): boolean => tool.inProcess === true || isMcpExposed(tool.mcp);
|
|
167
|
+
|
|
150
168
|
/**
|
|
151
169
|
* Every exposed action as a tool definition, in stable name order. The gateway and MCP ask
|
|
152
170
|
* `isMcpExposed` — @ultimat3/core's one predicate — so an in-app agent and an external one are
|
|
@@ -154,7 +172,7 @@ export function toLlmTool(action: ProjectableAction): LlmTool {
|
|
|
154
172
|
*/
|
|
155
173
|
export function toLlmTools(actions: readonly ProjectableAction[]): readonly LlmTool[] {
|
|
156
174
|
return actions
|
|
157
|
-
.filter(
|
|
175
|
+
.filter(isOfferedTool)
|
|
158
176
|
.map(toLlmTool)
|
|
159
177
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
160
178
|
}
|
|
@@ -167,8 +185,10 @@ export async function runLlmToolCall(
|
|
|
167
185
|
actions: readonly ProjectableAction[],
|
|
168
186
|
call: LlmToolCall,
|
|
169
187
|
actor: Actor,
|
|
188
|
+
/** `discloseErrors: false` withholds every in-process tool's refusal (`agent({ discloseToolErrors })`). */
|
|
189
|
+
options: { readonly discloseErrors?: boolean } = {},
|
|
170
190
|
): Promise<LlmToolResult> {
|
|
171
|
-
const action = actions.find((a) => a.name === call.name &&
|
|
191
|
+
const action = actions.find((a) => a.name === call.name && isOfferedTool(a));
|
|
172
192
|
if (action === undefined) {
|
|
173
193
|
return { toolUseId: call.id, content: `unknown tool: ${call.name}`, isError: true };
|
|
174
194
|
}
|
|
@@ -177,7 +197,8 @@ export async function runLlmToolCall(
|
|
|
177
197
|
return resultOf(call.id, output);
|
|
178
198
|
} catch (error) {
|
|
179
199
|
// A policy denial is an outcome the model should read and react to, not a crash.
|
|
180
|
-
|
|
200
|
+
const content = describeFailure(error, action, options.discloseErrors !== false);
|
|
201
|
+
return { toolUseId: call.id, content, isError: true };
|
|
181
202
|
}
|
|
182
203
|
}
|
|
183
204
|
|
|
@@ -212,14 +233,38 @@ function resultOf(toolUseId: string, output: unknown): LlmToolResult {
|
|
|
212
233
|
export const HIDDEN_TOOL_CAUSE =
|
|
213
234
|
'the tool failed inside the server; the details are withheld from this conversation; the server log records its code and where it failed';
|
|
214
235
|
|
|
236
|
+
/** The most of an in-process refusal's `cause` / `fix` a model is sent. */
|
|
237
|
+
export const OWN_CAUSE_CHARS = 1_000;
|
|
238
|
+
export const OWN_FIX_CHARS = 500;
|
|
239
|
+
|
|
240
|
+
const clip = (text: string, chars: number): string =>
|
|
241
|
+
text.length <= chars ? text : `${text.slice(0, chars - 1)}…`;
|
|
242
|
+
|
|
215
243
|
/**
|
|
216
244
|
* `discloseFailure`'s verdict, rendered as the `tool_result` line — the same rule a hive member's
|
|
217
245
|
* reason follows, so what an agent may read of a throw is decided once. An uncoded throw says only
|
|
218
246
|
* that it failed; a withheld cause becomes `HIDDEN_TOOL_CAUSE`, and the log has the rest.
|
|
247
|
+
*
|
|
248
|
+
* An in-process tool's OWN refusal is rendered as JSON — `{ error: { code, cause, fix } }` — the
|
|
249
|
+
* structured form of the three authored fields, and nothing but them.
|
|
219
250
|
*/
|
|
220
|
-
function describeFailure(error: unknown,
|
|
221
|
-
const
|
|
251
|
+
function describeFailure(error: unknown, tool: ProjectableAction, disclose: boolean): string {
|
|
252
|
+
const logger = tryUseContext()?.logger;
|
|
253
|
+
const where = `tool ${tool.name}`;
|
|
254
|
+
const own = disclose && tool.inProcess === true && tool.discloseErrors !== false;
|
|
255
|
+
const shown = own
|
|
256
|
+
? discloseOwnFailure(error, logger, where)
|
|
257
|
+
: discloseFailure(error, logger, where);
|
|
222
258
|
if (shown.code === undefined) return 'tool failed';
|
|
259
|
+
if (own && shown.cause !== undefined) {
|
|
260
|
+
// Bounded BEFORE it is serialised: the loop truncates a tool result at `maxToolResultChars`,
|
|
261
|
+
// and a cut through the middle of this JSON is a string the model cannot read.
|
|
262
|
+
const cause = clip(shown.cause, OWN_CAUSE_CHARS);
|
|
263
|
+
const fix = shown.fix === undefined ? undefined : clip(shown.fix, OWN_FIX_CHARS);
|
|
264
|
+
return JSON.stringify({
|
|
265
|
+
error: { code: clip(shown.code, 128), cause, ...(fix === undefined ? {} : { fix }) },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
223
268
|
const line = `${shown.code}: ${shown.cause ?? HIDDEN_TOOL_CAUSE}`;
|
|
224
269
|
return shown.fix === undefined ? line : `${line} (fix: ${shown.fix})`;
|
|
225
270
|
}
|
package/src/wire.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { renderThrowable } from '@ultimat3/core';
|
|
|
11
11
|
import { AiTransportError } from './errors';
|
|
12
12
|
import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
|
|
13
13
|
import type { SseFrame } from './sse';
|
|
14
|
+
import type { MalformedToolCall } from './tool-arguments';
|
|
15
|
+
import { parseToolArguments } from './tool-arguments';
|
|
14
16
|
import type { LlmToolCall } from './tools';
|
|
15
17
|
|
|
16
18
|
export const ZERO_USAGE: TokenUsage = {
|
|
@@ -141,6 +143,8 @@ interface PendingTool {
|
|
|
141
143
|
export interface StreamState {
|
|
142
144
|
readonly text: string;
|
|
143
145
|
readonly toolCalls: readonly LlmToolCall[];
|
|
146
|
+
/** Calls whose arguments were not JSON — never among `toolCalls`; `openai-wire.ts`'s twin. */
|
|
147
|
+
readonly malformed: readonly MalformedToolCall[];
|
|
144
148
|
readonly stopReason: StopReason;
|
|
145
149
|
readonly stopDetails: StopDetails | undefined;
|
|
146
150
|
readonly usage: TokenUsage;
|
|
@@ -154,6 +158,7 @@ export interface StreamState {
|
|
|
154
158
|
export class MessageStream {
|
|
155
159
|
private text = '';
|
|
156
160
|
private readonly toolCalls: LlmToolCall[] = [];
|
|
161
|
+
private readonly malformed: MalformedToolCall[] = [];
|
|
157
162
|
private stopReason: StopReason = 'end_turn';
|
|
158
163
|
private stopDetails: StopDetails | undefined;
|
|
159
164
|
private usage: TokenUsage = ZERO_USAGE;
|
|
@@ -199,6 +204,7 @@ export class MessageStream {
|
|
|
199
204
|
return {
|
|
200
205
|
text: this.text,
|
|
201
206
|
toolCalls: [...this.toolCalls],
|
|
207
|
+
malformed: [...this.malformed],
|
|
202
208
|
// A refusal detail is a refusal whatever the stop reason says. `parseStopReason` answers
|
|
203
209
|
// `end_turn` for a spelling this build has never seen, and every consumer branches on the
|
|
204
210
|
// REASON, so the pair would read as a complete answer that happens to be empty.
|
|
@@ -272,24 +278,19 @@ export class MessageStream {
|
|
|
272
278
|
const tool = this.pending.get(index);
|
|
273
279
|
if (tool === undefined) return [];
|
|
274
280
|
this.pending.delete(index);
|
|
275
|
-
|
|
281
|
+
// A tool with no arguments streams no `input_json_delta` at all, which is `{}`, not a fault.
|
|
282
|
+
const input = parseToolArguments(tool.json);
|
|
283
|
+
if (input === undefined) {
|
|
284
|
+
// Not thrown here: the block closes BEFORE `message_delta` says why the turn stopped, and
|
|
285
|
+
// that reason is what tells a cut-off call from broken JSON. The provider decides, at the end.
|
|
286
|
+
this.malformed.push({ name: tool.name, chars: tool.json.length });
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
const call: LlmToolCall = { id: tool.id, name: tool.name, input };
|
|
276
290
|
this.toolCalls.push(call);
|
|
277
291
|
return [{ type: 'tool-call', call }];
|
|
278
292
|
}
|
|
279
293
|
|
|
280
|
-
/** A tool with no arguments streams no `input_json_delta` at all, which is `{}`, not a fault. */
|
|
281
|
-
private inputOf(tool: PendingTool): Record<string, unknown> {
|
|
282
|
-
if (tool.json === '') return {};
|
|
283
|
-
try {
|
|
284
|
-
return asToolInput(JSON.parse(tool.json));
|
|
285
|
-
} catch (error) {
|
|
286
|
-
throw new AiTransportError({
|
|
287
|
-
provider: 'anthropic',
|
|
288
|
-
detail: `tool "${tool.name}" streamed arguments that are not JSON: ${renderThrowable(error)}`,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
294
|
private onMessageDelta(payload: Record<string, unknown>): readonly StreamChunk[] {
|
|
294
295
|
const delta = asRecord(payload['delta']);
|
|
295
296
|
// ABSENT is not a report, and neither is `null`. `!== null` alone admitted the absent key, so
|