@agilesyndrome/cf-genai-llm 4.1.1 → 5.0.0
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 +6 -6
- package/README.md +36 -32
- package/changelog.md +8 -0
- package/package.json +1 -1
- package/src/index.js +56 -30
- package/tests/feature.test.mjs +74 -30
package/CONTRACT.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# LLM feature contract
|
|
2
2
|
|
|
3
3
|
- createFeature(options) returns name and middleware.
|
|
4
|
-
- createLLM(options) returns `generate`, `generateMulti`, `review`, and `reviewMulti`.
|
|
4
|
+
- createLLM(options) returns `generate`, `generateJob`, `generateMulti`, `review`, and `reviewMulti`.
|
|
5
|
+
- `generateJob(jobId, ...)` executes an already-dispatched base job, reports progress without model text, and stores `{}` unless `job.toJobResult` supplies a compact domain result.
|
|
5
6
|
- `generateMulti` and `reviewMulti` start all requests concurrently and preserve input order.
|
|
6
|
-
- OpenAI is the default
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- An authenticated Gateway uses `cf-aig-authorization`; provider authentication remains in `Authorization`.
|
|
7
|
+
- The client uses the OpenAI Responses-compatible contract; OpenAI is the default endpoint and arbitrary compatible endpoints are supported.
|
|
8
|
+
- `LLM_API_URL`, `LLM_API_TOKEN`, and `LLM_MODEL` are the universal configuration variables.
|
|
9
|
+
- Cloudflare AI Gateway is detected from its URL and uses `cf-aig-authorization`; direct compatible endpoints use `Authorization`.
|
|
10
|
+
- `LLM_MODEL=auto` selects the first model returned by the configured `/models` endpoint.
|
|
11
11
|
- A JSON Schema may be passed as the second argument or in `{ schema }`; invalid model data gets one repair request.
|
|
12
12
|
- Failed responses throw `LLMResponseError` with `code=response_failed`, `responseFailed=true`, and the raw `llmResponse`.
|
|
13
13
|
- Each request emits one-line JSON request/response logs with request ID, metadata, duration, status, and token counts.
|
package/README.md
CHANGED
|
@@ -11,53 +11,57 @@ const result = await llm.generate("Write a summary", schema, { schemaName: "summ
|
|
|
11
11
|
const reviews = await llm.reviewMulti(result, reviewerPrompts, reviewSchema);
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
Long-running generation is explicit. Dispatch a base job to an application-owned
|
|
15
|
+
Cloudflare Workflow, then execute the generation from that Workflow with the
|
|
16
|
+
same job ID:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
const execution = await llm.generateJob(event.payload.jobId, prompt, schema, {
|
|
20
|
+
env: this.env,
|
|
21
|
+
job: {
|
|
22
|
+
toJobResult: async (recipe) => {
|
|
23
|
+
await recipes.save(recipe);
|
|
24
|
+
return { resourceType: "recipe", resourceId: recipe.id };
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`generateJob` reports phases through base's durable job events. It deliberately
|
|
31
|
+
stores no generated content by default; `job.toJobResult` should persist the
|
|
32
|
+
domain value and return only the small result descriptor needed by the UI.
|
|
33
|
+
|
|
14
34
|
## Providers and AI Gateway
|
|
15
35
|
|
|
16
|
-
|
|
17
|
-
|
|
36
|
+
The client speaks the OpenAI Responses-compatible API. OpenAI is the default
|
|
37
|
+
endpoint, while any compatible service (including OpenRelay-style endpoints)
|
|
38
|
+
can be selected with the same URL, token, and model variables.
|
|
18
39
|
|
|
19
40
|
```js
|
|
20
41
|
// Direct OpenAI API access remains the default.
|
|
21
|
-
const direct = createLLM({
|
|
42
|
+
const direct = createLLM({ env });
|
|
22
43
|
|
|
23
44
|
// Gateway is a routing/control layer; OpenAI still performs inference.
|
|
24
|
-
const gateway = createLLM({
|
|
25
|
-
apiKey: env.OPENAI_API_KEY,
|
|
26
|
-
gateway: {
|
|
27
|
-
url: "https://gateway.ai.cloudflare.com/v1/account-id/gateway-id/openai",
|
|
28
|
-
token: env.CF_AI_GATEWAY_TOKEN,
|
|
29
|
-
},
|
|
30
|
-
});
|
|
45
|
+
const gateway = createLLM({ env });
|
|
31
46
|
```
|
|
32
47
|
|
|
33
|
-
The
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
provider credential, the Gateway token is sufficient and `apiKey` may be
|
|
38
|
-
omitted.
|
|
48
|
+
The URL may be either a provider base URL or its `/responses` endpoint. Model
|
|
49
|
+
list requests use the matching `/models` route. Cloudflare AI Gateway is
|
|
50
|
+
detected from its hostname and receives the token in `cf-aig-authorization`;
|
|
51
|
+
direct compatible endpoints use `Authorization: Bearer ...`.
|
|
39
52
|
|
|
40
|
-
Cloudflare Workers AI
|
|
41
|
-
|
|
42
|
-
independent so a future Workers AI adapter can run either directly or through
|
|
43
|
-
AI Gateway.
|
|
53
|
+
Cloudflare Workers AI can be reached through an OpenAI-compatible Gateway URL;
|
|
54
|
+
the package does not use the Workers AI binding directly.
|
|
44
55
|
|
|
45
56
|
### Environment configuration
|
|
46
57
|
|
|
47
58
|
| Variable | Purpose |
|
|
48
59
|
| --- | --- |
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `LLM_MODEL` |
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
| `LLM_ENDPOINT` | Explicit Responses endpoint override |
|
|
55
|
-
| `LLM_MODELS_ENDPOINT` | Explicit model-list endpoint override |
|
|
56
|
-
|
|
57
|
-
`OPENAI_COMPLETIONS_URL` remains supported for direct OpenAI-compatible
|
|
58
|
-
endpoints. `OPENAI_MODELS_URL` may explicitly configure its model-list route.
|
|
59
|
-
Pass `gateway: false` to `createLLM` to bypass an environment-configured
|
|
60
|
-
Gateway for a particular client.
|
|
60
|
+
| `LLM_API_URL` | Universal OpenAI-compatible Responses URL or API base URL |
|
|
61
|
+
| `LLM_API_TOKEN` | Universal provider or Gateway token |
|
|
62
|
+
| `LLM_MODEL` | Universal model name; use `auto` to select from `/models` |
|
|
63
|
+
|
|
64
|
+
Set `LLM_MODEL=auto` when the provider supports a `/models` endpoint.
|
|
61
65
|
|
|
62
66
|
A feature exports an object with middleware(request, env, ctx, next, state).
|
|
63
67
|
Applications layer it into @agilesyndrome/cf-genai-base:
|
package/changelog.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 5.0.0
|
|
4
|
+
|
|
5
|
+
- Require cf-genai-base 5.
|
|
6
|
+
- Add explicit durable generation through `generateJob`.
|
|
7
|
+
- Report generation progress through base job events.
|
|
8
|
+
- Keep model output out of durable job records unless the application supplies a compact result mapper.
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"@agilesyndrome/cf-genai-llm","version":"
|
|
1
|
+
{"name":"@agilesyndrome/cf-genai-llm","version":"5.0.0","description":"Composable Cloudflare Worker LLM generation and review client.","type":"module","exports":{".":"./src/index.js"},"files":["src","tests","README.md","CONTRACT.md","changelog.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","dependencies":{"@agilesyndrome/cf-genai-base":"^5.0.0"}}
|
package/src/index.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
const DEFAULT_ENDPOINT = "https://api.openai.com/v1/responses";
|
|
2
2
|
const DEFAULT_MODEL = "gpt-5.4";
|
|
3
|
-
const DEFAULT_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
|
|
4
|
-
const DEFAULT_PROVIDER = "openai";
|
|
5
3
|
export const PACKAGE_NAME = "@agilesyndrome/cf-genai-llm";
|
|
6
|
-
export const VERSION = "
|
|
4
|
+
export const VERSION = "5.0.0";
|
|
7
5
|
export class LLMCircuitBreakerError extends Error { constructor(message = "LLM generation is temporarily unavailable") { super(message); this.name = "LLMCircuitBreakerError"; this.code = "circuit_breaker_open"; this.circuitBreakerOpen = true; } }
|
|
8
6
|
|
|
9
|
-
import { getCircuitBreaker, registerCircuitBreaker, registerHealthcheck, setCircuitBreaker } from "@agilesyndrome/cf-genai-base";
|
|
7
|
+
import { executeJob, getCircuitBreaker, registerCircuitBreaker, registerHealthcheck, setCircuitBreaker } from "@agilesyndrome/cf-genai-base";
|
|
10
8
|
export class LLMResponseError extends Error {
|
|
11
9
|
constructor(message, llmResponse, cause) {
|
|
12
10
|
super(message, { cause });
|
|
@@ -42,7 +40,8 @@ export function createLLM(options = {}) {
|
|
|
42
40
|
const env = requestOptions.env || options.env;
|
|
43
41
|
const config = resolveConfig(options, requestOptions, env);
|
|
44
42
|
const metadata = { ...defaults, ...(requestOptions.metadata || {}) };
|
|
45
|
-
const
|
|
43
|
+
const model = await resolveModel(config, fetcher, requestOptions);
|
|
44
|
+
const body = { model, input: prompt, store: false };
|
|
46
45
|
if (Object.keys(metadata).length) body.metadata = metadata;
|
|
47
46
|
if (schema) body.text = { format: { type: "json_schema", name: requestOptions.schemaName || "response", strict: true, schema } };
|
|
48
47
|
log("debug", { event: "llm.request", requestId, metadata, provider: config.provider, gateway: config.gateway, model: body.model, hasSchema: Boolean(schema) });
|
|
@@ -88,6 +87,36 @@ export function createLLM(options = {}) {
|
|
|
88
87
|
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 || {}) })));
|
|
89
88
|
}
|
|
90
89
|
|
|
90
|
+
async function generateJob(jobId, promptOrRequest, schemaOrOptions, maybeOptions) {
|
|
91
|
+
const input = normalizeGenerateArgs(promptOrRequest, schemaOrOptions, maybeOptions);
|
|
92
|
+
const jobOptions = input.options.job || {};
|
|
93
|
+
const generationOptions = { ...input.options };
|
|
94
|
+
delete generationOptions.job;
|
|
95
|
+
const env = generationOptions.env || options.env;
|
|
96
|
+
if (!env?.DB) throw new TypeError("generateJob requires an environment with DB");
|
|
97
|
+
const onText = generationOptions.onText;
|
|
98
|
+
const onUsage = generationOptions.onUsage;
|
|
99
|
+
return executeJob(env, jobId, async ({ report }) => {
|
|
100
|
+
await report({ phase: "generating" });
|
|
101
|
+
const value = await generate(input.prompt, input.schema, {
|
|
102
|
+
...generationOptions,
|
|
103
|
+
onText: async (text) => {
|
|
104
|
+
if (onText) await onText(text);
|
|
105
|
+
await report({ phase: "generated" });
|
|
106
|
+
},
|
|
107
|
+
onUsage: async (usage) => {
|
|
108
|
+
if (onUsage) await onUsage(usage);
|
|
109
|
+
await report({ phase: "generating", usage });
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
return value;
|
|
113
|
+
}, {
|
|
114
|
+
who: jobOptions.who || generationOptions.who || "system:update",
|
|
115
|
+
ctx: jobOptions.ctx || generationOptions.ctx,
|
|
116
|
+
toJobResult: jobOptions.toJobResult || emptyJobResult,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
async function review(originalTextOrRequest, reviewPromptOrPrompts, schemaOrOptions, maybeOptions) {
|
|
92
121
|
if (Array.isArray(reviewPromptOrPrompts)) return reviewMulti(originalTextOrRequest, reviewPromptOrPrompts, schemaOrOptions, maybeOptions);
|
|
93
122
|
const args = normalizeReviewArgs(originalTextOrRequest, reviewPromptOrPrompts, schemaOrOptions, maybeOptions);
|
|
@@ -103,7 +132,7 @@ export function createLLM(options = {}) {
|
|
|
103
132
|
}));
|
|
104
133
|
}
|
|
105
134
|
|
|
106
|
-
return { listModels, generate, generateMulti, generateWithSchema: generate, generateMultiWithSchema: generateMulti, review, reviewMulti, reviewWithSchema: review, reviewMultiWithSchema: reviewMulti };
|
|
135
|
+
return { listModels, generate, generateJob, generateMulti, generateWithSchema: generate, generateMultiWithSchema: generateMulti, review, reviewMulti, reviewWithSchema: review, reviewMultiWithSchema: reviewMulti };
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
function normalizeGenerateArgs(promptOrRequest, schemaOrOptions, maybeOptions) {
|
|
@@ -119,30 +148,26 @@ function endpointFor(baseUrl, resource) {
|
|
|
119
148
|
return normalized.replace(/\/(responses|models)$/, "") + "/" + resource;
|
|
120
149
|
}
|
|
121
150
|
function resolveConfig(options, requestOptions, env) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
: env?.CF_AI_GATEWAY_URL;
|
|
131
|
-
const gatewayUrl = gatewaySetting && (typeof gatewaySetting === "string" ? gatewaySetting : gatewaySetting.url || env?.CF_AI_GATEWAY_URL);
|
|
132
|
-
if (gatewaySetting === true && !gatewayUrl) throw new Error("CF_AI_GATEWAY_URL is not configured");
|
|
133
|
-
const gatewayToken = requestOptions.gatewayToken || (gatewaySetting && typeof gatewaySetting === "object" && gatewaySetting.token) || resolveValue(options.gatewayToken, env) || env?.CF_AI_GATEWAY_TOKEN;
|
|
134
|
-
if (!apiKey && !(gatewayUrl && gatewayToken)) throw new Error("LLM_API_KEY or OPENAI_API_KEY is not configured");
|
|
135
|
-
|
|
136
|
-
const explicitEndpoint = resolveValue(requestOptions.endpoint, env) || resolveValue(options.endpoint, env) || env?.LLM_ENDPOINT;
|
|
137
|
-
const endpoint = gatewayUrl ? endpointFor(gatewayUrl, "responses") : explicitEndpoint || env?.OPENAI_COMPLETIONS_URL || DEFAULT_ENDPOINT;
|
|
138
|
-
const configuredModelsEndpoint = resolveValue(requestOptions.modelsEndpoint, env) || resolveValue(options.modelsEndpoint, env) || env?.LLM_MODELS_ENDPOINT || env?.OPENAI_MODELS_URL;
|
|
139
|
-
const modelsEndpoint = configuredModelsEndpoint || (gatewayUrl || explicitEndpoint ? endpointFor(gatewayUrl || explicitEndpoint, "models") : DEFAULT_MODELS_ENDPOINT);
|
|
140
|
-
const model = requestOptions.model || resolveValue(options.model, env) || env?.LLM_MODEL || env?.OPENAI_MODEL || DEFAULT_MODEL;
|
|
151
|
+
if (requestOptions.apiUrl !== undefined && options.allowDynamicApiUrl !== true) throw new Error("Per-request LLM API URLs are disabled; configure apiUrl at client creation time.");
|
|
152
|
+
const apiUrl = (options.allowDynamicApiUrl === true ? resolveValue(requestOptions.apiUrl, env) : null) || resolveValue(options.apiUrl, env) || env?.LLM_API_URL || DEFAULT_ENDPOINT;
|
|
153
|
+
if (new URL(apiUrl).protocol !== "https:") throw new Error("LLM_API_URL must use HTTPS");
|
|
154
|
+
const token = requestOptions.apiToken || resolveValue(options.apiToken, env) || env?.LLM_API_TOKEN;
|
|
155
|
+
if (!token) throw new Error("LLM_API_TOKEN is not configured");
|
|
156
|
+
const endpoint = endpointFor(apiUrl, "responses");
|
|
157
|
+
const modelsEndpoint = endpointFor(apiUrl, "models");
|
|
158
|
+
const gateway = isCloudflareGateway(apiUrl);
|
|
141
159
|
const headers = { "Content-Type": "application/json", ...resolveValue(options.headers, env), ...requestOptions.headers };
|
|
142
|
-
if (
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
160
|
+
if (token && !gateway) headers.Authorization = `Bearer ${token}`;
|
|
161
|
+
if (gateway) headers["cf-aig-authorization"] = `Bearer ${token}`;
|
|
162
|
+
return { provider: gateway ? "cloudflare-ai-gateway" : "openai-compatible", gateway, endpoint, modelsEndpoint, model: requestOptions.model || resolveValue(options.model, env) || env?.LLM_MODEL || DEFAULT_MODEL, headers };
|
|
163
|
+
}
|
|
164
|
+
function isCloudflareGateway(url) { try { return new URL(url).hostname === "gateway.ai.cloudflare.com"; } catch { return false; } }
|
|
165
|
+
async function resolveModel(config, fetcher, requestOptions) {
|
|
166
|
+
if (config.model !== "auto") return config.model;
|
|
167
|
+
const response = await fetcher(config.modelsEndpoint, { method: "GET", headers: config.headers, signal: requestOptions.signal });
|
|
168
|
+
const payload = await response.json();
|
|
169
|
+
if (!response.ok || !Array.isArray(payload.data) || !payload.data[0]?.id) throw new LLMResponseError("Automatic model selection failed", payload);
|
|
170
|
+
return payload.data[0].id;
|
|
146
171
|
}
|
|
147
172
|
function extractText(payload) { return payload?.output_text || payload?.output?.flatMap((item) => item.content || []).find((item) => item.type === "output_text")?.text || ""; }
|
|
148
173
|
function parseBestEffort(text) { try { return JSON.parse(text); } catch { return text; } }
|
|
@@ -160,5 +185,6 @@ function validate(value, schema, path) {
|
|
|
160
185
|
if (schema.maximum !== undefined && value > schema.maximum) throw new Error(`${path} is above maximum`);
|
|
161
186
|
}
|
|
162
187
|
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 }; }
|
|
188
|
+
function emptyJobResult() { return {}; }
|
|
163
189
|
|
|
164
|
-
export function createFeature(options = {}) { const name = options.name || "cf-genai-llm"; const client = createLLM({ ...options, feature: name }); return { name, packageName: PACKAGE_NAME, version: VERSION, healthcheck: async (env) => { try { resolveConfig(options, {}, env); } catch { return [{ feature: name, component: "configuration", displayName: "LLM configuration", state: "red" }]; } try { await client.listModels({ env, who: "system:update" }); return [{ feature: name, component: "configuration", displayName: "LLM configuration", state: "green" }]; } catch { return [{ feature: name, component: "configuration", displayName: "LLM configuration", state: "yellow" }]; } }, healthchecks: [{ feature: name, component: "llm-models", displayName: "LLM model availability", state: "yellow" }], circuitBreakers: [{ id: name + ":llm-models", feature: name, name: "llm-models", displayName: "LLM model access", state: "on", allowSelfHealing: true, healthchecks: [name + ":llm-models"] }], 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(); } }; }
|
|
190
|
+
export function createFeature(options = {}) { const name = options.name || "cf-genai-llm"; const client = createLLM({ ...options, feature: name }); return { name, displayName: options.displayName || name, packageName: PACKAGE_NAME, version: VERSION, dataResources: options.dataResources || [], routes: options.routes || [], healthcheck: async (env) => { try { resolveConfig(options, {}, env); } catch { return [{ feature: name, component: "configuration", displayName: "LLM configuration", state: "red" }]; } try { await client.listModels({ env, who: "system:update" }); return [{ feature: name, component: "configuration", displayName: "LLM configuration", state: "green" }]; } catch { return [{ feature: name, component: "configuration", displayName: "LLM configuration", state: "yellow" }]; } }, healthchecks: options.healthchecks || [{ feature: name, component: "llm-models", displayName: "LLM model availability", state: "yellow" }], circuitBreakers: options.circuitBreakers || [{ id: name + ":llm-models", feature: name, name: "llm-models", displayName: "LLM model access", state: "on", allowSelfHealing: true, healthchecks: [name + ":llm-models"] }], 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(); } }; }
|
package/tests/feature.test.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { createJob, getJob } from "@agilesyndrome/cf-genai-base";
|
|
3
4
|
import { createFeature, createLLM, LLMResponseError } from "../src/index.js";
|
|
4
5
|
|
|
5
6
|
test("feature delegates to the next handler", async () => {
|
|
6
7
|
const feature = createFeature({ name: "example" });
|
|
7
|
-
assert.equal(feature.version, "
|
|
8
|
+
assert.equal(feature.version, "5.0.0");
|
|
8
9
|
const response = await feature.middleware(new Request("https://example.test/"), {}, {}, () => Response.json({ ok: true }), {});
|
|
9
10
|
assert.equal(response.status, 200);
|
|
10
11
|
assert.deepEqual(await response.json(), { ok: true });
|
|
@@ -13,24 +14,65 @@ test("feature delegates to the next handler", async () => {
|
|
|
13
14
|
const schema = { type: "object", additionalProperties: false, properties: { answer: { type: "string" } }, required: ["answer"] };
|
|
14
15
|
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" } }); }
|
|
15
16
|
|
|
17
|
+
function jobDatabase() {
|
|
18
|
+
const jobs = new Map();
|
|
19
|
+
const events = [];
|
|
20
|
+
return {
|
|
21
|
+
jobs,
|
|
22
|
+
events,
|
|
23
|
+
prepare(sql) {
|
|
24
|
+
const statement = { args: [], bind(...args) { this.args = args; return this; } };
|
|
25
|
+
statement.run = async () => {
|
|
26
|
+
if (sql.includes("INSERT INTO core_jobs")) {
|
|
27
|
+
const [id, type, status, ownerId, tenantId, resourceType, resourceId, input, progress, createdAt, updatedAt, expiresAt] = statement.args;
|
|
28
|
+
jobs.set(id, { id, type, status, owner_id: ownerId, tenant_id: tenantId, resource_type: resourceType, resource_id: resourceId, input_json: input, result_json: "{}", error_json: null, progress_json: progress, created_at: createdAt, started_at: null, finished_at: null, updated_at: updatedAt, expires_at: expiresAt });
|
|
29
|
+
} else if (sql.includes("INSERT INTO core_job_events")) {
|
|
30
|
+
const [id, jobId, type, payload] = statement.args;
|
|
31
|
+
events.push({ id, job_id: jobId, type, payload_json: payload, created_at: new Date().toISOString() });
|
|
32
|
+
} else if (sql.startsWith("UPDATE core_jobs SET")) {
|
|
33
|
+
const row = jobs.get(statement.args.at(-1));
|
|
34
|
+
for (const [index, assignment] of [...sql.matchAll(/([a-z_]+) = \?/g)].entries()) row[assignment[1]] = statement.args[index];
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
};
|
|
38
|
+
statement.first = async () => sql.includes("SELECT * FROM core_jobs WHERE id") ? jobs.get(statement.args[0]) || null : null;
|
|
39
|
+
statement.all = async () => ({ results: [] });
|
|
40
|
+
return statement;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
test("generate sends metadata, validates typed output, and logs token counts", async () => {
|
|
17
46
|
const calls = []; const logs = [];
|
|
18
|
-
const llm = createLLM({
|
|
47
|
+
const llm = createLLM({ env: { LLM_API_TOKEN: "test" }, fetch: async (url, init) => { calls.push({ url, headers: init.headers, body: 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" } });
|
|
19
48
|
assert.deepEqual(await llm.generate("hello", schema, { schemaName: "answer", metadata: { type: "unit" } }), { answer: "ok" });
|
|
20
49
|
assert.equal(calls[0].url, "https://api.openai.com/v1/responses");
|
|
21
50
|
assert.equal(calls[0].headers.Authorization, "Bearer test");
|
|
22
51
|
assert.equal(calls[0].body.metadata.type, "unit");
|
|
23
52
|
const responseLog = logs.find((entry) => entry.event === "llm.response");
|
|
24
53
|
assert.equal(responseLog.usage.totalTokens, 5);
|
|
25
|
-
assert.equal(responseLog.provider, "openai");
|
|
54
|
+
assert.equal(responseLog.provider, "openai-compatible");
|
|
26
55
|
assert.equal(responseLog.gateway, false);
|
|
27
56
|
});
|
|
28
57
|
|
|
58
|
+
test("generateJob updates a dispatched base job without persisting generated content", async () => {
|
|
59
|
+
const DB = jobDatabase();
|
|
60
|
+
const env = { DB, LLM_API_TOKEN: "test", eventHandler: async () => {} };
|
|
61
|
+
const job = await createJob(env, { type: "llm.recipe", ownerId: "user-1" });
|
|
62
|
+
const llm = createLLM({ env, fetch: async () => response("{\"answer\":\"secret output\"}") });
|
|
63
|
+
const execution = await llm.generateJob(job.id, "hello", schema, {
|
|
64
|
+
job: { toJobResult: (value) => ({ answerLength: value.answer.length }) },
|
|
65
|
+
});
|
|
66
|
+
assert.deepEqual(execution.value, { answer: "secret output" });
|
|
67
|
+
assert.deepEqual(execution.job.result, { answerLength: 13 });
|
|
68
|
+
assert.equal((await getJob(env, job.id)).status, "succeeded");
|
|
69
|
+
assert.deepEqual(DB.events.map((event) => event.type), ["job.created", "job.running", "job.progress", "job.progress", "job.progress", "job.succeeded"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
29
72
|
test("Cloudflare AI Gateway routes Responses and model health through the gateway", async () => {
|
|
30
73
|
const calls = [];
|
|
31
74
|
const llm = createLLM({
|
|
32
|
-
|
|
33
|
-
gateway: { url: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/", token: "gateway-key" },
|
|
75
|
+
env: { LLM_API_URL: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/", LLM_API_TOKEN: "gateway-key" },
|
|
34
76
|
fetch: async (url, init) => {
|
|
35
77
|
calls.push({ url, headers: init.headers });
|
|
36
78
|
return init.method === "GET"
|
|
@@ -43,21 +85,13 @@ test("Cloudflare AI Gateway routes Responses and model health through the gatewa
|
|
|
43
85
|
assert.deepEqual(await llm.listModels(), [{ id: "gpt-5.4" }]);
|
|
44
86
|
assert.equal(calls[0].url, "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/responses");
|
|
45
87
|
assert.equal(calls[1].url, "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/models");
|
|
46
|
-
assert.equal(calls[0].headers.Authorization, "Bearer openai-key");
|
|
47
88
|
assert.equal(calls[0].headers["cf-aig-authorization"], "Bearer gateway-key");
|
|
48
89
|
});
|
|
49
90
|
|
|
50
91
|
test("environment configuration supports Gateway routing and provider-neutral names", async () => {
|
|
51
92
|
const calls = [];
|
|
52
93
|
const llm = createLLM({
|
|
53
|
-
env: {
|
|
54
|
-
LLM_API_KEY: "provider-key",
|
|
55
|
-
LLM_MODEL: "gpt-5.4-mini",
|
|
56
|
-
CF_AI_GATEWAY_URL: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/responses",
|
|
57
|
-
CF_AI_GATEWAY_TOKEN: "gateway-key",
|
|
58
|
-
// A legacy URL must not bypass an explicitly enabled Gateway.
|
|
59
|
-
OPENAI_COMPLETIONS_URL: "https://legacy.example/responses",
|
|
60
|
-
},
|
|
94
|
+
env: { LLM_API_URL: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/responses", LLM_API_TOKEN: "gateway-key", LLM_MODEL: "gpt-5.4-mini" },
|
|
61
95
|
fetch: async (url, init) => { calls.push({ url, body: JSON.parse(init.body) }); return response("ok"); },
|
|
62
96
|
});
|
|
63
97
|
|
|
@@ -66,38 +100,48 @@ test("environment configuration supports Gateway routing and provider-neutral na
|
|
|
66
100
|
assert.equal(calls[0].body.model, "gpt-5.4-mini");
|
|
67
101
|
});
|
|
68
102
|
|
|
69
|
-
test("
|
|
103
|
+
test("the universal URL selects direct routing or Cloudflare Gateway routing", async () => {
|
|
70
104
|
const calls = [];
|
|
71
105
|
const llm = createLLM({
|
|
72
|
-
|
|
73
|
-
fetch: async (url
|
|
106
|
+
env: { LLM_API_URL: "https://api.openai.com/v1/responses", LLM_API_TOKEN: "openai-key" },
|
|
107
|
+
fetch: async (url) => { calls.push(url); return response("ok"); },
|
|
74
108
|
});
|
|
75
109
|
|
|
76
110
|
assert.equal(await llm.generate("hello"), "ok");
|
|
77
|
-
assert.equal(calls[0].
|
|
78
|
-
assert.equal(calls[0].headers["cf-aig-authorization"], "Bearer gateway-key");
|
|
111
|
+
assert.equal(calls[0], "https://api.openai.com/v1/responses");
|
|
79
112
|
});
|
|
80
113
|
|
|
81
|
-
test("
|
|
114
|
+
test("universal URL, token, and model variables support OpenAI-compatible endpoints", async () => {
|
|
82
115
|
const calls = [];
|
|
83
116
|
const llm = createLLM({
|
|
84
|
-
env: {
|
|
85
|
-
|
|
86
|
-
fetch: async (url) => { calls.push(url); return response("ok"); },
|
|
117
|
+
env: { LLM_API_URL: "https://openrelay.example/v1/responses", LLM_API_TOKEN: "relay-key", LLM_MODEL: "relay-model" },
|
|
118
|
+
fetch: async (url, init) => { calls.push({ url, headers: init.headers, body: JSON.parse(init.body) }); return response("ok"); },
|
|
87
119
|
});
|
|
88
120
|
|
|
89
121
|
assert.equal(await llm.generate("hello"), "ok");
|
|
90
|
-
assert.equal(calls[0], "https://
|
|
122
|
+
assert.equal(calls[0].url, "https://openrelay.example/v1/responses");
|
|
123
|
+
assert.equal(calls[0].headers.Authorization, "Bearer relay-key");
|
|
124
|
+
assert.equal(calls[0].body.model, "relay-model");
|
|
91
125
|
});
|
|
92
126
|
|
|
93
|
-
test("
|
|
94
|
-
const
|
|
95
|
-
|
|
127
|
+
test("auto model selection uses the compatible endpoint's model list", async () => {
|
|
128
|
+
const calls = [];
|
|
129
|
+
const llm = createLLM({
|
|
130
|
+
env: { LLM_API_URL: "https://openrelay.example/v1/responses", LLM_API_TOKEN: "relay-key", LLM_MODEL: "auto" },
|
|
131
|
+
fetch: async (url, init) => {
|
|
132
|
+
calls.push({ url, method: init.method, body: init.body && JSON.parse(init.body) });
|
|
133
|
+
return init.method === "GET" ? new Response(JSON.stringify({ data: [{ id: "auto-model" }] }), { status: 200 }) : response("ok");
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
assert.equal(await llm.generate("hello"), "ok");
|
|
138
|
+
assert.equal(calls[0].url, "https://openrelay.example/v1/models");
|
|
139
|
+
assert.equal(calls[1].body.model, "auto-model");
|
|
96
140
|
});
|
|
97
141
|
|
|
98
142
|
test("generateMulti starts parallel typed requests and reviewMulti preserves order", async () => {
|
|
99
143
|
let active = 0; let peak = 0;
|
|
100
|
-
const llm = createLLM({
|
|
144
|
+
const llm = createLLM({ env: { LLM_API_TOKEN: "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" })); } });
|
|
101
145
|
const results = await llm.generateMulti(["one", "two"], schema);
|
|
102
146
|
assert.deepEqual(results, [{ answer: "one" }, { answer: "two" }]); assert.equal(peak, 2);
|
|
103
147
|
assert.deepEqual(await llm.reviewMulti("source", ["first", "second"], schema), [{ answer: "one" }, { answer: "one" }]);
|
|
@@ -105,8 +149,8 @@ test("generateMulti starts parallel typed requests and reviewMulti preserves ord
|
|
|
105
149
|
|
|
106
150
|
test("invalid typed output gets one repair, then exposes the raw model response", async () => {
|
|
107
151
|
let count = 0;
|
|
108
|
-
const llm = createLLM({
|
|
152
|
+
const llm = createLLM({ env: { LLM_API_TOKEN: "test" }, fetch: async () => { count++; return response(count === 1 ? "{\"wrong\":true}" : "{\"answer\":\"fixed\"}"); } });
|
|
109
153
|
assert.deepEqual(await llm.generate("repair me", schema), { answer: "fixed" }); assert.equal(count, 2);
|
|
110
|
-
const failing = createLLM({
|
|
154
|
+
const failing = createLLM({ env: { LLM_API_TOKEN: "test" }, fetch: async () => response("{\"wrong\":true}") });
|
|
111
155
|
await assert.rejects(() => failing.generate("fail", schema), (error) => error instanceof LLMResponseError && error.responseFailed && error.llmResponse.output_text === "{\"wrong\":true}");
|
|
112
156
|
});
|