@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.
@@ -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
+ }
@@ -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 response = await doFetch(`${this.config.baseUrl ?? DEFAULT_BASE_URL}/embeddings`, {
69
- method: 'POST',
70
- headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
71
- body: JSON.stringify({ model: this.name, input: texts }),
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
- return this.decode((await response.json()) as unknown, texts.length);
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 `action.run`, so they authorize identically. There is no "LLM
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
- /** Every exposed action as a tool definition, in stable name order. */
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?.expose === true)
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?.expose === true);
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
- stopReason: this.stopReason,
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
- const parsed: unknown = JSON.parse(tool.json);
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
- const error = asRecord(payload['error']);
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>;