@agilesyndrome/cf-genai-llm 4.1.0 → 4.1.1
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 +5 -0
- package/README.md +48 -0
- package/package.json +1 -1
- package/src/index.js +43 -15
- package/tests/feature.test.mjs +78 -3
package/CONTRACT.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
- createFeature(options) returns name and middleware.
|
|
4
4
|
- createLLM(options) returns `generate`, `generateMulti`, `review`, and `reviewMulti`.
|
|
5
5
|
- `generateMulti` and `reviewMulti` start all requests concurrently and preserve input order.
|
|
6
|
+
- OpenAI is the default provider; unsupported providers fail at the provider adapter boundary.
|
|
7
|
+
- Cloudflare AI Gateway routing is optional and independent of provider selection.
|
|
8
|
+
- Direct OpenAI access remains compatible with the existing `OPENAI_*` environment variables.
|
|
9
|
+
- Provider-neutral `LLM_*` variables take precedence over legacy `OPENAI_*` variables.
|
|
10
|
+
- An authenticated Gateway uses `cf-aig-authorization`; provider authentication remains in `Authorization`.
|
|
6
11
|
- A JSON Schema may be passed as the second argument or in `{ schema }`; invalid model data gets one repair request.
|
|
7
12
|
- Failed responses throw `LLMResponseError` with `code=response_failed`, `responseFailed=true`, and the raw `llmResponse`.
|
|
8
13
|
- Each request emits one-line JSON request/response logs with request ID, metadata, duration, status, and token counts.
|
package/README.md
CHANGED
|
@@ -11,6 +11,54 @@ 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
|
+
## Providers and AI Gateway
|
|
15
|
+
|
|
16
|
+
OpenAI is the default inference provider. Requests may go directly to OpenAI or
|
|
17
|
+
through Cloudflare AI Gateway without changing the generation API.
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// Direct OpenAI API access remains the default.
|
|
21
|
+
const direct = createLLM({ apiKey: env.OPENAI_API_KEY });
|
|
22
|
+
|
|
23
|
+
// 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
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The gateway URL may be either the provider base URL or its `/responses`
|
|
34
|
+
endpoint. Model-list health checks use the matching `/models` route. An
|
|
35
|
+
authenticated Gateway token is sent in `cf-aig-authorization`; the provider
|
|
36
|
+
key remains in `Authorization`. When Gateway BYOK or Unified Billing stores the
|
|
37
|
+
provider credential, the Gateway token is sufficient and `apiKey` may be
|
|
38
|
+
omitted.
|
|
39
|
+
|
|
40
|
+
Cloudflare Workers AI is a separate inference provider and is not implemented
|
|
41
|
+
by this release. Provider selection and Gateway routing are deliberately
|
|
42
|
+
independent so a future Workers AI adapter can run either directly or through
|
|
43
|
+
AI Gateway.
|
|
44
|
+
|
|
45
|
+
### Environment configuration
|
|
46
|
+
|
|
47
|
+
| Variable | Purpose |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `LLM_PROVIDER` | Inference provider; currently `openai` (default) |
|
|
50
|
+
| `LLM_API_KEY` | Provider API key; falls back to `OPENAI_API_KEY` |
|
|
51
|
+
| `LLM_MODEL` | Model name; falls back to `OPENAI_MODEL`, then `gpt-5.4` |
|
|
52
|
+
| `CF_AI_GATEWAY_URL` | Enables Gateway routing using the OpenAI provider URL |
|
|
53
|
+
| `CF_AI_GATEWAY_TOKEN` | Optional token for an authenticated Gateway |
|
|
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.
|
|
61
|
+
|
|
14
62
|
A feature exports an object with middleware(request, env, ctx, next, state).
|
|
15
63
|
Applications layer it into @agilesyndrome/cf-genai-base:
|
|
16
64
|
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"@agilesyndrome/cf-genai-llm","version":"4.1.
|
|
1
|
+
{"name":"@agilesyndrome/cf-genai-llm","version":"4.1.1","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","dependencies":{"@agilesyndrome/cf-genai-base":"^4.1.1"}}
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
const DEFAULT_ENDPOINT = "https://api.openai.com/v1/responses";
|
|
2
2
|
const DEFAULT_MODEL = "gpt-5.4";
|
|
3
|
-
|
|
4
3
|
const DEFAULT_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
|
|
4
|
+
const DEFAULT_PROVIDER = "openai";
|
|
5
5
|
export const PACKAGE_NAME = "@agilesyndrome/cf-genai-llm";
|
|
6
|
-
export const VERSION = "
|
|
6
|
+
export const VERSION = "4.1.0";
|
|
7
7
|
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
8
|
|
|
9
9
|
import { getCircuitBreaker, registerCircuitBreaker, registerHealthcheck, setCircuitBreaker } from "@agilesyndrome/cf-genai-base";
|
|
@@ -20,13 +20,11 @@ export class LLMResponseError extends Error {
|
|
|
20
20
|
export function createLLM(options = {}) {
|
|
21
21
|
const fetcher = options.fetch || globalThis.fetch;
|
|
22
22
|
const logger = options.logger || console;
|
|
23
|
-
const endpoint = options.endpoint || ((env) => env?.OPENAI_COMPLETIONS_URL || DEFAULT_ENDPOINT);
|
|
24
|
-
const model = options.model || ((env) => env?.OPENAI_MODEL || DEFAULT_MODEL);
|
|
25
23
|
const defaults = options.metadata || {};
|
|
26
24
|
const debugLogging = options.debugLogging === true;
|
|
27
25
|
const featureName = options.feature || options.featureName || "cf-genai-llm";
|
|
28
|
-
const breakerId = options.breakerId || featureName + ":
|
|
29
|
-
const healthcheckId = options.healthcheckId || featureName + ":
|
|
26
|
+
const breakerId = options.breakerId || featureName + ":llm-models";
|
|
27
|
+
const healthcheckId = options.healthcheckId || featureName + ":llm-models";
|
|
30
28
|
if (typeof fetcher !== "function") throw new TypeError("createLLM requires fetch");
|
|
31
29
|
|
|
32
30
|
const log = (level, event) => {
|
|
@@ -36,30 +34,29 @@ export function createLLM(options = {}) {
|
|
|
36
34
|
|
|
37
35
|
async function assertAvailable(requestOptions = {}) { const env = requestOptions.env || options.env; if (!env || !env.DB || requestOptions.allowWhenCircuitTripped) return; const breaker = await getCircuitBreaker(env, breakerId, { who: requestOptions.who || "system:read" }).catch(() => null); if (breaker && breaker.state !== "on") throw new LLMCircuitBreakerError(); }
|
|
38
36
|
|
|
39
|
-
async function listModels(requestOptions = {}) { const env = requestOptions.env || options.env; const
|
|
37
|
+
async function listModels(requestOptions = {}) { const env = requestOptions.env || options.env; const config = resolveConfig(options, requestOptions, env); try { const response = await fetcher(config.modelsEndpoint, { method: "GET", headers: config.headers, signal: requestOptions.signal }); const payload = await response.json(); if (!response.ok) throw new LLMResponseError("LLM model list failed (" + response.status + ")", payload); if (env && env.DB) { await registerHealthcheck(env, { id: healthcheckId, feature: featureName, component: "llm-models", displayName: "LLM model availability", state: "green", metadata: { provider: config.provider, gateway: config.gateway, count: Array.isArray(payload.data) ? payload.data.length : 0 } }, { who: requestOptions.who || "system:update" }); await registerCircuitBreaker(env, { id: breakerId, feature: featureName, name: "llm-models", displayName: "LLM model access", state: "on", allowSelfHealing: true, healthchecks: [healthcheckId] }, { who: requestOptions.who || "system:update" }); await setCircuitBreaker(env, breakerId, "on", { who: requestOptions.who || "system:update", automated: true }).catch(() => {}); } return payload.data || []; } catch (error) { if (env && env.DB) { await registerHealthcheck(env, { id: healthcheckId, feature: featureName, component: "llm-models", displayName: "LLM model availability", state: "red", metadata: { provider: config.provider, gateway: config.gateway, error: error.message } }, { who: requestOptions.who || "system:update" }).catch(() => {}); await registerCircuitBreaker(env, { id: breakerId, feature: featureName, name: "llm-models", displayName: "LLM model access", state: "on", allowSelfHealing: true, healthchecks: [healthcheckId] }, { who: requestOptions.who || "system:update" }).then(() => setCircuitBreaker(env, breakerId, "tripped", { who: requestOptions.who || "system:update", automated: true })).catch(() => {}); } throw error; } }
|
|
40
38
|
|
|
41
39
|
async function request(prompt, schema, requestOptions = {}) { await assertAvailable(requestOptions);
|
|
42
40
|
const started = Date.now();
|
|
43
41
|
const requestId = requestOptions.requestId || crypto.randomUUID();
|
|
44
42
|
const env = requestOptions.env || options.env;
|
|
45
|
-
const
|
|
46
|
-
if (!apiKey) throw new Error("OPENAI_API_KEY is not configured");
|
|
43
|
+
const config = resolveConfig(options, requestOptions, env);
|
|
47
44
|
const metadata = { ...defaults, ...(requestOptions.metadata || {}) };
|
|
48
|
-
const body = { model:
|
|
45
|
+
const body = { model: config.model, input: prompt, store: false };
|
|
49
46
|
if (Object.keys(metadata).length) body.metadata = metadata;
|
|
50
47
|
if (schema) body.text = { format: { type: "json_schema", name: requestOptions.schemaName || "response", strict: true, schema } };
|
|
51
|
-
log("debug", { event: "llm.request", requestId, metadata, model: body.model, hasSchema: Boolean(schema) });
|
|
48
|
+
log("debug", { event: "llm.request", requestId, metadata, provider: config.provider, gateway: config.gateway, model: body.model, hasSchema: Boolean(schema) });
|
|
52
49
|
let response, payload;
|
|
53
50
|
try {
|
|
54
|
-
response = await fetcher(
|
|
51
|
+
response = await fetcher(config.endpoint, { method: "POST", headers: config.headers, body: JSON.stringify(body), signal: requestOptions.signal });
|
|
55
52
|
payload = await response.json();
|
|
56
53
|
} catch (error) {
|
|
57
|
-
log("error", { event: "llm.error", requestId, durationMs: Date.now() - started, error: error.message });
|
|
54
|
+
log("error", { event: "llm.error", requestId, durationMs: Date.now() - started, error: error.message, provider: config.provider, gateway: config.gateway, model: body.model });
|
|
58
55
|
throw error;
|
|
59
56
|
}
|
|
60
57
|
const usage = normalizeUsage(payload?.usage);
|
|
61
58
|
if (requestOptions.onUsage) await requestOptions.onUsage(usage);
|
|
62
|
-
log(response.ok ? "info" : "error", { event: "llm.response", requestId, status: response.status, durationMs: Date.now() - started, usage, metadata });
|
|
59
|
+
log(response.ok ? "info" : "error", { event: "llm.response", requestId, status: response.status, durationMs: Date.now() - started, usage, metadata, provider: config.provider, gateway: config.gateway, model: body.model });
|
|
63
60
|
if (!response.ok) throw new LLMResponseError(`LLM request failed (${response.status})`, payload);
|
|
64
61
|
const text = extractText(payload);
|
|
65
62
|
if (!text) throw new LLMResponseError("LLM returned no text", payload);
|
|
@@ -116,6 +113,37 @@ function normalizeGenerateArgs(promptOrRequest, schemaOrOptions, maybeOptions) {
|
|
|
116
113
|
}
|
|
117
114
|
function normalizeReviewArgs(originalText, reviewPrompt, schemaOrOptions, maybeOptions) { const { schema, options } = normalizeSchemaOptions(schemaOrOptions, maybeOptions); return { originalText: String(originalText || ""), prompt: String(reviewPrompt || ""), schema, options }; }
|
|
118
115
|
function normalizeSchemaOptions(value, options) { if (value && value.type) return { schema: value, options: options || {} }; return { schema: value?.schema, options: { ...(value || {}), ...(options || {}) } }; }
|
|
116
|
+
function resolveValue(value, env) { return typeof value === "function" ? value(env) : value; }
|
|
117
|
+
function endpointFor(baseUrl, resource) {
|
|
118
|
+
const normalized = String(baseUrl).replace(/\/+$/, "");
|
|
119
|
+
return normalized.replace(/\/(responses|models)$/, "") + "/" + resource;
|
|
120
|
+
}
|
|
121
|
+
function resolveConfig(options, requestOptions, env) {
|
|
122
|
+
const provider = requestOptions.provider || resolveValue(options.provider, env) || env?.LLM_PROVIDER || DEFAULT_PROVIDER;
|
|
123
|
+
if (provider !== "openai") throw new TypeError(`Unsupported LLM provider: ${provider}`);
|
|
124
|
+
|
|
125
|
+
const apiKey = requestOptions.apiKey || resolveValue(options.apiKey, env) || env?.LLM_API_KEY || env?.OPENAI_API_KEY;
|
|
126
|
+
const gatewaySetting = requestOptions.gateway !== undefined
|
|
127
|
+
? resolveValue(requestOptions.gateway, env)
|
|
128
|
+
: options.gateway !== undefined
|
|
129
|
+
? resolveValue(options.gateway, env)
|
|
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;
|
|
141
|
+
const headers = { "Content-Type": "application/json", ...resolveValue(options.headers, env), ...requestOptions.headers };
|
|
142
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
143
|
+
if (gatewayToken) headers["cf-aig-authorization"] = `Bearer ${gatewayToken}`;
|
|
144
|
+
|
|
145
|
+
return { provider, gateway: Boolean(gatewayUrl), endpoint, modelsEndpoint, model, headers };
|
|
146
|
+
}
|
|
119
147
|
function extractText(payload) { return payload?.output_text || payload?.output?.flatMap((item) => item.content || []).find((item) => item.type === "output_text")?.text || ""; }
|
|
120
148
|
function parseBestEffort(text) { try { return JSON.parse(text); } catch { return text; } }
|
|
121
149
|
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; }
|
|
@@ -133,4 +161,4 @@ function validate(value, schema, path) {
|
|
|
133
161
|
}
|
|
134
162
|
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 }; }
|
|
135
163
|
|
|
136
|
-
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) => {
|
|
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(); } }; }
|
package/tests/feature.test.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { createFeature, createLLM, LLMResponseError } from "../src/index.js";
|
|
|
4
4
|
|
|
5
5
|
test("feature delegates to the next handler", async () => {
|
|
6
6
|
const feature = createFeature({ name: "example" });
|
|
7
|
+
assert.equal(feature.version, "4.1.0");
|
|
7
8
|
const response = await feature.middleware(new Request("https://example.test/"), {}, {}, () => Response.json({ ok: true }), {});
|
|
8
9
|
assert.equal(response.status, 200);
|
|
9
10
|
assert.deepEqual(await response.json(), { ok: true });
|
|
@@ -14,10 +15,84 @@ function response(text, usage = { input_tokens: 3, output_tokens: 2, total_token
|
|
|
14
15
|
|
|
15
16
|
test("generate sends metadata, validates typed output, and logs token counts", async () => {
|
|
16
17
|
const calls = []; const logs = [];
|
|
17
|
-
const llm = createLLM({ apiKey: "test", fetch: async (
|
|
18
|
+
const llm = createLLM({ apiKey: "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" } });
|
|
18
19
|
assert.deepEqual(await llm.generate("hello", schema, { schemaName: "answer", metadata: { type: "unit" } }), { answer: "ok" });
|
|
19
|
-
assert.equal(calls[0].
|
|
20
|
-
assert.equal(
|
|
20
|
+
assert.equal(calls[0].url, "https://api.openai.com/v1/responses");
|
|
21
|
+
assert.equal(calls[0].headers.Authorization, "Bearer test");
|
|
22
|
+
assert.equal(calls[0].body.metadata.type, "unit");
|
|
23
|
+
const responseLog = logs.find((entry) => entry.event === "llm.response");
|
|
24
|
+
assert.equal(responseLog.usage.totalTokens, 5);
|
|
25
|
+
assert.equal(responseLog.provider, "openai");
|
|
26
|
+
assert.equal(responseLog.gateway, false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("Cloudflare AI Gateway routes Responses and model health through the gateway", async () => {
|
|
30
|
+
const calls = [];
|
|
31
|
+
const llm = createLLM({
|
|
32
|
+
apiKey: "openai-key",
|
|
33
|
+
gateway: { url: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/", token: "gateway-key" },
|
|
34
|
+
fetch: async (url, init) => {
|
|
35
|
+
calls.push({ url, headers: init.headers });
|
|
36
|
+
return init.method === "GET"
|
|
37
|
+
? new Response(JSON.stringify({ data: [{ id: "gpt-5.4" }] }), { status: 200 })
|
|
38
|
+
: response("gateway response");
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.equal(await llm.generate("hello"), "gateway response");
|
|
43
|
+
assert.deepEqual(await llm.listModels(), [{ id: "gpt-5.4" }]);
|
|
44
|
+
assert.equal(calls[0].url, "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/responses");
|
|
45
|
+
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
|
+
assert.equal(calls[0].headers["cf-aig-authorization"], "Bearer gateway-key");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("environment configuration supports Gateway routing and provider-neutral names", async () => {
|
|
51
|
+
const calls = [];
|
|
52
|
+
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
|
+
},
|
|
61
|
+
fetch: async (url, init) => { calls.push({ url, body: JSON.parse(init.body) }); return response("ok"); },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(await llm.generate("hello"), "ok");
|
|
65
|
+
assert.equal(calls[0].url, "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/responses");
|
|
66
|
+
assert.equal(calls[0].body.model, "gpt-5.4-mini");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("Gateway supports Cloudflare-stored provider keys", async () => {
|
|
70
|
+
const calls = [];
|
|
71
|
+
const llm = createLLM({
|
|
72
|
+
gateway: { url: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai", token: "gateway-key" },
|
|
73
|
+
fetch: async (url, init) => { calls.push({ url, headers: init.headers }); return response("ok"); },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(await llm.generate("hello"), "ok");
|
|
77
|
+
assert.equal(calls[0].headers.Authorization, undefined);
|
|
78
|
+
assert.equal(calls[0].headers["cf-aig-authorization"], "Bearer gateway-key");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("gateway false preserves direct OpenAI routing", async () => {
|
|
82
|
+
const calls = [];
|
|
83
|
+
const llm = createLLM({
|
|
84
|
+
env: { OPENAI_API_KEY: "openai-key", CF_AI_GATEWAY_URL: "https://gateway.example/openai" },
|
|
85
|
+
gateway: false,
|
|
86
|
+
fetch: async (url) => { calls.push(url); return response("ok"); },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
assert.equal(await llm.generate("hello"), "ok");
|
|
90
|
+
assert.equal(calls[0], "https://api.openai.com/v1/responses");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("unsupported providers fail at the adapter boundary", async () => {
|
|
94
|
+
const llm = createLLM({ provider: "workers-ai", apiKey: "test", fetch: async () => response("unused") });
|
|
95
|
+
await assert.rejects(() => llm.generate("hello"), /Unsupported LLM provider: workers-ai/);
|
|
21
96
|
});
|
|
22
97
|
|
|
23
98
|
test("generateMulti starts parallel typed requests and reviewMulti preserves order", async () => {
|