@ultimat3/ai 1.2.0 → 2.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 +363 -0
- package/README.md +229 -6
- package/package.json +10 -9
- package/src/agent.ts +287 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +142 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +40 -10
- package/src/index.ts +45 -8
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +260 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +94 -35
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +13 -4
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
package/src/redaction.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The one gate between `vars()` and the provider: a `Secret` never reaches a prompt.
|
|
2
|
+
//
|
|
3
|
+
// Not a leak check — `Secret` redacts by value, so the string would have arrived as `[redacted]`.
|
|
4
|
+
// It is a CORRECTNESS check, and the same one `render()` already makes for an unfilled `{{slot}}`:
|
|
5
|
+
// a prompt that reads fine and means something else is the failure nobody sees, and here it is
|
|
6
|
+
// also a token bill for an answer about a placeholder.
|
|
7
|
+
|
|
8
|
+
import { isSecret } from '@ultimat3/core';
|
|
9
|
+
import { AiPromptSecretError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Refuse a `Secret` among a prompt's variables, naming every key that carries one. Runs whether
|
|
13
|
+
* or not an app installed a redactor: the redactor is the app's policy, this is the framework's
|
|
14
|
+
* invariant, and an invariant that only holds when something optional is configured is not one.
|
|
15
|
+
*
|
|
16
|
+
* Structural (`isSecret` reads the shared brand), so two copies of `@ultimat3/core` in one tree
|
|
17
|
+
* still recognise each other's secrets.
|
|
18
|
+
*/
|
|
19
|
+
export function assertNoSecrets(ref: string, vars: Readonly<Record<string, unknown>>): void {
|
|
20
|
+
const keys = Object.keys(vars).filter((key) => isSecret(vars[key]));
|
|
21
|
+
if (keys.length > 0) throw new AiPromptSecretError({ ref, keys: keys.sort() });
|
|
22
|
+
}
|
package/src/remote-embedder.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
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 { readWithinLimit } from '@ultimat3/core';
|
|
9
10
|
import type { Embedder } from './embeddings';
|
|
10
11
|
import { normalize } from './embeddings';
|
|
11
12
|
import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
|
|
@@ -15,6 +16,14 @@ const DEFAULT_BASE_URL = 'https://api.voyageai.com/v1';
|
|
|
15
16
|
/** Providers cap a batch around 128 inputs; 96 leaves headroom for long texts. */
|
|
16
17
|
const DEFAULT_BATCH_SIZE = 96;
|
|
17
18
|
const DETAIL_LIMIT = 300;
|
|
19
|
+
/**
|
|
20
|
+
* A hosted endpoint is a third party and `baseUrl` is app config, so neither its latency nor its
|
|
21
|
+
* response size is ours to assume. 30s is longer than any healthy embedding batch and shorter than
|
|
22
|
+
* a job lease; 32 MiB is an order of magnitude past the worst legitimate batch (96 inputs x 3072
|
|
23
|
+
* dimensions of JSON floats is under 4 MiB), so nothing real hits it and nothing unreal is held.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
26
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
18
27
|
|
|
19
28
|
export interface RemoteEmbedderInput {
|
|
20
29
|
/** The provider's model id. Doubles as the embedder name, so a store records what wrote it. */
|
|
@@ -30,6 +39,10 @@ export interface RemoteEmbedderInput {
|
|
|
30
39
|
readonly baseUrl?: string;
|
|
31
40
|
/** Inputs per request. Larger calls are split; the provider's own cap is not the caller's. */
|
|
32
41
|
readonly batchSize?: number;
|
|
42
|
+
/** Deadline for ONE batch request. Defaults to 30s; `AbortSignal.timeout` enforces it. */
|
|
43
|
+
readonly timeoutMs?: number;
|
|
44
|
+
/** Bytes this process will hold of one response. Defaults to 32 MiB. */
|
|
45
|
+
readonly maxResponseBytes?: number;
|
|
33
46
|
/** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
|
|
34
47
|
readonly fetch?: typeof fetch;
|
|
35
48
|
}
|
|
@@ -65,19 +78,53 @@ export class RemoteEmbedder implements Embedder {
|
|
|
65
78
|
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
66
79
|
}
|
|
67
80
|
const doFetch = this.config.fetch ?? fetch;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
82
|
+
const url = `${this.config.baseUrl ?? DEFAULT_BASE_URL}/embeddings`;
|
|
83
|
+
let response: Response;
|
|
84
|
+
try {
|
|
85
|
+
response = await doFetch(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ model: this.name, input: texts }),
|
|
89
|
+
// Without this the call has no deadline at all: the per-request budget produces a
|
|
90
|
+
// `ctx.signal` that never reaches here, so a provider that accepts the connection and
|
|
91
|
+
// never answers holds a worker for as long as the socket stays open.
|
|
92
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new AiTransportError({
|
|
96
|
+
provider: this.name,
|
|
97
|
+
detail: `${error instanceof Error ? error.message : 'the request failed before a response'} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
|
|
98
|
+
envVar: API_KEY_ENV,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
73
101
|
if (!response.ok) {
|
|
74
102
|
throw new AiTransportError({
|
|
75
103
|
provider: this.name,
|
|
76
104
|
status: response.status,
|
|
77
105
|
detail: (await response.text().catch(() => '')).slice(0, DETAIL_LIMIT),
|
|
106
|
+
envVar: API_KEY_ENV,
|
|
78
107
|
});
|
|
79
108
|
}
|
|
80
|
-
|
|
109
|
+
// Read through core's counting reader rather than `response.json()`: a body is buffered whole
|
|
110
|
+
// before anything measures it otherwise, and a `content-length` a remote wrote is not a bound.
|
|
111
|
+
const maxBytes = this.config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
112
|
+
const read = await readWithinLimit(response.body, maxBytes);
|
|
113
|
+
if ('over' in read) {
|
|
114
|
+
throw new AiTransportError({
|
|
115
|
+
provider: this.name,
|
|
116
|
+
status: response.status,
|
|
117
|
+
detail: `response body is at least ${read.over} bytes, limit is ${maxBytes}`,
|
|
118
|
+
envVar: API_KEY_ENV,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
let payload: unknown;
|
|
122
|
+
try {
|
|
123
|
+
payload = JSON.parse(new TextDecoder().decode(read.bytes));
|
|
124
|
+
} catch {
|
|
125
|
+
throw this.malformed('the response body is not valid JSON');
|
|
126
|
+
}
|
|
127
|
+
return this.decode(payload, texts.length);
|
|
81
128
|
}
|
|
82
129
|
|
|
83
130
|
private decode(payload: unknown, expected: number): readonly Float32Array[] {
|
package/src/runtime.ts
CHANGED
|
@@ -24,12 +24,35 @@ export interface AiRuntimeInput {
|
|
|
24
24
|
readonly embedder?: Embedder;
|
|
25
25
|
/** One cache per scope key. Defaults to the in-memory driver; pgvector in production. */
|
|
26
26
|
readonly semanticCache?: (scope: string) => SemanticCache;
|
|
27
|
+
/**
|
|
28
|
+
* The last thing that runs over a prompt before it leaves the process. Declared here, in the
|
|
29
|
+
* one place the framework already owns, because `vars()` is the one declared place a model call
|
|
30
|
+
* loads data — the framework knows exactly where the row enters the prompt, and until now
|
|
31
|
+
* nothing sat between that and a third-party endpoint.
|
|
32
|
+
*
|
|
33
|
+
* WHAT to remove is the app's decision, not the framework's: a PII classifier is a model choice
|
|
34
|
+
* and ships in no framework (axiom 8). Ultimate ships the seam, the span attribute
|
|
35
|
+
* (`llm.redacted`) and the one rule it can enforce structurally — a `Secret` reaching `vars()`
|
|
36
|
+
* is `X_AI_PROMPT_SECRET`, whether or not a redactor is installed.
|
|
37
|
+
*/
|
|
38
|
+
readonly redact?: Redactor;
|
|
27
39
|
}
|
|
28
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Rewrites a prompt on its way to the provider. Sees the whole RENDERED text, system prompt
|
|
43
|
+
* included — template as well as values, because a redactor shown only the values cannot tell
|
|
44
|
+
* a name in a data slot from the same name in an instruction.
|
|
45
|
+
*/
|
|
46
|
+
export type Redactor = (text: string) => string;
|
|
47
|
+
|
|
48
|
+
/** No app redactor installed. Named rather than inline so `llm.redacted` has one thing to mean. */
|
|
49
|
+
const noRedaction: Redactor = (text) => text;
|
|
50
|
+
|
|
29
51
|
interface AiRuntime {
|
|
30
52
|
readonly gateway: Gateway;
|
|
31
53
|
readonly embedder: Embedder;
|
|
32
54
|
readonly semanticCache: (scope: string) => SemanticCache;
|
|
55
|
+
readonly redact: Redactor;
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
let runtime: AiRuntime | undefined;
|
|
@@ -40,6 +63,7 @@ export function configureAi(input: AiRuntimeInput): void {
|
|
|
40
63
|
gateway: input.gateway,
|
|
41
64
|
embedder: input.embedder ?? new HashEmbedder(),
|
|
42
65
|
semanticCache: input.semanticCache ?? (() => createMemorySemanticCache()),
|
|
66
|
+
redact: input.redact ?? noRedaction,
|
|
43
67
|
};
|
|
44
68
|
// A new runtime means a new embedder and a new gateway; vectors from the old one are not
|
|
45
69
|
// comparable to vectors from the new one, and a stale hit would answer the wrong question.
|
|
@@ -56,6 +80,11 @@ export function aiEmbedder(): Embedder {
|
|
|
56
80
|
return runtime?.embedder ?? new HashEmbedder();
|
|
57
81
|
}
|
|
58
82
|
|
|
83
|
+
/** The installed redactor, or the identity. Never absent, so the call site has no branch. */
|
|
84
|
+
export function aiRedactor(): Redactor {
|
|
85
|
+
return runtime?.redact ?? noRedaction;
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
/**
|
|
60
89
|
* The cache for one scope. Scopes are separate CACHE INSTANCES, never a filter over a shared
|
|
61
90
|
* one: cosine similarity has no notion of a tenant, so two tenants asking near-identical
|
package/src/tools.ts
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
//
|
|
3
3
|
// This is the SAME projection @ultimat3/mcp performs, in a different wire format: an
|
|
4
4
|
// in-app agent calling a tool through the gateway and an external agent calling it over
|
|
5
|
-
// MCP both end at `
|
|
5
|
+
// MCP both end at the same `invoke`, so they authorize identically. There is no "LLM
|
|
6
6
|
// permissions" concept in Ultimate, because there is no second authz system.
|
|
7
7
|
//
|
|
8
|
+
// `run` below is `ProjectableAction`'s — the projection SEAM, which is what carries `invoke`.
|
|
9
|
+
// It is not a member of the action facade: an `action()` is `as`/`tool`/`openapi`/`job`/
|
|
10
|
+
// `contract` and the callable itself, and this header claimed `action.run` until 2026-08.
|
|
11
|
+
//
|
|
8
12
|
// The JSON Schema type and the projectable-primitive shape are declared here rather than
|
|
9
13
|
// imported from @ultimat3/mcp: that package is the same tier, so importing it would be a
|
|
10
14
|
// boundary error. Both packages describe the same structural contract.
|
|
11
15
|
|
|
12
16
|
import type { Actor } from '@ultimat3/core';
|
|
17
|
+
import { isMcpExposed } from '@ultimat3/core';
|
|
13
18
|
|
|
14
19
|
/** The JSON Schema subset the framework emits for tool arguments. */
|
|
15
20
|
export interface JsonSchema {
|
|
@@ -76,10 +81,14 @@ export function toLlmTool(action: ProjectableAction): LlmTool {
|
|
|
76
81
|
};
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Every exposed action as a tool definition, in stable name order. The gateway and MCP ask
|
|
86
|
+
* `isMcpExposed` — @ultimat3/core's one predicate — so an in-app agent and an external one are
|
|
87
|
+
* offered exactly the same tools.
|
|
88
|
+
*/
|
|
80
89
|
export function toLlmTools(actions: readonly ProjectableAction[]): readonly LlmTool[] {
|
|
81
90
|
return actions
|
|
82
|
-
.filter((a) => a.mcp
|
|
91
|
+
.filter((a) => isMcpExposed(a.mcp))
|
|
83
92
|
.map(toLlmTool)
|
|
84
93
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
85
94
|
}
|
|
@@ -93,7 +102,7 @@ export async function runLlmToolCall(
|
|
|
93
102
|
call: LlmToolCall,
|
|
94
103
|
actor: Actor,
|
|
95
104
|
): Promise<LlmToolResult> {
|
|
96
|
-
const action = actions.find((a) => a.name === call.name && a.mcp
|
|
105
|
+
const action = actions.find((a) => a.name === call.name && isMcpExposed(a.mcp));
|
|
97
106
|
if (action === undefined) {
|
|
98
107
|
return { toolUseId: call.id, content: `unknown tool: ${call.name}`, isError: true };
|
|
99
108
|
}
|
package/src/vector.ts
CHANGED
|
Binary file
|
package/src/wire.ts
CHANGED
|
@@ -93,6 +93,27 @@ const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
93
93
|
overloaded_error: 529,
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A 200 whose body carries an `error` object instead of an answer, refused — how a gateway in
|
|
98
|
+
* front of a model reports a fault it noticed after the headers were sent. Exported because the
|
|
99
|
+
* non-streaming read needs the identical rule and the identical status table: the envelope arrives
|
|
100
|
+
* on either transport, and one copy of the mapping is what keeps the gateway's retry decision the
|
|
101
|
+
* same on both. The twin of `openai-wire.ts`'s.
|
|
102
|
+
*/
|
|
103
|
+
export function throwInBandError(payload: Record<string, unknown>): void {
|
|
104
|
+
const error = asRecord(payload['error']);
|
|
105
|
+
if (error === undefined) return;
|
|
106
|
+
throw inBandFailure(error);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A tool call's arguments, or `{}`. Never a cast: `input` is untrusted, so a string or an array
|
|
111
|
+
* arriving under `Record<string, unknown>` is a type lie every later reader indexes into.
|
|
112
|
+
*/
|
|
113
|
+
export function asToolInput(value: unknown): Record<string, unknown> {
|
|
114
|
+
return asRecord(value) ?? {};
|
|
115
|
+
}
|
|
116
|
+
|
|
96
117
|
interface PendingTool {
|
|
97
118
|
readonly id: string;
|
|
98
119
|
readonly name: string;
|
|
@@ -156,7 +177,10 @@ export class MessageStream {
|
|
|
156
177
|
return {
|
|
157
178
|
text: this.text,
|
|
158
179
|
toolCalls: [...this.toolCalls],
|
|
159
|
-
|
|
180
|
+
// A refusal detail is a refusal whatever the stop reason says. `parseStopReason` answers
|
|
181
|
+
// `end_turn` for a spelling this build has never seen, and every consumer branches on the
|
|
182
|
+
// REASON, so the pair would read as a complete answer that happens to be empty.
|
|
183
|
+
stopReason: this.stopDetails === undefined ? this.stopReason : 'refusal',
|
|
160
184
|
stopDetails: this.stopDetails,
|
|
161
185
|
usage: this.usage,
|
|
162
186
|
};
|
|
@@ -237,8 +261,7 @@ export class MessageStream {
|
|
|
237
261
|
private inputOf(tool: PendingTool): Record<string, unknown> {
|
|
238
262
|
if (tool.json === '') return {};
|
|
239
263
|
try {
|
|
240
|
-
|
|
241
|
-
return asRecord(parsed) ?? {};
|
|
264
|
+
return asToolInput(JSON.parse(tool.json));
|
|
242
265
|
} catch (error) {
|
|
243
266
|
throw new AiTransportError({
|
|
244
267
|
provider: 'anthropic',
|
|
@@ -261,18 +284,25 @@ export class MessageStream {
|
|
|
261
284
|
return [];
|
|
262
285
|
}
|
|
263
286
|
|
|
287
|
+
/**
|
|
288
|
+
* An `error` EVENT is a failure whether or not it carried a detail — the event type is itself
|
|
289
|
+
* the report. A body has no such signal, which is why `throwInBandError` reads the object first.
|
|
290
|
+
*/
|
|
264
291
|
private onError(payload: Record<string, unknown>): never {
|
|
265
|
-
|
|
266
|
-
const type = typeof error?.['type'] === 'string' ? error['type'] : 'api_error';
|
|
267
|
-
const message = typeof error?.['message'] === 'string' ? error['message'] : type;
|
|
268
|
-
throw new AiTransportError({
|
|
269
|
-
provider: 'anthropic',
|
|
270
|
-
status: ERROR_STATUS[type],
|
|
271
|
-
detail: message,
|
|
272
|
-
});
|
|
292
|
+
throw inBandFailure(asRecord(payload['error']) ?? {});
|
|
273
293
|
}
|
|
274
294
|
}
|
|
275
295
|
|
|
296
|
+
function inBandFailure(error: Record<string, unknown>): AiTransportError {
|
|
297
|
+
const type = typeof error['type'] === 'string' ? error['type'] : 'api_error';
|
|
298
|
+
const message = typeof error['message'] === 'string' ? error['message'] : type;
|
|
299
|
+
return new AiTransportError({
|
|
300
|
+
provider: 'anthropic',
|
|
301
|
+
status: ERROR_STATUS[type],
|
|
302
|
+
detail: message,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
276
306
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
277
307
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
278
308
|
return value as Record<string, unknown>;
|