@loomwork/llm 0.0.0-stage → 0.1.1
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,380 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type LlmCapabilities,
|
|
3
|
+
assertRequestSupported,
|
|
4
|
+
capabilitiesForModel,
|
|
5
|
+
} from "../capabilities";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MAX_LINE_BYTES,
|
|
8
|
+
DEFAULT_MAX_RESPONSE_BYTES,
|
|
9
|
+
DEFAULT_MAX_STREAM_BYTES,
|
|
10
|
+
LlmHttpError,
|
|
11
|
+
drainBody,
|
|
12
|
+
readBoundedErrorJson,
|
|
13
|
+
readBoundedJson,
|
|
14
|
+
resolveLimit,
|
|
15
|
+
} from "../errors";
|
|
16
|
+
import {
|
|
17
|
+
type LlmLayoutEntry,
|
|
18
|
+
type LlmMediaPart,
|
|
19
|
+
type LlmReasoning,
|
|
20
|
+
type LlmResultPart,
|
|
21
|
+
layoutOf,
|
|
22
|
+
} from "../parts";
|
|
23
|
+
import { inlineData } from "../resolveContent";
|
|
24
|
+
import type { LlmMessage, LlmProvider, LlmRequest, LlmResponse, LlmToolCall } from "../types";
|
|
25
|
+
import { fenceUntrustedMedia } from "../untrusted";
|
|
26
|
+
import { validateExtraHeaders } from "./extraHeaders";
|
|
27
|
+
import { resolveSystem } from "./systemPrompt";
|
|
28
|
+
|
|
29
|
+
export interface GeminiProviderConfig {
|
|
30
|
+
/**
|
|
31
|
+
* Extra request headers sent on every request (for example `anthropic-workspace-id`). Header
|
|
32
|
+
* names and values must be strings without CR/LF; a header the provider sets itself (auth,
|
|
33
|
+
* protocol, content type) cannot be overridden and throws at construction. Unset, the request
|
|
34
|
+
* is unchanged.
|
|
35
|
+
*/
|
|
36
|
+
headers?: Readonly<Record<string, string>>;
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
model?: string;
|
|
39
|
+
baseUrl?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Cap on a complete response body, in bytes after decompression (default 8 MiB). Over it the
|
|
42
|
+
* body is cancelled and `LlmResponseLimitError` (provider and reason only) is thrown.
|
|
43
|
+
*/
|
|
44
|
+
maxResponseBytes?: number;
|
|
45
|
+
/** Cap on the total bytes read from a stream (default 128 MiB); same error. */
|
|
46
|
+
maxStreamBytes?: number;
|
|
47
|
+
/** Cap on one line of a stream, in UTF-8 bytes (default 1 MiB); same error. */
|
|
48
|
+
maxLineBytes?: number;
|
|
49
|
+
/** Override the declared modalities. */
|
|
50
|
+
capabilities?: LlmCapabilities;
|
|
51
|
+
/**
|
|
52
|
+
* Ask for thought summaries (`generationConfig.thinkingConfig.includeThoughts`, ADR 0133).
|
|
53
|
+
* Unset, nothing is sent.
|
|
54
|
+
*/
|
|
55
|
+
includeThoughts?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Cap on thinking tokens (`generationConfig.thinkingConfig.thinkingBudget`). Thinking tokens
|
|
58
|
+
* count against `maxOutputTokens`, so without a cap a small output budget can be spent entirely
|
|
59
|
+
* on thinking (`MAX_TOKENS`, no text). An integer: `-1` lets the model decide (dynamic), `0`
|
|
60
|
+
* disables thinking on models that allow it. The accepted range is per model and enforced by the
|
|
61
|
+
* server (Gemini 2.5: Pro 128-32768, cannot be disabled; Flash 0-24576; Flash-Lite 512-24576, `0`
|
|
62
|
+
* disables). Unset, nothing is sent.
|
|
63
|
+
*/
|
|
64
|
+
thinkingBudget?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `generateContent` takes any inline mime type the model supports; declare the common ones. */
|
|
68
|
+
export const GEMINI_CAPABILITIES: LlmCapabilities = {
|
|
69
|
+
image: ["image/png", "image/jpeg", "image/webp", "image/heic", "image/heif"],
|
|
70
|
+
audio: ["audio/wav", "audio/mp3", "audio/mpeg", "audio/aac", "audio/ogg", "audio/flac"],
|
|
71
|
+
file: ["application/pdf", "text/plain"],
|
|
72
|
+
reasoning: true,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const PROVIDER_ID = "gemini";
|
|
76
|
+
|
|
77
|
+
/** Model families that take media: Gemini 1.5 and later, and the `-latest` aliases (1.0 Pro does not). */
|
|
78
|
+
export const GEMINI_MEDIA_MODELS = [/^gemini-(1\.5|[2-9])/, /^gemini-(flash|pro)(-lite)?-latest$/];
|
|
79
|
+
const GEMINI_DEFAULT_URL = "https://generativelanguage.googleapis.com";
|
|
80
|
+
|
|
81
|
+
/** Gemini returns no call id by default; the ones we synthesise carry this prefix and are never sent back. */
|
|
82
|
+
const SYNTHETIC_ID = "gemini-call-";
|
|
83
|
+
|
|
84
|
+
interface GeminiPart {
|
|
85
|
+
text?: string;
|
|
86
|
+
/** ADR 0133 - a thought SUMMARY part (returned when `includeThoughts` is set). */
|
|
87
|
+
thought?: boolean;
|
|
88
|
+
thoughtSignature?: string;
|
|
89
|
+
functionCall?: { id?: string; name: string; args?: unknown };
|
|
90
|
+
}
|
|
91
|
+
interface GeminiCandidate {
|
|
92
|
+
content?: { parts?: GeminiPart[] };
|
|
93
|
+
finishReason?: string;
|
|
94
|
+
}
|
|
95
|
+
interface GeminiResponse {
|
|
96
|
+
candidates?: GeminiCandidate[];
|
|
97
|
+
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function inlineBlock(p: LlmMediaPart): Record<string, unknown> {
|
|
101
|
+
return { inlineData: { mimeType: p.mediaType, data: inlineData(p) } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* SPI messages → Gemini `contents`. `functionResponse` is matched to its call by NAME, which
|
|
106
|
+
* the SPI's `tool_result` does not carry, so names are recovered from the earlier `tool_use`
|
|
107
|
+
* part with the same id. Media a tool returned rides as extra `inlineData` parts alongside.
|
|
108
|
+
* Exported for tests.
|
|
109
|
+
*/
|
|
110
|
+
export function mapContents(messages: readonly LlmMessage[]): Record<string, unknown>[] {
|
|
111
|
+
const names = new Map<string, string>();
|
|
112
|
+
const out: Record<string, unknown>[] = [];
|
|
113
|
+
for (const m of messages) {
|
|
114
|
+
if (m.role === "system") continue;
|
|
115
|
+
const role = m.role === "assistant" ? "model" : "user";
|
|
116
|
+
if (typeof m.content === "string") {
|
|
117
|
+
out.push({ role, parts: [{ text: m.content }] });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const parts: Record<string, unknown>[] = [];
|
|
121
|
+
const trailing: Record<string, unknown>[] = [];
|
|
122
|
+
for (const p of fenceUntrustedMedia(m.content)) {
|
|
123
|
+
switch (p.type) {
|
|
124
|
+
case "text":
|
|
125
|
+
parts.push({ text: p.text });
|
|
126
|
+
break;
|
|
127
|
+
case "reasoning":
|
|
128
|
+
// Only our own thought parts go back, unchanged; another provider's signature means
|
|
129
|
+
// nothing here. A redacted part has no Gemini form.
|
|
130
|
+
if (p.provider === PROVIDER_ID && p.kind !== "redacted") {
|
|
131
|
+
parts.push({
|
|
132
|
+
text: p.text ?? "",
|
|
133
|
+
thought: true,
|
|
134
|
+
...(p.signature !== undefined && { thoughtSignature: p.signature }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
case "image":
|
|
139
|
+
case "audio":
|
|
140
|
+
case "file":
|
|
141
|
+
parts.push(inlineBlock(p));
|
|
142
|
+
break;
|
|
143
|
+
case "tool_use":
|
|
144
|
+
names.set(p.id, p.name);
|
|
145
|
+
parts.push({
|
|
146
|
+
functionCall: {
|
|
147
|
+
...(!p.id.startsWith(SYNTHETIC_ID) && { id: p.id }),
|
|
148
|
+
name: p.name,
|
|
149
|
+
args: p.input ?? {},
|
|
150
|
+
},
|
|
151
|
+
// Gemini 3 400s a current-turn functionCall sent back without its signature.
|
|
152
|
+
...(p.signature !== undefined && { thoughtSignature: p.signature }),
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
case "tool_result": {
|
|
156
|
+
const items =
|
|
157
|
+
typeof p.content === "string"
|
|
158
|
+
? [{ type: "text" as const, text: p.content }]
|
|
159
|
+
: p.content;
|
|
160
|
+
const text = items.flatMap((r) => (r.type === "text" ? [r.text] : [])).join("\n");
|
|
161
|
+
parts.push({
|
|
162
|
+
functionResponse: {
|
|
163
|
+
...(!p.toolUseId.startsWith(SYNTHETIC_ID) && { id: p.toolUseId }),
|
|
164
|
+
name: names.get(p.toolUseId) ?? p.toolUseId,
|
|
165
|
+
response: p.isError === true ? { error: text } : { output: text },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
for (const r of items) {
|
|
169
|
+
if (r.type === "text") continue;
|
|
170
|
+
// The fence travels with the media into the trailing parts; the functionResponse
|
|
171
|
+
// holds text only.
|
|
172
|
+
for (const q of fenceUntrustedMedia<LlmResultPart>([r])) {
|
|
173
|
+
trailing.push(q.type === "text" ? { text: q.text } : inlineBlock(q));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
out.push({ role, parts: [...parts, ...trailing] });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo";
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `google.rpc.ErrorInfo.reason` values that mean the API key itself was refused. Only
|
|
189
|
+
* `API_KEY_INVALID` is documented for this (googleapis `google/api/error_reason.proto`; there is no
|
|
190
|
+
* separate documented reason for an expired key); the `API_KEY_*_BLOCKED` reasons are restrictions, not a bad key.
|
|
191
|
+
*/
|
|
192
|
+
function isApiKeyInvalid(body: unknown): boolean {
|
|
193
|
+
const details = (body as { error?: { details?: unknown } } | null)?.error?.details;
|
|
194
|
+
return (
|
|
195
|
+
Array.isArray(details) &&
|
|
196
|
+
details.some(
|
|
197
|
+
(d) =>
|
|
198
|
+
typeof d === "object" &&
|
|
199
|
+
d !== null &&
|
|
200
|
+
(d as { "@type"?: unknown })["@type"] === ERROR_INFO_TYPE &&
|
|
201
|
+
(d as { reason?: unknown }).reason === "API_KEY_INVALID"
|
|
202
|
+
)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A Google Gemini (Generative Language API) provider — the same fetch-based shape as
|
|
207
|
+
// OpenAiProvider / AnthropicProvider, mapping the Loom `LlmRequest`/`LlmResponse` contract
|
|
208
|
+
// onto Gemini's `generateContent`. Gemini uses roles "user" / "model" (no "assistant") and
|
|
209
|
+
// carries the system prompt as a separate `systemInstruction`, so we translate accordingly.
|
|
210
|
+
export class GeminiProvider implements LlmProvider {
|
|
211
|
+
private readonly apiKey: string;
|
|
212
|
+
private readonly model: string;
|
|
213
|
+
private readonly baseUrl: string;
|
|
214
|
+
private readonly maxResponseBytes: number;
|
|
215
|
+
private readonly maxStreamBytes: number;
|
|
216
|
+
private readonly maxLineBytes: number;
|
|
217
|
+
private readonly includeThoughts: boolean | undefined;
|
|
218
|
+
private readonly thinkingBudget: number | undefined;
|
|
219
|
+
private readonly extraHeaders: Readonly<Record<string, string>>;
|
|
220
|
+
readonly capabilities: LlmCapabilities;
|
|
221
|
+
|
|
222
|
+
constructor(config: GeminiProviderConfig = {}) {
|
|
223
|
+
this.extraHeaders = validateExtraHeaders(
|
|
224
|
+
config.headers,
|
|
225
|
+
["x-goog-api-key", "content-type", "authorization"],
|
|
226
|
+
"GeminiProvider"
|
|
227
|
+
);
|
|
228
|
+
this.includeThoughts = config.includeThoughts;
|
|
229
|
+
if (
|
|
230
|
+
config.thinkingBudget !== undefined &&
|
|
231
|
+
!(Number.isInteger(config.thinkingBudget) && config.thinkingBudget >= -1)
|
|
232
|
+
)
|
|
233
|
+
throw new RangeError("thinkingBudget must be an integer >= -1 (-1 = dynamic)");
|
|
234
|
+
this.thinkingBudget = config.thinkingBudget;
|
|
235
|
+
const model = config.model ?? "gemini-2.0-flash";
|
|
236
|
+
this.capabilities =
|
|
237
|
+
config.capabilities ??
|
|
238
|
+
(config.baseUrl !== undefined && config.baseUrl !== GEMINI_DEFAULT_URL
|
|
239
|
+
? {}
|
|
240
|
+
: capabilitiesForModel(model, GEMINI_CAPABILITIES, GEMINI_MEDIA_MODELS));
|
|
241
|
+
this.apiKey =
|
|
242
|
+
config.apiKey ??
|
|
243
|
+
(typeof process !== "undefined"
|
|
244
|
+
? (process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? "")
|
|
245
|
+
: "");
|
|
246
|
+
this.model = config.model ?? "gemini-2.0-flash";
|
|
247
|
+
this.baseUrl = config.baseUrl ?? GEMINI_DEFAULT_URL;
|
|
248
|
+
this.maxResponseBytes = resolveLimit(
|
|
249
|
+
config.maxResponseBytes,
|
|
250
|
+
DEFAULT_MAX_RESPONSE_BYTES,
|
|
251
|
+
"maxResponseBytes"
|
|
252
|
+
);
|
|
253
|
+
this.maxStreamBytes = resolveLimit(
|
|
254
|
+
config.maxStreamBytes,
|
|
255
|
+
DEFAULT_MAX_STREAM_BYTES,
|
|
256
|
+
"maxStreamBytes"
|
|
257
|
+
);
|
|
258
|
+
this.maxLineBytes = resolveLimit(config.maxLineBytes, DEFAULT_MAX_LINE_BYTES, "maxLineBytes");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private buildBody(request: LlmRequest): Record<string, unknown> {
|
|
262
|
+
// The one system-prompt rule (see `resolveSystem`).
|
|
263
|
+
const systemText = resolveSystem(request) ?? "";
|
|
264
|
+
|
|
265
|
+
const contents = mapContents(request.messages);
|
|
266
|
+
|
|
267
|
+
const generationConfig: Record<string, unknown> = {};
|
|
268
|
+
if (request.maxTokens !== undefined) generationConfig.maxOutputTokens = request.maxTokens;
|
|
269
|
+
if (request.temperature !== undefined) generationConfig.temperature = request.temperature;
|
|
270
|
+
const thinking: Record<string, unknown> = {};
|
|
271
|
+
if (this.includeThoughts !== undefined) thinking.includeThoughts = this.includeThoughts;
|
|
272
|
+
if (this.thinkingBudget !== undefined) thinking.thinkingBudget = this.thinkingBudget;
|
|
273
|
+
if (Object.keys(thinking).length > 0) generationConfig.thinkingConfig = thinking;
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
contents,
|
|
277
|
+
...(request.tools !== undefined &&
|
|
278
|
+
request.tools.length > 0 && {
|
|
279
|
+
tools: [
|
|
280
|
+
{
|
|
281
|
+
functionDeclarations: request.tools.map((t) => ({
|
|
282
|
+
name: t.name,
|
|
283
|
+
description: t.description,
|
|
284
|
+
parametersJsonSchema: t.inputSchema,
|
|
285
|
+
})),
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
}),
|
|
289
|
+
...(systemText.length > 0 && { systemInstruction: { parts: [{ text: systemText }] } }),
|
|
290
|
+
...(Object.keys(generationConfig).length > 0 && { generationConfig }),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async complete(request: LlmRequest): Promise<LlmResponse> {
|
|
295
|
+
assertRequestSupported(request, this.capabilities, "GeminiProvider");
|
|
296
|
+
const body = this.buildBody(request);
|
|
297
|
+
const resp = await fetch(`${this.baseUrl}/v1beta/models/${this.model}:generateContent`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: {
|
|
300
|
+
...this.extraHeaders,
|
|
301
|
+
"content-type": "application/json",
|
|
302
|
+
"x-goog-api-key": this.apiKey,
|
|
303
|
+
},
|
|
304
|
+
body: JSON.stringify(body),
|
|
305
|
+
...(request.signal !== undefined && { signal: request.signal }),
|
|
306
|
+
// Never follow a redirect (it would re-send the body and credentials to another origin): a 3xx
|
|
307
|
+
// comes back as a response and becomes `LlmHttpError(provider, status)` below.
|
|
308
|
+
redirect: "manual",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (!resp.ok) {
|
|
312
|
+
// Google answers a bad/expired API key with a 400 whose ErrorInfo names the reason; the
|
|
313
|
+
// status alone reads as a malformed request. Only a 400 is inspected, bounded, and nothing of
|
|
314
|
+
// the body is kept.
|
|
315
|
+
if (resp.status === 400) {
|
|
316
|
+
// The bounded read consumes or cancels the body itself.
|
|
317
|
+
const kind = isApiKeyInvalid(await readBoundedErrorJson(resp, request.signal))
|
|
318
|
+
? "auth"
|
|
319
|
+
: undefined;
|
|
320
|
+
throw new LlmHttpError("gemini", 400, kind);
|
|
321
|
+
}
|
|
322
|
+
await drainBody(resp);
|
|
323
|
+
throw new LlmHttpError("gemini", resp.status);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const data = (await readBoundedJson(resp, "gemini", this.maxResponseBytes)) as GeminiResponse;
|
|
327
|
+
const candidate = data.candidates?.[0];
|
|
328
|
+
const parts = candidate?.content?.parts ?? [];
|
|
329
|
+
// A thought part is reasoning, never answer text: it must not leak into `content`.
|
|
330
|
+
const content = parts.map((p) => (p.thought === true ? "" : (p.text ?? ""))).join("");
|
|
331
|
+
const toolCalls: LlmToolCall[] = [];
|
|
332
|
+
const reasoning: LlmReasoning[] = [];
|
|
333
|
+
// ADR 0133 D5 - the vendor's part order, so thought / text / functionCall positions round-trip.
|
|
334
|
+
const entries: LlmLayoutEntry[] = [];
|
|
335
|
+
for (const p of parts) {
|
|
336
|
+
if (p.thought === true) {
|
|
337
|
+
reasoning.push({
|
|
338
|
+
kind: "summary",
|
|
339
|
+
text: p.text ?? "",
|
|
340
|
+
...(p.thoughtSignature !== undefined && { signature: p.thoughtSignature }),
|
|
341
|
+
provider: PROVIDER_ID,
|
|
342
|
+
});
|
|
343
|
+
entries.push("reasoning");
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (p.text !== undefined) entries.push({ text: p.text.length });
|
|
347
|
+
if (p.functionCall !== undefined) {
|
|
348
|
+
const id = p.functionCall.id ?? `${SYNTHETIC_ID}${toolCalls.length}`;
|
|
349
|
+
toolCalls.push({
|
|
350
|
+
id,
|
|
351
|
+
name: p.functionCall.name,
|
|
352
|
+
input: p.functionCall.args ?? {},
|
|
353
|
+
...(p.thoughtSignature !== undefined && { signature: p.thoughtSignature }),
|
|
354
|
+
});
|
|
355
|
+
entries.push({ call: id });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const layout = layoutOf(entries);
|
|
359
|
+
const stopReason =
|
|
360
|
+
toolCalls.length > 0
|
|
361
|
+
? "tool_use"
|
|
362
|
+
: candidate?.finishReason === "MAX_TOKENS"
|
|
363
|
+
? "max_tokens"
|
|
364
|
+
: "end_turn";
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
content,
|
|
368
|
+
...(toolCalls.length > 0 && { toolCalls }),
|
|
369
|
+
stopReason,
|
|
370
|
+
...(data.usageMetadata !== undefined && {
|
|
371
|
+
usage: {
|
|
372
|
+
inputTokens: data.usageMetadata.promptTokenCount ?? 0,
|
|
373
|
+
outputTokens: data.usageMetadata.candidatesTokenCount ?? 0,
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
...(reasoning.length > 0 && { reasoning }),
|
|
377
|
+
...(layout !== undefined && { layout }),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|