@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
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { Secret } from '@ultimat3/core';
11
11
  import { isSecret, revealSecret } from '@ultimat3/core';
12
+ import { providerFailure } from './context-overflow';
12
13
  import { costOf, exactCostOf } from './cost';
13
14
  import { detailOf, withoutKey } from './error-body';
14
15
  import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
@@ -18,7 +19,9 @@ import type { ModelId } from './models';
18
19
  import { chatCompletionBody, sessionHeaders } from './openai-body';
19
20
  import type { ChatAnswer } from './openai-wire';
20
21
  import { ChatCompletionStream, parseChatCompletion } from './openai-wire';
22
+ import type { ProviderCacheRow, ProviderPromptCache } from './prompt-cache';
21
23
  import { promptCacheRule } from './prompt-cache';
24
+ import { screenCacheRows } from './prompt-cache-rows';
22
25
  import type {
23
26
  GenerateRequest,
24
27
  GenerateResult,
@@ -68,6 +71,14 @@ export interface OpenAiProviderInput {
68
71
  * thing that says which of them answered.
69
72
  */
70
73
  readonly name?: string;
74
+ /**
75
+ * What this endpoint does with a cache hint, when `PROMPT_CACHE_RULES` does not name its host —
76
+ * a company gateway fronting a marking family:
77
+ * `{ rules: [{ model: 'anthropic/', marks: 'parts', session: 'x-session-id' }] }`. Read before the
78
+ * table, first match by model prefix. This wire speaks `parts` or `none`, and carries a session as
79
+ * `x-session-id`, `prompt_cache_key` or `none`; anything else is `X_AI_REQUEST_INVALID` here.
80
+ */
81
+ readonly promptCache?: ProviderPromptCache;
71
82
  /** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
72
83
  readonly fetch?: AiFetch;
73
84
  }
@@ -91,6 +102,7 @@ class OpenAiProvider implements Provider {
91
102
  readonly name: string;
92
103
  readonly models: readonly ModelId[];
93
104
  private readonly config: OpenAiProviderInput;
105
+ private readonly cacheRows: readonly ProviderCacheRow[];
94
106
 
95
107
  // `| undefined` for the reason `AnthropicProvider`'s is: no argument at all, from plain JS or a
96
108
  // cast, is the empty-list boot mistake and gets its coded refusal, never a TypeError.
@@ -107,10 +119,11 @@ class OpenAiProvider implements Provider {
107
119
  this.name = config.name ?? 'openai';
108
120
  this.models = config.models;
109
121
  this.config = config;
122
+ this.cacheRows = screenCacheRows('openai', config.promptCache);
110
123
  }
111
124
 
112
125
  marksPromptCache(model: ModelId): boolean {
113
- return promptCacheRule('openai', this.config.baseUrl, model).marks === 'parts';
126
+ return promptCacheRule('openai', this.config.baseUrl, model, this.cacheRows).marks === 'parts';
114
127
  }
115
128
 
116
129
  /**
@@ -123,12 +136,14 @@ class OpenAiProvider implements Provider {
123
136
  // this provider's first listed model — the app's three places are the only ones.
124
137
  const model = resolveModel('provider', request.model);
125
138
  if (requiresStreaming({ ...request, model })) return this.assemble(request);
126
- const { baseUrl } = this.config;
139
+ const cache = { baseUrl: this.config.baseUrl, cacheRows: this.cacheRows };
140
+ const cacheRule = promptCacheRule('openai', cache.baseUrl, model, cache.cacheRows);
127
141
  const response = await this.send(
128
- chatCompletionBody({ request, model, stream: false, provider: this.name, baseUrl }),
142
+ model,
143
+ chatCompletionBody({ request, model, stream: false, provider: this.name, ...cache }),
129
144
  false,
130
145
  request.signal,
131
- sessionHeaders(request, model, baseUrl),
146
+ sessionHeaders(request, cacheRule),
132
147
  );
133
148
  const answer = parseChatCompletion((await response.json()) as unknown, this.name);
134
149
  return this.result(request, model, answer);
@@ -136,12 +151,14 @@ class OpenAiProvider implements Provider {
136
151
 
137
152
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
138
153
  const model = resolveModel('provider', request.model);
139
- const { baseUrl } = this.config;
154
+ const cache = { baseUrl: this.config.baseUrl, cacheRows: this.cacheRows };
155
+ const cacheRule = promptCacheRule('openai', cache.baseUrl, model, cache.cacheRows);
140
156
  const response = await this.send(
141
- chatCompletionBody({ request, model, stream: true, provider: this.name, baseUrl }),
157
+ model,
158
+ chatCompletionBody({ request, model, stream: true, provider: this.name, ...cache }),
142
159
  true,
143
160
  request.signal,
144
- sessionHeaders(request, model, baseUrl),
161
+ sessionHeaders(request, cacheRule),
145
162
  );
146
163
  if (response.body === null) {
147
164
  throw new AiTransportError({
@@ -202,6 +219,7 @@ class OpenAiProvider implements Provider {
202
219
  * if it were a message would read as an empty, successful answer.
203
220
  */
204
221
  private async send(
222
+ model: ModelId,
205
223
  body: Record<string, unknown>,
206
224
  streaming: boolean,
207
225
  signal: AbortSignal | undefined,
@@ -228,8 +246,10 @@ class OpenAiProvider implements Provider {
228
246
  ...(signal === undefined ? {} : { signal }),
229
247
  });
230
248
  if (!response.ok) {
231
- throw new AiTransportError({
249
+ // An overflow or the transport's code — `context-overflow.ts` decides, for both wires.
250
+ throw providerFailure({
232
251
  provider: this.name,
252
+ model,
233
253
  status: response.status,
234
254
  // The endpoint's own message, with the credential scrubbed out of it — `error-body.ts`
235
255
  // says why, and both providers call the same pair.
@@ -436,16 +436,20 @@ export class ChatCompletionStream {
436
436
  }
437
437
 
438
438
  private payloadOf(frame: SseFrame): Record<string, unknown> {
439
+ const where = `unreadable "${frame.event}" frame`;
440
+ let parsed: unknown;
439
441
  try {
440
- const record = asRecord(JSON.parse(frame.data));
441
- if (record === undefined) throw new SyntaxError('frame data is not an object');
442
- return record;
442
+ parsed = JSON.parse(frame.data);
443
443
  } catch (error) {
444
- throw malformed(
445
- this.provider,
446
- `unreadable "${frame.event}" frame: ${renderThrowable(error)}`,
447
- );
444
+ throw malformed(this.provider, `${where}: ${renderThrowable(error)}`);
448
445
  }
446
+ const record = asRecord(parsed);
447
+ // The same coded refusal the catch above produces, thrown where the frame is known: a bare
448
+ // `SyntaxError` in shipped source carries no code, no cause and no fix.
449
+ if (record === undefined) {
450
+ throw malformed(this.provider, `${where}: frame data is not an object`);
451
+ }
452
+ return record;
449
453
  }
450
454
  }
451
455
 
@@ -0,0 +1,82 @@
1
+ // Single responsibility: screen the prompt-cache rows an app declares on a provider, at
2
+ // construction, against what that provider's wire can speak. A row the wire cannot honour would
3
+ // be a 400 on every hinted call, so it is refused at boot rather than sent.
4
+
5
+ import { renderCauseValue } from '@ultimat3/core';
6
+ import { AiRequestInvalidError } from './errors';
7
+ import type { CacheWire, ProviderCacheRow, ProviderPromptCache } from './prompt-cache';
8
+
9
+ interface WireSpeaks {
10
+ readonly factory: string;
11
+ readonly marks: readonly ProviderCacheRow['marks'][];
12
+ readonly session: readonly ProviderCacheRow['session'][];
13
+ }
14
+
15
+ /** What each wire carries: Messages has no session carrier; chat-completions has two. */
16
+ const SPEAKS: ReadonlyMap<CacheWire, WireSpeaks> = new Map<CacheWire, WireSpeaks>([
17
+ ['anthropic', { factory: 'anthropicProvider', marks: ['blocks', 'none'], session: ['none'] }],
18
+ [
19
+ 'openai',
20
+ {
21
+ factory: 'openAiProvider',
22
+ marks: ['parts', 'none'],
23
+ session: ['x-session-id', 'prompt_cache_key', 'none'],
24
+ },
25
+ ],
26
+ ]);
27
+
28
+ /** The rows, screened; `[]` for no option. Reads `unknown` because plain JS reaches here too. */
29
+ export function screenCacheRows(
30
+ wire: CacheWire,
31
+ option: ProviderPromptCache | undefined,
32
+ ): readonly ProviderCacheRow[] {
33
+ if (option === undefined) return [];
34
+ const speaks = SPEAKS.get(wire);
35
+ if (speaks === undefined) return [];
36
+ const rules: unknown = (option as { rules?: unknown }).rules;
37
+ if (!Array.isArray(rules)) {
38
+ throw refusal(speaks, `promptCache.rules is ${renderCauseValue(rules)}, not an array of rows`);
39
+ }
40
+ return rules.map((row: unknown, index) => screenRow(speaks, row, index));
41
+ }
42
+
43
+ function screenRow(speaks: WireSpeaks, row: unknown, index: number): ProviderCacheRow {
44
+ if (typeof row !== 'object' || row === null) {
45
+ throw refusal(speaks, `promptCache.rules row ${index} is ${renderCauseValue(row)}, not a row`);
46
+ }
47
+ const { model, marks, session } = row as Record<'model' | 'marks' | 'session', unknown>;
48
+ if (typeof model !== 'string') {
49
+ throw refusal(
50
+ speaks,
51
+ `promptCache.rules row ${index} has model ${renderCauseValue(model)}; a model is an id prefix string ('' is every model)`,
52
+ );
53
+ }
54
+ const mark = speaks.marks.find((known) => known === marks);
55
+ if (mark === undefined) {
56
+ throw refusal(
57
+ speaks,
58
+ `promptCache.rules row ${index} has marks ${renderCauseValue(marks)}; ${speaks.factory}()'s wire speaks marks ${quoted(speaks.marks)}`,
59
+ );
60
+ }
61
+ const carrier = speaks.session.find((known) => known === session);
62
+ if (carrier === undefined) {
63
+ throw refusal(
64
+ speaks,
65
+ `promptCache.rules row ${index} has session ${renderCauseValue(session)}; ${speaks.factory}()'s wire carries a session as ${quoted(speaks.session)}`,
66
+ );
67
+ }
68
+ return { model, marks: mark, session: carrier };
69
+ }
70
+
71
+ function refusal(speaks: WireSpeaks, detail: string): AiRequestInvalidError {
72
+ const marks = speaks.marks[0] ?? 'none';
73
+ const session = speaks.session[0] ?? 'none';
74
+ return new AiRequestInvalidError({
75
+ detail,
76
+ fix: `${speaks.factory}({ …, promptCache: { rules: [{ model: '<id prefix>', marks: '${marks}', session: '${session}' }] } }) # marks ${quoted(speaks.marks)}; session ${quoted(speaks.session)}`,
77
+ });
78
+ }
79
+
80
+ function quoted(values: readonly string[]): string {
81
+ return values.map((value) => `'${value}'`).join(' or ');
82
+ }
@@ -41,7 +41,8 @@
41
41
  //
42
42
  // Two limits of the rows below, stated rather than hidden:
43
43
  // - the `anthropic` row matches ANY host an `anthropicProvider({ baseUrl })` is pointed at — a
44
- // proxy speaking that wire is assumed to speak its `cache_control` too;
44
+ // proxy speaking that wire is assumed to speak its `cache_control` too, unless the provider
45
+ // declares a `marks: 'none'` row for it;
45
46
  // - the `parts` row marks the tail only, so the next turn finds this turn's entry through the
46
47
  // 20-position lookback alone. A turn that appends more blocks than that — more than about 9
47
48
  // tool calls in ONE turn, each a `tool_use` and a `tool_result` — misses it and is billed as a
@@ -49,6 +50,7 @@
49
50
  //
50
51
  // The table is DATA: a new host or model family is a row. Unknown endpoints get NOTHING — a field
51
52
  // a self-hosted server has never heard of is a 400 on some of them, and a mark is never worth that.
53
+ // What the app knows about its own endpoint is a row too, on the provider (`ProviderCacheRow`).
52
54
 
53
55
  /**
54
56
  * A system prompt in two parts. `stable` is byte-identical across runs — rules, a knowledge-base
@@ -142,17 +144,37 @@ export const PROMPT_CACHE_RULES: readonly PromptCacheRule[] = [
142
144
  { wire: 'openai', host: 'api.openai.com', model: '', marks: 'none', session: 'prompt_cache_key' },
143
145
  ];
144
146
 
147
+ /**
148
+ * A row an app declares on its provider (`anthropicProvider({ promptCache: { rules } })`): what
149
+ * this package cannot know about an endpoint the app runs — a company gateway fronting a marking
150
+ * family, a proxy that 400s on `cache_control`. The provider IS the wire and the host, so a row
151
+ * names neither. Screened at construction against what the wire speaks (./prompt-cache-rows).
152
+ */
153
+ export type ProviderCacheRow = Pick<PromptCacheRule, 'model' | 'marks' | 'session'>;
154
+
155
+ /** The provider option: rows read before `PROMPT_CACHE_RULES`, first match wins. */
156
+ export interface ProviderPromptCache {
157
+ readonly rules: readonly ProviderCacheRow[];
158
+ }
159
+
145
160
  const NO_RULE: Pick<PromptCacheRule, 'marks' | 'session'> = { marks: 'none', session: 'none' };
146
161
 
147
162
  /** OpenAI's own endpoint, which is what an `openAiProvider()` with no `baseUrl` talks to. */
148
163
  const DEFAULT_OPENAI_HOST = 'api.openai.com';
149
164
 
150
- /** The rule for one request. Total: a base URL that does not parse matches no host. */
165
+ /**
166
+ * The rule for one request. Total: a base URL that does not parse matches no host. The provider's
167
+ * own `rows` come first — the app's statement about its endpoint outranks this package's guess —
168
+ * then the table, then nothing.
169
+ */
151
170
  export function promptCacheRule(
152
171
  wire: CacheWire,
153
172
  baseUrl: string | undefined,
154
173
  model: string,
174
+ rows: readonly ProviderCacheRow[] = [],
155
175
  ): Pick<PromptCacheRule, 'marks' | 'session'> {
176
+ const own = rows.find((row) => model.startsWith(row.model));
177
+ if (own !== undefined) return { marks: own.marks, session: own.session };
156
178
  const host = baseUrl === undefined ? DEFAULT_OPENAI_HOST : hostOf(baseUrl);
157
179
  const found = PROMPT_CACHE_RULES.find(
158
180
  (rule) =>
package/src/provider.ts CHANGED
@@ -1,34 +1,19 @@
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
- // ./wire owns the response half.
1
+ // The provider-neutral shapes — `Provider`, the request, the result, the stream chunk — and the
2
+ // pre-flight estimates. `AnthropicProvider` is ./anthropic-provider's, the double ./echo-provider's,
3
+ // the money arithmetic ./cost's; `costOf` and the Anthropic names are re-exported here, where every
4
+ // caller has always imported them from. ./models owns the per-model rules, ./wire the response half.
5
5
 
6
6
  import type { Money } from '@ultimat3/money';
7
7
  import type { AiMediaBlock } from './content-blocks';
8
- import { assertMediaContent, blockText, mediaTokenEstimate } from './content-blocks';
9
- import { costOf, exactCostOf } from './cost';
10
- import { detailOf, withoutKey } from './error-body';
11
- import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
12
- import type { AiFetch } from './fetch-seam';
8
+ import { blockText, mediaTokenEstimate } from './content-blocks';
9
+ import { costOf } from './cost';
13
10
  import { resolveModel } from './model-resolve';
14
11
  import type { Effort, ModelId, ThinkingMode } from './models';
15
- import { modelSpec, reasoningBody } from './models';
12
+ import { modelSpec } from './models';
16
13
  import type { PromptCacheHint, SystemPrompt } from './prompt-cache';
17
- import { promptCacheRule, systemText } from './prompt-cache';
18
- import { anthropicPrompt } from './prompt-cache-blocks';
14
+ import { systemText } from './prompt-cache';
19
15
  import type { SamplingWire } from './sampling';
20
- import { samplingBody } from './sampling';
21
- import { readSse } from './sse';
22
- import { assertToolArguments } from './tool-arguments';
23
16
  import type { LlmTool, LlmToolCall } from './tools';
24
- import {
25
- asToolInput,
26
- MessageStream,
27
- parseStopDetails,
28
- parseStopReason,
29
- parseUsage,
30
- throwInBandError,
31
- } from './wire';
32
17
 
33
18
  /**
34
19
  * One block of a structured message. A plain string message is still the common case and still
@@ -172,6 +157,13 @@ export type StreamChunk =
172
157
  | { readonly type: 'tool-call'; readonly call: LlmToolCall }
173
158
  | { readonly type: 'done'; readonly result: GenerateResult };
174
159
 
160
+ export type { AnthropicProvider, AnthropicProviderInput } from './anthropic-provider';
161
+ export {
162
+ anthropicProvider,
163
+ parseMessage,
164
+ requiresStreaming,
165
+ STREAM_ONLY_MAX_TOKENS,
166
+ } from './anthropic-provider';
175
167
  export { costOf } from './cost';
176
168
 
177
169
  export interface Provider {
@@ -195,257 +187,6 @@ export function totalTokens(usage: TokenUsage): number {
195
187
  return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
196
188
  }
197
189
 
198
- // ── Anthropic ────────────────────────────────────────────────────────────────
199
-
200
- export interface AnthropicProviderInput {
201
- /**
202
- * The ids this endpoint serves — REQUIRED, the app's list, never a built-in one: the gateway
203
- * routes by membership, and each id is one the app `registerModel`-ed. A direct call names its
204
- * model or is refused (`X_AI_MODEL_UNRESOLVED`), as `openAiProvider({ models })`'s is.
205
- */
206
- readonly models: readonly ModelId[];
207
- /** Reads `ANTHROPIC_API_KEY` when omitted. Absent at call time is a labelled throw. */
208
- readonly apiKey?: string;
209
- readonly baseUrl?: string;
210
- /** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
211
- readonly fetch?: AiFetch;
212
- }
213
-
214
- const ANTHROPIC_VERSION = '2023-06-01';
215
- const API_KEY_ENV = 'ANTHROPIC_API_KEY';
216
-
217
- /**
218
- * Above this ceiling a non-streaming request sits on an open socket past the HTTP timeout and
219
- * fails AFTER the completion was generated and billed. Every current model can be asked for
220
- * eight times this, so the limit is the transport's, not the model's.
221
- */
222
- export const STREAM_ONLY_MAX_TOKENS = 16_000;
223
-
224
- /** Whether this request has to go over the streaming transport to arrive at all. */
225
- export function requiresStreaming(request: GenerateRequest): boolean {
226
- const model = resolveModel('provider', request.model);
227
- return Math.min(request.maxTokens, modelSpec(model).maxOutput) > STREAM_ONLY_MAX_TOKENS;
228
- }
229
-
230
- /** The Anthropic Messages API provider — the one way to build one; the class is a type only. */
231
- export function anthropicProvider(input: AnthropicProviderInput): AnthropicProvider {
232
- return new AnthropicProvider(input);
233
- }
234
-
235
- /**
236
- * The real Messages API shape. The request-surface rules are encoded rather than documented,
237
- * because getting them wrong is a 400:
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.
240
- * - `thinking.budget_tokens` is REJECTED. Use `output_config.effort`.
241
- * - `effort` and adaptive thinking are per-model, and ./models owns which model takes which.
242
- */
243
- export class AnthropicProvider implements Provider {
244
- readonly name = 'anthropic';
245
- readonly wire = 'anthropic' as const;
246
- /** The app's list, never the registry's: a model served elsewhere must not be routed here. */
247
- readonly models: readonly ModelId[];
248
- private readonly config: AnthropicProviderInput;
249
-
250
- // `| undefined`: plain JS (or a cast) reaches here with no argument, and that is the same boot
251
- // mistake as an empty list — it gets the coded refusal, never a TypeError on `.models`.
252
- constructor(config: AnthropicProviderInput | undefined) {
253
- if (config === undefined || (config.models ?? []).length === 0) {
254
- // A provider serving nothing can never be routed to — the same boot mistake, refused the
255
- // same way, as an empty `openAiProvider({ models })`.
256
- throw new AiRequestInvalidError({
257
- detail:
258
- 'anthropicProvider() was given an empty models list, so the gateway can never route to it',
259
- fix: "anthropicProvider({ models: ['<id>'] }) # the ids this endpoint serves, each one your app registerModel-ed",
260
- });
261
- }
262
- this.models = config.models;
263
- this.config = config;
264
- }
265
-
266
- /**
267
- * The model this request is for. The gateway resolves one before it routes; a direct call names
268
- * one or is refused — this provider's own list is never a fourth place a model comes from.
269
- */
270
- private modelOf(request: GenerateRequest): ModelId {
271
- return resolveModel('provider', request.model);
272
- }
273
-
274
- /**
275
- * Above `STREAM_ONLY_MAX_TOKENS` this runs the STREAMING transport and returns the assembled
276
- * result, rather than refusing. Refusing would leak a transport limit into the API: `llm()`
277
- * has no streaming path, so a declaration asking for a legal 64k completion would become
278
- * undeclarable instead of merely awkward — and the caller wanting the whole answer at once is
279
- * the same caller either way.
280
- */
281
- async generate(request: GenerateRequest): Promise<GenerateResult> {
282
- const model = this.modelOf(request);
283
- if (requiresStreaming({ ...request, model })) return this.assemble(request);
284
- const response = await this.send({ ...this.body(request), stream: false }, request.signal);
285
- const raw = (await response.json()) as Record<string, unknown>;
286
- return parseMessage(model, raw);
287
- }
288
-
289
- /** Drive `stream()` to its `done` chunk. It throws on a cut stream, so a partial never lands. */
290
- private async assemble(request: GenerateRequest): Promise<GenerateResult> {
291
- for await (const chunk of this.stream(request)) {
292
- if (chunk.type === 'done') return chunk.result;
293
- }
294
- throw new AiTransportError({
295
- provider: this.name,
296
- detail: 'the stream completed without a result',
297
- });
298
- }
299
-
300
- /**
301
- * Streaming is mandatory above ~16k `maxTokens`: a non-streaming request that large hits the
302
- * HTTP timeout before the model finishes. The final `done` chunk carries the assembled
303
- * result, so a consumer that only wants the answer can ignore every chunk before it.
304
- */
305
- async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
306
- const model = this.modelOf(request);
307
- const response = await this.send({ ...this.body(request), stream: true }, request.signal);
308
- if (response.body === null) {
309
- throw new AiTransportError({
310
- provider: this.name,
311
- status: response.status,
312
- detail: 'a streaming response arrived with no body',
313
- });
314
- }
315
- const message = new MessageStream();
316
- for await (const frame of readSse(response.body, this.name)) {
317
- for (const chunk of message.push(frame)) yield chunk;
318
- }
319
- // A connection cut mid-answer must fail, not resolve: the partial text reads as a complete
320
- // answer, and `end_turn` would be a lie the caller has no way to detect.
321
- if (!message.isComplete()) {
322
- throw new AiTransportError({
323
- provider: this.name,
324
- detail:
325
- 'the stream ended before message_stop, or with a tool call still open — the answer is truncated',
326
- });
327
- }
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 });
332
- yield {
333
- type: 'done',
334
- result: { model, ...state, cost, costExact: exactCostOf(model, state.usage) },
335
- };
336
- }
337
-
338
- marksPromptCache(model: ModelId): boolean {
339
- return promptCacheRule('anthropic', this.config.baseUrl, model).marks === 'blocks';
340
- }
341
-
342
- /** The request body. Pure and side-effect free so a test can assert it directly. */
343
- body(request: GenerateRequest): Record<string, unknown> {
344
- const model = this.modelOf(request);
345
- // The blocks pass through untouched below — they ARE this wire's shapes — so this is the screen.
346
- assertMediaContent(request.messages, { provider: this.name, model });
347
- const prompt = anthropicPrompt(
348
- request,
349
- request.promptCache !== undefined && this.marksPromptCache(model),
350
- );
351
- const body: Record<string, unknown> = {
352
- model,
353
- max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
354
- messages: prompt.messages,
355
- ...reasoningBody(model, request.effort, request.thinking),
356
- ...samplingBody('anthropic', model, request),
357
- };
358
- if (prompt.system !== undefined) body['system'] = prompt.system;
359
- if (request.tools !== undefined && request.tools.length > 0) body['tools'] = request.tools;
360
- if (request.stopSequences !== undefined) body['stop_sequences'] = request.stopSequences;
361
- return body;
362
- }
363
-
364
- /**
365
- * The one place a request leaves the process. A non-2xx becomes an `AiTransportError`
366
- * carrying its status, because the gateway decides whether to retry from that status and a
367
- * body parsed as if it were a message would read as an empty, successful answer.
368
- */
369
- private async send(
370
- body: Record<string, unknown>,
371
- signal: AbortSignal | undefined,
372
- ): Promise<Response> {
373
- const apiKey = this.config.apiKey ?? Bun.env[API_KEY_ENV];
374
- if (apiKey === undefined || apiKey === '') {
375
- throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
376
- }
377
- const doFetch: AiFetch = this.config.fetch ?? fetch;
378
- const url = `${this.config.baseUrl ?? 'https://api.anthropic.com'}/v1/messages`;
379
- const response = await doFetch(url, {
380
- method: 'POST',
381
- headers: {
382
- 'x-api-key': apiKey,
383
- 'anthropic-version': ANTHROPIC_VERSION,
384
- 'content-type': 'application/json',
385
- accept: body['stream'] === true ? 'text/event-stream' : 'application/json',
386
- },
387
- body: JSON.stringify(body),
388
- // Attached only when the caller has one: `exactOptionalPropertyTypes`, and an explicit
389
- // `signal: undefined` is a different value to some fetch implementations.
390
- ...(signal === undefined ? {} : { signal }),
391
- });
392
- if (!response.ok) {
393
- throw new AiTransportError({
394
- provider: this.name,
395
- status: response.status,
396
- // The endpoint's own message, with the credential scrubbed out of it — the same rule the
397
- // OpenAI-format provider follows, and for the same reason: a proxy echoing the request
398
- // headers into its 4xx body is the one path by which `x-api-key` reaches an error.
399
- detail: withoutKey(await detailOf(response), apiKey),
400
- envVar: API_KEY_ENV,
401
- });
402
- }
403
- return response;
404
- }
405
- }
406
-
407
- /** Map a Messages API response onto `GenerateResult`. Exported so tests can drive it. */
408
- export function parseMessage(model: ModelId, raw: Record<string, unknown>): GenerateResult {
409
- // A 200 carrying an `error` object instead of an answer — how a gateway in front of a model
410
- // reports a fault it noticed after the headers were sent. Read as a message it is an empty,
411
- // successful answer, which is the one outcome nothing downstream can detect; the STREAMED half
412
- // of this same provider has always refused it.
413
- throwInBandError(raw);
414
- const content = Array.isArray(raw['content']) ? raw['content'] : [];
415
- let text = '';
416
- const toolCalls: LlmToolCall[] = [];
417
- for (const block of content) {
418
- if (typeof block !== 'object' || block === null) continue;
419
- const b = block as Record<string, unknown>;
420
- if (b['type'] === 'text' && typeof b['text'] === 'string') text += b['text'];
421
- if (b['type'] === 'tool_use' && typeof b['id'] === 'string' && typeof b['name'] === 'string') {
422
- toolCalls.push({
423
- id: b['id'],
424
- name: b['name'],
425
- // Parsed, never cast: `input` is untrusted, and a string under `Record<string, unknown>`
426
- // is a lie every later reader indexes into. The streamed half already parses it.
427
- input: asToolInput(b['input']),
428
- });
429
- }
430
- }
431
- const usage = parseUsage(raw['usage']);
432
- const stopDetails = parseStopDetails(raw['stop_details']);
433
- return {
434
- model,
435
- text,
436
- toolCalls,
437
- // A refusal detail is a refusal whatever the stop field says: `parseStopReason` answers
438
- // `end_turn` for a spelling this build has never seen, and `llm()` branches on the REASON —
439
- // `stopDetails` has no reader that can refuse — so the pair would arrive as a complete answer
440
- // that happens to be empty. The OpenAI-format read has always forced it.
441
- stopReason: stopDetails === undefined ? parseStopReason(raw['stop_reason']) : 'refusal',
442
- stopDetails,
443
- usage,
444
- cost: costOf(model, usage),
445
- costExact: exactCostOf(model, usage),
446
- };
447
- }
448
-
449
190
  /**
450
191
  * ~4 characters per token. Deliberately an ESTIMATE and never used for billing — the gateway's
451
192
  * pre-flight budget check needs a number before the call exists, and `usage` replaces it after.
@@ -6,7 +6,14 @@
6
6
  // vendor: `baseUrl` selects the provider and nothing else changes. A second class per vendor
7
7
  // would be a second thing to learn for a difference that does not exist on the wire.
8
8
 
9
- import { finiteCount, readWithinLimit, renderThrowable } from '@ultimat3/core';
9
+ import {
10
+ abandonment,
11
+ finiteCount,
12
+ isUltimateError,
13
+ readWithinLimit,
14
+ renderThrowable,
15
+ } from '@ultimat3/core';
16
+ import { outboundResponse } from '@ultimat3/core/outbound';
10
17
  import type { Embedder } from './embeddings';
11
18
  import { normalizeVector } from './embeddings';
12
19
  import { detailOf, withoutKey } from './error-body';
@@ -108,21 +115,41 @@ export class RemoteEmbedder implements Embedder {
108
115
  if (apiKey === undefined || apiKey === '') {
109
116
  throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
110
117
  }
111
- const doFetch: AiFetch = this.config.fetch ?? fetch;
118
+ const doFetch: AiFetch =
119
+ this.config.fetch ??
120
+ ((input, init) => fetch(input, { ...init, signal: init.signal ?? null }));
112
121
  const timeoutMs = this.timeoutMs;
113
122
  const url = `${this.config.baseUrl ?? DEFAULT_BASE_URL}/embeddings`;
114
123
  let response: Response;
115
124
  try {
116
- response = await doFetch(url, {
117
- method: 'POST',
118
- headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
119
- body: JSON.stringify({ model: this.name, input: texts }),
120
- // Without this the call has no deadline at all: the per-request budget produces a
121
- // `ctx.signal` that never reaches here, so a provider that accepts the connection and
122
- // never answers holds a worker for as long as the socket stays open.
123
- signal: AbortSignal.timeout(timeoutMs),
124
- });
125
+ // Through core's `outbound()`: `timeoutMs` bounds the WHOLE batch, the ambient request's
126
+ // signal and deadline end it, and an embedding is a pure read of its input, so a 5xx or a
127
+ // throttle is retried inside that bound (`idempotent: true`). The body is capped as it lands.
128
+ response = await outboundResponse(
129
+ {
130
+ url,
131
+ vendor: this.name,
132
+ dial: 'named',
133
+ method: 'POST',
134
+ idempotent: true,
135
+ deadlineMs: timeoutMs,
136
+ maxBytes: this.maxResponseBytes,
137
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
138
+ body: JSON.stringify({ model: this.name, input: texts }),
139
+ },
140
+ { fetch: doFetch },
141
+ );
125
142
  } catch (error) {
143
+ // A caller gone keeps its own reason; the cap is the over-limit refusal below, said once.
144
+ if (abandonment() !== undefined) throw error;
145
+ if (isUltimateError(error) && error.code === 'X_OUTBOUND_TOO_LARGE') {
146
+ throw new AiTransportError({
147
+ provider: this.name,
148
+ status: 200,
149
+ detail: `response body is over the limit of ${this.maxResponseBytes} bytes`,
150
+ envVar: API_KEY_ENV,
151
+ });
152
+ }
126
153
  throw new AiTransportError({
127
154
  provider: this.name,
128
155
  // `renderThrowable`, never `error instanceof Error` and `.message`: `fetch` is injected