@ultimat3/ai 24.0.0 → 25.1.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 +72 -34
- package/README.md +271 -69
- package/package.json +12 -12
- package/src/agent-facts.ts +9 -2
- package/src/agent-job.ts +41 -2
- package/src/agent.ts +30 -13
- package/src/budget.ts +41 -6
- package/src/content-blocks.ts +227 -0
- package/src/content-errors.ts +67 -0
- package/src/echo-provider.ts +102 -0
- package/src/embeddings.ts +30 -10
- package/src/errors.ts +17 -11
- package/src/eval-errors.ts +3 -1
- package/src/evals.ts +1 -1
- package/src/failure-disclosure.ts +90 -0
- package/src/gateway.ts +40 -16
- package/src/hive-pool.ts +24 -16
- package/src/hive-result.ts +4 -1
- package/src/hive.ts +6 -4
- package/src/index.ts +63 -24
- package/src/llm-cache.ts +1 -1
- package/src/llm.ts +15 -11
- package/src/model-resolve.ts +79 -0
- package/src/models.ts +125 -111
- package/src/openai-body.ts +13 -2
- package/src/openai-messages.ts +86 -15
- package/src/openai-provider.ts +28 -31
- package/src/openai-wire.ts +4 -1
- package/src/pg-vector-sql.ts +11 -7
- package/src/pg-vector.ts +14 -6
- package/src/prompt-fence.ts +128 -0
- package/src/prompt.ts +43 -13
- package/src/provider.ts +75 -112
- package/src/rag.ts +7 -6
- package/src/remote-embedder.ts +10 -5
- package/src/runtime.ts +7 -14
- package/src/scorers.ts +1 -1
- package/src/tool-idempotency.ts +79 -0
- package/src/tools.ts +38 -36
- package/src/vector-scope.ts +2 -2
- package/src/vector.ts +103 -37
- package/src/wire.ts +7 -2
- package/src/fix-line.evals.ts +0 -35
- package/src/fix-line.ts +0 -27
- package/src/openai-models.ts +0 -84
package/src/agent-job.ts
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { Action, ActionJobHandle } from '@ultimat3/action';
|
|
16
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
17
|
+
import { impersonate, useContext } from '@ultimat3/core';
|
|
16
18
|
import type { JobHandle, JobTenant, RetryPolicy } from '@ultimat3/jobs';
|
|
17
|
-
import { job } from '@ultimat3/jobs';
|
|
19
|
+
import { JobTenantMismatchError, job } from '@ultimat3/jobs';
|
|
18
20
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
21
|
|
|
20
22
|
export interface AgentJobOptions<I> {
|
|
@@ -51,6 +53,19 @@ export interface AgentJobOptions<I> {
|
|
|
51
53
|
* indistinguishable here, and a rule refusing both would be a wrong refusal. See the README.
|
|
52
54
|
*/
|
|
53
55
|
idempotencyKey?(input: I): string;
|
|
56
|
+
/**
|
|
57
|
+
* Who the run acts FOR, resolved from its own input on EVERY attempt — the jobs rule "a job
|
|
58
|
+
* acting for a user takes the user's id in its input and re-authorises it in the body", for a
|
|
59
|
+
* body that is the agent's. Without it the agent, its policy and every tool run as the WORKER,
|
|
60
|
+
* which in a served app is the anonymous actor: a member rule refuses it `X_UNAUTHENTICATED`.
|
|
61
|
+
*
|
|
62
|
+
* Load the member under the job's tenant (the ambient context already carries it) and build the
|
|
63
|
+
* same actor a request would: a member who left, or lost the role, is refused on the next
|
|
64
|
+
* attempt rather than trusted from the enqueue. The swap is core's `impersonate`, so the worker
|
|
65
|
+
* stays on the record as `onBehalfOf`. An actor in another org than `tenant` declared is refused
|
|
66
|
+
* before the agent starts — `tenant` is the org this run acts under, and nothing moves it.
|
|
67
|
+
*/
|
|
68
|
+
actor?(args: { readonly input: I; readonly ctx: Ctx }): Promise<Actor> | Actor;
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
/**
|
|
@@ -82,7 +97,31 @@ export function agentJob<TInput extends StandardSchemaV1, TOutput extends Standa
|
|
|
82
97
|
// input, { surface: 'job', ctx })`, so the agent's policy, its input parse, its budget scope
|
|
83
98
|
// and its span all apply — and `ctx` is the WORKER's, so `ctx.signal` aborting at the attempt
|
|
84
99
|
// timeout reaches the agent's turn loop.
|
|
85
|
-
run: ({ input, ctx }) =>
|
|
100
|
+
run: async ({ input, ctx }) => {
|
|
101
|
+
if (options.actor === undefined) return bridge().invoke(asInput<TInput>(input), ctx);
|
|
102
|
+
const actor = await options.actor({ input, ctx });
|
|
103
|
+
assertSameTenant(options.name, actor, ctx.actor.orgId);
|
|
104
|
+
return impersonate(actor, `agentJob ${options.name} acts for the actor its input names`, () =>
|
|
105
|
+
// The CHILD context: the resolved actor, the worker's signal, deadline and services.
|
|
106
|
+
bridge().invoke(asInput<TInput>(input), useContext()),
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The resolver's actor stays inside the org the job declared. Coded and TERMINAL — jobs' own
|
|
114
|
+
* `X_JOB_TENANT_MISMATCH`, the refusal a webhook delivery naming the wrong org gets — because the
|
|
115
|
+
* same payload resolves the same foreign actor on every attempt: an unclassified `assert` spent the
|
|
116
|
+
* whole retry policy re-proving it.
|
|
117
|
+
*/
|
|
118
|
+
function assertSameTenant(name: string, actor: Actor, tenant: string | undefined): void {
|
|
119
|
+
const orgId = actor.orgId ?? undefined;
|
|
120
|
+
if (orgId === tenant) return;
|
|
121
|
+
throw new JobTenantMismatchError({
|
|
122
|
+
job: name,
|
|
123
|
+
reason: `resolved an actor in org ${orgId ?? '(none)'} through actor(), but the run is under ${tenant === undefined ? "tenant 'none'" : `org ${tenant}`}, so it would act outside the org it declared`,
|
|
124
|
+
fix: `actor: ({ input, ctx }) => memberActorIn(ctx.actor.orgId, input) — in agentJob("${name}"), load the member under the job's own tenant, so a member of another org is not found rather than returned`,
|
|
86
125
|
});
|
|
87
126
|
}
|
|
88
127
|
|
package/src/agent.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* ninth kind, and the list is what a reader counts instead of a sentence in one of them that
|
|
7
7
|
* cannot see the rest. A tool-using run is
|
|
8
8
|
* still one server-authoritative operation with an input schema, an output schema and a policy —
|
|
9
|
-
* so this returns an `action`, and inherits `.
|
|
10
|
-
*
|
|
9
|
+
* so this returns an `action`, and inherits `.openapi()`, `.client()`, `.job()`, `.contract()`,
|
|
10
|
+
* its MCP tool (`@ultimat3/mcp`'s `toolFrom`) and its manifest row without a line here.
|
|
11
11
|
*
|
|
12
12
|
* It exists because the alternative is a hand-rolled loop outside the framework, and a hand-rolled
|
|
13
13
|
* loop is where the dangerous mistake lives: taking the ACTOR from the model's output. Here the
|
|
@@ -31,7 +31,7 @@ import type { AgentFact } from './agent-facts';
|
|
|
31
31
|
import { registerAgentFact } from './agent-facts';
|
|
32
32
|
import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
|
|
33
33
|
import type { BudgetLimits } from './budget';
|
|
34
|
-
import { budgetKeysFor, currentBudget, withBudget } from './budget';
|
|
34
|
+
import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
|
|
35
35
|
import {
|
|
36
36
|
AgentMaxTurnsError,
|
|
37
37
|
AgentToolUnexposedError,
|
|
@@ -41,8 +41,9 @@ import {
|
|
|
41
41
|
} from './errors';
|
|
42
42
|
import type { LlmBudget } from './llm';
|
|
43
43
|
import { answerAttributes } from './llm';
|
|
44
|
+
import { describedModel, type ModelSource, modelForCall } from './model-resolve';
|
|
44
45
|
import type { ModelId } from './models';
|
|
45
|
-
import {
|
|
46
|
+
import { moreCapableThan } from './models';
|
|
46
47
|
import type { Prompt, PromptVars } from './prompt';
|
|
47
48
|
import type {
|
|
48
49
|
AiMessage,
|
|
@@ -55,7 +56,7 @@ import { isTruncated } from './provider';
|
|
|
55
56
|
import { assertNoSecrets } from './redaction';
|
|
56
57
|
import type { Respond } from './respond';
|
|
57
58
|
import { RESPOND, respondFor } from './respond';
|
|
58
|
-
import { aiGateway, aiRedactor } from './runtime';
|
|
59
|
+
import { aiGateway, aiRedactor, installedGateway } from './runtime';
|
|
59
60
|
import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
|
|
60
61
|
import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
|
|
61
62
|
|
|
@@ -203,6 +204,16 @@ export function agent<
|
|
|
203
204
|
return built;
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
// `null`, not `undefined`, for a row naming no model yet: the row is a published fact, and an
|
|
208
|
+
// absent key reads as a reader that forgot to ask.
|
|
209
|
+
const modelFact = (described: {
|
|
210
|
+
readonly model: string | undefined;
|
|
211
|
+
readonly from: ModelSource | undefined;
|
|
212
|
+
}): Pick<AgentFact, 'model' | 'modelFrom'> => ({
|
|
213
|
+
model: described.model ?? null,
|
|
214
|
+
modelFrom: described.from ?? null,
|
|
215
|
+
});
|
|
216
|
+
|
|
206
217
|
function factsOf<
|
|
207
218
|
TInput extends StandardSchemaV1,
|
|
208
219
|
TOutput extends StandardSchemaV1,
|
|
@@ -213,7 +224,10 @@ function factsOf<
|
|
|
213
224
|
prompt: def.prompt.ref,
|
|
214
225
|
promptId: def.prompt.id,
|
|
215
226
|
promptHash: def.prompt.hash,
|
|
216
|
-
|
|
227
|
+
// The run's own precedence, so the row names the model a call would use NOW: read when the
|
|
228
|
+
// facts are, at describe time, like every name in the row. `modelFrom` says which place
|
|
229
|
+
// answered — `gateway` (or `null`, none yet) depends on the gateway installed at that moment.
|
|
230
|
+
...modelFact(describedModel(def.model, def.prompt.model, installedGateway()?.defaultModel)),
|
|
217
231
|
maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
218
232
|
maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
|
|
219
233
|
tools: [...def.tools.map(toolLabel)].sort(),
|
|
@@ -249,7 +263,10 @@ async function run<
|
|
|
249
263
|
): Promise<InferOutput<TOutput>> {
|
|
250
264
|
const { prompt } = def;
|
|
251
265
|
const name = prompt.ref;
|
|
252
|
-
|
|
266
|
+
// Screened first: a bad declaration is refused whether or not a gateway exists.
|
|
267
|
+
const limits = limitsOf(def);
|
|
268
|
+
// The declaration, its prompt, then the gateway's `defaultModel` — the app's three places.
|
|
269
|
+
const model = modelForCall('agent', name, def.model, prompt.model);
|
|
253
270
|
const vars = await def.vars({ input: args.input, ctx: args.ctx });
|
|
254
271
|
assertNoSecrets(name, vars);
|
|
255
272
|
const redact = aiRedactor();
|
|
@@ -269,8 +286,6 @@ async function run<
|
|
|
269
286
|
'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
|
|
270
287
|
});
|
|
271
288
|
|
|
272
|
-
// Screened first, as before: a bad declaration is refused whether or not a gateway exists.
|
|
273
|
-
const limits = limitsOf(def);
|
|
274
289
|
// Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
|
|
275
290
|
const gateway = aiGateway(name);
|
|
276
291
|
// Keyed on the CALLER, so the gateway's `actor` / `org` ceilings count this call against them.
|
|
@@ -323,7 +338,7 @@ async function run<
|
|
|
323
338
|
usage: result.usage,
|
|
324
339
|
cost: result.cost,
|
|
325
340
|
});
|
|
326
|
-
assertAnswerable(result, name);
|
|
341
|
+
assertAnswerable(result, name, base);
|
|
327
342
|
|
|
328
343
|
if (requested.length > 0) {
|
|
329
344
|
// Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
|
|
@@ -398,12 +413,12 @@ async function observeTurn<
|
|
|
398
413
|
}
|
|
399
414
|
|
|
400
415
|
/** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
|
|
401
|
-
function assertAnswerable(result: GenerateResult, name: string): void {
|
|
416
|
+
function assertAnswerable(result: GenerateResult, name: string, request: GenerateRequest): void {
|
|
402
417
|
if (result.stopReason !== 'refusal') return;
|
|
403
418
|
throw new LlmRefusedError({
|
|
404
419
|
prompt: name,
|
|
405
420
|
model: result.model,
|
|
406
|
-
alternative: moreCapableThan(result.model),
|
|
421
|
+
alternative: moreCapableThan(result.model, request),
|
|
407
422
|
category: result.stopDetails?.category,
|
|
408
423
|
explanation: result.stopDetails?.explanation,
|
|
409
424
|
});
|
|
@@ -421,7 +436,9 @@ function limitsOf<
|
|
|
421
436
|
...(budget?.tokensIn === undefined
|
|
422
437
|
? {}
|
|
423
438
|
: { tokensIn: finiteCount(SUBJECT, 'budget.tokensIn', budget.tokensIn) }),
|
|
424
|
-
...(budget?.costPerCall === undefined
|
|
439
|
+
...(budget?.costPerCall === undefined
|
|
440
|
+
? {}
|
|
441
|
+
: { costPerCall: screenedMoney(SUBJECT, 'budget.costPerCall', budget.costPerCall) }),
|
|
425
442
|
// The ledger's `request` scope accumulates across every call made under one ledger, which for
|
|
426
443
|
// a run under `withBudget` is exactly "the whole run".
|
|
427
444
|
...(budget?.tokensPerRun === undefined
|
package/src/budget.ts
CHANGED
|
@@ -101,12 +101,23 @@ export interface BudgetKeys {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
export function budgetKeysFor(actor: Actor): BudgetKeys {
|
|
104
|
+
const orgId: string | null | undefined = actor.orgId;
|
|
104
105
|
return {
|
|
105
106
|
actorKey: `actor:${actor.kind}:${actor.id}`,
|
|
106
|
-
...(
|
|
107
|
+
...(orgless(orgId) ? {} : { orgKey: `org:${orgId}` }),
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* "Inside no org", in all three spellings `orgId` arrives in: it is a value off the wire (a decoded
|
|
113
|
+
* session row, an app's adapter), so `null` and `''` reach here despite core's
|
|
114
|
+
* `string | undefined`. Testing `undefined` alone turned the other two into the real keys `org:null`
|
|
115
|
+
* and `org:` — ONE org window shared by every org-less caller in the deployment. The same predicate
|
|
116
|
+
* as `@ultimat3/query`'s `cache.ts`, restated because that one is module-private.
|
|
117
|
+
*/
|
|
118
|
+
const orgless = (orgId: string | null | undefined): boolean =>
|
|
119
|
+
orgId === undefined || orgId === null || orgId === '';
|
|
120
|
+
|
|
110
121
|
/**
|
|
111
122
|
* What `reserve` debited, so `record` can reconcile it against the provider's real counts and
|
|
112
123
|
* `release` can give it back. Held by the caller rather than the ledger because one ledger serves
|
|
@@ -159,6 +170,11 @@ export class MemoryBudgetStore implements BudgetStore {
|
|
|
159
170
|
}
|
|
160
171
|
}
|
|
161
172
|
|
|
173
|
+
/** The one way to build the per-process store; the class is a type in the barrel only. */
|
|
174
|
+
export function memoryBudgetStore(): MemoryBudgetStore {
|
|
175
|
+
return new MemoryBudgetStore();
|
|
176
|
+
}
|
|
177
|
+
|
|
162
178
|
export interface BudgetLedgerInput {
|
|
163
179
|
readonly limits: BudgetLimits;
|
|
164
180
|
/** Stable identity keys. Omit a key to skip that scope even when a limit is set. */
|
|
@@ -200,7 +216,7 @@ export class BudgetLedger {
|
|
|
200
216
|
// limit makes `limit - spent` a `NaN`, `want > NaN` false, and the scope silently unlimited —
|
|
201
217
|
// the ceiling does not become wrong, it stops existing. `llm()`, `agent()` and `hive()` screen
|
|
202
218
|
// the same numbers first under the key names their declarations use (`tokensPerRun` is this
|
|
203
|
-
// `request`), so this is the backstop for a `
|
|
219
|
+
// `request`), so this is the backstop for a `providerGateway({ budget })` or a hand-built ledger,
|
|
204
220
|
// where these ARE the names the caller wrote.
|
|
205
221
|
this.limits = assertFiniteLimits(input.limits);
|
|
206
222
|
this.actorKey = input.actorKey;
|
|
@@ -289,6 +305,9 @@ export class BudgetLedger {
|
|
|
289
305
|
* an `llm()` action must not be able to widen the actor or org ceiling it runs inside.
|
|
290
306
|
*/
|
|
291
307
|
derive(limits: BudgetLimits): BudgetLedger {
|
|
308
|
+
// Screened BEFORE tightening, not only by the child's constructor: a `NaN` that won
|
|
309
|
+
// `tighterMoney` would otherwise reach the child as the parent's ceiling, widened.
|
|
310
|
+
assertFiniteLimits(limits);
|
|
292
311
|
const child = new BudgetLedger({
|
|
293
312
|
limits: {
|
|
294
313
|
...pick('request', tighterNumber(this.limits.request, limits.request)),
|
|
@@ -387,18 +406,34 @@ export class BudgetLedger {
|
|
|
387
406
|
}
|
|
388
407
|
|
|
389
408
|
/**
|
|
390
|
-
* Every declared
|
|
409
|
+
* Every declared ceiling, proven to be a whole count. A limit is optional and an absent one is
|
|
391
410
|
* "unlimited" by design — which is exactly why a `NaN` one is the dangerous value: it reads as a
|
|
392
|
-
* declared ceiling everywhere (`report()`, a manifest row, a log line) and enforces nothing.
|
|
411
|
+
* declared ceiling everywhere (`report()`, a manifest row, a log line) and enforces nothing. The
|
|
412
|
+
* money one most of all: `tighterMoney` asks `a.minor <= b.minor`, false against a `NaN`, so an
|
|
413
|
+
* unscreened `costPerCall` won every `derive` and WIDENED the stricter ceiling it ran inside.
|
|
414
|
+
* `subject` and `prefix` let a caller name the key its own declaration wrote (`gateway`'s
|
|
415
|
+
* `budget.org`), because a fix line naming a key the app never typed is not a fix.
|
|
393
416
|
*/
|
|
394
|
-
function assertFiniteLimits(
|
|
417
|
+
export function assertFiniteLimits(
|
|
418
|
+
limits: BudgetLimits,
|
|
419
|
+
subject = 'the AI budget',
|
|
420
|
+
prefix = '',
|
|
421
|
+
): BudgetLimits {
|
|
395
422
|
for (const scope of ['request', 'tokensIn', 'actor', 'org'] as const) {
|
|
396
423
|
const limit = limits[scope];
|
|
397
|
-
if (limit !== undefined) finiteCount(
|
|
424
|
+
if (limit !== undefined) finiteCount(subject, `${prefix}${scope}`, limit);
|
|
398
425
|
}
|
|
426
|
+
const money = limits.costPerCall;
|
|
427
|
+
if (money !== undefined) finiteCount(subject, `${prefix}costPerCall.minor`, money.minor);
|
|
399
428
|
return limits;
|
|
400
429
|
}
|
|
401
430
|
|
|
431
|
+
/** One money ceiling screened under `key`, for a declaration whose field names differ from ours. */
|
|
432
|
+
export function screenedMoney(subject: string, key: string, money: Money): Money {
|
|
433
|
+
finiteCount(subject, `${key}.minor`, money.minor);
|
|
434
|
+
return money;
|
|
435
|
+
}
|
|
436
|
+
|
|
402
437
|
function isBudgetTake(value: unknown): value is BudgetTake {
|
|
403
438
|
if (typeof value !== 'object' || value === null) return false;
|
|
404
439
|
const record = value as Record<string, unknown>;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// The image and document blocks a user turn may carry: their shapes (the Messages API's own field
|
|
2
|
+
// names, so the Anthropic body passes them through untouched), the screen every request runs them
|
|
3
|
+
// through before it leaves, and what they add to the pre-flight token estimate.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import { AiContentUnsupportedError } from './content-errors';
|
|
7
|
+
import { AiRequestInvalidError } from './errors';
|
|
8
|
+
import type { ContentKind, ModelId } from './models';
|
|
9
|
+
import { modelSpec } from './models';
|
|
10
|
+
import type { AiContentBlock } from './provider';
|
|
11
|
+
|
|
12
|
+
/** "JPEG, PNG, GIF, and WebP" — the one list both wire formats publish. */
|
|
13
|
+
export const IMAGE_MEDIA_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const;
|
|
14
|
+
export type ImageMediaType = (typeof IMAGE_MEDIA_TYPES)[number];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ceilings on a base64 `data` string, in CHARACTERS — the encoded size, which is what the limits
|
|
18
|
+
* are stated in. Image: the Claude API's 10 MB per image (MiB, as its own refusal counts them).
|
|
19
|
+
* Document: 32 MiB, the Messages API's whole-request ceiling, so no single PDF can be legal on
|
|
20
|
+
* its own and still be a request the endpoint refuses. The OpenAI format allows more for a file
|
|
21
|
+
* (50 MB); one table for both, the stricter, so which provider answers never changes the verdict.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_IMAGE_BASE64_CHARS = 10 * 1024 * 1024;
|
|
24
|
+
export const MAX_DOCUMENT_BASE64_CHARS = 32 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An image's pre-flight token estimate: the most visual tokens Claude 4.7+ spends on one image
|
|
28
|
+
* after resizing (4,784 — vision guide, high-resolution tier). Pessimistic on purpose, like the
|
|
29
|
+
* rest of the estimate: the budget reserves it and `record` gives back what the call did not use.
|
|
30
|
+
*/
|
|
31
|
+
export const IMAGE_TOKEN_ESTIMATE = 4_784;
|
|
32
|
+
|
|
33
|
+
export type AiImageSource =
|
|
34
|
+
| { readonly type: 'base64'; readonly media_type: ImageMediaType; readonly data: string }
|
|
35
|
+
| { readonly type: 'url'; readonly url: string };
|
|
36
|
+
|
|
37
|
+
export type AiDocumentSource =
|
|
38
|
+
| { readonly type: 'base64'; readonly media_type: 'application/pdf'; readonly data: string }
|
|
39
|
+
| { readonly type: 'url'; readonly url: string }
|
|
40
|
+
| { readonly type: 'text'; readonly media_type: 'text/plain'; readonly data: string };
|
|
41
|
+
|
|
42
|
+
export interface AiImageBlock {
|
|
43
|
+
readonly type: 'image';
|
|
44
|
+
readonly source: AiImageSource;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `title` is the Messages API's; on the OpenAI format it becomes the file's `filename`. */
|
|
48
|
+
export interface AiDocumentBlock {
|
|
49
|
+
readonly type: 'document';
|
|
50
|
+
readonly source: AiDocumentSource;
|
|
51
|
+
readonly title?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type AiMediaBlock = AiImageBlock | AiDocumentBlock;
|
|
55
|
+
|
|
56
|
+
/** The two kinds this module screens; a narrowing every consumer of `AiContentBlock` can reuse. */
|
|
57
|
+
export function isMediaBlock(block: { readonly type: string }): block is AiMediaBlock {
|
|
58
|
+
return block.type === 'image' || block.type === 'document';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The fields of a message this screen reads — structural, so `provider.ts` stays the owner. */
|
|
62
|
+
interface ScreenedMessage {
|
|
63
|
+
readonly role: string;
|
|
64
|
+
readonly content: string | readonly { readonly type: string }[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Refuse every media block the target cannot take, and every one that is malformed, BEFORE the
|
|
69
|
+
* request is built. Blocks reach here from typed code and from replayed JSON alike, so the screen
|
|
70
|
+
* is at runtime: a media type outside the list, a URL that is not `https:`, a base64 string over
|
|
71
|
+
* its ceiling or outside the alphabet. A malformed block is `X_AI_REQUEST_INVALID`; a well-formed
|
|
72
|
+
* one the role or model cannot take is `X_AI_CONTENT_UNSUPPORTED`.
|
|
73
|
+
*/
|
|
74
|
+
export function assertMediaContent(
|
|
75
|
+
messages: readonly ScreenedMessage[],
|
|
76
|
+
target: { readonly provider: string; readonly model: ModelId },
|
|
77
|
+
): void {
|
|
78
|
+
messages.forEach((message, m) => {
|
|
79
|
+
if (typeof message.content === 'string') return;
|
|
80
|
+
message.content.forEach((block, b) => {
|
|
81
|
+
if (!isMediaBlock(block)) return;
|
|
82
|
+
const at = `messages[${m}].content[${b}]`;
|
|
83
|
+
const kind: ContentKind = block.type;
|
|
84
|
+
if (message.role !== 'user') {
|
|
85
|
+
const refusal = { by: 'role', ...target, role: message.role } as const;
|
|
86
|
+
throw new AiContentUnsupportedError({ kind, at, refusal });
|
|
87
|
+
}
|
|
88
|
+
if (!(modelSpec(target.model).input ?? []).includes(kind)) {
|
|
89
|
+
throw new AiContentUnsupportedError({ kind, at, refusal: { by: 'model', ...target } });
|
|
90
|
+
}
|
|
91
|
+
assertSource(block, at);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertSource(block: AiMediaBlock, at: string): void {
|
|
97
|
+
const fields = fieldsOf(block.source);
|
|
98
|
+
const type = fields['type'];
|
|
99
|
+
// Read as untrusted: a replayed block's title is whatever the JSON held, on either kind.
|
|
100
|
+
const title: unknown = (block as { readonly title?: unknown }).title;
|
|
101
|
+
if (title !== undefined && typeof title !== 'string') {
|
|
102
|
+
throw invalid(
|
|
103
|
+
at,
|
|
104
|
+
`has a title of ${renderCauseValue(title)}`,
|
|
105
|
+
"title: 'q3.pdf' # a string, or omit the field",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (type === 'url') {
|
|
109
|
+
assertUrl(fields['url'], at);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (type === 'base64') {
|
|
113
|
+
assertBase64(block.type, fields, at);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (type === 'text' && block.type === 'document') {
|
|
117
|
+
assertText(fields, at);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const legal = block.type === 'image' ? "'base64' or 'url'" : "'base64', 'url' or 'text'";
|
|
121
|
+
throw invalid(
|
|
122
|
+
at,
|
|
123
|
+
`has source.type ${renderCauseValue(type)}`,
|
|
124
|
+
`source: { type: 'base64', media_type, data } # source.type is ${legal}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertUrl(url: unknown, at: string): void {
|
|
129
|
+
// `https:` only: a `data:` URL is the base64 source spelled a second way, and plain `http:` is a
|
|
130
|
+
// fetch the vendor makes over a channel anyone on the path can rewrite.
|
|
131
|
+
if (typeof url === 'string' && URL.canParse(url) && new URL(url).protocol === 'https:') return;
|
|
132
|
+
throw invalid(
|
|
133
|
+
at,
|
|
134
|
+
`has source.url ${renderCauseValue(url)}, which is not an https: URL`,
|
|
135
|
+
"source: { type: 'url', url: 'https://…' } # or send the bytes as { type: 'base64', media_type, data }",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The standard alphabet with its padding, whole: no whitespace, no `data:` prefix, no URL-safe. */
|
|
140
|
+
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
141
|
+
|
|
142
|
+
function assertBase64(kind: ContentKind, fields: Record<string, unknown>, at: string): void {
|
|
143
|
+
const mediaType = fields['media_type'];
|
|
144
|
+
const legal: readonly unknown[] = kind === 'image' ? IMAGE_MEDIA_TYPES : ['application/pdf'];
|
|
145
|
+
if (!legal.includes(mediaType)) {
|
|
146
|
+
throw invalid(
|
|
147
|
+
at,
|
|
148
|
+
`has media_type ${renderCauseValue(mediaType)}, which a ${kind} block does not take`,
|
|
149
|
+
`media_type: '${String(legal[0])}' # one of ${legal.join(', ')}${kind === 'document' ? "; text goes as { type: 'text', media_type: 'text/plain', data }" : ''}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const data = fields['data'];
|
|
153
|
+
const cap = kind === 'image' ? MAX_IMAGE_BASE64_CHARS : MAX_DOCUMENT_BASE64_CHARS;
|
|
154
|
+
if (
|
|
155
|
+
typeof data !== 'string' ||
|
|
156
|
+
data.length > cap ||
|
|
157
|
+
data.length % 4 !== 0 ||
|
|
158
|
+
!BASE64.test(data)
|
|
159
|
+
) {
|
|
160
|
+
const size = typeof data === 'string' ? `${data.length} characters` : renderCauseValue(data);
|
|
161
|
+
throw invalid(
|
|
162
|
+
at,
|
|
163
|
+
`has source.data of ${size}: not one non-empty, padded, standard-alphabet base64 string of at most ${cap} characters`,
|
|
164
|
+
`data: Buffer.from(bytes).toString('base64') # no data: prefix, at most ${cap} characters; a larger ${kind} goes as an https:// url source`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function assertText(fields: Record<string, unknown>, at: string): void {
|
|
170
|
+
const data = fields['data'];
|
|
171
|
+
if (fields['media_type'] === 'text/plain' && typeof data === 'string' && data !== '') return;
|
|
172
|
+
throw invalid(
|
|
173
|
+
at,
|
|
174
|
+
'has a text source that is not { media_type: "text/plain", data: <non-empty string> }',
|
|
175
|
+
"source: { type: 'text', media_type: 'text/plain', data: text } # the document's text, non-empty",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const invalid = (at: string, what: string, fix: string): AiRequestInvalidError =>
|
|
180
|
+
new AiRequestInvalidError({ detail: `${at} ${what}`, fix });
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The readable text of one block, for `messageText`. An image and a PDF have none — their cost is
|
|
184
|
+
* `mediaTokenEstimate`'s, never their base64 read as prose — and a text document's is its data.
|
|
185
|
+
*/
|
|
186
|
+
export function blockText(block: AiContentBlock): string {
|
|
187
|
+
if (block.type === 'text') return block.text;
|
|
188
|
+
if (block.type === 'tool_result') return block.content;
|
|
189
|
+
if (block.type === 'tool_use') return JSON.stringify(block.input);
|
|
190
|
+
if (block.type !== 'document') return '';
|
|
191
|
+
const source = fieldsOf(block.source);
|
|
192
|
+
return source['type'] === 'text' ? stringOr(source['data']) : '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What the media blocks add to the pre-flight estimate. An image is `IMAGE_TOKEN_ESTIMATE`, its
|
|
197
|
+
* worst case. A base64 PDF is its encoded length over four — the same 4-characters-per-token rule
|
|
198
|
+
* as text, and in the published per-page range (text plus one page image) for an ordinary PDF. A
|
|
199
|
+
* text document's `data` is already counted by `messageText`. A PDF by URL is UNKNOWABLE before
|
|
200
|
+
* the vendor fetches it and adds nothing: `record` reconciles to the real usage afterwards.
|
|
201
|
+
*
|
|
202
|
+
* Runs BEFORE the screen (the gateway reserves, then the provider builds), so every field is read
|
|
203
|
+
* as untrusted — a malformed block is estimated as nothing and refused a moment later.
|
|
204
|
+
*/
|
|
205
|
+
export function mediaTokenEstimate(messages: readonly ScreenedMessage[]): number {
|
|
206
|
+
let tokens = 0;
|
|
207
|
+
for (const message of messages) {
|
|
208
|
+
if (typeof message.content === 'string') continue;
|
|
209
|
+
for (const block of message.content) {
|
|
210
|
+
if (!isMediaBlock(block)) continue;
|
|
211
|
+
if (block.type === 'image') {
|
|
212
|
+
tokens += IMAGE_TOKEN_ESTIMATE;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const source = fieldsOf(block.source);
|
|
216
|
+
if (source['type'] === 'base64') tokens += Math.ceil(stringOr(source['data']).length / 4);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return tokens;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const stringOr = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
223
|
+
|
|
224
|
+
/** A block's `source` as untrusted fields: a replayed block may carry none, or `null`. */
|
|
225
|
+
function fieldsOf(source: unknown): Readonly<Record<string, unknown>> {
|
|
226
|
+
return typeof source === 'object' && source !== null ? (source as Record<string, unknown>) : {};
|
|
227
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The X_AI_CONTENT_UNSUPPORTED class, apart from ./errors because that catalogue is at its ceiling —
|
|
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.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** Where a block was refused: a capability of the WIRE format, or of the model's own row. */
|
|
8
|
+
export type ContentRefusal =
|
|
9
|
+
| { readonly by: 'model'; readonly model: string; readonly provider: string }
|
|
10
|
+
| {
|
|
11
|
+
readonly by: 'provider';
|
|
12
|
+
readonly model: string;
|
|
13
|
+
readonly provider: string;
|
|
14
|
+
/** What the wire format lacks — and, in `fix`, the block this provider DOES take instead. */
|
|
15
|
+
readonly why: string;
|
|
16
|
+
readonly fix: string;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
readonly by: 'role';
|
|
20
|
+
readonly model: string;
|
|
21
|
+
readonly provider: string;
|
|
22
|
+
readonly role: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A message carries an image or document block the target cannot take. Refused before the request
|
|
27
|
+
* leaves, because the alternative is a 400 whose message names a JSON path, not the fix — or, on
|
|
28
|
+
* the endpoints that skip what they cannot parse, an answer about a document the model never saw.
|
|
29
|
+
*
|
|
30
|
+
* Never collected into `X_AI_PROVIDER_UNAVAILABLE`: the same blocks get the same refusal from
|
|
31
|
+
* every provider serving that model, so retrying burns attempts and discards this fix line.
|
|
32
|
+
*/
|
|
33
|
+
export class AiContentUnsupportedError extends UltimateError {
|
|
34
|
+
constructor(input: {
|
|
35
|
+
readonly kind: string;
|
|
36
|
+
readonly at: string;
|
|
37
|
+
readonly refusal: ContentRefusal;
|
|
38
|
+
}) {
|
|
39
|
+
const { kind, at, refusal } = input;
|
|
40
|
+
super({
|
|
41
|
+
code: 'X_AI_CONTENT_UNSUPPORTED',
|
|
42
|
+
cause: `${at} is a ${kind} block, and ${causeOf(kind, refusal)}`,
|
|
43
|
+
fix: fixOf(kind, refusal),
|
|
44
|
+
meta: { kind, at, by: refusal.by, model: refusal.model, provider: refusal.provider },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function causeOf(kind: string, refusal: ContentRefusal): string {
|
|
50
|
+
if (refusal.by === 'model') {
|
|
51
|
+
return `model "${refusal.model}" is registered without '${kind}' in its input list`;
|
|
52
|
+
}
|
|
53
|
+
if (refusal.by === 'role') {
|
|
54
|
+
return `a ${kind} block may only travel in a user turn, not a ${refusal.role} turn (provider "${refusal.provider}", model "${refusal.model}")`;
|
|
55
|
+
}
|
|
56
|
+
return `provider "${refusal.provider}" cannot send it to model "${refusal.model}": ${refusal.why}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fixOf(kind: string, refusal: ContentRefusal): string {
|
|
60
|
+
if (refusal.by === 'model') {
|
|
61
|
+
return `registerModel({ ...modelSpec('${refusal.model}'), input: ['image', 'document'] }) # if its endpoint accepts ${kind}s; or route the request to a model that does`;
|
|
62
|
+
}
|
|
63
|
+
if (refusal.by === 'role') {
|
|
64
|
+
return `{ role: 'user', content: [/* the ${kind} block */] } # move the block out of the ${refusal.role} turn`;
|
|
65
|
+
}
|
|
66
|
+
return refusal.fix;
|
|
67
|
+
}
|