extrait 0.5.1 → 0.5.3

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/README.md CHANGED
@@ -70,6 +70,7 @@ const llm = createLLM({
70
70
  mode: "loose" | "strict", // loose allows repair
71
71
  selfHeal: 0 | 1 | 2, // retry attempts
72
72
  debug: false, // show repair logs
73
+ timeout: { request: 30_000 }, // optional default timeouts
73
74
  },
74
75
  });
75
76
  ```
@@ -159,11 +160,90 @@ const result = await llm.structured(
159
160
  request: {
160
161
  signal: abortController.signal, // optional AbortSignal
161
162
  },
163
+ timeout: {
164
+ request: 30_000, // ms per LLM HTTP request
165
+ tool: 10_000, // ms per MCP tool call
166
+ },
162
167
  }
163
168
  );
164
169
  ```
165
170
 
166
- `prompt()` builds an ordered `messages` payload. Use `prompt\`...\`` for a single string prompt, or the fluent builder for multi-turn conversations. The `LLMMessage` type is exported if you need to type your own message arrays.
171
+ `prompt()` builds an ordered `messages` payload. Use ``prompt`...` `` for a single string prompt, or the fluent builder for multi-turn conversations. The `LLMMessage` type is exported if you need to type your own message arrays.
172
+
173
+ ### Images (multimodal)
174
+
175
+ Use `images()` to build base64 image content blocks for vision-capable models.
176
+
177
+ ```typescript
178
+ import { images, prompt } from "extrait";
179
+ import { readFileSync } from "fs";
180
+
181
+ const base64 = readFileSync("photo.png").toString("base64");
182
+ const img = { base64, mimeType: "image/png" };
183
+
184
+ // With prompt() builder — pass LLMMessageContent array to .user() or .assistant()
185
+ const result = await llm.structured(Schema,
186
+ prompt()
187
+ .system`You are a vision assistant.`
188
+ .user([{ type: "text", text: "Describe this image." }, ...images(img)])
189
+ );
190
+
191
+ // With raw messages array
192
+ const result = await llm.structured(Schema, {
193
+ messages: [
194
+ {
195
+ role: "user",
196
+ content: [
197
+ { type: "text", text: "Describe this image." },
198
+ ...images(img),
199
+ ],
200
+ },
201
+ ],
202
+ });
203
+
204
+ // Multiple images
205
+ const content = [
206
+ { type: "text", text: "Compare these two images." },
207
+ ...images([
208
+ { base64: base64A, mimeType: "image/png" },
209
+ { base64: base64B, mimeType: "image/jpeg" },
210
+ ]),
211
+ ];
212
+ ```
213
+
214
+ `images()` accepts a single `{ base64, mimeType }` object or an array, and always returns an `LLMImageContent[]` that spreads directly into a content array.
215
+
216
+ ### Conversations (multi-turn history)
217
+
218
+ Use `conversation()` to build a `LLMMessage[]` from an existing conversation history. This is the idiomatic way to pass prior turns to the LLM.
219
+
220
+ ```typescript
221
+ import { conversation } from "extrait";
222
+
223
+ const messages = conversation("You are a helpful assistant.", [
224
+ { role: "user", text: "What is the speed of light?" },
225
+ { role: "assistant", text: "Approximately 299,792 km/s in a vacuum." },
226
+ { role: "user", text: "How long does light take to reach Earth from the Sun?" },
227
+ ]);
228
+
229
+ // Pass to adapter directly
230
+ const response = await llm.adapter.complete({ messages });
231
+
232
+ // Or to structured extraction
233
+ const result = await llm.structured(Schema, { messages });
234
+ ```
235
+
236
+ Entries with `images` produce multimodal content automatically:
237
+
238
+ ```typescript
239
+ const messages = conversation("You are a vision assistant.", [
240
+ {
241
+ role: "user",
242
+ text: "What is in this image?",
243
+ images: [{ base64, mimeType: "image/png" }],
244
+ },
245
+ ]);
246
+ ```
167
247
 
168
248
  ### Result Object
169
249
 
@@ -246,6 +326,34 @@ const result = await llm.structured(
246
326
  await mcpClient.close?.();
247
327
  ```
248
328
 
329
+ ### Timeouts
330
+
331
+ Use `timeout` to set per-request and per-tool-call time limits without managing `AbortSignal` manually.
332
+
333
+ ```typescript
334
+ const result = await llm.structured(Schema, prompt`...`, {
335
+ timeout: {
336
+ request: 30_000, // abort the LLM HTTP request after 30s
337
+ tool: 5_000, // abort each MCP tool call after 5s
338
+ },
339
+ });
340
+ ```
341
+
342
+ Both fields are optional. `timeout.request` creates an `AbortSignal.timeout` internally; it is ignored if you also pass `request.signal` (your signal takes precedence). `timeout.tool` wraps each MCP client transparently.
343
+
344
+ You can also set defaults on the client:
345
+
346
+ ```typescript
347
+ const llm = createLLM({
348
+ provider: "openai-compatible",
349
+ model: "gpt-5-nano",
350
+ transport: { apiKey: process.env.LLM_API_KEY },
351
+ defaults: {
352
+ timeout: { request: 60_000 },
353
+ },
354
+ });
355
+ ```
356
+
249
357
  ## Examples
250
358
 
251
359
  Run examples with: `bun run dev <example-name>`
@@ -254,17 +362,21 @@ Available examples:
254
362
  - `streaming` - Real LLM streaming + snapshot self-check ([streaming.ts](examples/streaming.ts))
255
363
  - `streaming-with-tools` - Real text streaming with MCP tools + self-check ([streaming-with-tools.ts](examples/streaming-with-tools.ts))
256
364
  - `abort-signal` - Start a generation then cancel quickly with `AbortSignal` ([abort-signal.ts](examples/abort-signal.ts))
365
+ - `timeout` - Set per-request and per-tool timeouts via the `timeout` option ([timeout.ts](examples/timeout.ts))
257
366
  - `simple` - Basic structured output with streaming ([simple.ts](examples/simple.ts))
258
367
  - `sentiment-analysis` - Enum validation, strict mode ([sentiment-analysis.ts](examples/sentiment-analysis.ts))
259
368
  - `data-extraction` - Complex nested schemas, self-healing ([data-extraction.ts](examples/data-extraction.ts))
260
369
  - `multi-step-reasoning` - Chained structured calls ([multi-step-reasoning.ts](examples/multi-step-reasoning.ts))
261
370
  - `calculator-tool` - MCP tool integration ([calculator-tool.ts](examples/calculator-tool.ts))
371
+ - `image-analysis` - Multimodal structured extraction from an image file ([image-analysis.ts](examples/image-analysis.ts))
372
+ - `conversation` - Multi-turn conversation history and inline image messages ([conversation.ts](examples/conversation.ts))
262
373
 
263
374
  Pass arguments after the example name:
264
375
  ```bash
265
376
  bun run dev streaming
266
377
  bun run dev streaming-with-tools
267
378
  bun run dev abort-signal 120 "JSON cancellation demo"
379
+ bun run dev timeout 5000
268
380
  bun run dev simple "Bun.js runtime"
269
381
  bun run dev sentiment-analysis "I love this product."
270
382
  bun run dev multi-step-reasoning "Why is the sky blue?"
@@ -0,0 +1,8 @@
1
+ import { type ImageInput } from "./image";
2
+ import type { LLMMessage } from "./types";
3
+ export interface ConversationEntry {
4
+ role: "user" | "assistant";
5
+ text: string;
6
+ images?: ImageInput[];
7
+ }
8
+ export declare function conversation(systemPrompt: string, entries: ConversationEntry[]): LLMMessage[];
@@ -0,0 +1,8 @@
1
+ import type { LLMImageContent } from "./types";
2
+ export interface ImageInput {
3
+ base64: string;
4
+ mimeType: string;
5
+ }
6
+ export type ImageSize = "low" | "mid" | "high" | "xhigh" | "raw" | number;
7
+ export declare function images(input: ImageInput | ImageInput[]): LLMImageContent[];
8
+ export declare function resizeImage(source: string | Uint8Array | ArrayBuffer, size: ImageSize, mimeType?: string): Promise<ImageInput>;
package/dist/index.cjs CHANGED
@@ -45,11 +45,13 @@ __export(exports_src, {
45
45
  sanitizeThink: () => sanitizeThink,
46
46
  s: () => s,
47
47
  resolveSchemaInstruction: () => resolveSchemaInstruction,
48
+ resizeImage: () => resizeImage,
48
49
  registerBuiltinProviders: () => registerBuiltinProviders,
49
50
  prompt: () => prompt,
50
51
  parseLLMOutput: () => parseLLMOutput,
51
52
  inspectSchemaMetadata: () => inspectSchemaMetadata,
52
53
  inferSchemaExample: () => inferSchemaExample,
54
+ images: () => images,
53
55
  formatZodIssues: () => formatZodIssues,
54
56
  formatPrompt: () => formatPrompt,
55
57
  extractMarkdownCodeBlocks: () => extractMarkdownCodeBlocks,
@@ -62,6 +64,7 @@ __export(exports_src, {
62
64
  createLLM: () => createLLM,
63
65
  createDefaultProviderRegistry: () => createDefaultProviderRegistry,
64
66
  createAnthropicCompatibleAdapter: () => createAnthropicCompatibleAdapter,
67
+ conversation: () => conversation,
65
68
  buildSelfHealPrompt: () => buildSelfHealPrompt,
66
69
  buildDefaultStructuredPrompt: () => buildDefaultStructuredPrompt,
67
70
  StructuredParseError: () => StructuredParseError,
@@ -3927,19 +3930,24 @@ async function structured(adapter, schemaOrOptions, promptInput, callOptions) {
3927
3930
  const resolvedPrompt = applyPromptOutdent(resolvePrompt(normalized.prompt, { mode }), useOutdent);
3928
3931
  const resolvedSystemPrompt = applyOutdentToOptionalPrompt(normalized.systemPrompt, useOutdent);
3929
3932
  const preparedPrompt = prepareStructuredPromptPayload(resolvedPrompt, resolvedSystemPrompt, normalized.schema, normalized.schemaInstruction);
3933
+ const resolvedRequest = normalized.timeout?.tool !== undefined && normalized.request?.mcpClients !== undefined ? {
3934
+ ...normalized.request,
3935
+ mcpClients: applyToolTimeout(normalized.request.mcpClients, normalized.timeout.tool)
3936
+ } : normalized.request;
3930
3937
  const first = await executeAttempt(adapter, {
3931
3938
  prompt: preparedPrompt.prompt,
3932
3939
  messages: preparedPrompt.messages,
3933
3940
  schema: normalized.schema,
3934
3941
  parseOptions,
3935
3942
  stream: streamConfig,
3936
- request: normalized.request,
3943
+ request: resolvedRequest,
3937
3944
  systemPrompt: preparedPrompt.systemPrompt,
3938
3945
  observe: normalized.observe,
3939
3946
  debug: debugConfig,
3940
3947
  attemptNumber: 1,
3941
3948
  selfHeal: false,
3942
- selfHealEnabled: selfHealConfig.enabled
3949
+ selfHealEnabled: selfHealConfig.enabled,
3950
+ timeout: normalized.timeout
3943
3951
  });
3944
3952
  attempts.push(first.trace);
3945
3953
  if (first.trace.success) {
@@ -3993,13 +4001,14 @@ async function structured(adapter, schemaOrOptions, promptInput, callOptions) {
3993
4001
  schema: normalized.schema,
3994
4002
  parseOptions,
3995
4003
  stream: streamConfig,
3996
- request: normalized.request,
4004
+ request: resolvedRequest,
3997
4005
  systemPrompt: preparedPrompt.systemPrompt,
3998
4006
  observe: normalized.observe,
3999
4007
  debug: debugConfig,
4000
4008
  attemptNumber,
4001
4009
  selfHeal: true,
4002
- selfHealEnabled: selfHealConfig.enabled
4010
+ selfHealEnabled: selfHealConfig.enabled,
4011
+ timeout: normalized.timeout
4003
4012
  });
4004
4013
  attempts.push(healed.trace);
4005
4014
  if (healed.trace.success) {
@@ -4131,6 +4140,19 @@ function injectStructuredFormatIntoMessages(messages, schema, schemaInstruction)
4131
4140
  throw new Error("Structured prompts with messages must include at least one user message.");
4132
4141
  }
4133
4142
  const target = messages[lastUserIndex];
4143
+ if (Array.isArray(target?.content)) {
4144
+ const parts = target.content;
4145
+ const textIndex = parts.findIndex((p) => p.type === "text");
4146
+ const existingText = textIndex !== -1 ? (parts[textIndex]?.text ?? "").trim() : "";
4147
+ const formatted2 = shouldInjectFormat(existingText, schemaInstruction) ? formatPrompt(schema, existingText, { schemaInstruction }) : existingText;
4148
+ let newParts;
4149
+ if (textIndex !== -1) {
4150
+ newParts = parts.map((p, i) => i === textIndex ? { ...p, text: formatted2 } : p);
4151
+ } else {
4152
+ newParts = [{ type: "text", text: formatted2 }, ...parts];
4153
+ }
4154
+ return messages.map((message, index) => index === lastUserIndex ? { ...message, content: newParts } : message);
4155
+ }
4134
4156
  const content = typeof target?.content === "string" ? target.content.trim() : stringifyPromptContent(target?.content);
4135
4157
  const formatted = shouldInjectFormat(content, schemaInstruction) ? formatPrompt(schema, content, { schemaInstruction }) : content.trim();
4136
4158
  return messages.map((message, index) => index === lastUserIndex ? {
@@ -4345,7 +4367,8 @@ async function executeAttempt(adapter, input) {
4345
4367
  debug: input.debug,
4346
4368
  attempt: input.attemptNumber,
4347
4369
  selfHeal: input.selfHeal,
4348
- selfHealEnabled: input.selfHealEnabled
4370
+ selfHealEnabled: input.selfHealEnabled,
4371
+ timeout: input.timeout
4349
4372
  });
4350
4373
  const parsed = parseWithObserve(response.text, input.schema, input.parseOptions, {
4351
4374
  observe: input.observe,
@@ -4372,7 +4395,29 @@ async function executeAttempt(adapter, input) {
4372
4395
  trace
4373
4396
  };
4374
4397
  }
4398
+ function withToolTimeout(client, toolTimeoutMs) {
4399
+ return {
4400
+ id: client.id,
4401
+ listTools: client.listTools.bind(client),
4402
+ close: client.close?.bind(client),
4403
+ async callTool(params) {
4404
+ let timeoutId;
4405
+ const timeoutPromise = new Promise((_, reject) => {
4406
+ timeoutId = setTimeout(() => reject(new Error(`Tool call timed out after ${toolTimeoutMs}ms`)), toolTimeoutMs);
4407
+ });
4408
+ try {
4409
+ return await Promise.race([client.callTool(params), timeoutPromise]);
4410
+ } finally {
4411
+ clearTimeout(timeoutId);
4412
+ }
4413
+ }
4414
+ };
4415
+ }
4416
+ function applyToolTimeout(clients, toolTimeoutMs) {
4417
+ return clients.map((client) => withToolTimeout(client, toolTimeoutMs));
4418
+ }
4375
4419
  async function callModel(adapter, options) {
4420
+ const requestSignal = options.request?.signal ?? (options.timeout?.request !== undefined ? AbortSignal.timeout(options.timeout.request) : undefined);
4376
4421
  const requestPayload = {
4377
4422
  prompt: options.prompt,
4378
4423
  messages: options.messages,
@@ -4386,7 +4431,7 @@ async function callModel(adapter, options) {
4386
4431
  onToolExecution: options.request?.onToolExecution,
4387
4432
  toolDebug: options.request?.toolDebug,
4388
4433
  body: options.request?.body,
4389
- signal: options.request?.signal
4434
+ signal: requestSignal
4390
4435
  };
4391
4436
  emitDebugRequest(options.debug, {
4392
4437
  provider: adapter.provider,
@@ -4769,7 +4814,8 @@ function mergeStructuredOptions(defaults, overrides) {
4769
4814
  },
4770
4815
  stream: mergeObjectLike(defaults?.stream, overrides?.stream),
4771
4816
  selfHeal: mergeObjectLike(defaults?.selfHeal, overrides?.selfHeal),
4772
- debug: mergeObjectLike(defaults?.debug, overrides?.debug)
4817
+ debug: mergeObjectLike(defaults?.debug, overrides?.debug),
4818
+ timeout: mergeObjectLike(defaults?.timeout, overrides?.timeout)
4773
4819
  };
4774
4820
  }
4775
4821
  function mergeObjectLike(defaults, overrides) {
@@ -4858,6 +4904,63 @@ function toImplementation(clientInfo) {
4858
4904
  version: clientInfo?.version ?? "0.1.0"
4859
4905
  };
4860
4906
  }
4907
+ // src/image.ts
4908
+ var import_path = require("path");
4909
+ var IMAGE_SIZE_MAP = {
4910
+ low: 256,
4911
+ mid: 512,
4912
+ high: 1024,
4913
+ xhigh: 1280
4914
+ };
4915
+ var IMAGE_MIME_TYPES = {
4916
+ ".png": "image/png",
4917
+ ".jpg": "image/jpeg",
4918
+ ".jpeg": "image/jpeg",
4919
+ ".gif": "image/gif",
4920
+ ".webp": "image/webp"
4921
+ };
4922
+ var MIME_TO_SHARP_FORMAT = {
4923
+ "image/jpeg": "jpeg",
4924
+ "image/png": "png",
4925
+ "image/webp": "webp",
4926
+ "image/gif": "gif"
4927
+ };
4928
+ function images(input) {
4929
+ const inputs = Array.isArray(input) ? input : [input];
4930
+ return inputs.map(({ base64, mimeType }) => ({
4931
+ type: "image_url",
4932
+ image_url: { url: `data:${mimeType};base64,${base64}` }
4933
+ }));
4934
+ }
4935
+ async function resizeImage(source, size, mimeType) {
4936
+ const resolvedMime = mimeType ?? (typeof source === "string" ? IMAGE_MIME_TYPES[import_path.extname(source).toLowerCase()] ?? "image/jpeg" : "image/jpeg");
4937
+ let sharp;
4938
+ try {
4939
+ sharp = (await import("sharp")).default;
4940
+ } catch {
4941
+ throw new Error('resizeImage() requires "sharp" to be installed. Run: bun add sharp');
4942
+ }
4943
+ const input = source instanceof ArrayBuffer ? Buffer.from(source) : source;
4944
+ let img = sharp(input);
4945
+ if (size !== "raw") {
4946
+ const targetPx = typeof size === "number" ? size : IMAGE_SIZE_MAP[size];
4947
+ img = img.resize(targetPx, targetPx, { fit: "inside", withoutEnlargement: true });
4948
+ }
4949
+ const sharpFormat = MIME_TO_SHARP_FORMAT[resolvedMime] ?? "jpeg";
4950
+ const outputMime = MIME_TO_SHARP_FORMAT[resolvedMime] ? resolvedMime : "image/jpeg";
4951
+ const buf = await img.toFormat(sharpFormat).toBuffer();
4952
+ return { base64: buf.toString("base64"), mimeType: outputMime };
4953
+ }
4954
+ // src/conversation.ts
4955
+ function conversation(systemPrompt, entries) {
4956
+ return [
4957
+ { role: "system", content: systemPrompt },
4958
+ ...entries.map((entry) => ({
4959
+ role: entry.role,
4960
+ content: entry.images && entry.images.length > 0 ? [{ type: "text", text: entry.text }, ...images(entry.images)] : entry.text
4961
+ }))
4962
+ ];
4963
+ }
4861
4964
  // src/prompt.ts
4862
4965
  function toPromptString(value) {
4863
4966
  if (value === null || value === undefined) {
@@ -4932,6 +5035,12 @@ class PromptMessageBuilderImpl {
4932
5035
  return this.pushMessage("assistant", input, values);
4933
5036
  }
4934
5037
  pushMessage(role, input, values) {
5038
+ if (Array.isArray(input) && !isTemplateStringsArray(input)) {
5039
+ if (input.length > 0) {
5040
+ this.messages.push({ role, content: input });
5041
+ }
5042
+ return this;
5043
+ }
4935
5044
  const message = toPromptMessage(input, values);
4936
5045
  if (message.length > 0) {
4937
5046
  this.messages.push({ role, content: message });
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@ export { sanitizeThink } from "./think";
5
5
  export { createLLM, type CreateLLMOptions, type LLMClient } from "./llm";
6
6
  export { formatZodIssues, parseLLMOutput } from "./parse";
7
7
  export { createMCPClient, wrapMCPClient, type CreateMCPClientOptions, type MCPClientInfo, type MCPInMemoryTransportConfig, type MCPStdioTransportConfig, type MCPStreamableHTTPTransportConfig, type MCPTransportConfig, type ManagedMCPToolClient, } from "./mcp";
8
+ export { images, resizeImage, type ImageInput, type ImageSize } from "./image";
9
+ export { conversation, type ConversationEntry } from "./conversation";
8
10
  export { prompt, type PromptMessageBuilder } from "./prompt";
9
11
  export { s, inspectSchemaMetadata, inferSchemaExample } from "./schema-builder";
10
12
  export { buildDefaultStructuredPrompt, DEFAULT_LOOSE_PARSE_OPTIONS, DEFAULT_SELF_HEAL_BY_MODE, DEFAULT_SELF_HEAL_CONTEXT_LABEL, DEFAULT_SELF_HEAL_FIX_INSTRUCTION, DEFAULT_SELF_HEAL_MAX_CONTEXT_CHARS, DEFAULT_SELF_HEAL_NO_ISSUES_MESSAGE, DEFAULT_SELF_HEAL_PROTOCOL, DEFAULT_SELF_HEAL_RAW_OUTPUT_LABEL, DEFAULT_SELF_HEAL_RETURN_INSTRUCTION, DEFAULT_SELF_HEAL_STOP_ON_NO_PROGRESS, DEFAULT_SELF_HEAL_VALIDATION_LABEL, DEFAULT_STRICT_PARSE_OPTIONS, DEFAULT_STRUCTURED_OBJECT_INSTRUCTION, DEFAULT_STRUCTURED_STYLE_INSTRUCTION, buildSelfHealPrompt, structured, StructuredParseError, type BuildDefaultStructuredPromptOptions, type SelfHealPromptTextOptions, } from "./structured";
@@ -12,4 +14,4 @@ export { createOpenAICompatibleAdapter, type OpenAICompatibleAdapterOptions, } f
12
14
  export { createAnthropicCompatibleAdapter, DEFAULT_ANTHROPIC_MAX_TOKENS, DEFAULT_ANTHROPIC_VERSION, type AnthropicCompatibleAdapterOptions, } from "./providers/anthropic-compatible";
13
15
  export { DEFAULT_MAX_TOOL_ROUNDS } from "./providers/mcp-runtime";
14
16
  export { createDefaultProviderRegistry, createModelAdapter, createProviderRegistry, registerBuiltinProviders, type BuiltinProviderKind, type ModelAdapterConfig, type ProviderFactory, type ProviderRegistry, type ProviderTransportConfig, } from "./providers/registry";
15
- export type { CandidateDiagnostics, ExtractJsonCandidatesOptions, ExtractionCandidate, ExtractionHeuristicsOptions, ExtractionParseHint, HTTPHeaders, LLMAdapter, LLMMessage, LLMRequest, LLMResponse, LLMStreamCallbacks, LLMStreamChunk, LLMToolCall, LLMToolDebugOptions, LLMToolExecution, LLMToolOutputTransformer, LLMToolArgumentsTransformer, LLMToolChoice, MCPCallToolParams, MCPListToolsResult, MCPToolClient, MCPToolDescriptor, MCPToolSchema, LLMUsage, MarkdownCodeBlock, MarkdownCodeOptions, ParseLLMOutputOptions, ParseLLMOutputResult, ParseTraceEvent, PipelineError, StructuredAttempt, StructuredCallOptions, StructuredDebugOptions, StructuredError, StructuredMode, StructuredOptions, StructuredPromptBuilder, StructuredPromptContext, StructuredPromptPayload, StructuredPromptResolver, StructuredPromptValue, StructuredResult, StructuredStreamData, StructuredStreamEvent, StructuredStreamInput, StructuredStreamOptions, StructuredSelfHealInput, ThinkDiagnostics, ThinkBlock, StructuredTraceEvent, } from "./types";
17
+ export type { CandidateDiagnostics, LLMImageContent, LLMMessageContent, LLMTextContent, ExtractJsonCandidatesOptions, ExtractionCandidate, ExtractionHeuristicsOptions, ExtractionParseHint, HTTPHeaders, LLMAdapter, LLMMessage, LLMRequest, LLMResponse, LLMStreamCallbacks, LLMStreamChunk, LLMToolCall, LLMToolDebugOptions, LLMToolExecution, LLMToolOutputTransformer, LLMToolArgumentsTransformer, LLMToolChoice, MCPCallToolParams, MCPListToolsResult, MCPToolClient, MCPToolDescriptor, MCPToolSchema, LLMUsage, MarkdownCodeBlock, MarkdownCodeOptions, ParseLLMOutputOptions, ParseLLMOutputResult, ParseTraceEvent, PipelineError, StructuredAttempt, StructuredCallOptions, StructuredDebugOptions, StructuredError, StructuredMode, StructuredOptions, StructuredPromptBuilder, StructuredPromptContext, StructuredPromptPayload, StructuredPromptResolver, StructuredPromptValue, StructuredResult, StructuredStreamData, StructuredStreamEvent, StructuredStreamInput, StructuredStreamOptions, StructuredSelfHealInput, StructuredTimeoutOptions, ThinkDiagnostics, ThinkBlock, StructuredTraceEvent, } from "./types";
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { createRequire } from "node:module";
2
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
+
1
4
  // src/extract.ts
2
5
  import { jsonrepair } from "jsonrepair";
3
6
 
@@ -3838,19 +3841,24 @@ async function structured(adapter, schemaOrOptions, promptInput, callOptions) {
3838
3841
  const resolvedPrompt = applyPromptOutdent(resolvePrompt(normalized.prompt, { mode }), useOutdent);
3839
3842
  const resolvedSystemPrompt = applyOutdentToOptionalPrompt(normalized.systemPrompt, useOutdent);
3840
3843
  const preparedPrompt = prepareStructuredPromptPayload(resolvedPrompt, resolvedSystemPrompt, normalized.schema, normalized.schemaInstruction);
3844
+ const resolvedRequest = normalized.timeout?.tool !== undefined && normalized.request?.mcpClients !== undefined ? {
3845
+ ...normalized.request,
3846
+ mcpClients: applyToolTimeout(normalized.request.mcpClients, normalized.timeout.tool)
3847
+ } : normalized.request;
3841
3848
  const first = await executeAttempt(adapter, {
3842
3849
  prompt: preparedPrompt.prompt,
3843
3850
  messages: preparedPrompt.messages,
3844
3851
  schema: normalized.schema,
3845
3852
  parseOptions,
3846
3853
  stream: streamConfig,
3847
- request: normalized.request,
3854
+ request: resolvedRequest,
3848
3855
  systemPrompt: preparedPrompt.systemPrompt,
3849
3856
  observe: normalized.observe,
3850
3857
  debug: debugConfig,
3851
3858
  attemptNumber: 1,
3852
3859
  selfHeal: false,
3853
- selfHealEnabled: selfHealConfig.enabled
3860
+ selfHealEnabled: selfHealConfig.enabled,
3861
+ timeout: normalized.timeout
3854
3862
  });
3855
3863
  attempts.push(first.trace);
3856
3864
  if (first.trace.success) {
@@ -3904,13 +3912,14 @@ async function structured(adapter, schemaOrOptions, promptInput, callOptions) {
3904
3912
  schema: normalized.schema,
3905
3913
  parseOptions,
3906
3914
  stream: streamConfig,
3907
- request: normalized.request,
3915
+ request: resolvedRequest,
3908
3916
  systemPrompt: preparedPrompt.systemPrompt,
3909
3917
  observe: normalized.observe,
3910
3918
  debug: debugConfig,
3911
3919
  attemptNumber,
3912
3920
  selfHeal: true,
3913
- selfHealEnabled: selfHealConfig.enabled
3921
+ selfHealEnabled: selfHealConfig.enabled,
3922
+ timeout: normalized.timeout
3914
3923
  });
3915
3924
  attempts.push(healed.trace);
3916
3925
  if (healed.trace.success) {
@@ -4042,6 +4051,19 @@ function injectStructuredFormatIntoMessages(messages, schema, schemaInstruction)
4042
4051
  throw new Error("Structured prompts with messages must include at least one user message.");
4043
4052
  }
4044
4053
  const target = messages[lastUserIndex];
4054
+ if (Array.isArray(target?.content)) {
4055
+ const parts = target.content;
4056
+ const textIndex = parts.findIndex((p) => p.type === "text");
4057
+ const existingText = textIndex !== -1 ? (parts[textIndex]?.text ?? "").trim() : "";
4058
+ const formatted2 = shouldInjectFormat(existingText, schemaInstruction) ? formatPrompt(schema, existingText, { schemaInstruction }) : existingText;
4059
+ let newParts;
4060
+ if (textIndex !== -1) {
4061
+ newParts = parts.map((p, i) => i === textIndex ? { ...p, text: formatted2 } : p);
4062
+ } else {
4063
+ newParts = [{ type: "text", text: formatted2 }, ...parts];
4064
+ }
4065
+ return messages.map((message, index) => index === lastUserIndex ? { ...message, content: newParts } : message);
4066
+ }
4045
4067
  const content = typeof target?.content === "string" ? target.content.trim() : stringifyPromptContent(target?.content);
4046
4068
  const formatted = shouldInjectFormat(content, schemaInstruction) ? formatPrompt(schema, content, { schemaInstruction }) : content.trim();
4047
4069
  return messages.map((message, index) => index === lastUserIndex ? {
@@ -4256,7 +4278,8 @@ async function executeAttempt(adapter, input) {
4256
4278
  debug: input.debug,
4257
4279
  attempt: input.attemptNumber,
4258
4280
  selfHeal: input.selfHeal,
4259
- selfHealEnabled: input.selfHealEnabled
4281
+ selfHealEnabled: input.selfHealEnabled,
4282
+ timeout: input.timeout
4260
4283
  });
4261
4284
  const parsed = parseWithObserve(response.text, input.schema, input.parseOptions, {
4262
4285
  observe: input.observe,
@@ -4283,7 +4306,29 @@ async function executeAttempt(adapter, input) {
4283
4306
  trace
4284
4307
  };
4285
4308
  }
4309
+ function withToolTimeout(client, toolTimeoutMs) {
4310
+ return {
4311
+ id: client.id,
4312
+ listTools: client.listTools.bind(client),
4313
+ close: client.close?.bind(client),
4314
+ async callTool(params) {
4315
+ let timeoutId;
4316
+ const timeoutPromise = new Promise((_, reject) => {
4317
+ timeoutId = setTimeout(() => reject(new Error(`Tool call timed out after ${toolTimeoutMs}ms`)), toolTimeoutMs);
4318
+ });
4319
+ try {
4320
+ return await Promise.race([client.callTool(params), timeoutPromise]);
4321
+ } finally {
4322
+ clearTimeout(timeoutId);
4323
+ }
4324
+ }
4325
+ };
4326
+ }
4327
+ function applyToolTimeout(clients, toolTimeoutMs) {
4328
+ return clients.map((client) => withToolTimeout(client, toolTimeoutMs));
4329
+ }
4286
4330
  async function callModel(adapter, options) {
4331
+ const requestSignal = options.request?.signal ?? (options.timeout?.request !== undefined ? AbortSignal.timeout(options.timeout.request) : undefined);
4287
4332
  const requestPayload = {
4288
4333
  prompt: options.prompt,
4289
4334
  messages: options.messages,
@@ -4297,7 +4342,7 @@ async function callModel(adapter, options) {
4297
4342
  onToolExecution: options.request?.onToolExecution,
4298
4343
  toolDebug: options.request?.toolDebug,
4299
4344
  body: options.request?.body,
4300
- signal: options.request?.signal
4345
+ signal: requestSignal
4301
4346
  };
4302
4347
  emitDebugRequest(options.debug, {
4303
4348
  provider: adapter.provider,
@@ -4680,7 +4725,8 @@ function mergeStructuredOptions(defaults, overrides) {
4680
4725
  },
4681
4726
  stream: mergeObjectLike(defaults?.stream, overrides?.stream),
4682
4727
  selfHeal: mergeObjectLike(defaults?.selfHeal, overrides?.selfHeal),
4683
- debug: mergeObjectLike(defaults?.debug, overrides?.debug)
4728
+ debug: mergeObjectLike(defaults?.debug, overrides?.debug),
4729
+ timeout: mergeObjectLike(defaults?.timeout, overrides?.timeout)
4684
4730
  };
4685
4731
  }
4686
4732
  function mergeObjectLike(defaults, overrides) {
@@ -4773,6 +4819,63 @@ function toImplementation(clientInfo) {
4773
4819
  version: clientInfo?.version ?? "0.1.0"
4774
4820
  };
4775
4821
  }
4822
+ // src/image.ts
4823
+ import { extname } from "path";
4824
+ var IMAGE_SIZE_MAP = {
4825
+ low: 256,
4826
+ mid: 512,
4827
+ high: 1024,
4828
+ xhigh: 1280
4829
+ };
4830
+ var IMAGE_MIME_TYPES = {
4831
+ ".png": "image/png",
4832
+ ".jpg": "image/jpeg",
4833
+ ".jpeg": "image/jpeg",
4834
+ ".gif": "image/gif",
4835
+ ".webp": "image/webp"
4836
+ };
4837
+ var MIME_TO_SHARP_FORMAT = {
4838
+ "image/jpeg": "jpeg",
4839
+ "image/png": "png",
4840
+ "image/webp": "webp",
4841
+ "image/gif": "gif"
4842
+ };
4843
+ function images(input) {
4844
+ const inputs = Array.isArray(input) ? input : [input];
4845
+ return inputs.map(({ base64, mimeType }) => ({
4846
+ type: "image_url",
4847
+ image_url: { url: `data:${mimeType};base64,${base64}` }
4848
+ }));
4849
+ }
4850
+ async function resizeImage(source, size, mimeType) {
4851
+ const resolvedMime = mimeType ?? (typeof source === "string" ? IMAGE_MIME_TYPES[extname(source).toLowerCase()] ?? "image/jpeg" : "image/jpeg");
4852
+ let sharp;
4853
+ try {
4854
+ sharp = (await import("sharp")).default;
4855
+ } catch {
4856
+ throw new Error('resizeImage() requires "sharp" to be installed. Run: bun add sharp');
4857
+ }
4858
+ const input = source instanceof ArrayBuffer ? Buffer.from(source) : source;
4859
+ let img = sharp(input);
4860
+ if (size !== "raw") {
4861
+ const targetPx = typeof size === "number" ? size : IMAGE_SIZE_MAP[size];
4862
+ img = img.resize(targetPx, targetPx, { fit: "inside", withoutEnlargement: true });
4863
+ }
4864
+ const sharpFormat = MIME_TO_SHARP_FORMAT[resolvedMime] ?? "jpeg";
4865
+ const outputMime = MIME_TO_SHARP_FORMAT[resolvedMime] ? resolvedMime : "image/jpeg";
4866
+ const buf = await img.toFormat(sharpFormat).toBuffer();
4867
+ return { base64: buf.toString("base64"), mimeType: outputMime };
4868
+ }
4869
+ // src/conversation.ts
4870
+ function conversation(systemPrompt, entries) {
4871
+ return [
4872
+ { role: "system", content: systemPrompt },
4873
+ ...entries.map((entry) => ({
4874
+ role: entry.role,
4875
+ content: entry.images && entry.images.length > 0 ? [{ type: "text", text: entry.text }, ...images(entry.images)] : entry.text
4876
+ }))
4877
+ ];
4878
+ }
4776
4879
  // src/prompt.ts
4777
4880
  function toPromptString(value) {
4778
4881
  if (value === null || value === undefined) {
@@ -4847,6 +4950,12 @@ class PromptMessageBuilderImpl {
4847
4950
  return this.pushMessage("assistant", input, values);
4848
4951
  }
4849
4952
  pushMessage(role, input, values) {
4953
+ if (Array.isArray(input) && !isTemplateStringsArray(input)) {
4954
+ if (input.length > 0) {
4955
+ this.messages.push({ role, content: input });
4956
+ }
4957
+ return this;
4958
+ }
4850
4959
  const message = toPromptMessage(input, values);
4851
4960
  if (message.length > 0) {
4852
4961
  this.messages.push({ role, content: message });
@@ -5058,11 +5167,13 @@ export {
5058
5167
  sanitizeThink,
5059
5168
  s,
5060
5169
  resolveSchemaInstruction,
5170
+ resizeImage,
5061
5171
  registerBuiltinProviders,
5062
5172
  prompt,
5063
5173
  parseLLMOutput,
5064
5174
  inspectSchemaMetadata,
5065
5175
  inferSchemaExample,
5176
+ images,
5066
5177
  formatZodIssues,
5067
5178
  formatPrompt,
5068
5179
  extractMarkdownCodeBlocks,
@@ -5075,6 +5186,7 @@ export {
5075
5186
  createLLM,
5076
5187
  createDefaultProviderRegistry,
5077
5188
  createAnthropicCompatibleAdapter,
5189
+ conversation,
5078
5190
  buildSelfHealPrompt,
5079
5191
  buildDefaultStructuredPrompt,
5080
5192
  StructuredParseError,
package/dist/prompt.d.ts CHANGED
@@ -1,11 +1,13 @@
1
- import type { StructuredPromptPayload, StructuredPromptResolver } from "./types";
1
+ import type { LLMMessageContent, StructuredPromptPayload, StructuredPromptResolver } from "./types";
2
2
  export interface PromptMessageBuilder extends StructuredPromptResolver {
3
3
  system(input: string): PromptMessageBuilder;
4
4
  system(strings: TemplateStringsArray, ...values: unknown[]): PromptMessageBuilder;
5
5
  user(input: string): PromptMessageBuilder;
6
6
  user(strings: TemplateStringsArray, ...values: unknown[]): PromptMessageBuilder;
7
+ user(content: LLMMessageContent): PromptMessageBuilder;
7
8
  assistant(input: string): PromptMessageBuilder;
8
9
  assistant(strings: TemplateStringsArray, ...values: unknown[]): PromptMessageBuilder;
10
+ assistant(content: LLMMessageContent): PromptMessageBuilder;
9
11
  build(): StructuredPromptPayload;
10
12
  }
11
13
  export declare function prompt(strings: TemplateStringsArray, ...values: unknown[]): string;
package/dist/types.d.ts CHANGED
@@ -119,9 +119,20 @@ export interface MCPToolClient {
119
119
  callTool(params: MCPCallToolParams): Promise<unknown>;
120
120
  close?(): Promise<void>;
121
121
  }
122
+ export interface LLMTextContent {
123
+ type: "text";
124
+ text: string;
125
+ }
126
+ export interface LLMImageContent {
127
+ type: "image_url";
128
+ image_url: {
129
+ url: string;
130
+ };
131
+ }
132
+ export type LLMMessageContent = string | (LLMTextContent | LLMImageContent)[];
122
133
  export interface LLMMessage {
123
134
  role: "system" | "user" | "assistant" | "tool";
124
- content: unknown;
135
+ content: LLMMessageContent;
125
136
  }
126
137
  export interface LLMRequest {
127
138
  prompt?: string;
@@ -250,6 +261,12 @@ export interface StructuredSelfHealOptions {
250
261
  maxContextChars?: number;
251
262
  }
252
263
  export type StructuredSelfHealInput = boolean | number | StructuredSelfHealOptions;
264
+ export interface StructuredTimeoutOptions {
265
+ /** Timeout in ms for each LLM HTTP request. Creates an AbortSignal.timeout internally if no signal is already provided. */
266
+ request?: number;
267
+ /** Timeout in ms for each MCP tool call. */
268
+ tool?: number;
269
+ }
253
270
  export type StructuredStreamData<T> = T extends Array<infer TItem> ? Array<StructuredStreamData<TItem>> : T extends object ? {
254
271
  [K in keyof T]?: StructuredStreamData<T[K]> | null;
255
272
  } : T | null;
@@ -277,6 +294,7 @@ export interface StructuredCallOptions<TSchema extends z.ZodTypeAny> {
277
294
  systemPrompt?: string;
278
295
  request?: Omit<LLMRequest, "prompt" | "systemPrompt" | "messages">;
279
296
  schemaInstruction?: string;
297
+ timeout?: StructuredTimeoutOptions;
280
298
  }
281
299
  export interface StructuredOptions<TSchema extends z.ZodTypeAny> extends StructuredCallOptions<TSchema> {
282
300
  schema: TSchema;
package/package.json CHANGED
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "extrait",
3
- "version": "0.5.1",
4
- "license": "MIT",
3
+ "version": "0.5.3",
5
4
  "repository": {
6
5
  "type": "git",
7
6
  "url": "git+https://github.com/tterrasson/extrait.git"
8
7
  },
9
- "bugs": {
10
- "url": "https://github.com/tterrasson/extrait/issues"
11
- },
12
8
  "main": "./dist/index.cjs",
13
9
  "module": "./dist/index.js",
14
- "types": "./dist/index.d.ts",
10
+ "dependencies": {
11
+ "@modelcontextprotocol/sdk": "^1.27.1",
12
+ "jsonrepair": "^3.13.2",
13
+ "zod": "^4.3.6"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "^1.3.10",
17
+ "@types/sharp": "^0.32.0",
18
+ "typescript": "^5.9.3"
19
+ },
15
20
  "exports": {
16
21
  ".": {
17
22
  "types": "./dist/index.d.ts",
@@ -20,12 +25,29 @@
20
25
  "default": "./dist/index.js"
21
26
  }
22
27
  },
28
+ "bugs": {
29
+ "url": "https://github.com/tterrasson/extrait/issues"
30
+ },
23
31
  "files": [
24
32
  "dist",
25
33
  "README.md",
26
34
  "LICENSE"
27
35
  ],
28
- "type": "module",
36
+ "license": "MIT",
37
+ "overrides": {
38
+ "zod": "^4.3.6"
39
+ },
40
+ "peerDependencies": {
41
+ "sharp": "^0.34.5"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "sharp": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "resolutions": {
49
+ "zod": "^4.3.6"
50
+ },
29
51
  "scripts": {
30
52
  "dev": "bun run examples/runner.ts",
31
53
  "build": "bun run build:esm && bun run build:cjs",
@@ -38,19 +60,6 @@
38
60
  "typecheck": "bunx tsc --noEmit",
39
61
  "pack": "bun run build:types && bun run build && npm pack"
40
62
  },
41
- "dependencies": {
42
- "@modelcontextprotocol/sdk": "^1.27.1",
43
- "jsonrepair": "^3.13.2",
44
- "zod": "^4.3.6"
45
- },
46
- "devDependencies": {
47
- "@types/bun": "^1.3.10",
48
- "typescript": "^5.9.3"
49
- },
50
- "overrides": {
51
- "zod": "^4.3.6"
52
- },
53
- "resolutions": {
54
- "zod": "^4.3.6"
55
- }
63
+ "type": "module",
64
+ "types": "./dist/index.d.ts"
56
65
  }