@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.
Files changed (47) hide show
  1. package/CLAUDE.md +63 -90
  2. package/README.md +125 -8
  3. package/package.json +23 -11
  4. package/src/agent-checkpoint.ts +44 -0
  5. package/src/agent-compact-def.ts +11 -0
  6. package/src/agent-compact-measure.ts +38 -0
  7. package/src/agent-compact-request.ts +100 -0
  8. package/src/agent-compact-text.ts +95 -0
  9. package/src/agent-compact.ts +75 -50
  10. package/src/agent-continue.ts +15 -3
  11. package/src/agent-def.ts +9 -0
  12. package/src/agent-errors.ts +12 -2
  13. package/src/agent-facts.ts +8 -0
  14. package/src/agent-send.ts +82 -0
  15. package/src/agent-stream.ts +76 -0
  16. package/src/agent.ts +71 -21
  17. package/src/answer-errors.ts +1 -1
  18. package/src/anthropic-provider.ts +291 -0
  19. package/src/content-errors.ts +1 -1
  20. package/src/context-overflow.ts +86 -0
  21. package/src/error-codes.ts +95 -0
  22. package/src/errors-retry.ts +8 -4
  23. package/src/errors.ts +6 -87
  24. package/src/eval-agent.ts +100 -0
  25. package/src/eval-errors.ts +31 -5
  26. package/src/evals.ts +125 -45
  27. package/src/gateway-providers.ts +215 -0
  28. package/src/gateway.ts +63 -153
  29. package/src/hive-errors.ts +1 -1
  30. package/src/in-process-tool.ts +20 -11
  31. package/src/index.ts +31 -7
  32. package/src/llm.ts +39 -8
  33. package/src/model-resolve.ts +7 -1
  34. package/src/openai-body.ts +12 -5
  35. package/src/openai-provider.ts +28 -8
  36. package/src/openai-wire.ts +11 -7
  37. package/src/prompt-cache-rows.ts +82 -0
  38. package/src/prompt-cache.ts +24 -2
  39. package/src/provider.ts +15 -274
  40. package/src/remote-embedder.ts +38 -11
  41. package/src/run-meter.ts +40 -2
  42. package/src/runtime.ts +38 -3
  43. package/src/scorers.ts +35 -1
  44. package/src/scripted-provider.ts +146 -0
  45. package/src/tools-gate.ts +46 -0
  46. package/src/tools.ts +101 -27
  47. 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
- handle: async (args) =>
120
- run(
121
- def,
122
- respond,
123
- await resolvePerRun(def, { ...declared, adapted: adapt() }, args),
124
- { sampling, owner, compact },
125
- args,
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.sampling;
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, never after a 400: a turn the window cannot take is
303
- // compacted first, or refused (`X_AGENT_CONTEXT_EXHAUSTED`) without being sent.
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 request: GenerateRequest = { ...base, messages };
307
- assertCost(request);
308
- const result = await deadline
309
- .guard(meter.call(gateway.generate(request)))
310
- .catch((error: unknown) => {
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;
@@ -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 ./errors.
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
+ }
@@ -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
- // ./errors with the rest: one owner, one registration, one place a duplicate can surface.
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