@ljoukov/llm 0.1.2 → 0.1.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
@@ -25,8 +25,10 @@ npm i @ljoukov/llm
25
25
 
26
26
  ## Environment variables
27
27
 
28
- This package reads a `.env.local` file in `process.cwd()` (Node.js) using the same rules as Spark, and falls back to
29
- plain environment variables.
28
+ This package optionally loads a `.env.local` file from `process.cwd()` (Node.js) on first use (dotenv-style `KEY=value`
29
+ syntax) and does not override already-set `process.env` values. It always falls back to plain environment variables.
30
+
31
+ See Node.js docs on environment variables and dotenv files: https://nodejs.org/api/environment_variables.html#dotenv
30
32
 
31
33
  ### OpenAI
32
34
 
@@ -36,17 +38,43 @@ plain environment variables.
36
38
 
37
39
  - `GOOGLE_SERVICE_ACCOUNT_JSON` (the contents of a service account JSON key file, not a file path)
38
40
 
39
- For local dev it is usually easiest to store the JSON on one line:
41
+ #### Get a service account key JSON
42
+
43
+ You need a **Google service account key JSON** for your Firebase / GCP project (this is what you put into
44
+ `GOOGLE_SERVICE_ACCOUNT_JSON`).
45
+
46
+ - **Firebase Console:** your project -> Project settings -> **Service accounts** -> **Generate new private key**
47
+ - **Google Cloud Console:** IAM & Admin -> **Service Accounts** -> select/create an account -> **Keys** -> **Add key** ->
48
+ **Create new key** -> JSON
49
+
50
+ Either path is enough. Both produce the same kind of service account key `.json` file.
51
+
52
+ Official docs: https://docs.cloud.google.com/iam/docs/keys-create-delete
53
+
54
+ Store the JSON on one line (recommended):
40
55
 
41
56
  ```bash
42
57
  jq -c . < path/to/service-account.json
43
58
  ```
44
59
 
60
+ Set it for local dev:
61
+
62
+ ```bash
63
+ export GOOGLE_SERVICE_ACCOUNT_JSON="$(jq -c . < path/to/service-account.json)"
64
+ ```
65
+
66
+ If deploying to Cloudflare Workers/Pages:
67
+
68
+ ```bash
69
+ jq -c . < path/to/service-account.json | wrangler secret put GOOGLE_SERVICE_ACCOUNT_JSON
70
+ ```
71
+
45
72
  ### ChatGPT subscription models
46
73
 
47
74
  - `CHATGPT_AUTH_JSON_B64`
48
75
 
49
- This is a base64url-encoded JSON blob containing the ChatGPT OAuth tokens + account id (Spark-compatible).
76
+ This is a base64url-encoded JSON blob containing the ChatGPT OAuth tokens + account id (RFC 4648):
77
+ https://www.rfc-editor.org/rfc/rfc4648
50
78
 
51
79
  ## Usage
52
80
 
@@ -90,6 +118,116 @@ const result = await call.result;
90
118
  console.log("\nmodelVersion:", result.modelVersion);
91
119
  ```
92
120
 
121
+ ### Full conversation (multi-turn)
122
+
123
+ If you want to pass the full conversation (multiple user/assistant turns), use `contents` instead of `prompt`.
124
+
125
+ Note: assistant messages use `role: "model"`.
126
+
127
+ ```ts
128
+ import { generateText, type LlmContent } from "@ljoukov/llm";
129
+
130
+ const contents: LlmContent[] = [
131
+ {
132
+ role: "system",
133
+ parts: [{ type: "text", text: "You are a concise assistant." }],
134
+ },
135
+ {
136
+ role: "user",
137
+ parts: [{ type: "text", text: "Summarize: Rust is a systems programming language." }],
138
+ },
139
+ {
140
+ role: "model",
141
+ parts: [{ type: "text", text: "Rust is a fast, memory-safe systems language." }],
142
+ },
143
+ {
144
+ role: "user",
145
+ parts: [{ type: "text", text: "Now rewrite it in 1 sentence." }],
146
+ },
147
+ ];
148
+
149
+ const result = await generateText({ model: "gpt-5.2", contents });
150
+ console.log(result.text);
151
+ ```
152
+
153
+ ### Attachments (files / images)
154
+
155
+ Use `inlineData` parts to attach base64-encoded bytes (intermixed with text). `inlineData.data` is base64 (not a data
156
+ URL).
157
+
158
+ Note: `inlineData` is mapped based on `mimeType`.
159
+
160
+ - `image/*` -> image input (`input_image`)
161
+ - otherwise -> file input (`input_file`, e.g. `application/pdf`)
162
+
163
+ ```ts
164
+ import fs from "node:fs";
165
+ import { generateText, type LlmContent } from "@ljoukov/llm";
166
+
167
+ const imageB64 = fs.readFileSync("image.png").toString("base64");
168
+
169
+ const contents: LlmContent[] = [
170
+ {
171
+ role: "user",
172
+ parts: [
173
+ { type: "text", text: "Describe this image in 1 paragraph." },
174
+ { type: "inlineData", mimeType: "image/png", data: imageB64 },
175
+ ],
176
+ },
177
+ ];
178
+
179
+ const result = await generateText({ model: "gpt-5.2", contents });
180
+ console.log(result.text);
181
+ ```
182
+
183
+ PDF attachment example:
184
+
185
+ ```ts
186
+ import fs from "node:fs";
187
+ import { generateText, type LlmContent } from "@ljoukov/llm";
188
+
189
+ const pdfB64 = fs.readFileSync("doc.pdf").toString("base64");
190
+
191
+ const contents: LlmContent[] = [
192
+ {
193
+ role: "user",
194
+ parts: [
195
+ { type: "text", text: "Summarize this PDF in 5 bullet points." },
196
+ { type: "inlineData", mimeType: "application/pdf", data: pdfB64 },
197
+ ],
198
+ },
199
+ ];
200
+
201
+ const result = await generateText({ model: "gpt-5.2", contents });
202
+ console.log(result.text);
203
+ ```
204
+
205
+ Intermixed text + multiple images (e.g. compare two images):
206
+
207
+ ```ts
208
+ import fs from "node:fs";
209
+ import { generateText, type LlmContent } from "@ljoukov/llm";
210
+
211
+ const a = fs.readFileSync("a.png").toString("base64");
212
+ const b = fs.readFileSync("b.png").toString("base64");
213
+
214
+ const contents: LlmContent[] = [
215
+ {
216
+ role: "user",
217
+ parts: [
218
+ { type: "text", text: "Compare the two images. List the important differences." },
219
+ { type: "text", text: "Image A:" },
220
+ { type: "inlineData", mimeType: "image/png", data: a },
221
+ { type: "text", text: "Image B:" },
222
+ { type: "inlineData", mimeType: "image/png", data: b },
223
+ ],
224
+ },
225
+ ];
226
+
227
+ const result = await generateText({ model: "gpt-5.2", contents });
228
+ console.log(result.text);
229
+ ```
230
+
93
231
  ### Gemini
94
232
 
95
233
  ```ts
package/dist/index.cjs CHANGED
@@ -1655,6 +1655,20 @@ function isInlineImageMime(mimeType) {
1655
1655
  }
1656
1656
  return mimeType.startsWith("image/");
1657
1657
  }
1658
+ function guessInlineDataFilename(mimeType) {
1659
+ switch (mimeType) {
1660
+ case "application/pdf":
1661
+ return "document.pdf";
1662
+ case "application/json":
1663
+ return "data.json";
1664
+ case "text/markdown":
1665
+ return "document.md";
1666
+ case "text/plain":
1667
+ return "document.txt";
1668
+ default:
1669
+ return "attachment.bin";
1670
+ }
1671
+ }
1658
1672
  function mergeConsecutiveTextParts(parts) {
1659
1673
  if (parts.length === 0) {
1660
1674
  return [];
@@ -1797,9 +1811,18 @@ function toOpenAiInput(contents) {
1797
1811
  parts.push({ type: "input_text", text: part.text });
1798
1812
  continue;
1799
1813
  }
1800
- const mimeType = part.mimeType ?? "application/octet-stream";
1801
- const dataUrl = `data:${mimeType};base64,${part.data}`;
1802
- parts.push({ type: "input_image", image_url: dataUrl, detail: "auto" });
1814
+ const mimeType = part.mimeType;
1815
+ if (isInlineImageMime(mimeType)) {
1816
+ const dataUrl = `data:${mimeType};base64,${part.data}`;
1817
+ parts.push({ type: "input_image", image_url: dataUrl, detail: "auto" });
1818
+ continue;
1819
+ }
1820
+ const fileData = decodeInlineDataBuffer(part.data).toString("base64");
1821
+ parts.push({
1822
+ type: "input_file",
1823
+ filename: guessInlineDataFilename(mimeType),
1824
+ file_data: fileData
1825
+ });
1803
1826
  }
1804
1827
  if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
1805
1828
  return {
@@ -1835,19 +1858,29 @@ function toChatGptInput(contents) {
1835
1858
  });
1836
1859
  continue;
1837
1860
  }
1838
- const mimeType = part.mimeType ?? "application/octet-stream";
1839
- const dataUrl = `data:${mimeType};base64,${part.data}`;
1840
1861
  if (isAssistant) {
1862
+ const mimeType = part.mimeType ?? "application/octet-stream";
1841
1863
  parts.push({
1842
1864
  type: "output_text",
1843
- text: `[image:${mimeType}]`
1865
+ text: isInlineImageMime(part.mimeType) ? `[image:${mimeType}]` : `[file:${mimeType}]`
1844
1866
  });
1845
1867
  } else {
1846
- parts.push({
1847
- type: "input_image",
1848
- image_url: dataUrl,
1849
- detail: "auto"
1850
- });
1868
+ if (isInlineImageMime(part.mimeType)) {
1869
+ const mimeType = part.mimeType ?? "application/octet-stream";
1870
+ const dataUrl = `data:${mimeType};base64,${part.data}`;
1871
+ parts.push({
1872
+ type: "input_image",
1873
+ image_url: dataUrl,
1874
+ detail: "auto"
1875
+ });
1876
+ } else {
1877
+ const fileData = decodeInlineDataBuffer(part.data).toString("base64");
1878
+ parts.push({
1879
+ type: "input_file",
1880
+ filename: guessInlineDataFilename(part.mimeType),
1881
+ file_data: fileData
1882
+ });
1883
+ }
1851
1884
  }
1852
1885
  }
1853
1886
  if (parts.length === 0) {