@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
package/src/openai-provider.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
142
|
+
model,
|
|
143
|
+
chatCompletionBody({ request, model, stream: false, provider: this.name, ...cache }),
|
|
129
144
|
false,
|
|
130
145
|
request.signal,
|
|
131
|
-
sessionHeaders(request,
|
|
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
|
|
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
|
-
|
|
157
|
+
model,
|
|
158
|
+
chatCompletionBody({ request, model, stream: true, provider: this.name, ...cache }),
|
|
142
159
|
true,
|
|
143
160
|
request.signal,
|
|
144
|
-
sessionHeaders(request,
|
|
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
|
-
|
|
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.
|
package/src/openai-wire.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/prompt-cache.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// ./
|
|
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 {
|
|
9
|
-
import { costOf
|
|
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
|
|
12
|
+
import { modelSpec } from './models';
|
|
16
13
|
import type { PromptCacheHint, SystemPrompt } from './prompt-cache';
|
|
17
|
-
import {
|
|
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.
|
package/src/remote-embedder.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|