@agilesyndrome/cf-genai-llm 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/CONTRACT.md +12 -0
- package/LICENSE +10 -0
- package/README.md +25 -0
- package/package.json +1 -0
- package/src/index.js +123 -0
- package/tests/feature.test.mjs +37 -0
package/CONTRACT.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# LLM feature contract
|
|
2
|
+
|
|
3
|
+
- createFeature(options) returns name and middleware.
|
|
4
|
+
- createLLM(options) returns `generate`, `generateMulti`, `review`, and `reviewMulti`.
|
|
5
|
+
- `generateMulti` and `reviewMulti` start all requests concurrently and preserve input order.
|
|
6
|
+
- A JSON Schema may be passed as the second argument or in `{ schema }`; invalid model data gets one repair request.
|
|
7
|
+
- Failed responses throw `LLMResponseError` with `code=response_failed`, `responseFailed=true`, and the raw `llmResponse`.
|
|
8
|
+
- Each request emits one-line JSON request/response logs with request ID, metadata, duration, status, and token counts.
|
|
9
|
+
- Middleware may return a response or call next().
|
|
10
|
+
- Request state belongs in the state object supplied by cf-genai-base.
|
|
11
|
+
- Cloudflare bindings are supplied by the consuming Worker environment.
|
|
12
|
+
- Feature-owned migrations and schemas must be versioned with the feature.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agile Syndrome
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# @agilesyndrome/cf-genai-llm
|
|
2
|
+
|
|
3
|
+
Reusable Cloudflare Worker LLM access with typed generation, parallel batches,
|
|
4
|
+
reviews, one-shot schema repair, structured logs, and token accounting.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
import { createLLM } from "@agilesyndrome/cf-genai-llm";
|
|
8
|
+
|
|
9
|
+
const llm = createLLM({ env, metadata: { app: "cookbook" } });
|
|
10
|
+
const result = await llm.generate("Write a summary", schema, { schemaName: "summary" });
|
|
11
|
+
const reviews = await llm.reviewMulti(result, reviewerPrompts, reviewSchema);
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
A feature exports an object with middleware(request, env, ctx, next, state).
|
|
15
|
+
Applications layer it into @agilesyndrome/cf-genai-base:
|
|
16
|
+
|
|
17
|
+
import { createWorker } from "@agilesyndrome/cf-genai-base";
|
|
18
|
+
import { createFeature } from "@agilesyndrome/cf-genai-feature";
|
|
19
|
+
|
|
20
|
+
const feature = createFeature();
|
|
21
|
+
export default createWorker({ features: [feature], fetch: router });
|
|
22
|
+
|
|
23
|
+
Keep migrations, binding names, API clients, schemas, and route policy in the
|
|
24
|
+
feature package. The site supplies only its D1/R2 bindings and domain handlers.
|
|
25
|
+
Do not use module-level mutable request state.
|
package/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"@agilesyndrome/cf-genai-llm","version":"0.1.3","description":"Composable Cloudflare Worker LLM generation and review client.","type":"module","exports":{".":"./src/index.js"},"files":["src","tests","README.md","CONTRACT.md","LICENSE"],"scripts":{"check":"node --check src/index.js","test":"node --test tests/*.test.mjs","build":"npm run check && npm test && npm pack --dry-run"},"license":"MIT","publishConfig":{"access":"public","provenance":true},"repository":{"type":"git","url":"git+https://github.com/agilesyndrome/cf-genai-llm.git"},"homepage":"https://github.com/agilesyndrome/cf-genai-llm#readme"}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const DEFAULT_ENDPOINT = "https://api.openai.com/v1/responses";
|
|
2
|
+
const DEFAULT_MODEL = "gpt-5.4";
|
|
3
|
+
|
|
4
|
+
export class LLMResponseError extends Error {
|
|
5
|
+
constructor(message, llmResponse, cause) {
|
|
6
|
+
super(message, { cause });
|
|
7
|
+
this.name = "LLMResponseError";
|
|
8
|
+
this.code = "response_failed";
|
|
9
|
+
this.responseFailed = true;
|
|
10
|
+
this.llmResponse = llmResponse;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createLLM(options = {}) {
|
|
15
|
+
const fetcher = options.fetch || globalThis.fetch;
|
|
16
|
+
const logger = options.logger || console;
|
|
17
|
+
const endpoint = options.endpoint || ((env) => env?.OPENAI_COMPLETIONS_URL || DEFAULT_ENDPOINT);
|
|
18
|
+
const model = options.model || ((env) => env?.OPENAI_MODEL || DEFAULT_MODEL);
|
|
19
|
+
const defaults = options.metadata || {};
|
|
20
|
+
const debugLogging = options.debugLogging === true;
|
|
21
|
+
if (typeof fetcher !== "function") throw new TypeError("createLLM requires fetch");
|
|
22
|
+
|
|
23
|
+
const log = (level, event) => {
|
|
24
|
+
if (level === "debug" && !debugLogging) return;
|
|
25
|
+
try { (logger[level] || logger.info || (() => {})).call(logger, JSON.stringify({ source: "cf-genai-llm", ...event })); } catch { /* logging cannot break a request */ }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
async function request(prompt, schema, requestOptions = {}) {
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
const requestId = requestOptions.requestId || crypto.randomUUID();
|
|
31
|
+
const env = requestOptions.env || options.env;
|
|
32
|
+
const apiKey = requestOptions.apiKey || options.apiKey || env?.OPENAI_API_KEY;
|
|
33
|
+
if (!apiKey) throw new Error("OPENAI_API_KEY is not configured");
|
|
34
|
+
const metadata = { ...defaults, ...(requestOptions.metadata || {}) };
|
|
35
|
+
const body = { model: requestOptions.model || (typeof model === "function" ? model(env) : model), input: prompt, store: false };
|
|
36
|
+
if (Object.keys(metadata).length) body.metadata = metadata;
|
|
37
|
+
if (schema) body.text = { format: { type: "json_schema", name: requestOptions.schemaName || "response", strict: true, schema } };
|
|
38
|
+
log("debug", { event: "llm.request", requestId, metadata, model: body.model, hasSchema: Boolean(schema) });
|
|
39
|
+
let response, payload;
|
|
40
|
+
try {
|
|
41
|
+
response = await fetcher(typeof endpoint === "function" ? endpoint(env) : endpoint, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: requestOptions.signal });
|
|
42
|
+
payload = await response.json();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
log("error", { event: "llm.error", requestId, durationMs: Date.now() - started, error: error.message });
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const usage = normalizeUsage(payload?.usage);
|
|
48
|
+
if (requestOptions.onUsage) await requestOptions.onUsage(usage);
|
|
49
|
+
log(response.ok ? "info" : "error", { event: "llm.response", requestId, status: response.status, durationMs: Date.now() - started, usage, metadata });
|
|
50
|
+
if (!response.ok) throw new LLMResponseError(`LLM request failed (${response.status})`, payload);
|
|
51
|
+
const text = extractText(payload);
|
|
52
|
+
if (!text) throw new LLMResponseError("LLM returned no text", payload);
|
|
53
|
+
if (requestOptions.onText) await requestOptions.onText(text);
|
|
54
|
+
return { text, payload, usage, requestId, metadata };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function generate(promptOrRequest, schemaOrOptions, maybeOptions) {
|
|
58
|
+
const input = normalizeGenerateArgs(promptOrRequest, schemaOrOptions, maybeOptions);
|
|
59
|
+
const first = await request(input.prompt, input.schema, input.options);
|
|
60
|
+
if (!input.schema) return parseBestEffort(first.text);
|
|
61
|
+
try { return validateAndReturn(first.text, input.schema); } catch (error) {
|
|
62
|
+
const repairPrompt = `${input.prompt}\n\nYour previous response was invalid for the required schema. Return only corrected JSON matching this schema exactly.\nSchema: ${JSON.stringify(input.schema)}\nPrevious response: ${first.text}\nValidation error: ${error.message}`;
|
|
63
|
+
let repairedResponse;
|
|
64
|
+
try {
|
|
65
|
+
repairedResponse = await request(repairPrompt, input.schema, { ...input.options, schemaName: `${input.options.schemaName || "response"}_repair` });
|
|
66
|
+
return validateAndReturn(repairedResponse.text, input.schema);
|
|
67
|
+
} catch (repairError) {
|
|
68
|
+
if (repairError instanceof LLMResponseError) throw repairError;
|
|
69
|
+
throw new LLMResponseError(`LLM response did not match the requested schema after one repair attempt: ${repairError.message}`, repairedResponse?.payload || first.payload, repairError);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function generateMulti(requests, options = {}) {
|
|
75
|
+
const items = Array.isArray(requests) ? requests : requests.requests;
|
|
76
|
+
if (!Array.isArray(items)) throw new TypeError("generateMulti requires an array of requests");
|
|
77
|
+
const shared = options?.type ? { schema: options, options: {} } : { schema: options.schema, options };
|
|
78
|
+
return Promise.all(items.map((item) => generate(typeof item === "string" ? { prompt: item, schema: shared.schema } : { ...item, schema: item.schema || shared.schema }, { ...shared.options, ...(item.options || {}) })));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function review(originalTextOrRequest, reviewPromptOrPrompts, schemaOrOptions, maybeOptions) {
|
|
82
|
+
if (Array.isArray(reviewPromptOrPrompts)) return reviewMulti(originalTextOrRequest, reviewPromptOrPrompts, schemaOrOptions, maybeOptions);
|
|
83
|
+
const args = normalizeReviewArgs(originalTextOrRequest, reviewPromptOrPrompts, schemaOrOptions, maybeOptions);
|
|
84
|
+
return generate(`${args.prompt}\n\nOriginal output to review:\n${args.originalText}`, args.schema, args.options);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function reviewMulti(originalText, reviewPrompts, schemaOrOptions, maybeOptions) {
|
|
88
|
+
const { schema, options: shared } = normalizeSchemaOptions(schemaOrOptions, maybeOptions);
|
|
89
|
+
return Promise.all(reviewPrompts.map((item) => {
|
|
90
|
+
const prompt = typeof item === "string" ? item : item.prompt;
|
|
91
|
+
const name = typeof item === "string" ? undefined : item.name;
|
|
92
|
+
return review(originalText, prompt, schema, { ...shared, ...(typeof item === "object" ? item.options : {}), metadata: { ...shared.metadata, ...(name ? { reviewer: name } : {}) } });
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { generate, generateMulti, generateWithSchema: generate, generateMultiWithSchema: generateMulti, review, reviewMulti, reviewWithSchema: review, reviewMultiWithSchema: reviewMulti };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeGenerateArgs(promptOrRequest, schemaOrOptions, maybeOptions) {
|
|
100
|
+
if (promptOrRequest && typeof promptOrRequest === "object" && !Array.isArray(promptOrRequest)) return { prompt: String(promptOrRequest.prompt || ""), schema: promptOrRequest.schema, options: { ...promptOrRequest.options, ...maybeOptions } };
|
|
101
|
+
const { schema, options } = normalizeSchemaOptions(schemaOrOptions, maybeOptions);
|
|
102
|
+
return { prompt: String(promptOrRequest || ""), schema, options };
|
|
103
|
+
}
|
|
104
|
+
function normalizeReviewArgs(originalText, reviewPrompt, schemaOrOptions, maybeOptions) { const { schema, options } = normalizeSchemaOptions(schemaOrOptions, maybeOptions); return { originalText: String(originalText || ""), prompt: String(reviewPrompt || ""), schema, options }; }
|
|
105
|
+
function normalizeSchemaOptions(value, options) { if (value && value.type) return { schema: value, options: options || {} }; return { schema: value?.schema, options: { ...(value || {}), ...(options || {}) } }; }
|
|
106
|
+
function extractText(payload) { return payload?.output_text || payload?.output?.flatMap((item) => item.content || []).find((item) => item.type === "output_text")?.text || ""; }
|
|
107
|
+
function parseBestEffort(text) { try { return JSON.parse(text); } catch { return text; } }
|
|
108
|
+
function validateAndReturn(text, schema) { let value; try { value = JSON.parse(text); } catch (error) { throw new Error(`response is not JSON: ${error.message}`); } validate(value, schema, "$root"); return value; }
|
|
109
|
+
function validate(value, schema, path) {
|
|
110
|
+
if (!schema) return;
|
|
111
|
+
if (schema.type === "object") { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`); for (const key of schema.required || []) if (!(key in value)) throw new Error(`${path}.${key} is required`); if (schema.additionalProperties === false) for (const key of Object.keys(value)) if (!schema.properties?.[key]) throw new Error(`${path}.${key} is not allowed`); for (const [key, child] of Object.entries(schema.properties || {})) if (key in value) validate(value[key], child, `${path}.${key}`); return; }
|
|
112
|
+
if (schema.type === "array") { if (!Array.isArray(value)) throw new Error(`${path} must be an array`); if (schema.minItems !== undefined && value.length < schema.minItems) throw new Error(`${path} has too few items`); if (schema.maxItems !== undefined && value.length > schema.maxItems) throw new Error(`${path} has too many items`); value.forEach((item, index) => validate(item, schema.items, `${path}[${index}]`)); return; }
|
|
113
|
+
if (schema.type === "string" && typeof value !== "string") throw new Error(`${path} must be a string`);
|
|
114
|
+
if (schema.type === "integer" && !Number.isInteger(value)) throw new Error(`${path} must be an integer`);
|
|
115
|
+
if (schema.type === "number" && (typeof value !== "number" || Number.isNaN(value))) throw new Error(`${path} must be a number`);
|
|
116
|
+
if (schema.type === "boolean" && typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
|
|
117
|
+
if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path} must be one of ${schema.enum.join(", ")}`);
|
|
118
|
+
if (schema.minimum !== undefined && value < schema.minimum) throw new Error(`${path} is below minimum`);
|
|
119
|
+
if (schema.maximum !== undefined && value > schema.maximum) throw new Error(`${path} is above maximum`);
|
|
120
|
+
}
|
|
121
|
+
function normalizeUsage(usage = {}) { return { inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? 0, outputTokens: usage.output_tokens ?? usage.completion_tokens ?? 0, totalTokens: usage.total_tokens ?? 0 }; }
|
|
122
|
+
|
|
123
|
+
export function createFeature(options = {}) { const name = options.name || "cf-genai-llm"; return { name, middleware: async (request, env, ctx, next, state) => { if (options.boot) await options.boot(env, { request, ctx, state }); return options.handle ? options.handle(request, env, ctx, next, state) : next(); } }; }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createFeature, createLLM, LLMResponseError } from "../src/index.js";
|
|
4
|
+
|
|
5
|
+
test("feature delegates to the next handler", async () => {
|
|
6
|
+
const feature = createFeature({ name: "example" });
|
|
7
|
+
const response = await feature.middleware(new Request("https://example.test/"), {}, {}, () => Response.json({ ok: true }), {});
|
|
8
|
+
assert.equal(response.status, 200);
|
|
9
|
+
assert.deepEqual(await response.json(), { ok: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const schema = { type: "object", additionalProperties: false, properties: { answer: { type: "string" } }, required: ["answer"] };
|
|
13
|
+
function response(text, usage = { input_tokens: 3, output_tokens: 2, total_tokens: 5 }) { return new Response(JSON.stringify({ output_text: text, usage }), { status: 200, headers: { "content-type": "application/json" } }); }
|
|
14
|
+
|
|
15
|
+
test("generate sends metadata, validates typed output, and logs token counts", async () => {
|
|
16
|
+
const calls = []; const logs = [];
|
|
17
|
+
const llm = createLLM({ apiKey: "test", fetch: async (_url, init) => { calls.push(JSON.parse(init.body)); return response("{\"answer\":\"ok\"}"); }, logger: { debug: (line) => logs.push(JSON.parse(line)), info: (line) => logs.push(JSON.parse(line)) }, metadata: { app: "test" } });
|
|
18
|
+
assert.deepEqual(await llm.generate("hello", schema, { schemaName: "answer", metadata: { type: "unit" } }), { answer: "ok" });
|
|
19
|
+
assert.equal(calls[0].metadata.type, "unit");
|
|
20
|
+
assert.equal(logs.find((entry) => entry.event === "llm.response").usage.totalTokens, 5);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("generateMulti starts parallel typed requests and reviewMulti preserves order", async () => {
|
|
24
|
+
let active = 0; let peak = 0;
|
|
25
|
+
const llm = createLLM({ apiKey: "test", fetch: async (_url, init) => { active++; peak = Math.max(peak, active); const body = JSON.parse(init.body); await new Promise((resolve) => setTimeout(resolve, 5)); active--; return response(JSON.stringify({ answer: body.input.includes("two") ? "two" : "one" })); } });
|
|
26
|
+
const results = await llm.generateMulti(["one", "two"], schema);
|
|
27
|
+
assert.deepEqual(results, [{ answer: "one" }, { answer: "two" }]); assert.equal(peak, 2);
|
|
28
|
+
assert.deepEqual(await llm.reviewMulti("source", ["first", "second"], schema), [{ answer: "one" }, { answer: "one" }]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("invalid typed output gets one repair, then exposes the raw model response", async () => {
|
|
32
|
+
let count = 0;
|
|
33
|
+
const llm = createLLM({ apiKey: "test", fetch: async () => { count++; return response(count === 1 ? "{\"wrong\":true}" : "{\"answer\":\"fixed\"}"); } });
|
|
34
|
+
assert.deepEqual(await llm.generate("repair me", schema), { answer: "fixed" }); assert.equal(count, 2);
|
|
35
|
+
const failing = createLLM({ apiKey: "test", fetch: async () => response("{\"wrong\":true}") });
|
|
36
|
+
await assert.rejects(() => failing.generate("fail", schema), (error) => error instanceof LLMResponseError && error.responseFailed && error.llmResponse.output_text === "{\"wrong\":true}");
|
|
37
|
+
});
|