@loomwork/llm 0.0.0-stage → 0.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +80 -2
- package/dist/assembleStream.d.ts +95 -0
- package/dist/assembleStream.d.ts.map +1 -0
- package/dist/assembleStream.js +180 -0
- package/dist/assembleStream.js.map +1 -0
- package/dist/capabilities.d.ts +43 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +77 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/createLlmEffect.d.ts +110 -0
- package/dist/createLlmEffect.d.ts.map +1 -0
- package/dist/createLlmEffect.js +177 -0
- package/dist/createLlmEffect.js.map +1 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +280 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/parts.d.ts +220 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +231 -0
- package/dist/parts.js.map +1 -0
- package/dist/providers/AnthropicProvider.d.ts +53 -0
- package/dist/providers/AnthropicProvider.d.ts.map +1 -0
- package/dist/providers/AnthropicProvider.js +443 -0
- package/dist/providers/AnthropicProvider.js.map +1 -0
- package/dist/providers/GeminiProvider.d.ts +66 -0
- package/dist/providers/GeminiProvider.d.ts.map +1 -0
- package/dist/providers/GeminiProvider.js +281 -0
- package/dist/providers/GeminiProvider.js.map +1 -0
- package/dist/providers/OpenAiProvider.d.ts +56 -0
- package/dist/providers/OpenAiProvider.d.ts.map +1 -0
- package/dist/providers/OpenAiProvider.js +402 -0
- package/dist/providers/OpenAiProvider.js.map +1 -0
- package/dist/providers/extraHeaders.d.ts +7 -0
- package/dist/providers/extraHeaders.d.ts.map +1 -0
- package/dist/providers/extraHeaders.js +40 -0
- package/dist/providers/extraHeaders.js.map +1 -0
- package/dist/providers/systemPrompt.d.ts +10 -0
- package/dist/providers/systemPrompt.d.ts.map +1 -0
- package/dist/providers/systemPrompt.js +22 -0
- package/dist/providers/systemPrompt.js.map +1 -0
- package/dist/reasoning.d.ts +154 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/reasoning.js +256 -0
- package/dist/reasoning.js.map +1 -0
- package/dist/resolveContent.d.ts +74 -0
- package/dist/resolveContent.d.ts.map +1 -0
- package/dist/resolveContent.js +335 -0
- package/dist/resolveContent.js.map +1 -0
- package/dist/types.d.ts +152 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/untrusted.d.ts +91 -0
- package/dist/untrusted.d.ts.map +1 -0
- package/dist/untrusted.js +385 -0
- package/dist/untrusted.js.map +1 -0
- package/package.json +54 -3
- package/src/assembleStream.ts +219 -0
- package/src/capabilities.ts +96 -0
- package/src/createLlmEffect.ts +287 -0
- package/src/errors.ts +269 -0
- package/src/index.ts +137 -0
- package/src/parts.ts +409 -0
- package/src/providers/AnthropicProvider.ts +550 -0
- package/src/providers/GeminiProvider.ts +380 -0
- package/src/providers/OpenAiProvider.ts +511 -0
- package/src/providers/extraHeaders.ts +41 -0
- package/src/providers/systemPrompt.ts +19 -0
- package/src/reasoning.ts +386 -0
- package/src/resolveContent.ts +425 -0
- package/src/types.ts +175 -0
- package/src/untrusted.ts +440 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import type { PrincipalChain } from "@loomwork/effect-runtime";
|
|
2
|
+
import type {
|
|
3
|
+
LlmContentPart,
|
|
4
|
+
LlmContentRef,
|
|
5
|
+
LlmMediaPart,
|
|
6
|
+
LlmReasoningPart,
|
|
7
|
+
LlmResultPart,
|
|
8
|
+
} from "./parts";
|
|
9
|
+
import { reasoningDigest } from "./reasoning";
|
|
10
|
+
import type { LlmMessage, LlmRequest } from "./types";
|
|
11
|
+
import { validRefSize } from "./untrusted";
|
|
12
|
+
|
|
13
|
+
// ── R5 — resolving claim-checked media at call time ──────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// The journaled model-call input carries `{ kind: "ref", ref: { address } }`; the bytes
|
|
16
|
+
// are fetched HERE, inside the effect handler, immediately before the provider call. A
|
|
17
|
+
// replayed call is served from the log and never reaches this, so replay neither needs
|
|
18
|
+
// the bytes nor changes if they are erased afterwards (crypto-shred / GC leave recorded
|
|
19
|
+
// history intact; only a LIVE call needs them, and an absent blob is a loud failure).
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a resolver is told about the call it is serving — enough for a store to AUTHORIZE the read.
|
|
23
|
+
*
|
|
24
|
+
* **A resolver MUST authorize the ref.** The ContentStore is content-addressed and has no tenancy:
|
|
25
|
+
* whoever can name an address can read its bytes. `executionId` / `principal` identify who is asking;
|
|
26
|
+
* a store shared across tenants must check them (typically against the execution that wrote the blob)
|
|
27
|
+
* rather than serving any address it is handed. `origin: "tool"` marks a ref that came out of a tool
|
|
28
|
+
* result (a tool's output is data from outside the prompt author), `"app"` one the application put in
|
|
29
|
+
* a message itself. A ref named by an untrusted party never reaches a resolver: the guard refuses it.
|
|
30
|
+
*/
|
|
31
|
+
export interface ContentResolveContext {
|
|
32
|
+
readonly executionId: string;
|
|
33
|
+
/**
|
|
34
|
+
* The workflow of the asking execution (ADR 0143). A store that seals per subject maps
|
|
35
|
+
* `(executionId, workflowId)` to the erasure subject with the SAME `subjectOf` the event store runs, and a
|
|
36
|
+
* `subjectOf` keyed by workflow needs it: pass it on as `store.get(ref, { executionId, workflowId })`.
|
|
37
|
+
* Absent only on a hand-built context (a caller that did not know it).
|
|
38
|
+
*/
|
|
39
|
+
readonly workflowId?: string;
|
|
40
|
+
readonly stepId?: string;
|
|
41
|
+
readonly principal?: PrincipalChain;
|
|
42
|
+
readonly origin: "tool" | "app" | "reasoning";
|
|
43
|
+
/** Bytes still available under the request's budget; return fewer, or `null`/throw, rather than exceed it. */
|
|
44
|
+
readonly maxBytes: number;
|
|
45
|
+
/** Aborts when the call is cancelled or the request's byte budget is already spent. */
|
|
46
|
+
readonly signal?: AbortSignal;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fetch a blob by address. `null` means erased/absent (never throw for absence). */
|
|
50
|
+
export type ContentResolver = (
|
|
51
|
+
ref: LlmContentRef,
|
|
52
|
+
context: ContentResolveContext
|
|
53
|
+
) => Promise<Uint8Array | null>;
|
|
54
|
+
|
|
55
|
+
/** A `ref` source reached a provider (or the effect) with no resolver able to turn it into bytes. */
|
|
56
|
+
export class UnresolvedContentError extends Error {
|
|
57
|
+
readonly address: string;
|
|
58
|
+
constructor(address: string, reason: string) {
|
|
59
|
+
super(`Cannot resolve content "${address}": ${reason}`);
|
|
60
|
+
this.name = "UnresolvedContentError";
|
|
61
|
+
this.address = address;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Default cap on the TOTAL resolved media bytes in one request (providers cap well below this). */
|
|
66
|
+
export const MAX_RESOLVED_BYTES = 32 * 1024 * 1024;
|
|
67
|
+
/** How many blobs are fetched at once; a spent budget stops the rest from starting. */
|
|
68
|
+
const FETCH_CONCURRENCY = 4;
|
|
69
|
+
|
|
70
|
+
function toBase64(bytes: Uint8Array): string {
|
|
71
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isMedia(p: { type: string }): p is LlmMediaPart {
|
|
75
|
+
return p.type === "image" || p.type === "audio" || p.type === "file";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RefUse {
|
|
79
|
+
ref: LlmContentRef;
|
|
80
|
+
/** True when at least one use sits inside a `tool_result` (a tool-originated ref). */
|
|
81
|
+
tool: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Distinct refs in the request, in first-seen order. */
|
|
85
|
+
function collectRefs(messages: readonly LlmMessage[]): Map<string, RefUse> {
|
|
86
|
+
const refs = new Map<string, RefUse>();
|
|
87
|
+
const visit = (p: { type: string }, tool: boolean): void => {
|
|
88
|
+
if (!isMedia(p) || p.source.kind !== "ref") return;
|
|
89
|
+
const prior = refs.get(p.source.ref.address);
|
|
90
|
+
// An address used both ways is held to the stricter (app-supplied) rule: failing it fails the call.
|
|
91
|
+
refs.set(p.source.ref.address, { ref: p.source.ref, tool: tool && (prior?.tool ?? true) });
|
|
92
|
+
};
|
|
93
|
+
for (const m of messages) {
|
|
94
|
+
if (typeof m.content === "string") continue;
|
|
95
|
+
for (const p of m.content) {
|
|
96
|
+
if (p.type === "tool_result" && typeof p.content !== "string") {
|
|
97
|
+
for (const r of p.content) visit(r, true);
|
|
98
|
+
} else visit(p, false);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return refs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type Fetched = { ok: true; b64: string; bytes: number } | { ok: false; reason: string };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Replace every `ref` source with inline base64 `data`, fetching each distinct address once.
|
|
108
|
+
* Returns the request unchanged (same object) when it has no refs.
|
|
109
|
+
*
|
|
110
|
+
* The byte budget is a RUNNING one: fetches start at most {@link FETCH_CONCURRENCY} at a time, each is
|
|
111
|
+
* told what is left, and the first to push the total over `maxBytes` aborts the rest (their signal
|
|
112
|
+
* fires and no queued fetch starts). A blob whose actual size differs from the size its ref declares
|
|
113
|
+
* is refused. Overshoot is bounded, not zero: at most {@link FETCH_CONCURRENCY} fetches are in flight when
|
|
114
|
+
* the budget is spent, so a resolver that ignores `maxBytes` can hold at most that many blobs before the
|
|
115
|
+
* abort lands. Resolvers SHOULD honour `maxBytes` (return `null` or throw rather than read more).
|
|
116
|
+
* A failure — absent, over budget, size mismatch, a throwing resolver — for a ref that
|
|
117
|
+
* sits inside a `tool_result` does NOT fail the call: the part becomes a text error and the
|
|
118
|
+
* `tool_result` is marked `isError`, so the model sees that the tool's media is unavailable and can
|
|
119
|
+
* carry on (the agent loop feeds a tool failure back the same way). The same failure for a ref the
|
|
120
|
+
* application put in a message itself throws {@link UnresolvedContentError}: the app asked for that
|
|
121
|
+
* content, so continuing without it would answer a different question.
|
|
122
|
+
*/
|
|
123
|
+
export async function resolveRequestContent(
|
|
124
|
+
request: LlmRequest,
|
|
125
|
+
resolver: ContentResolver | undefined,
|
|
126
|
+
options: {
|
|
127
|
+
maxBytes?: number;
|
|
128
|
+
signal?: AbortSignal;
|
|
129
|
+
/** REQUIRED: who is asking. A resolver cannot authorize a read it cannot attribute. */
|
|
130
|
+
context: Pick<ContentResolveContext, "executionId" | "workflowId" | "stepId" | "principal">;
|
|
131
|
+
/**
|
|
132
|
+
* ADR 0133 D5 - how an assistant `reasoning` part's `textRef` is read. Defaults to `resolver`.
|
|
133
|
+
* A request with no reasoning parts never calls it.
|
|
134
|
+
*/
|
|
135
|
+
reasoningResolver?: ContentResolver;
|
|
136
|
+
}
|
|
137
|
+
): Promise<LlmRequest> {
|
|
138
|
+
const withReasoning = await resolveReasoningParts(
|
|
139
|
+
request,
|
|
140
|
+
options.reasoningResolver ?? resolver,
|
|
141
|
+
options
|
|
142
|
+
);
|
|
143
|
+
return resolveMediaRefs(withReasoning, resolver, options);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const REASONING_FETCH_CAP = 8 * 1024 * 1024;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* ADR 0133 D5 (the drop rule for UNRESOLVABLE parts) - resolve each assistant reasoning part's
|
|
150
|
+
* `textRef`, check its digest, and put the text on the part for the provider to map back unchanged.
|
|
151
|
+
* A part whose text cannot be reproduced (blob absent or erased - the resolver answered `null` - a
|
|
152
|
+
* digest or size mismatch, no resolver, or readable text that was never stored) is dropped TOGETHER
|
|
153
|
+
* WITH every reasoning part after it, across later messages too: a provider's signature covers
|
|
154
|
+
* everything before the block, so a block after a hole is no longer valid. A resolver that THROWS is
|
|
155
|
+
* different: that is an outage, not an answer, so the call fails (and the step retries) rather than
|
|
156
|
+
* quietly continuing with less. A pure function of the request and the resolver's answers. A call's own `tool_use.signature` is never touched. Returns the request
|
|
157
|
+
* unchanged (same object) when it has no reasoning parts.
|
|
158
|
+
*/
|
|
159
|
+
async function resolveReasoningParts(
|
|
160
|
+
request: LlmRequest,
|
|
161
|
+
resolver: ContentResolver | undefined,
|
|
162
|
+
options: {
|
|
163
|
+
signal?: AbortSignal;
|
|
164
|
+
context: Pick<ContentResolveContext, "executionId" | "workflowId" | "stepId" | "principal">;
|
|
165
|
+
}
|
|
166
|
+
): Promise<LlmRequest> {
|
|
167
|
+
const addresses = new Map<string, LlmReasoningPart>();
|
|
168
|
+
for (const m of request.messages) {
|
|
169
|
+
if (typeof m.content === "string") continue;
|
|
170
|
+
for (const p of m.content) {
|
|
171
|
+
if (p.type === "reasoning" && p.textRef !== undefined && !addresses.has(p.textRef.address)) {
|
|
172
|
+
addresses.set(p.textRef.address, p);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const hasReasoning = request.messages.some(
|
|
177
|
+
(m) => typeof m.content !== "string" && m.content.some((p) => p.type === "reasoning")
|
|
178
|
+
);
|
|
179
|
+
if (!hasReasoning) return request;
|
|
180
|
+
|
|
181
|
+
// address -> verified text, or undefined when the blob is DEFINITIVELY not reproducible (absent
|
|
182
|
+
// or erased, digest or size mismatch). A resolver that THROWS (a store outage, a timeout, a
|
|
183
|
+
// denied read) is not an answer: the error propagates and fails the call, so the step retries per
|
|
184
|
+
// normal effect semantics instead of silently continuing with less than was recorded.
|
|
185
|
+
const texts = new Map<string, string | undefined>();
|
|
186
|
+
if (resolver !== undefined) {
|
|
187
|
+
const queue = [...addresses.values()];
|
|
188
|
+
const worker = async (): Promise<void> => {
|
|
189
|
+
for (let part = queue.shift(); part !== undefined; part = queue.shift()) {
|
|
190
|
+
const ref = part.textRef as LlmContentRef;
|
|
191
|
+
let bytes: Uint8Array | null | undefined;
|
|
192
|
+
try {
|
|
193
|
+
bytes = await resolver(ref, {
|
|
194
|
+
executionId: options.context.executionId,
|
|
195
|
+
...(options.context.workflowId !== undefined && {
|
|
196
|
+
workflowId: options.context.workflowId,
|
|
197
|
+
}),
|
|
198
|
+
...(options.context.stepId !== undefined && { stepId: options.context.stepId }),
|
|
199
|
+
...(options.context.principal !== undefined && {
|
|
200
|
+
principal: options.context.principal,
|
|
201
|
+
}),
|
|
202
|
+
origin: "reasoning",
|
|
203
|
+
maxBytes: REASONING_FETCH_CAP,
|
|
204
|
+
...(options.signal !== undefined && { signal: options.signal }),
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
queue.length = 0; // stop the other workers starting more reads for a call that has failed
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
const ok =
|
|
211
|
+
bytes != null &&
|
|
212
|
+
part.digest !== undefined &&
|
|
213
|
+
reasoningDigest(bytes) === part.digest &&
|
|
214
|
+
(ref.size === undefined || bytes.byteLength === ref.size);
|
|
215
|
+
texts.set(ref.address, ok && bytes != null ? new TextDecoder().decode(bytes) : undefined);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, queue.length) }, worker));
|
|
219
|
+
// A cancelled call is a cancelled call, not a missing blob.
|
|
220
|
+
if (options.signal?.aborted === true) throw options.signal.reason ?? new Error("aborted");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let dropping = false;
|
|
224
|
+
const messages: LlmMessage[] = request.messages.map((m) => {
|
|
225
|
+
if (typeof m.content === "string") return m;
|
|
226
|
+
if (!m.content.some((p) => p.type === "reasoning")) return m;
|
|
227
|
+
const kept: LlmContentPart[] = [];
|
|
228
|
+
for (const p of m.content) {
|
|
229
|
+
if (p.type !== "reasoning") {
|
|
230
|
+
kept.push(p);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (dropping) continue;
|
|
234
|
+
if (p.omitted === true) {
|
|
235
|
+
dropping = true;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (p.textRef === undefined) {
|
|
239
|
+
kept.push(p);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const text = texts.get(p.textRef.address);
|
|
243
|
+
if (text === undefined) {
|
|
244
|
+
dropping = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
kept.push({ ...p, text });
|
|
248
|
+
}
|
|
249
|
+
// Providers reject an empty assistant turn; keep the shape `assistantMessageOf` gives one.
|
|
250
|
+
return { ...m, content: kept.length > 0 ? kept : [{ type: "text", text: "" }] };
|
|
251
|
+
});
|
|
252
|
+
return { ...request, messages };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function resolveMediaRefs(
|
|
256
|
+
request: LlmRequest,
|
|
257
|
+
resolver: ContentResolver | undefined,
|
|
258
|
+
options: {
|
|
259
|
+
maxBytes?: number;
|
|
260
|
+
signal?: AbortSignal;
|
|
261
|
+
context: Pick<ContentResolveContext, "executionId" | "workflowId" | "stepId" | "principal">;
|
|
262
|
+
}
|
|
263
|
+
): Promise<LlmRequest> {
|
|
264
|
+
const refs = collectRefs(request.messages);
|
|
265
|
+
if (refs.size === 0) return request;
|
|
266
|
+
const first = refs.keys().next().value as string;
|
|
267
|
+
if (typeof options.context?.executionId !== "string" || options.context.executionId === "") {
|
|
268
|
+
throw new UnresolvedContentError(
|
|
269
|
+
first,
|
|
270
|
+
"no execution identity was supplied for the read; a resolver cannot authorize a read it cannot attribute"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
for (const { ref } of refs.values()) {
|
|
274
|
+
if (!validRefSize(ref.size)) {
|
|
275
|
+
throw new UnresolvedContentError(
|
|
276
|
+
ref.address,
|
|
277
|
+
"its declared size is not a non-negative safe integer"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (resolver === undefined) {
|
|
282
|
+
throw new UnresolvedContentError(
|
|
283
|
+
first,
|
|
284
|
+
"the request references stored content but no `resolveContent` is configured"
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const limit = options.maxBytes ?? MAX_RESOLVED_BYTES;
|
|
288
|
+
// Declared sizes are informational (and a ref can lie), but a ref that ADMITS to being too big is
|
|
289
|
+
// refused before a single byte is fetched.
|
|
290
|
+
let declared = 0;
|
|
291
|
+
for (const { ref } of refs.values()) declared += ref.size ?? 0;
|
|
292
|
+
if (declared > limit) {
|
|
293
|
+
throw new UnresolvedContentError(
|
|
294
|
+
first,
|
|
295
|
+
`declared media totals ${declared} bytes, over the ${limit}-byte cap`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const budget = new AbortController();
|
|
300
|
+
const onOuterAbort = (): void => budget.abort(options.signal?.reason);
|
|
301
|
+
if (options.signal?.aborted === true) onOuterAbort();
|
|
302
|
+
else options.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
303
|
+
|
|
304
|
+
const results = new Map<string, Fetched>();
|
|
305
|
+
let used = 0;
|
|
306
|
+
const queue = [...refs.values()];
|
|
307
|
+
const worker = async (): Promise<void> => {
|
|
308
|
+
for (;;) {
|
|
309
|
+
const use = queue.shift();
|
|
310
|
+
if (use === undefined || budget.signal.aborted) return;
|
|
311
|
+
const { ref } = use;
|
|
312
|
+
try {
|
|
313
|
+
const bytes = await resolver(ref, {
|
|
314
|
+
executionId: options.context.executionId,
|
|
315
|
+
...(options.context.workflowId !== undefined && {
|
|
316
|
+
workflowId: options.context.workflowId,
|
|
317
|
+
}),
|
|
318
|
+
...(options.context.stepId !== undefined && { stepId: options.context.stepId }),
|
|
319
|
+
...(options.context.principal !== undefined && { principal: options.context.principal }),
|
|
320
|
+
origin: use.tool ? "tool" : "app",
|
|
321
|
+
maxBytes: Math.max(0, limit - used),
|
|
322
|
+
signal: budget.signal,
|
|
323
|
+
});
|
|
324
|
+
if (bytes === null) {
|
|
325
|
+
results.set(ref.address, {
|
|
326
|
+
ok: false,
|
|
327
|
+
reason: "the content is absent (erased or never stored)",
|
|
328
|
+
});
|
|
329
|
+
} else if (ref.size !== undefined && bytes.byteLength !== ref.size) {
|
|
330
|
+
results.set(ref.address, {
|
|
331
|
+
ok: false,
|
|
332
|
+
reason: `the content is ${bytes.byteLength} bytes but its ref declares ${ref.size}`,
|
|
333
|
+
});
|
|
334
|
+
} else {
|
|
335
|
+
used += bytes.byteLength;
|
|
336
|
+
if (used > limit) {
|
|
337
|
+
results.set(ref.address, {
|
|
338
|
+
ok: false,
|
|
339
|
+
reason: `resolved media exceeds the ${limit}-byte cap`,
|
|
340
|
+
});
|
|
341
|
+
budget.abort(new Error("media byte budget spent"));
|
|
342
|
+
} else {
|
|
343
|
+
results.set(ref.address, { ok: true, b64: toBase64(bytes), bytes: bytes.byteLength });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
results.set(ref.address, {
|
|
348
|
+
ok: false,
|
|
349
|
+
reason: `the resolver failed: ${String((err as Error)?.message ?? err)}`,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
try {
|
|
355
|
+
await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, queue.length) }, worker));
|
|
356
|
+
} finally {
|
|
357
|
+
options.signal?.removeEventListener("abort", onOuterAbort);
|
|
358
|
+
}
|
|
359
|
+
// A cancelled call is a cancelled call, not a missing blob.
|
|
360
|
+
if (options.signal?.aborted === true) throw options.signal.reason ?? new Error("aborted");
|
|
361
|
+
|
|
362
|
+
const failure = (address: string): string | undefined => {
|
|
363
|
+
const r = results.get(address);
|
|
364
|
+
if (r === undefined) return "not fetched: the request's media byte budget was spent first";
|
|
365
|
+
return r.ok ? undefined : r.reason;
|
|
366
|
+
};
|
|
367
|
+
// An app-supplied ref that failed fails the call, naming the address.
|
|
368
|
+
for (const [address, use] of refs) {
|
|
369
|
+
const why = failure(address);
|
|
370
|
+
if (why !== undefined && !use.tool) throw new UnresolvedContentError(address, why);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const inline = (p: LlmResultPart): LlmResultPart => {
|
|
374
|
+
if (!isMedia(p) || p.source.kind !== "ref") return p;
|
|
375
|
+
const r = results.get(p.source.ref.address);
|
|
376
|
+
return r?.ok === true ? ({ ...p, source: { kind: "data", data: r.b64 } } as LlmResultPart) : p;
|
|
377
|
+
};
|
|
378
|
+
const messages: LlmMessage[] = request.messages.map((m) => {
|
|
379
|
+
if (typeof m.content === "string") return m;
|
|
380
|
+
return {
|
|
381
|
+
...m,
|
|
382
|
+
content: m.content.map((p) => {
|
|
383
|
+
if (p.type === "tool_result" && typeof p.content !== "string") {
|
|
384
|
+
let isError = p.isError;
|
|
385
|
+
const content = p.content.map((r): LlmResultPart => {
|
|
386
|
+
if (isMedia(r) && r.source.kind === "ref") {
|
|
387
|
+
const why = failure(r.source.ref.address);
|
|
388
|
+
if (why !== undefined) {
|
|
389
|
+
isError = true;
|
|
390
|
+
return {
|
|
391
|
+
type: "text",
|
|
392
|
+
text: JSON.stringify({
|
|
393
|
+
error: {
|
|
394
|
+
code: "content_unavailable",
|
|
395
|
+
address: r.source.ref.address,
|
|
396
|
+
message: why,
|
|
397
|
+
},
|
|
398
|
+
}),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return inline(r);
|
|
403
|
+
});
|
|
404
|
+
return { ...p, content, ...(isError !== undefined && { isError }) };
|
|
405
|
+
}
|
|
406
|
+
if (isMedia(p) && p.source.kind === "ref") return inline(p) as typeof p;
|
|
407
|
+
return p;
|
|
408
|
+
}),
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
return { ...request, messages };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The base64 bytes of a media part for a provider mapper. A provider only ever sees
|
|
416
|
+
* `data` sources (the effect resolves refs first); a `ref` here means it was called
|
|
417
|
+
* directly with an unresolved request, which fails closed rather than sending nothing.
|
|
418
|
+
*/
|
|
419
|
+
export function inlineData(p: LlmMediaPart): string {
|
|
420
|
+
if (p.source.kind === "data") return p.source.data;
|
|
421
|
+
throw new UnresolvedContentError(
|
|
422
|
+
p.source.ref.address,
|
|
423
|
+
"providers accept inline media only — resolve refs via createLlmEffect({ resolveContent })"
|
|
424
|
+
);
|
|
425
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { LlmCapabilities } from "./capabilities";
|
|
2
|
+
import type {
|
|
3
|
+
LlmContent,
|
|
4
|
+
LlmLayoutEntry,
|
|
5
|
+
LlmMessageContent,
|
|
6
|
+
LlmMessageContentInput,
|
|
7
|
+
LlmReasoning,
|
|
8
|
+
LlmReasoningPart,
|
|
9
|
+
} from "./parts";
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
LlmContent,
|
|
13
|
+
LlmLayoutEntry,
|
|
14
|
+
LlmReasoning,
|
|
15
|
+
LlmReasoningKind,
|
|
16
|
+
LlmReasoningPart,
|
|
17
|
+
} from "./parts";
|
|
18
|
+
|
|
19
|
+
export interface LlmMessage {
|
|
20
|
+
role: "system" | "user" | "assistant";
|
|
21
|
+
/**
|
|
22
|
+
* R5 — a string (text only) or content parts (text, image, audio, file, `tool_use`,
|
|
23
|
+
* `tool_result`). Use `textOf` / `toParts` to read it without caring which.
|
|
24
|
+
*/
|
|
25
|
+
content: LlmMessageContent;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LlmToolSpec {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
inputSchema: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── The input side of the prompt boundary ────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// `buildRequest` may hand back content that is still tainted (`@loomwork/classify`);
|
|
37
|
+
// `guardRequest` refuses it outside a `user` message and fences it inside one,
|
|
38
|
+
// so what reaches an `LlmProvider` is always the plain-string `LlmRequest` above.
|
|
39
|
+
// Splitting the two types is what keeps every provider ignorant of taint — and
|
|
40
|
+
// `LlmRequest` is assignable to `LlmRequestInput`, so an application that never
|
|
41
|
+
// taints anything sees no change at all.
|
|
42
|
+
|
|
43
|
+
export interface LlmMessageInput {
|
|
44
|
+
role: "system" | "user" | "assistant";
|
|
45
|
+
content: LlmMessageContentInput;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LlmToolSpecInput {
|
|
49
|
+
name: LlmContent;
|
|
50
|
+
description: LlmContent;
|
|
51
|
+
inputSchema: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LlmRequestInput extends Omit<LlmRequest, "messages" | "tools" | "system"> {
|
|
55
|
+
messages: LlmMessageInput[];
|
|
56
|
+
tools?: LlmToolSpecInput[];
|
|
57
|
+
system?: LlmContent;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LlmRequest {
|
|
61
|
+
messages: LlmMessage[];
|
|
62
|
+
tools?: LlmToolSpec[];
|
|
63
|
+
/**
|
|
64
|
+
* The system prompt. ONE rule for every provider: a non-empty `system` wins and the system-role
|
|
65
|
+
* entries in `messages` are dropped; an empty string counts as unset; with no `system`, the
|
|
66
|
+
* system-role messages' text is joined in order (blank-line separated) and sent as the system prompt.
|
|
67
|
+
*/
|
|
68
|
+
system?: string;
|
|
69
|
+
maxTokens?: number;
|
|
70
|
+
temperature?: number;
|
|
71
|
+
/** Cooperative cancellation — forwarded to the underlying HTTP request. */
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface LlmToolCall {
|
|
76
|
+
id: string;
|
|
77
|
+
name: string;
|
|
78
|
+
input: unknown;
|
|
79
|
+
/** Provider-opaque continuation bytes that belong to this call (Gemini `thoughtSignature`); sent back unchanged (ADR 0133). */
|
|
80
|
+
signature?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface LlmResponse {
|
|
84
|
+
content: string;
|
|
85
|
+
toolCalls?: LlmToolCall[];
|
|
86
|
+
stopReason: "end_turn" | "tool_use" | "max_tokens";
|
|
87
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
88
|
+
/**
|
|
89
|
+
* ADR 0133 - the reasoning the provider returned, as it returned it. ABSENT when the provider
|
|
90
|
+
* returned none (G2). Readable `text` appears only on a provider's own response: `createLlmEffect`
|
|
91
|
+
* replaces this with {@link LlmReasoningPart}s before `parseResponse`, so text never reaches a step value.
|
|
92
|
+
*/
|
|
93
|
+
reasoning?: LlmReasoning[];
|
|
94
|
+
/**
|
|
95
|
+
* ADR 0133 D5 - the vendor's block order (reasoning, text and tool-call positions), present ONLY
|
|
96
|
+
* when there is reasoning AND the order is not the canonical one. Lets a signature-binding provider
|
|
97
|
+
* get its response back exactly. Positions only; absent otherwise (G2).
|
|
98
|
+
*/
|
|
99
|
+
layout?: LlmLayoutEntry[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What `parseResponse` receives: reasoning already reduced to its recorded form (ADR 0133 D3). */
|
|
103
|
+
export type LlmCapturedResponse = Omit<LlmResponse, "reasoning"> & {
|
|
104
|
+
reasoning?: LlmReasoningPart[];
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One increment of a streamed completion.
|
|
109
|
+
*
|
|
110
|
+
* The outcome fields ride the TERMINAL chunk (`done: true`). Without them a stream
|
|
111
|
+
* can carry prose to a human and nothing else — a ReAct loop branches on
|
|
112
|
+
* `toolCalls` and `stopReason`, and metering needs `usage` — which is why the
|
|
113
|
+
* streaming path could not back an agent turn while it yielded `{ delta, done }`
|
|
114
|
+
* alone.
|
|
115
|
+
*
|
|
116
|
+
* All three are OPTIONAL: a provider that streams text only leaves them undefined
|
|
117
|
+
* and is unchanged (G2). What is NOT optional is honesty — when a field IS
|
|
118
|
+
* reported it must equal what `complete` would have returned for the same request.
|
|
119
|
+
* `runLlmProviderConformance` pins exactly that (see {@link assembleStream}).
|
|
120
|
+
*
|
|
121
|
+
* **`toolCalls` is optional in the type, but not silently so at the point of use.**
|
|
122
|
+
* A provider that supports `stream` and is proven against `runLlmProviderConformance`
|
|
123
|
+
* with a `toolRequest` (a request it is known to answer with a tool call) MUST report
|
|
124
|
+
* `toolCalls` on the terminal chunk, matching `complete`'s — an omission there fails
|
|
125
|
+
* the kit rather than passing as "not reported". Only a provider that opts out with
|
|
126
|
+
* `{ toolRequest: null }` (because it genuinely never calls tools) is exempt.
|
|
127
|
+
*/
|
|
128
|
+
export interface LlmStreamChunk {
|
|
129
|
+
delta: string;
|
|
130
|
+
done: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* R5 — an increment of a tool call's INPUT, streamed as it is generated. The first
|
|
133
|
+
* fragment of a call carries `id` and `name` (with `inputDelta` possibly `""`); later
|
|
134
|
+
* fragments carry the `id` and the next slice of the JSON text. Transient by
|
|
135
|
+
* construction, like `delta`: the assembled call still arrives whole on the terminal
|
|
136
|
+
* chunk's `toolCalls`, which is the only thing that is journaled. A fragment is
|
|
137
|
+
* partial JSON — never parse it.
|
|
138
|
+
*/
|
|
139
|
+
toolDelta?: { id: string; name?: string; inputDelta: string };
|
|
140
|
+
/** Tool calls the model made, assembled from the stream. Terminal chunk only. */
|
|
141
|
+
toolCalls?: LlmToolCall[];
|
|
142
|
+
/** Why generation stopped. Terminal chunk only. */
|
|
143
|
+
stopReason?: LlmResponse["stopReason"];
|
|
144
|
+
/** Token accounting — what K3 meters and what K8.4 needs to sever mid-flight. Terminal chunk only. */
|
|
145
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
146
|
+
/** ADR 0133 - the assembled reasoning; equals what `complete` returns. Terminal chunk only. */
|
|
147
|
+
reasoning?: LlmReasoning[];
|
|
148
|
+
/** ADR 0133 D5 - the block order; equals what `complete` returns. Terminal chunk only. */
|
|
149
|
+
layout?: LlmLayoutEntry[];
|
|
150
|
+
/**
|
|
151
|
+
* ADR 0133 D6 - an increment of READABLE reasoning (a summary or text), streamed as it arrives.
|
|
152
|
+
* A separate field from `delta` so no consumer of text deltas sees reasoning by accident.
|
|
153
|
+
* Signatures and redacted data are never streamed. Transient: only the terminal `reasoning` is kept.
|
|
154
|
+
*/
|
|
155
|
+
reasoningDelta?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface LlmProvider {
|
|
159
|
+
/**
|
|
160
|
+
* R5 — which non-text modalities this provider accepts. ABSENT means text and tool
|
|
161
|
+
* calls only: a request carrying an image/audio/file part is refused with
|
|
162
|
+
* {@link UnsupportedModalityError} before any network call (fail closed — a provider
|
|
163
|
+
* that never declared media must not be handed it and left to drop it silently).
|
|
164
|
+
*/
|
|
165
|
+
readonly capabilities?: LlmCapabilities;
|
|
166
|
+
complete(request: LlmRequest): Promise<LlmResponse>;
|
|
167
|
+
stream?(request: LlmRequest): AsyncIterable<LlmStreamChunk>;
|
|
168
|
+
/**
|
|
169
|
+
* Advisory pre-warm (latency-only): keep a connection / HTTP-2 session / model
|
|
170
|
+
* cache hot so the next `complete` skips the cold handshake. Called via the
|
|
171
|
+
* effect's `warm()` (which the kernel invokes ahead of use). MUST NOT change
|
|
172
|
+
* outputs; a failure is a silent no-op → the next call runs cold.
|
|
173
|
+
*/
|
|
174
|
+
warm?(): void | Promise<void>;
|
|
175
|
+
}
|