@ultimat3/ai 29.4.0 → 30.0.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 +63 -90
- package/README.md +125 -8
- package/package.json +23 -11
- package/src/agent-checkpoint.ts +44 -0
- package/src/agent-compact-def.ts +11 -0
- package/src/agent-compact-measure.ts +38 -0
- package/src/agent-compact-request.ts +100 -0
- package/src/agent-compact-text.ts +95 -0
- package/src/agent-compact.ts +75 -50
- package/src/agent-continue.ts +15 -3
- package/src/agent-def.ts +9 -0
- package/src/agent-errors.ts +12 -2
- package/src/agent-facts.ts +8 -0
- package/src/agent-send.ts +82 -0
- package/src/agent-stream.ts +76 -0
- package/src/agent.ts +71 -21
- package/src/answer-errors.ts +1 -1
- package/src/anthropic-provider.ts +291 -0
- package/src/content-errors.ts +1 -1
- package/src/context-overflow.ts +86 -0
- package/src/error-codes.ts +95 -0
- package/src/errors-retry.ts +8 -4
- package/src/errors.ts +6 -87
- package/src/eval-agent.ts +100 -0
- package/src/eval-errors.ts +31 -5
- package/src/evals.ts +125 -45
- package/src/gateway-providers.ts +215 -0
- package/src/gateway.ts +63 -153
- package/src/hive-errors.ts +1 -1
- package/src/in-process-tool.ts +20 -11
- package/src/index.ts +31 -7
- package/src/llm.ts +39 -8
- package/src/model-resolve.ts +7 -1
- package/src/openai-body.ts +12 -5
- package/src/openai-provider.ts +28 -8
- package/src/openai-wire.ts +11 -7
- package/src/prompt-cache-rows.ts +82 -0
- package/src/prompt-cache.ts +24 -2
- package/src/provider.ts +15 -274
- package/src/remote-embedder.ts +38 -11
- package/src/run-meter.ts +40 -2
- package/src/runtime.ts +38 -3
- package/src/scorers.ts +35 -1
- package/src/scripted-provider.ts +146 -0
- package/src/tools-gate.ts +46 -0
- package/src/tools.ts +101 -27
- package/src/wire.ts +12 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Single responsibility: sending one `agent()` turn — and, when the provider refuses it as over
|
|
2
|
+
// the window (`X_AI_CONTEXT_OVERFLOW`), compacting once and sending it again. The loop still holds
|
|
3
|
+
// the transcript after that 400, so the refusal is recoverable; a second one, or `compact: 'off'`,
|
|
4
|
+
// is `X_AGENT_CONTEXT_EXHAUSTED` with the provider's own words as its reason.
|
|
5
|
+
|
|
6
|
+
import { isUltimateError, renderThrowable, stringField } from '@ultimat3/core';
|
|
7
|
+
import type { Compactor } from './agent-compact';
|
|
8
|
+
import { promptTokens, roomOf } from './agent-compact';
|
|
9
|
+
import { AgentContextExhaustedError } from './agent-errors';
|
|
10
|
+
import type { ModelId } from './models';
|
|
11
|
+
import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
|
|
12
|
+
|
|
13
|
+
const OVERFLOW = 'X_AI_CONTEXT_OVERFLOW';
|
|
14
|
+
|
|
15
|
+
export interface TurnSend {
|
|
16
|
+
readonly agent: string;
|
|
17
|
+
readonly model: ModelId;
|
|
18
|
+
/** The run's request with no messages. */
|
|
19
|
+
readonly base: GenerateRequest;
|
|
20
|
+
/** `undefined` under `compact: 'off'`: an overflow then ends the run. */
|
|
21
|
+
readonly compacting: Compactor | undefined;
|
|
22
|
+
/** One metered, deadline-guarded, cost-checked provider call. */
|
|
23
|
+
readonly send: (request: GenerateRequest) => Promise<GenerateResult>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SentTurn {
|
|
27
|
+
readonly result: GenerateResult;
|
|
28
|
+
/** What was sent: the messages given, or their forced compaction. */
|
|
29
|
+
readonly messages: readonly AiMessage[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The provider's text, read totally: the value came out of a `catch`. */
|
|
33
|
+
const refusalOf = (error: unknown): string =>
|
|
34
|
+
(isUltimateError(error) ? stringField(error.meta, 'detail') : undefined) ??
|
|
35
|
+
renderThrowable(error);
|
|
36
|
+
|
|
37
|
+
const isOverflow = (error: unknown): boolean => stringField(error, 'code') === OVERFLOW;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Send `messages` as turn `turn`. `onCompacted` hears the forced compaction before the re-send, so
|
|
41
|
+
* a failure after it resumes from the shorter transcript.
|
|
42
|
+
*/
|
|
43
|
+
export async function sendTurn(
|
|
44
|
+
input: TurnSend,
|
|
45
|
+
messages: readonly AiMessage[],
|
|
46
|
+
turn: number,
|
|
47
|
+
onCompacted: (messages: readonly AiMessage[]) => void,
|
|
48
|
+
): Promise<SentTurn> {
|
|
49
|
+
const { base, compacting } = input;
|
|
50
|
+
const exhausted = (sent: readonly AiMessage[], error: unknown, reason: string): never => {
|
|
51
|
+
throw new AgentContextExhaustedError({
|
|
52
|
+
agent: input.agent,
|
|
53
|
+
model: input.model,
|
|
54
|
+
estimate: promptTokens({ ...base, messages: sent }),
|
|
55
|
+
limit: roomOf(input.model, base.maxTokens),
|
|
56
|
+
reason,
|
|
57
|
+
refused: refusalOf(error),
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
const observed = async (sent: readonly AiMessage[]): Promise<SentTurn> => {
|
|
61
|
+
const request: GenerateRequest = { ...base, messages: sent };
|
|
62
|
+
const result = await input.send(request);
|
|
63
|
+
compacting?.observe(result.usage, request);
|
|
64
|
+
return { result, messages: sent };
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
return await observed(messages);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!isOverflow(error)) throw error;
|
|
70
|
+
if (compacting === undefined) {
|
|
71
|
+
return exhausted(messages, error, "compact is 'off', so nothing could shorten it");
|
|
72
|
+
}
|
|
73
|
+
const compacted = await compacting.force(messages, turn, refusalOf(error));
|
|
74
|
+
onCompacted(compacted);
|
|
75
|
+
try {
|
|
76
|
+
return await observed(compacted);
|
|
77
|
+
} catch (again) {
|
|
78
|
+
if (!isOverflow(again)) throw again;
|
|
79
|
+
return exhausted(compacted, again, 'the compacted request was refused as well');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An agent run, streamed. `agent()` declares `stream: AgentChunk`, so a page asking its route for
|
|
3
|
+
* `text/event-stream` sees each turn as it lands — the turn's text, each tool call and its outcome,
|
|
4
|
+
* the turn's end — instead of a spinner for the whole loop. Facts only: no arguments, no tool
|
|
5
|
+
* output, no transcript, the same line `onTurn` holds. `done` is the action's own final frame.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { t } from '@ultimat3/schema';
|
|
9
|
+
|
|
10
|
+
export const AgentChunk = t.discriminatedUnion(
|
|
11
|
+
'type',
|
|
12
|
+
t.object({ type: t.literal('text'), turn: t.number, text: t.string }),
|
|
13
|
+
t.object({ type: t.literal('tool_call'), turn: t.number, id: t.string, name: t.string }),
|
|
14
|
+
t.object({
|
|
15
|
+
type: t.literal('tool_result'),
|
|
16
|
+
turn: t.number,
|
|
17
|
+
id: t.string,
|
|
18
|
+
name: t.string,
|
|
19
|
+
isError: t.boolean,
|
|
20
|
+
}),
|
|
21
|
+
t.object({ type: t.literal('turn'), turn: t.number, maxTurns: t.number }),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export type AgentChunkValue =
|
|
25
|
+
| { readonly type: 'text'; readonly turn: number; readonly text: string }
|
|
26
|
+
| {
|
|
27
|
+
readonly type: 'tool_call';
|
|
28
|
+
readonly turn: number;
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly name: string;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
readonly type: 'tool_result';
|
|
34
|
+
readonly turn: number;
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly isError: boolean;
|
|
38
|
+
}
|
|
39
|
+
| { readonly type: 'turn'; readonly turn: number; readonly maxTurns: number };
|
|
40
|
+
|
|
41
|
+
export type EmitChunk = (chunk: AgentChunkValue) => void;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A callback-driven run as the async generator a stream-declaring action's `handle` is: chunks are
|
|
45
|
+
* queued as `run` emits them and yielded in order; the run's answer is the generator's return.
|
|
46
|
+
* A consumer that stops early (`return()`) leaves `run` to the context's own abort.
|
|
47
|
+
*/
|
|
48
|
+
export async function* streamOf<T>(
|
|
49
|
+
run: (emit: EmitChunk) => Promise<T>,
|
|
50
|
+
): AsyncGenerator<AgentChunkValue, T, undefined> {
|
|
51
|
+
const queue: AgentChunkValue[] = [];
|
|
52
|
+
let wake: (() => void) | undefined;
|
|
53
|
+
const emit: EmitChunk = (chunk) => {
|
|
54
|
+
queue.push(chunk);
|
|
55
|
+
wake?.();
|
|
56
|
+
};
|
|
57
|
+
let settled = false;
|
|
58
|
+
const finished = run(emit).finally(() => {
|
|
59
|
+
settled = true;
|
|
60
|
+
wake?.();
|
|
61
|
+
});
|
|
62
|
+
// Observed here so a failure is never unhandled while the loop is waiting; rethrown below.
|
|
63
|
+
finished.catch(() => undefined);
|
|
64
|
+
for (;;) {
|
|
65
|
+
const next = queue.shift();
|
|
66
|
+
if (next !== undefined) {
|
|
67
|
+
yield next;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (settled) return await finished;
|
|
71
|
+
await new Promise<void>((resolve) => {
|
|
72
|
+
wake = resolve;
|
|
73
|
+
});
|
|
74
|
+
wake = undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/agent.ts
CHANGED
|
@@ -21,11 +21,12 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { Action } from '@ultimat3/action';
|
|
24
|
-
import { action } from '@ultimat3/action';
|
|
24
|
+
import { action, noteToolNames } from '@ultimat3/action';
|
|
25
25
|
import type { Ctx } from '@ultimat3/core';
|
|
26
26
|
import { throwIfAborted, withSpan } from '@ultimat3/core';
|
|
27
27
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
28
28
|
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
29
|
+
import { checkpointer } from './agent-checkpoint';
|
|
29
30
|
import type { Compactor } from './agent-compact';
|
|
30
31
|
import { compactor } from './agent-compact';
|
|
31
32
|
import type { CompactPlan } from './agent-compact-def';
|
|
@@ -47,6 +48,10 @@ import { runDeadline, toolLoopGuard } from './agent-guards';
|
|
|
47
48
|
import type { Adapted, PerRun } from './agent-per-run';
|
|
48
49
|
import { project, resolvePerRun } from './agent-per-run';
|
|
49
50
|
import { assertRunCost } from './agent-run-limits';
|
|
51
|
+
import type { TurnSend } from './agent-send';
|
|
52
|
+
import { sendTurn } from './agent-send';
|
|
53
|
+
import type { EmitChunk } from './agent-stream';
|
|
54
|
+
import { AgentChunk, streamOf } from './agent-stream';
|
|
50
55
|
import { sameSystem, systemOf } from './agent-system';
|
|
51
56
|
import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
|
|
52
57
|
import { assertAnswerable, observeTurn } from './agent-turn';
|
|
@@ -96,6 +101,9 @@ export function agent<
|
|
|
96
101
|
tools: unexposed.map(toolLabel),
|
|
97
102
|
});
|
|
98
103
|
}
|
|
104
|
+
// An in-process tool is in no registry: the `manifest` step reads this to tell a gate on one from
|
|
105
|
+
// a gate on a typo. An action beside this one may have no name yet, and its registry answers it.
|
|
106
|
+
noteToolNames(def.tools.map((tool) => tool.name).filter((name) => name !== ''));
|
|
99
107
|
const bounds = screenBounds(def);
|
|
100
108
|
// What every run is held under; a run's `limits()` may only lower it (`narrowCeilings`).
|
|
101
109
|
const declared = { bounds, deadlineMs: def.deadlineMs, budget: def.budget };
|
|
@@ -116,13 +124,17 @@ export function agent<
|
|
|
116
124
|
output: def.output,
|
|
117
125
|
policy: def.policy,
|
|
118
126
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
// Streamed: an ordinary call drains it to the answer; `accept: text/event-stream` sees each turn.
|
|
128
|
+
stream: AgentChunk,
|
|
129
|
+
handle: (args) =>
|
|
130
|
+
streamOf(async (emit) =>
|
|
131
|
+
run(
|
|
132
|
+
def,
|
|
133
|
+
respond,
|
|
134
|
+
await resolvePerRun(def, { ...declared, adapted: adapt() }, args),
|
|
135
|
+
{ sampling, owner, compact, emit },
|
|
136
|
+
args,
|
|
137
|
+
),
|
|
126
138
|
),
|
|
127
139
|
});
|
|
128
140
|
// A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
|
|
@@ -159,6 +171,7 @@ async function run<
|
|
|
159
171
|
readonly sampling: Sampling;
|
|
160
172
|
readonly owner: object;
|
|
161
173
|
readonly compact: CompactPlan | undefined;
|
|
174
|
+
readonly emit: EmitChunk;
|
|
162
175
|
},
|
|
163
176
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
164
177
|
): Promise<InferOutput<TOutput>> {
|
|
@@ -201,7 +214,7 @@ async function run<
|
|
|
201
214
|
const system = systemOf(prompt.system, extra, redact);
|
|
202
215
|
const promptCache = promptCacheHint(def.promptCache, 'auto', args);
|
|
203
216
|
const repeated = toolLoopGuard(name, bounds.maxRepeatedToolCalls);
|
|
204
|
-
const sampling = fixed
|
|
217
|
+
const { sampling, emit } = fixed;
|
|
205
218
|
const { maxTurns, maxToolResultChars: chars } = bounds;
|
|
206
219
|
|
|
207
220
|
return withSpan('ai.agent', async (span) => {
|
|
@@ -277,6 +290,30 @@ async function run<
|
|
|
277
290
|
const count = compacting?.count() ?? prior?.compacted ?? 0;
|
|
278
291
|
return count > 0 ? { compacted: count } : {};
|
|
279
292
|
};
|
|
293
|
+
const turnSend: TurnSend = {
|
|
294
|
+
agent: name,
|
|
295
|
+
model,
|
|
296
|
+
base,
|
|
297
|
+
compacting,
|
|
298
|
+
send: (request) => {
|
|
299
|
+
assertCost(request);
|
|
300
|
+
return deadline.guard(meter.call(gateway.generate(request))).catch((error: unknown) => {
|
|
301
|
+
// The caller left mid-call: the aborted socket reads as a provider failure, which is
|
|
302
|
+
// RETRYABLE. Say what happened instead.
|
|
303
|
+
throwIfAborted(args.ctx);
|
|
304
|
+
throw error;
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
// Seals and hands over a handle between turns — `undefined`, and free, without the hook.
|
|
309
|
+
const checkpoint = checkpointer(def.onCheckpoint?.bind(def), args, binding, span);
|
|
310
|
+
const landed = (turn: number, messages: readonly AiMessage[], model: string) =>
|
|
311
|
+
checkpoint?.({
|
|
312
|
+
turn,
|
|
313
|
+
messages,
|
|
314
|
+
run: runTotal(model, prior?.usage, meter.total(), meter.below()),
|
|
315
|
+
compacted: compacting?.count() ?? prior?.compacted ?? 0,
|
|
316
|
+
});
|
|
280
317
|
// Where a failure would resume from: the request in flight, and the turns answered so far.
|
|
281
318
|
const at = { messages: [] as readonly AiMessage[], turns: prior?.turns ?? 0 };
|
|
282
319
|
const loop = async (): Promise<InferOutput<TOutput>> => {
|
|
@@ -299,20 +336,16 @@ async function run<
|
|
|
299
336
|
// budget, runs every remaining tool's side effects, and discards the answer.
|
|
300
337
|
throwIfAborted(args.ctx);
|
|
301
338
|
deadline.check();
|
|
302
|
-
// Before the request exists
|
|
303
|
-
//
|
|
339
|
+
// Before the request exists: a turn the window cannot take is compacted first, or refused
|
|
340
|
+
// (`X_AGENT_CONTEXT_EXHAUSTED`) without being sent. A provider that refuses it anyway
|
|
341
|
+
// gets one forced compaction and a re-send (`sendTurn`).
|
|
304
342
|
if (compacting !== undefined) messages = await compacting.fit(messages, turn);
|
|
305
343
|
at.messages = messages;
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// The caller left mid-call: the aborted socket reads as a provider failure, which is
|
|
312
|
-
// RETRYABLE. Say what happened instead.
|
|
313
|
-
throwIfAborted(args.ctx);
|
|
314
|
-
throw error;
|
|
315
|
-
});
|
|
344
|
+
const sent = await sendTurn(turnSend, messages, turn, (shorter) => {
|
|
345
|
+
at.messages = shorter;
|
|
346
|
+
});
|
|
347
|
+
messages = sent.messages;
|
|
348
|
+
const { result } = sent;
|
|
316
349
|
at.turns = turn;
|
|
317
350
|
const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
|
|
318
351
|
// The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
|
|
@@ -335,6 +368,7 @@ async function run<
|
|
|
335
368
|
...(rejected === undefined ? {} : { repairOf: rejected }),
|
|
336
369
|
run: runTotal(result.model, prior?.usage, meter.total(), meter.below()),
|
|
337
370
|
});
|
|
371
|
+
if (result.text !== '') emit({ type: 'text', turn, text: result.text });
|
|
338
372
|
assertAnswerable(result, name, base);
|
|
339
373
|
// Before a tool runs and before the answer is read: a turn cut off at the ceiling carries
|
|
340
374
|
// arguments that stop wherever the ceiling fell — `{}` on both wires for a call that never
|
|
@@ -361,6 +395,8 @@ async function run<
|
|
|
361
395
|
// first cannot be paired with a slow tool's call.
|
|
362
396
|
throwIfAborted(args.ctx);
|
|
363
397
|
deadline.check();
|
|
398
|
+
for (const call of requested)
|
|
399
|
+
emit({ type: 'tool_call', turn, id: call.id, name: call.name });
|
|
364
400
|
const results: readonly LlmToolResult[] = await deadline.guard(
|
|
365
401
|
// Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
|
|
366
402
|
deadline.scope(() =>
|
|
@@ -370,6 +406,17 @@ async function run<
|
|
|
370
406
|
),
|
|
371
407
|
);
|
|
372
408
|
calls += requested.length;
|
|
409
|
+
results.forEach((done, index) => {
|
|
410
|
+
const name = requested[index]?.name ?? '';
|
|
411
|
+
emit({
|
|
412
|
+
type: 'tool_result',
|
|
413
|
+
turn,
|
|
414
|
+
id: done.toolUseId,
|
|
415
|
+
name,
|
|
416
|
+
isError: done.isError === true,
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
emit({ type: 'turn', turn, maxTurns });
|
|
373
420
|
rejected = undefined;
|
|
374
421
|
// Before the next turn is bought: the same calls with the same results cannot lead
|
|
375
422
|
// anywhere a further turn would not also lead.
|
|
@@ -384,6 +431,7 @@ async function run<
|
|
|
384
431
|
// becomes an `X_AI_PROVIDER_UNAVAILABLE`.
|
|
385
432
|
toolResultTurn(results, chars, answers, redact),
|
|
386
433
|
];
|
|
434
|
+
await landed(turn, messages, result.model);
|
|
387
435
|
continue;
|
|
388
436
|
}
|
|
389
437
|
|
|
@@ -407,9 +455,11 @@ async function run<
|
|
|
407
455
|
}
|
|
408
456
|
// A wrong shape gets another turn like any other, because unlike `llm()` this loop has
|
|
409
457
|
// turns left by construction — and unlike a tool result, the correction is the message.
|
|
458
|
+
emit({ type: 'turn', turn, maxTurns });
|
|
410
459
|
rejected = formatIssues(parsed.issues);
|
|
411
460
|
issues = rejected.join('; ');
|
|
412
461
|
messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
|
|
462
|
+
await landed(turn, messages, result.model);
|
|
413
463
|
}
|
|
414
464
|
|
|
415
465
|
at.messages = messages;
|
package/src/answer-errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The two failures of an answer that ARRIVED and cannot be used: cut off at the ceiling, or a tool
|
|
2
2
|
// call whose arguments do not parse. Apart from ./errors only because one file has one job and
|
|
3
3
|
// that catalogue is at its line ceiling — the split `hive-errors.ts` and `agent-errors.ts` made.
|
|
4
|
-
// The codes, their titles and the single `registerErrorCodes` call stay in ./
|
|
4
|
+
// The codes, their titles and the single `registerErrorCodes` call stay in ./error-codes.
|
|
5
5
|
|
|
6
6
|
import { UltimateError } from '@ultimat3/core';
|
|
7
7
|
import type { Billed } from './billed';
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// The Anthropic Messages API provider, streaming and not: `anthropicProvider()`, the request body,
|
|
2
|
+
// the one `fetch` it sends, and `parseMessage` for a non-streamed answer. The shared shapes are
|
|
3
|
+
// ./provider's; ./models owns the per-model rules and ./wire the streamed response half.
|
|
4
|
+
|
|
5
|
+
import { assertMediaContent } from './content-blocks';
|
|
6
|
+
import { providerFailure } from './context-overflow';
|
|
7
|
+
import { costOf, exactCostOf } from './cost';
|
|
8
|
+
import { detailOf, withoutKey } from './error-body';
|
|
9
|
+
import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
|
|
10
|
+
import type { AiFetch } from './fetch-seam';
|
|
11
|
+
import { resolveModel } from './model-resolve';
|
|
12
|
+
import type { ModelId } from './models';
|
|
13
|
+
import { modelSpec, reasoningBody } from './models';
|
|
14
|
+
import type { ProviderCacheRow, ProviderPromptCache } from './prompt-cache';
|
|
15
|
+
import { promptCacheRule } from './prompt-cache';
|
|
16
|
+
import { anthropicPrompt } from './prompt-cache-blocks';
|
|
17
|
+
import { screenCacheRows } from './prompt-cache-rows';
|
|
18
|
+
import type { GenerateRequest, GenerateResult, Provider, StreamChunk } from './provider';
|
|
19
|
+
import { samplingBody } from './sampling';
|
|
20
|
+
import { readSse } from './sse';
|
|
21
|
+
import { assertToolArguments } from './tool-arguments';
|
|
22
|
+
import type { LlmToolCall } from './tools';
|
|
23
|
+
import {
|
|
24
|
+
asToolInput,
|
|
25
|
+
MessageStream,
|
|
26
|
+
parseStopDetails,
|
|
27
|
+
parseStopReason,
|
|
28
|
+
parseUsage,
|
|
29
|
+
throwInBandError,
|
|
30
|
+
} from './wire';
|
|
31
|
+
|
|
32
|
+
export interface AnthropicProviderInput {
|
|
33
|
+
/**
|
|
34
|
+
* The ids this endpoint serves — REQUIRED, the app's list, never a built-in one: the gateway
|
|
35
|
+
* routes by membership, and each id is one the app `registerModel`-ed. A direct call names its
|
|
36
|
+
* model or is refused (`X_AI_MODEL_UNRESOLVED`), as `openAiProvider({ models })`'s is.
|
|
37
|
+
*/
|
|
38
|
+
readonly models: readonly ModelId[];
|
|
39
|
+
/** Reads `ANTHROPIC_API_KEY` when omitted. Absent at call time is a labelled throw. */
|
|
40
|
+
readonly apiKey?: string;
|
|
41
|
+
readonly baseUrl?: string;
|
|
42
|
+
/**
|
|
43
|
+
* What this endpoint does with a `cache_control` mark, when `PROMPT_CACHE_RULES` guesses wrong —
|
|
44
|
+
* a proxy that rejects the field: `{ rules: [{ model: '', marks: 'none', session: 'none' }] }`.
|
|
45
|
+
* Read before the table, first match by model prefix. This wire speaks `blocks` or `none`, and
|
|
46
|
+
* carries no session; anything else is `X_AI_REQUEST_INVALID` here.
|
|
47
|
+
*/
|
|
48
|
+
readonly promptCache?: ProviderPromptCache;
|
|
49
|
+
/** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
|
|
50
|
+
readonly fetch?: AiFetch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
54
|
+
const API_KEY_ENV = 'ANTHROPIC_API_KEY';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Above this ceiling a non-streaming request sits on an open socket past the HTTP timeout and
|
|
58
|
+
* fails AFTER the completion was generated and billed. Every current model can be asked for
|
|
59
|
+
* eight times this, so the limit is the transport's, not the model's.
|
|
60
|
+
*/
|
|
61
|
+
export const STREAM_ONLY_MAX_TOKENS = 16_000;
|
|
62
|
+
|
|
63
|
+
/** Whether this request has to go over the streaming transport to arrive at all. */
|
|
64
|
+
export function requiresStreaming(request: GenerateRequest): boolean {
|
|
65
|
+
const model = resolveModel('provider', request.model);
|
|
66
|
+
return Math.min(request.maxTokens, modelSpec(model).maxOutput) > STREAM_ONLY_MAX_TOKENS;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The Anthropic Messages API provider — the one way to build one; the class is a type only. */
|
|
70
|
+
export function anthropicProvider(input: AnthropicProviderInput): AnthropicProvider {
|
|
71
|
+
return new AnthropicProvider(input);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The real Messages API shape. The request-surface rules are encoded rather than documented,
|
|
76
|
+
* because getting them wrong is a 400:
|
|
77
|
+
* - `top_p`/`top_k` are never sent, and `temperature` only to a model whose row takes it
|
|
78
|
+
* (`ModelSpec.sampling`): a reasoning model rejects all three.
|
|
79
|
+
* - `thinking.budget_tokens` is REJECTED. Use `output_config.effort`.
|
|
80
|
+
* - `effort` and adaptive thinking are per-model, and ./models owns which model takes which.
|
|
81
|
+
*/
|
|
82
|
+
export class AnthropicProvider implements Provider {
|
|
83
|
+
readonly name = 'anthropic';
|
|
84
|
+
readonly wire = 'anthropic' as const;
|
|
85
|
+
/** The app's list, never the registry's: a model served elsewhere must not be routed here. */
|
|
86
|
+
readonly models: readonly ModelId[];
|
|
87
|
+
private readonly config: AnthropicProviderInput;
|
|
88
|
+
private readonly cacheRows: readonly ProviderCacheRow[];
|
|
89
|
+
|
|
90
|
+
// `| undefined`: plain JS (or a cast) reaches here with no argument, and that is the same boot
|
|
91
|
+
// mistake as an empty list — it gets the coded refusal, never a TypeError on `.models`.
|
|
92
|
+
constructor(config: AnthropicProviderInput | undefined) {
|
|
93
|
+
if (config === undefined || (config.models ?? []).length === 0) {
|
|
94
|
+
// A provider serving nothing can never be routed to — the same boot mistake, refused the
|
|
95
|
+
// same way, as an empty `openAiProvider({ models })`.
|
|
96
|
+
throw new AiRequestInvalidError({
|
|
97
|
+
detail:
|
|
98
|
+
'anthropicProvider() was given an empty models list, so the gateway can never route to it',
|
|
99
|
+
fix: "anthropicProvider({ models: ['<id>'] }) # the ids this endpoint serves, each one your app registerModel-ed",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
this.models = config.models;
|
|
103
|
+
this.config = config;
|
|
104
|
+
this.cacheRows = screenCacheRows('anthropic', config.promptCache);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The model this request is for. The gateway resolves one before it routes; a direct call names
|
|
109
|
+
* one or is refused — this provider's own list is never a fourth place a model comes from.
|
|
110
|
+
*/
|
|
111
|
+
private modelOf(request: GenerateRequest): ModelId {
|
|
112
|
+
return resolveModel('provider', request.model);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Above `STREAM_ONLY_MAX_TOKENS` this runs the STREAMING transport and returns the assembled
|
|
117
|
+
* result, rather than refusing. Refusing would leak a transport limit into the API: `llm()`
|
|
118
|
+
* has no streaming path, so a declaration asking for a legal 64k completion would become
|
|
119
|
+
* undeclarable instead of merely awkward — and the caller wanting the whole answer at once is
|
|
120
|
+
* the same caller either way.
|
|
121
|
+
*/
|
|
122
|
+
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
123
|
+
const model = this.modelOf(request);
|
|
124
|
+
if (requiresStreaming({ ...request, model })) return this.assemble(request);
|
|
125
|
+
const response = await this.send(model, request, false);
|
|
126
|
+
const raw = (await response.json()) as Record<string, unknown>;
|
|
127
|
+
return parseMessage(model, raw);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Drive `stream()` to its `done` chunk. It throws on a cut stream, so a partial never lands. */
|
|
131
|
+
private async assemble(request: GenerateRequest): Promise<GenerateResult> {
|
|
132
|
+
for await (const chunk of this.stream(request)) {
|
|
133
|
+
if (chunk.type === 'done') return chunk.result;
|
|
134
|
+
}
|
|
135
|
+
throw new AiTransportError({
|
|
136
|
+
provider: this.name,
|
|
137
|
+
detail: 'the stream completed without a result',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Streaming is mandatory above ~16k `maxTokens`: a non-streaming request that large hits the
|
|
143
|
+
* HTTP timeout before the model finishes. The final `done` chunk carries the assembled
|
|
144
|
+
* result, so a consumer that only wants the answer can ignore every chunk before it.
|
|
145
|
+
*/
|
|
146
|
+
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
147
|
+
const model = this.modelOf(request);
|
|
148
|
+
const response = await this.send(model, request, true);
|
|
149
|
+
if (response.body === null) {
|
|
150
|
+
throw new AiTransportError({
|
|
151
|
+
provider: this.name,
|
|
152
|
+
status: response.status,
|
|
153
|
+
detail: 'a streaming response arrived with no body',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const message = new MessageStream();
|
|
157
|
+
for await (const frame of readSse(response.body, this.name)) {
|
|
158
|
+
for (const chunk of message.push(frame)) yield chunk;
|
|
159
|
+
}
|
|
160
|
+
// A connection cut mid-answer must fail, not resolve: the partial text reads as a complete
|
|
161
|
+
// answer, and `end_turn` would be a lie the caller has no way to detect.
|
|
162
|
+
if (!message.isComplete()) {
|
|
163
|
+
throw new AiTransportError({
|
|
164
|
+
provider: this.name,
|
|
165
|
+
detail:
|
|
166
|
+
'the stream ended before message_stop, or with a tool call still open — the answer is truncated',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const { malformed, ...state } = message.state();
|
|
170
|
+
const cost = costOf(model, state.usage);
|
|
171
|
+
// After usage is known, so a finished turn with unreadable arguments fails WITH its bill.
|
|
172
|
+
assertToolArguments({ provider: this.name, model, ...state, malformed, cost });
|
|
173
|
+
yield {
|
|
174
|
+
type: 'done',
|
|
175
|
+
result: { model, ...state, cost, costExact: exactCostOf(model, state.usage) },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
marksPromptCache(model: ModelId): boolean {
|
|
180
|
+
return (
|
|
181
|
+
promptCacheRule('anthropic', this.config.baseUrl, model, this.cacheRows).marks === 'blocks'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The request body. Pure and side-effect free so a test can assert it directly. */
|
|
186
|
+
body(request: GenerateRequest): Record<string, unknown> {
|
|
187
|
+
const model = this.modelOf(request);
|
|
188
|
+
// The blocks pass through untouched below — they ARE this wire's shapes — so this is the screen.
|
|
189
|
+
assertMediaContent(request.messages, { provider: this.name, model });
|
|
190
|
+
const prompt = anthropicPrompt(
|
|
191
|
+
request,
|
|
192
|
+
request.promptCache !== undefined && this.marksPromptCache(model),
|
|
193
|
+
);
|
|
194
|
+
const body: Record<string, unknown> = {
|
|
195
|
+
model,
|
|
196
|
+
max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
|
|
197
|
+
messages: prompt.messages,
|
|
198
|
+
...reasoningBody(model, request.effort, request.thinking),
|
|
199
|
+
...samplingBody('anthropic', model, request),
|
|
200
|
+
};
|
|
201
|
+
if (prompt.system !== undefined) body['system'] = prompt.system;
|
|
202
|
+
if (request.tools !== undefined && request.tools.length > 0) body['tools'] = request.tools;
|
|
203
|
+
if (request.stopSequences !== undefined) body['stop_sequences'] = request.stopSequences;
|
|
204
|
+
return body;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The one place a request leaves the process. A non-2xx becomes an `AiTransportError`
|
|
209
|
+
* carrying its status, because the gateway decides whether to retry from that status and a
|
|
210
|
+
* body parsed as if it were a message would read as an empty, successful answer.
|
|
211
|
+
*/
|
|
212
|
+
private async send(model: ModelId, request: GenerateRequest, stream: boolean): Promise<Response> {
|
|
213
|
+
const body = { ...this.body(request), stream };
|
|
214
|
+
const { signal } = request;
|
|
215
|
+
const apiKey = this.config.apiKey ?? Bun.env[API_KEY_ENV];
|
|
216
|
+
if (apiKey === undefined || apiKey === '') {
|
|
217
|
+
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
218
|
+
}
|
|
219
|
+
const doFetch: AiFetch = this.config.fetch ?? fetch;
|
|
220
|
+
const url = `${this.config.baseUrl ?? 'https://api.anthropic.com'}/v1/messages`;
|
|
221
|
+
const response = await doFetch(url, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: {
|
|
224
|
+
'x-api-key': apiKey,
|
|
225
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
226
|
+
'content-type': 'application/json',
|
|
227
|
+
accept: body['stream'] === true ? 'text/event-stream' : 'application/json',
|
|
228
|
+
},
|
|
229
|
+
body: JSON.stringify(body),
|
|
230
|
+
// Attached only when the caller has one: `exactOptionalPropertyTypes`, and an explicit
|
|
231
|
+
// `signal: undefined` is a different value to some fetch implementations.
|
|
232
|
+
...(signal === undefined ? {} : { signal }),
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
// An overflow or the transport's code — `context-overflow.ts` decides, for both wires.
|
|
236
|
+
throw providerFailure({
|
|
237
|
+
provider: this.name,
|
|
238
|
+
model,
|
|
239
|
+
status: response.status,
|
|
240
|
+
// The endpoint's own message, with the credential scrubbed out of it — the same rule the
|
|
241
|
+
// OpenAI-format provider follows, and for the same reason: a proxy echoing the request
|
|
242
|
+
// headers into its 4xx body is the one path by which `x-api-key` reaches an error.
|
|
243
|
+
detail: withoutKey(await detailOf(response), apiKey),
|
|
244
|
+
envVar: API_KEY_ENV,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return response;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Map a Messages API response onto `GenerateResult`. Exported so tests can drive it. */
|
|
252
|
+
export function parseMessage(model: ModelId, raw: Record<string, unknown>): GenerateResult {
|
|
253
|
+
// A 200 carrying an `error` object instead of an answer — how a gateway in front of a model
|
|
254
|
+
// reports a fault it noticed after the headers were sent. Read as a message it is an empty,
|
|
255
|
+
// successful answer, which is the one outcome nothing downstream can detect; the STREAMED half
|
|
256
|
+
// of this same provider has always refused it.
|
|
257
|
+
throwInBandError(raw);
|
|
258
|
+
const content = Array.isArray(raw['content']) ? raw['content'] : [];
|
|
259
|
+
let text = '';
|
|
260
|
+
const toolCalls: LlmToolCall[] = [];
|
|
261
|
+
for (const block of content) {
|
|
262
|
+
if (typeof block !== 'object' || block === null) continue;
|
|
263
|
+
const b = block as Record<string, unknown>;
|
|
264
|
+
if (b['type'] === 'text' && typeof b['text'] === 'string') text += b['text'];
|
|
265
|
+
if (b['type'] === 'tool_use' && typeof b['id'] === 'string' && typeof b['name'] === 'string') {
|
|
266
|
+
toolCalls.push({
|
|
267
|
+
id: b['id'],
|
|
268
|
+
name: b['name'],
|
|
269
|
+
// Parsed, never cast: `input` is untrusted, and a string under `Record<string, unknown>`
|
|
270
|
+
// is a lie every later reader indexes into. The streamed half already parses it.
|
|
271
|
+
input: asToolInput(b['input']),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const usage = parseUsage(raw['usage']);
|
|
276
|
+
const stopDetails = parseStopDetails(raw['stop_details']);
|
|
277
|
+
return {
|
|
278
|
+
model,
|
|
279
|
+
text,
|
|
280
|
+
toolCalls,
|
|
281
|
+
// A refusal detail is a refusal whatever the stop field says: `parseStopReason` answers
|
|
282
|
+
// `end_turn` for a spelling this build has never seen, and `llm()` branches on the REASON —
|
|
283
|
+
// `stopDetails` has no reader that can refuse — so the pair would arrive as a complete answer
|
|
284
|
+
// that happens to be empty. The OpenAI-format read has always forced it.
|
|
285
|
+
stopReason: stopDetails === undefined ? parseStopReason(raw['stop_reason']) : 'refusal',
|
|
286
|
+
stopDetails,
|
|
287
|
+
usage,
|
|
288
|
+
cost: costOf(model, usage),
|
|
289
|
+
costExact: exactCostOf(model, usage),
|
|
290
|
+
};
|
|
291
|
+
}
|
package/src/content-errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The X_AI_CONTENT_UNSUPPORTED class, apart from ./errors because that catalogue is at its ceiling —
|
|
2
2
|
// the split `hive-errors.ts` and `eval-errors.ts` made. Its code and title are registered in
|
|
3
|
-
// ./
|
|
3
|
+
// ./error-codes with the rest: one owner, one registration, one place a duplicate can surface.
|
|
4
4
|
|
|
5
5
|
import { UltimateError } from '@ultimat3/core';
|
|
6
6
|
|