@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
package/src/parts.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { type TaintedValue, isTainted } from "@loomwork/classify";
|
|
2
|
+
|
|
3
|
+
// ── R5 — content parts (ADR 0120) ─────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// A message's content is `string | LlmContentPart[]`. The string form is the
|
|
6
|
+
// compatibility path and stays first-class (a text-only caller changes nothing);
|
|
7
|
+
// the parts form carries media and the tool-call turn structure.
|
|
8
|
+
//
|
|
9
|
+
// Tool results ride in a `user`-role message as `tool_result` parts and tool calls
|
|
10
|
+
// in an `assistant` message as `tool_use` parts (the Anthropic Messages shape —
|
|
11
|
+
// there is NO `tool` role). One canonical shape, mapped per provider: OpenAI's
|
|
12
|
+
// `role: "tool"` messages and Gemini's `functionResponse` parts are the providers'
|
|
13
|
+
// problem. It also leaves ADR 0085's positional rule intact — "tainted content may
|
|
14
|
+
// appear only in a `user` message, fenced" — rather than adding a third position
|
|
15
|
+
// that every guard would have to learn.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A claim-check for large bytes held in a content store (ADR 0046).
|
|
19
|
+
*
|
|
20
|
+
* Deliberately NOT the store's own `{ __loomContent }` marker: the resolving event
|
|
21
|
+
* store would substitute that marker with a JSON-decoded value on read, which is
|
|
22
|
+
* wrong for raw media bytes. This is an opaque address the caller resolves (see
|
|
23
|
+
* `CreateLlmEffectOptions.resolveContent`) at call time, so the log carries the
|
|
24
|
+
* address and never the bytes.
|
|
25
|
+
*/
|
|
26
|
+
export interface LlmContentRef {
|
|
27
|
+
/** The content address, e.g. `"sha256:<hex>"`. */
|
|
28
|
+
readonly address: string;
|
|
29
|
+
/** Byte length, informational. */
|
|
30
|
+
readonly size?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where a media part's bytes are. */
|
|
34
|
+
export type LlmBinarySource =
|
|
35
|
+
| { readonly kind: "ref"; readonly ref: LlmContentRef }
|
|
36
|
+
/** Small inline bytes, base64. Capped (see `MAX_INLINE_BYTES`) so the log never carries real media. */
|
|
37
|
+
| { readonly kind: "data"; readonly data: string };
|
|
38
|
+
|
|
39
|
+
export interface LlmTextPart {
|
|
40
|
+
type: "text";
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
/** Provenance for a media part whose bytes came from an untrusted party (see `guardRequest`). */
|
|
44
|
+
export interface LlmUntrustedMark {
|
|
45
|
+
untrusted?: { origin: string };
|
|
46
|
+
}
|
|
47
|
+
export interface LlmImagePart extends LlmUntrustedMark {
|
|
48
|
+
type: "image";
|
|
49
|
+
/** e.g. `image/png`. */
|
|
50
|
+
mediaType: string;
|
|
51
|
+
source: LlmBinarySource;
|
|
52
|
+
}
|
|
53
|
+
export interface LlmAudioPart extends LlmUntrustedMark {
|
|
54
|
+
type: "audio";
|
|
55
|
+
mediaType: string;
|
|
56
|
+
source: LlmBinarySource;
|
|
57
|
+
}
|
|
58
|
+
export interface LlmFilePart extends LlmUntrustedMark {
|
|
59
|
+
type: "file";
|
|
60
|
+
mediaType: string;
|
|
61
|
+
source: LlmBinarySource;
|
|
62
|
+
name?: string;
|
|
63
|
+
}
|
|
64
|
+
export type LlmMediaPart = LlmImagePart | LlmAudioPart | LlmFilePart;
|
|
65
|
+
export type LlmModality = LlmMediaPart["type"];
|
|
66
|
+
|
|
67
|
+
/** A tool call the model made (assistant messages only). */
|
|
68
|
+
export interface LlmToolUsePart {
|
|
69
|
+
type: "tool_use";
|
|
70
|
+
id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
input: unknown;
|
|
73
|
+
/** Provider-opaque continuation bytes that belong to this call (Gemini `thoughtSignature`). Sent back unchanged; never dropped with reasoning parts (ADR 0133 D5). */
|
|
74
|
+
signature?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What the model returned as reasoning, in its RECORDED form (ADR 0133 D2): kind, provider,
|
|
79
|
+
* opaque signature, and a reference to the readable text in a sidecar store. Never readable text
|
|
80
|
+
* in a step value or a transcript. Assistant messages only.
|
|
81
|
+
*/
|
|
82
|
+
export interface LlmReasoningPart {
|
|
83
|
+
type: "reasoning";
|
|
84
|
+
kind: LlmReasoningKind;
|
|
85
|
+
/** The provider that produced it: a part is only sent back to this provider. */
|
|
86
|
+
provider: string;
|
|
87
|
+
signature?: string;
|
|
88
|
+
/** Where the text lives (sidecar store), when it was captured and is non-empty. */
|
|
89
|
+
textRef?: LlmContentRef;
|
|
90
|
+
/** SHA-256 hex of the text, so a resolved text can be checked. */
|
|
91
|
+
digest?: string;
|
|
92
|
+
/** The model returned readable text that was NOT kept (no store, or capture off). Such a part cannot be reproduced for continuation, so the drop rule (ADR 0133 D5) drops it. */
|
|
93
|
+
omitted?: true;
|
|
94
|
+
/**
|
|
95
|
+
* REQUEST-TIME ONLY: the readable text `resolveRequestContent` fetched from `textRef`, for the
|
|
96
|
+
* provider to map back unchanged. Never present on a recorded value.
|
|
97
|
+
*/
|
|
98
|
+
text?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What the provider returned. Never upgrade a `summary` to `text` (ADR 0133 D2). */
|
|
102
|
+
export type LlmReasoningKind = "text" | "summary" | "redacted";
|
|
103
|
+
|
|
104
|
+
/** Reasoning on a response, as the provider returned it. Lives only on `LlmResponse`, never in a step value. */
|
|
105
|
+
export interface LlmReasoning {
|
|
106
|
+
kind: LlmReasoningKind;
|
|
107
|
+
/** Readable text; "" for an omitted-display block; absent for `redacted`. */
|
|
108
|
+
text?: string;
|
|
109
|
+
/** Provider-opaque continuation bytes (signature, redacted data). Never read by loom. */
|
|
110
|
+
signature?: string;
|
|
111
|
+
/** The provider that produced it (`"anthropic"`, `"gemini"`, ...): parts are only sent back to it. */
|
|
112
|
+
provider: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What a tool result may contain: text and media. */
|
|
116
|
+
export type LlmResultPart = LlmTextPart | LlmMediaPart;
|
|
117
|
+
|
|
118
|
+
/** The answer to a tool call (user messages only). */
|
|
119
|
+
export interface LlmToolResultPart {
|
|
120
|
+
type: "tool_result";
|
|
121
|
+
/** The `id` of the `tool_use` this answers. */
|
|
122
|
+
toolUseId: string;
|
|
123
|
+
content: string | LlmResultPart[];
|
|
124
|
+
isError?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type LlmContentPart =
|
|
128
|
+
| LlmTextPart
|
|
129
|
+
| LlmMediaPart
|
|
130
|
+
| LlmToolUsePart
|
|
131
|
+
| LlmToolResultPart
|
|
132
|
+
| LlmReasoningPart;
|
|
133
|
+
export type LlmMessageContent = string | LlmContentPart[];
|
|
134
|
+
|
|
135
|
+
// ── input side: parts that may still carry provenance ────────────────────────
|
|
136
|
+
|
|
137
|
+
/** A content slot that may still carry provenance. */
|
|
138
|
+
export type LlmContent = string | TaintedValue<string>;
|
|
139
|
+
|
|
140
|
+
export interface LlmTextPartInput {
|
|
141
|
+
type: "text";
|
|
142
|
+
text: LlmContent;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A media part whose bytes came from an untrusted party. `untrusted` is the media
|
|
146
|
+
* analogue of `tainted()` on text: `guardRequest` refuses it outside a `user`
|
|
147
|
+
* message, refuses one that names a stored `ref`, and leaves the mark on the part; the provider
|
|
148
|
+
* mappers bracket it with the same fence tags as text (`fenceUntrustedMedia`) where their wire
|
|
149
|
+
* shape allows — the fence must travel with the media, which not every wire keeps in one message.
|
|
150
|
+
*/
|
|
151
|
+
export type LlmMediaPartInput = LlmMediaPart;
|
|
152
|
+
export type LlmResultPartInput = LlmTextPartInput | LlmMediaPartInput;
|
|
153
|
+
export interface LlmToolResultPartInput {
|
|
154
|
+
type: "tool_result";
|
|
155
|
+
toolUseId: string;
|
|
156
|
+
content: LlmContent | LlmResultPartInput[];
|
|
157
|
+
isError?: boolean;
|
|
158
|
+
}
|
|
159
|
+
export type LlmContentPartInput =
|
|
160
|
+
| LlmTextPartInput
|
|
161
|
+
| LlmMediaPartInput
|
|
162
|
+
| LlmToolUsePart
|
|
163
|
+
| LlmToolResultPartInput
|
|
164
|
+
| LlmReasoningPart;
|
|
165
|
+
export type LlmMessageContentInput = LlmContent | LlmContentPartInput[];
|
|
166
|
+
|
|
167
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/** Normalise message content to its parts form (a string becomes one text part). */
|
|
170
|
+
export function toParts(content: LlmMessageContent): LlmContentPart[] {
|
|
171
|
+
return typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Thrown by {@link textOf} when handed a `tainted()` wrapper (`{ __loomTainted, value }`) instead
|
|
176
|
+
* of message content. A wrapper is neither a string nor parts; reading it as text would either
|
|
177
|
+
* crash obscurely or, worse, silently drop the provenance. The message never echoes the content.
|
|
178
|
+
*/
|
|
179
|
+
export class TaintedContentError extends Error {
|
|
180
|
+
/** The `origin` recorded when the content was tainted. */
|
|
181
|
+
readonly origin: string;
|
|
182
|
+
constructor(origin: string) {
|
|
183
|
+
super(
|
|
184
|
+
`textOf() was given tainted content from "${origin}": untrusted data wrapped by tainted(), not message text. Unwrap it deliberately with unwrapTainted() (or, in a conversation summarizer, summarizerText() from @loomwork/conversation) and treat the result as DATA - never place it in an instruction position.`
|
|
185
|
+
);
|
|
186
|
+
this.name = "TaintedContentError";
|
|
187
|
+
this.origin = origin;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The text of a message: the string itself, or its text parts joined (including text
|
|
193
|
+
* inside `tool_result`s, which is what a text-only consumer wants to read). Media and
|
|
194
|
+
* `tool_use` parts contribute nothing.
|
|
195
|
+
*
|
|
196
|
+
* Throws {@link TaintedContentError} on a `tainted()` wrapper - at the top level, in a text part's
|
|
197
|
+
* `text`, or in a `tool_result`'s `content` (or its text parts): unwrap it on purpose first.
|
|
198
|
+
*/
|
|
199
|
+
export function textOf(content: LlmMessageContent): string {
|
|
200
|
+
if (typeof content === "string") return content;
|
|
201
|
+
if (isTainted(content)) throw new TaintedContentError(content.__loomTainted.origin);
|
|
202
|
+
let out = "";
|
|
203
|
+
for (const p of content) {
|
|
204
|
+
if (isTainted(p)) throw new TaintedContentError(p.__loomTainted.origin); // off-type: a wrapper as a part
|
|
205
|
+
if (p.type === "text") out += plainText(p.text);
|
|
206
|
+
else if (p.type === "tool_result") {
|
|
207
|
+
const c: unknown = p.content;
|
|
208
|
+
if (isTainted(c)) throw new TaintedContentError(c.__loomTainted.origin);
|
|
209
|
+
if (typeof c === "string") out += c;
|
|
210
|
+
else
|
|
211
|
+
for (const r of c as LlmResultPart[]) {
|
|
212
|
+
// an off-type wrapper used as an element of the parts is refused like any other
|
|
213
|
+
if (isTainted(r)) throw new TaintedContentError(r.__loomTainted.origin);
|
|
214
|
+
if (r.type === "text") out += plainText(r.text);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A text slot's string; a `tainted()` wrapper nested in a part is refused like a top-level one. */
|
|
222
|
+
function plainText(text: unknown): string {
|
|
223
|
+
if (isTainted(text)) throw new TaintedContentError(text.__loomTainted.origin);
|
|
224
|
+
return text as string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Whether a message carries any non-text part. */
|
|
228
|
+
export function hasStructuredParts(content: LlmMessageContent): boolean {
|
|
229
|
+
return typeof content !== "string" && content.some((p) => p.type !== "text");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* ADR 0133 D5 - where each block sat in the vendor's response, when that is NOT the canonical order
|
|
234
|
+
* (reasoning parts, then ONE text, then the tool calls). A provider whose signatures bind block
|
|
235
|
+
* order (Anthropic preserved thinking) needs the sequence back exactly: `thinking, text, tool_use,
|
|
236
|
+
* thinking, tool_use` must not become `thinking, thinking, text, tool_use, tool_use`.
|
|
237
|
+
*
|
|
238
|
+
* An entry is `"reasoning"` (the next recorded reasoning part), `{ call }` (the tool call with that
|
|
239
|
+
* id) or `{ text }` (the next `n` characters of the response's `content`). Positions only, no new
|
|
240
|
+
* content, so nothing readable is added to a step value.
|
|
241
|
+
*/
|
|
242
|
+
export type LlmLayoutEntry = "reasoning" | { call: string } | { text: number };
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The layout to record for a response, or `undefined` when none is needed: no reasoning (the shape
|
|
246
|
+
* stays exactly today's, G2) or the canonical order. Zero-length text blocks are dropped (a provider
|
|
247
|
+
* rejects an empty text block, so there is nothing to reproduce).
|
|
248
|
+
*/
|
|
249
|
+
export function layoutOf(entries: readonly LlmLayoutEntry[]): LlmLayoutEntry[] | undefined {
|
|
250
|
+
const kept = entries.filter((e) => typeof e !== "object" || !("text" in e) || e.text > 0);
|
|
251
|
+
if (!kept.includes("reasoning")) return undefined;
|
|
252
|
+
// canonical: "reasoning"*, at most one text, calls*. Phase 0 = reasoning, 1 = after the text, 2 = calls.
|
|
253
|
+
let phase = 0;
|
|
254
|
+
let canonical = true;
|
|
255
|
+
for (const e of kept) {
|
|
256
|
+
if (e === "reasoning") {
|
|
257
|
+
if (phase !== 0) canonical = false;
|
|
258
|
+
} else if ("text" in e) {
|
|
259
|
+
if (phase !== 0) canonical = false;
|
|
260
|
+
phase = 1;
|
|
261
|
+
} else phase = 2;
|
|
262
|
+
}
|
|
263
|
+
return canonical ? undefined : kept.map((e) => (typeof e === "object" ? { ...e } : e));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The `assistant` message that records a model response — text (when any) then one
|
|
268
|
+
* `tool_use` part per call — so the next turn's `tool_result`s have something to
|
|
269
|
+
* answer. A response with neither text nor calls yields an empty text part rather than
|
|
270
|
+
* an empty array (providers reject empty content).
|
|
271
|
+
*
|
|
272
|
+
* With a `layout` (ADR 0133 D5) the parts come back in the vendor's own order, text blocks at their
|
|
273
|
+
* positions. A layout that does not fit the response (malformed, text lengths not summing to `content`,
|
|
274
|
+
* or a reasoning count that differs) drops every reasoning part rather than reorder them; a named call
|
|
275
|
+
* that is missing drops the reasoning parts after it; calls it does not name go at the end.
|
|
276
|
+
*/
|
|
277
|
+
export function assistantMessageOf(response: {
|
|
278
|
+
content: string;
|
|
279
|
+
toolCalls?: readonly { id: string; name: string; input: unknown; signature?: string }[];
|
|
280
|
+
/** ADR 0133 - recorded reasoning parts (`LlmReasoningPart`), carried first. Raw `LlmReasoning` is not accepted: readable text never enters a transcript. */
|
|
281
|
+
reasoning?: readonly object[];
|
|
282
|
+
/** ADR 0133 D5 - the vendor's block order, when not canonical. */
|
|
283
|
+
layout?: readonly LlmLayoutEntry[];
|
|
284
|
+
}): { role: "assistant"; content: LlmContentPart[] } {
|
|
285
|
+
const recorded: LlmReasoningPart[] = [];
|
|
286
|
+
for (const r of response.reasoning ?? []) {
|
|
287
|
+
if ((r as { type?: string }).type === "reasoning") recorded.push(r as LlmReasoningPart);
|
|
288
|
+
}
|
|
289
|
+
const parts: LlmContentPart[] = laidOut(response, recorded) ?? canonicalParts(response, recorded);
|
|
290
|
+
if (!parts.some((p) => p.type === "text" || p.type === "tool_use")) {
|
|
291
|
+
parts.push({ type: "text", text: "" });
|
|
292
|
+
}
|
|
293
|
+
return { role: "assistant", content: parts };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface ToolCallLike {
|
|
297
|
+
id: string;
|
|
298
|
+
name: string;
|
|
299
|
+
input: unknown;
|
|
300
|
+
signature?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function toolUse(c: ToolCallLike): LlmToolUsePart {
|
|
304
|
+
return {
|
|
305
|
+
type: "tool_use",
|
|
306
|
+
id: c.id,
|
|
307
|
+
name: c.name,
|
|
308
|
+
input: c.input,
|
|
309
|
+
...(c.signature !== undefined && { signature: c.signature }),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function canonicalParts(
|
|
314
|
+
response: { content: string; toolCalls?: readonly ToolCallLike[] },
|
|
315
|
+
recorded: readonly LlmReasoningPart[]
|
|
316
|
+
): LlmContentPart[] {
|
|
317
|
+
const parts: LlmContentPart[] = [...recorded];
|
|
318
|
+
if (response.content !== "") parts.push({ type: "text", text: response.content });
|
|
319
|
+
for (const c of response.toolCalls ?? []) parts.push(toolUse(c));
|
|
320
|
+
return parts;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function laidOut(
|
|
324
|
+
response: {
|
|
325
|
+
content: string;
|
|
326
|
+
toolCalls?: readonly ToolCallLike[];
|
|
327
|
+
layout?: readonly LlmLayoutEntry[];
|
|
328
|
+
},
|
|
329
|
+
recorded: readonly LlmReasoningPart[]
|
|
330
|
+
): LlmContentPart[] | undefined {
|
|
331
|
+
const layout = response.layout;
|
|
332
|
+
if (layout === undefined || layout.length === 0) return undefined;
|
|
333
|
+
// A thinking block is valid only while everything before it is unchanged, so a layout that cannot be
|
|
334
|
+
// replayed exactly must never be "repaired" by moving blocks (a 400 on a preserved-thinking account).
|
|
335
|
+
// When it does not fit at all, every reasoning part is dropped instead: stripping is always valid.
|
|
336
|
+
let total = 0;
|
|
337
|
+
let reasoningEntries = 0;
|
|
338
|
+
for (const e of layout) {
|
|
339
|
+
if (e === "reasoning") reasoningEntries++;
|
|
340
|
+
else if (
|
|
341
|
+
typeof e === "object" &&
|
|
342
|
+
e !== null &&
|
|
343
|
+
typeof (e as { text?: unknown }).text === "number"
|
|
344
|
+
) {
|
|
345
|
+
total += (e as { text: number }).text;
|
|
346
|
+
} else if (
|
|
347
|
+
typeof e !== "object" ||
|
|
348
|
+
e === null ||
|
|
349
|
+
typeof (e as { call?: unknown }).call !== "string"
|
|
350
|
+
) {
|
|
351
|
+
return canonicalParts(response, []);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (total !== response.content.length || reasoningEntries !== recorded.length) {
|
|
355
|
+
return canonicalParts(response, []);
|
|
356
|
+
}
|
|
357
|
+
const calls = new Map((response.toolCalls ?? []).map((c) => [c.id, c]));
|
|
358
|
+
const used = new Set<string>();
|
|
359
|
+
const parts: LlmContentPart[] = [];
|
|
360
|
+
let nextReasoning = 0;
|
|
361
|
+
let offset = 0;
|
|
362
|
+
// Once a named call is missing (screened out by the loop), every later block's prefix has changed:
|
|
363
|
+
// drop the reasoning parts from there on (a suffix drop), keep the earlier ones.
|
|
364
|
+
let prefixChanged = false;
|
|
365
|
+
for (const e of layout) {
|
|
366
|
+
if (e === "reasoning") {
|
|
367
|
+
const r = recorded[nextReasoning++] as LlmReasoningPart;
|
|
368
|
+
if (!prefixChanged) parts.push(r);
|
|
369
|
+
} else if ("text" in e) {
|
|
370
|
+
parts.push({ type: "text", text: response.content.slice(offset, offset + e.text) });
|
|
371
|
+
offset += e.text;
|
|
372
|
+
} else {
|
|
373
|
+
const c = calls.get(e.call);
|
|
374
|
+
if (c !== undefined && !used.has(e.call)) {
|
|
375
|
+
used.add(e.call);
|
|
376
|
+
parts.push(toolUse(c));
|
|
377
|
+
} else prefixChanged = true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// A call the layout did not name goes after every block, so it changes no block's prefix.
|
|
381
|
+
for (const c of response.toolCalls ?? []) if (!used.has(c.id)) parts.push(toolUse(c));
|
|
382
|
+
return parts;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* ADR 0133 D5 - the same message without its `reasoning` parts (the edit-point drop). Returns the
|
|
387
|
+
* SAME object when it holds none, so callers can compare by identity; tool-call signatures are never
|
|
388
|
+
* touched. A message left empty keeps the shape `assistantMessageOf` gives an empty turn.
|
|
389
|
+
*/
|
|
390
|
+
export function withoutReasoningParts<M extends { content: unknown }>(message: M): M {
|
|
391
|
+
const content = message.content;
|
|
392
|
+
if (!Array.isArray(content) || !content.some((p) => p?.type === "reasoning")) return message;
|
|
393
|
+
const kept = content.filter((p) => p?.type !== "reasoning");
|
|
394
|
+
return { ...message, content: kept.length > 0 ? kept : [{ type: "text", text: "" }] };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Whether a message holds a `reasoning` part. */
|
|
398
|
+
export function hasReasoningParts(message: { content: unknown }): boolean {
|
|
399
|
+
return Array.isArray(message.content) && message.content.some((p) => p?.type === "reasoning");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
403
|
+
|
|
404
|
+
/** Decoded byte length of a base64 string, or `undefined` if it is not valid base64. */
|
|
405
|
+
export function base64ByteLength(data: string): number | undefined {
|
|
406
|
+
if (data.length % 4 !== 0 || !BASE64.test(data)) return undefined;
|
|
407
|
+
const pad = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
408
|
+
return (data.length / 4) * 3 - pad;
|
|
409
|
+
}
|