write-language 0.1.93 → 0.1.94

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "write-language",
3
- "version": "0.1.93",
3
+ "version": "0.1.94",
4
4
  "description": "Multi-provider language generation toolkit using Vercel AI SDK - generate responses with 10+ LLM providers including OpenAI, Anthropic, Google, and more.",
5
5
  "author": "vtempest <grokthiscontact@gmail.com>",
6
6
  "license": "rights.institute/PROSPER",
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @fileoverview Verifies that file/image attachments are forwarded to the
3
+ * Vercel AI SDK as multimodal `messages` content parts, and that plain
4
+ * (attachment-free) calls keep using the simpler `prompt` form.
5
+ */
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ // Capture the exact arguments passed to generateText so we can assert on the
9
+ // shape of the request the SDK receives.
10
+ const generateTextMock = vi.fn((_args: unknown) => Promise.resolve({ text: "ok" }));
11
+
12
+ vi.mock("ai", () => ({
13
+ generateText: (args: unknown) => generateTextMock(args),
14
+ stepCountIs: (n: number) => n,
15
+ tool: (t: unknown) => t,
16
+ }));
17
+
18
+ // Return a truthy fake model so writeLanguageResponse proceeds to generateText.
19
+ vi.mock("./provider-factory", () => ({
20
+ createLLMProvider: () => ({ id: "fake-model" }),
21
+ }));
22
+
23
+ // Avoid pulling the markdown/prism stack into the test.
24
+ vi.mock("./utils/markdown-to-html", () => ({
25
+ convertMarkdownToHTMLEscaped: async (s: string) => s,
26
+ }));
27
+
28
+ import { writeLanguageResponse } from "./generate-response";
29
+
30
+ afterEach(() => {
31
+ generateTextMock.mockClear();
32
+ });
33
+
34
+ describe("writeLanguageResponse attachments", () => {
35
+ const base = {
36
+ provider: "groq",
37
+ apiKey: "test-key",
38
+ agent: "question",
39
+ query: "Describe the attached file",
40
+ html: false,
41
+ applyContextLimit: false,
42
+ } as const;
43
+
44
+ it("sends a plain text prompt when there are no attachments", async () => {
45
+ await writeLanguageResponse({ ...base });
46
+
47
+ expect(generateTextMock).toHaveBeenCalledTimes(1);
48
+ const arg = generateTextMock.mock.calls[0][0] as Record<string, unknown>;
49
+ expect(typeof arg.prompt).toBe("string");
50
+ expect(arg.messages).toBeUndefined();
51
+ });
52
+
53
+ it("sends an image attachment as an image content part", async () => {
54
+ await writeLanguageResponse({
55
+ ...base,
56
+ attachments: [
57
+ { mediaType: "image/png", data: "data:image/png;base64,AAAA" },
58
+ ],
59
+ });
60
+
61
+ const arg = generateTextMock.mock.calls[0][0] as any;
62
+ expect(arg.prompt).toBeUndefined();
63
+ expect(Array.isArray(arg.messages)).toBe(true);
64
+ const parts = arg.messages[0].content;
65
+ expect(parts[0]).toMatchObject({ type: "text" });
66
+ expect(parts[1]).toMatchObject({
67
+ type: "image",
68
+ image: "data:image/png;base64,AAAA",
69
+ });
70
+ });
71
+
72
+ it("sends a document attachment as a file content part with its mediaType", async () => {
73
+ await writeLanguageResponse({
74
+ ...base,
75
+ attachments: [
76
+ {
77
+ mediaType: "application/pdf",
78
+ data: "JVBERi0=",
79
+ filename: "report.pdf",
80
+ },
81
+ ],
82
+ });
83
+
84
+ const arg = generateTextMock.mock.calls[0][0] as any;
85
+ const parts = arg.messages[0].content;
86
+ expect(parts[1]).toMatchObject({
87
+ type: "file",
88
+ mediaType: "application/pdf",
89
+ data: "JVBERi0=",
90
+ filename: "report.pdf",
91
+ });
92
+ });
93
+
94
+ it("skips malformed attachments (missing data or mediaType)", async () => {
95
+ await writeLanguageResponse({
96
+ ...base,
97
+ attachments: [
98
+ { mediaType: "", data: "x" } as any,
99
+ { mediaType: "image/png" } as any,
100
+ { mediaType: "image/jpeg", data: "data:image/jpeg;base64,BBBB" },
101
+ ],
102
+ });
103
+
104
+ const arg = generateTextMock.mock.calls[0][0] as any;
105
+ const parts = arg.messages[0].content;
106
+ // 1 text part + 1 valid image part only.
107
+ expect(parts).toHaveLength(2);
108
+ expect(parts[1]).toMatchObject({ type: "image" });
109
+ });
110
+ });
@@ -13,12 +13,14 @@ import type {
13
13
  AgentTool,
14
14
  GenerateLanguageOptions,
15
15
  GenerateLanguageResult,
16
+ LanguageAttachment,
16
17
  } from "./generation-types";
17
18
 
18
19
  export type {
19
20
  LLMProviderName,
20
21
  GenerateLanguageOptions,
21
22
  GenerateLanguageResult,
23
+ LanguageAttachment,
22
24
  } from "./generation-types";
23
25
  export { convertMarkdownToHTMLEscaped } from "./utils/markdown-to-html";
24
26
 
@@ -61,6 +63,7 @@ export async function writeLanguageResponse(
61
63
  temperature = 1,
62
64
  html = true,
63
65
  applyContextLimit = true,
66
+ attachments,
64
67
  ...context
65
68
  } = options;
66
69
 
@@ -144,10 +147,23 @@ export async function writeLanguageResponse(
144
147
  : undefined;
145
148
 
146
149
  // \u2500\u2500 8. Invoke LLM via Vercel AI SDK generateText \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
150
+ // When files/images are attached, issue a multimodal `messages` request so
151
+ // the model receives the uploaded content directly. Otherwise fall back to
152
+ // the simpler `prompt` form.
153
+ const fileParts = buildAttachmentParts(attachments);
147
154
  const { text: rawReply } = await generateText({
148
155
  model: llm,
149
- prompt,
150
156
  temperature,
157
+ ...(fileParts.length > 0
158
+ ? {
159
+ messages: [
160
+ {
161
+ role: "user" as const,
162
+ content: [{ type: "text" as const, text: prompt }, ...fileParts],
163
+ },
164
+ ],
165
+ }
166
+ : { prompt }),
151
167
  ...(tools && { tools, stopWhen: stepCountIs(10) }),
152
168
  });
153
169
 
@@ -172,6 +188,42 @@ export async function writeLanguageResponse(
172
188
  }
173
189
  }
174
190
 
191
+ /**
192
+ * Converts {@link LanguageAttachment}s into Vercel AI SDK message content
193
+ * parts. `image/*` attachments (or those with `kind: "image"`) become
194
+ * `image` parts; everything else becomes a `file` part carrying its
195
+ * `mediaType`. Attachments without usable data are skipped.
196
+ *
197
+ * @param attachments - File/image attachments from the caller
198
+ * @returns An array of AI SDK content parts (empty when none are supplied)
199
+ */
200
+ function buildAttachmentParts(
201
+ attachments: LanguageAttachment[] | undefined,
202
+ ): Array<
203
+ | { type: "image"; image: LanguageAttachment["data"] }
204
+ | { type: "file"; data: LanguageAttachment["data"]; mediaType: string; filename?: string }
205
+ > {
206
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
207
+
208
+ return attachments
209
+ .filter((a) => a && a.data != null && a.mediaType)
210
+ .map((a) => {
211
+ const isImage =
212
+ a.kind === "image" ||
213
+ (a.kind !== "file" && a.mediaType.toLowerCase().startsWith("image/"));
214
+
215
+ if (isImage) {
216
+ return { type: "image" as const, image: a.data };
217
+ }
218
+ return {
219
+ type: "file" as const,
220
+ data: a.data,
221
+ mediaType: a.mediaType,
222
+ ...(a.filename ? { filename: a.filename } : {}),
223
+ };
224
+ });
225
+ }
226
+
175
227
  /**
176
228
  * Substitutes `{variableName}` placeholders in a template string with values
177
229
  * from `vars`. Object/array values are pretty-printed without braces or
@@ -20,6 +20,31 @@ export type LLMProviderName =
20
20
  | "togetherai"
21
21
  | (string & {}); // preserve autocomplete while allowing arbitrary strings
22
22
 
23
+ /**
24
+ * A file or image attachment passed to the model alongside the text prompt.
25
+ *
26
+ * The Vercel AI SDK accepts these as multimodal message content parts:
27
+ * images become `{ type: "image", image }` parts and every other media type
28
+ * becomes a `{ type: "file", data, mediaType }` part. `data` may be a base64
29
+ * string, a `data:` URL, an `http(s)` URL, a `URL` instance, or raw bytes.
30
+ */
31
+ export interface LanguageAttachment {
32
+ /** MIME type, e.g. `"image/png"`, `"application/pdf"`. */
33
+ mediaType: string;
34
+ /**
35
+ * The attachment payload: a base64 string, a `data:`/`http(s)` URL, a `URL`
36
+ * instance, or raw bytes (`Uint8Array`/`ArrayBuffer`).
37
+ */
38
+ data: string | Uint8Array | ArrayBuffer | URL;
39
+ /** Optional original filename (used by providers that surface it). */
40
+ filename?: string;
41
+ /**
42
+ * Force how the part is sent. Defaults to auto-detection from `mediaType`
43
+ * (`image/*` → image part, otherwise a file part).
44
+ */
45
+ kind?: "image" | "file";
46
+ }
47
+
23
48
  /**
24
49
  * Configuration options for {@link writeLanguageResponse}.
25
50
  */
@@ -47,6 +72,12 @@ export interface GenerateLanguageOptions {
47
72
  article?: string;
48
73
  /** Prior conversation history for context-aware agents */
49
74
  chat_history?: string;
75
+ /**
76
+ * File and image attachments to send to the model alongside the prompt.
77
+ * When present, the request is issued as a multimodal `messages` call so the
78
+ * model receives the uploaded files (images and documents) directly.
79
+ */
80
+ attachments?: LanguageAttachment[];
50
81
  /** Return `HTML` (`true`) or raw Markdown (`false`). Default: `true` */
51
82
  html?: boolean;
52
83
  /** Truncate the prompt to the model's context window length. Default: `true` */
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export type {
18
18
  LLMProviderName,
19
19
  GenerateLanguageOptions,
20
20
  GenerateLanguageResult,
21
+ LanguageAttachment,
21
22
  } from "./generate-response";
22
23
 
23
24
  export type {