@agilesyndrome/cf-genai-llm 4.1.1 → 4.1.2

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 CHANGED
@@ -3,11 +3,10 @@
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
+ - The client uses the OpenAI Responses-compatible contract; OpenAI is the default endpoint and arbitrary compatible endpoints are supported.
7
+ - `LLM_API_URL`, `LLM_API_TOKEN`, and `LLM_MODEL` are the universal configuration variables.
8
+ - Cloudflare AI Gateway is detected from its URL and uses `cf-aig-authorization`; direct compatible endpoints use `Authorization`.
9
+ - `LLM_MODEL=auto` selects the first model returned by the configured `/models` endpoint.
11
10
  - A JSON Schema may be passed as the second argument or in `{ schema }`; invalid model data gets one repair request.
12
11
  - Failed responses throw `LLMResponseError` with `code=response_failed`, `responseFailed=true`, and the raw `llmResponse`.
13
12
  - Each request emits one-line JSON request/response logs with request ID, metadata, duration, status, and token counts.
package/README.md CHANGED
@@ -13,51 +13,35 @@ const reviews = await llm.reviewMulti(result, reviewerPrompts, reviewSchema);
13
13
 
14
14
  ## Providers and AI Gateway
15
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.
16
+ The client speaks the OpenAI Responses-compatible API. OpenAI is the default
17
+ endpoint, while any compatible service (including OpenRelay-style endpoints)
18
+ can be selected with the same URL, token, and model variables.
18
19
 
19
20
  ```js
20
21
  // Direct OpenAI API access remains the default.
21
- const direct = createLLM({ apiKey: env.OPENAI_API_KEY });
22
+ const direct = createLLM({ env });
22
23
 
23
24
  // 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
- });
25
+ const gateway = createLLM({ env });
31
26
  ```
32
27
 
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.
28
+ The URL may be either a provider base URL or its `/responses` endpoint. Model
29
+ list requests use the matching `/models` route. Cloudflare AI Gateway is
30
+ detected from its hostname and receives the token in `cf-aig-authorization`;
31
+ direct compatible endpoints use `Authorization: Bearer ...`.
39
32
 
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.
33
+ Cloudflare Workers AI can be reached through an OpenAI-compatible Gateway URL;
34
+ the package does not use the Workers AI binding directly.
44
35
 
45
36
  ### Environment configuration
46
37
 
47
38
  | Variable | Purpose |
48
39
  | --- | --- |
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.
40
+ | `LLM_API_URL` | Universal OpenAI-compatible Responses URL or API base URL |
41
+ | `LLM_API_TOKEN` | Universal provider or Gateway token |
42
+ | `LLM_MODEL` | Universal model name; use `auto` to select from `/models` |
43
+
44
+ Set `LLM_MODEL=auto` when the provider supports a `/models` endpoint.
61
45
 
62
46
  A feature exports an object with middleware(request, env, ctx, next, state).
63
47
  Applications layer it into @agilesyndrome/cf-genai-base:
package/package.json CHANGED
@@ -1 +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"}}
1
+ {"name":"@agilesyndrome/cf-genai-llm","version":"4.1.2","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,7 @@
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.1.0";
4
+ export const VERSION = "4.1.1";
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
7
  import { getCircuitBreaker, registerCircuitBreaker, registerHealthcheck, setCircuitBreaker } from "@agilesyndrome/cf-genai-base";
@@ -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 body = { model: config.model, input: prompt, store: false };
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) });
@@ -119,30 +118,26 @@ function endpointFor(baseUrl, resource) {
119
118
  return normalized.replace(/\/(responses|models)$/, "") + "/" + resource;
120
119
  }
121
120
  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;
121
+ if (requestOptions.apiUrl !== undefined && options.allowDynamicApiUrl !== true) throw new Error("Per-request LLM API URLs are disabled; configure apiUrl at client creation time.");
122
+ const apiUrl = (options.allowDynamicApiUrl === true ? resolveValue(requestOptions.apiUrl, env) : null) || resolveValue(options.apiUrl, env) || env?.LLM_API_URL || DEFAULT_ENDPOINT;
123
+ if (new URL(apiUrl).protocol !== "https:") throw new Error("LLM_API_URL must use HTTPS");
124
+ const token = requestOptions.apiToken || resolveValue(options.apiToken, env) || env?.LLM_API_TOKEN;
125
+ if (!token) throw new Error("LLM_API_TOKEN is not configured");
126
+ const endpoint = endpointFor(apiUrl, "responses");
127
+ const modelsEndpoint = endpointFor(apiUrl, "models");
128
+ const gateway = isCloudflareGateway(apiUrl);
141
129
  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 };
130
+ if (token && !gateway) headers.Authorization = `Bearer ${token}`;
131
+ if (gateway) headers["cf-aig-authorization"] = `Bearer ${token}`;
132
+ return { provider: gateway ? "cloudflare-ai-gateway" : "openai-compatible", gateway, endpoint, modelsEndpoint, model: requestOptions.model || resolveValue(options.model, env) || env?.LLM_MODEL || DEFAULT_MODEL, headers };
133
+ }
134
+ function isCloudflareGateway(url) { try { return new URL(url).hostname === "gateway.ai.cloudflare.com"; } catch { return false; } }
135
+ async function resolveModel(config, fetcher, requestOptions) {
136
+ if (config.model !== "auto") return config.model;
137
+ const response = await fetcher(config.modelsEndpoint, { method: "GET", headers: config.headers, signal: requestOptions.signal });
138
+ const payload = await response.json();
139
+ if (!response.ok || !Array.isArray(payload.data) || !payload.data[0]?.id) throw new LLMResponseError("Automatic model selection failed", payload);
140
+ return payload.data[0].id;
146
141
  }
147
142
  function extractText(payload) { return payload?.output_text || payload?.output?.flatMap((item) => item.content || []).find((item) => item.type === "output_text")?.text || ""; }
148
143
  function parseBestEffort(text) { try { return JSON.parse(text); } catch { return text; } }
@@ -161,4 +156,4 @@ function validate(value, schema, path) {
161
156
  }
162
157
  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 }; }
163
158
 
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(); } }; }
159
+ 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(); } }; }
@@ -4,7 +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
+ assert.equal(feature.version, "4.1.1");
8
8
  const response = await feature.middleware(new Request("https://example.test/"), {}, {}, () => Response.json({ ok: true }), {});
9
9
  assert.equal(response.status, 200);
10
10
  assert.deepEqual(await response.json(), { ok: true });
@@ -15,22 +15,21 @@ function response(text, usage = { input_tokens: 3, output_tokens: 2, total_token
15
15
 
16
16
  test("generate sends metadata, validates typed output, and logs token counts", async () => {
17
17
  const calls = []; const logs = [];
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
+ 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
19
  assert.deepEqual(await llm.generate("hello", schema, { schemaName: "answer", metadata: { type: "unit" } }), { answer: "ok" });
20
20
  assert.equal(calls[0].url, "https://api.openai.com/v1/responses");
21
21
  assert.equal(calls[0].headers.Authorization, "Bearer test");
22
22
  assert.equal(calls[0].body.metadata.type, "unit");
23
23
  const responseLog = logs.find((entry) => entry.event === "llm.response");
24
24
  assert.equal(responseLog.usage.totalTokens, 5);
25
- assert.equal(responseLog.provider, "openai");
25
+ assert.equal(responseLog.provider, "openai-compatible");
26
26
  assert.equal(responseLog.gateway, false);
27
27
  });
28
28
 
29
29
  test("Cloudflare AI Gateway routes Responses and model health through the gateway", async () => {
30
30
  const calls = [];
31
31
  const llm = createLLM({
32
- apiKey: "openai-key",
33
- gateway: { url: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/", token: "gateway-key" },
32
+ env: { LLM_API_URL: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/", LLM_API_TOKEN: "gateway-key" },
34
33
  fetch: async (url, init) => {
35
34
  calls.push({ url, headers: init.headers });
36
35
  return init.method === "GET"
@@ -43,21 +42,13 @@ test("Cloudflare AI Gateway routes Responses and model health through the gatewa
43
42
  assert.deepEqual(await llm.listModels(), [{ id: "gpt-5.4" }]);
44
43
  assert.equal(calls[0].url, "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/responses");
45
44
  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
45
  assert.equal(calls[0].headers["cf-aig-authorization"], "Bearer gateway-key");
48
46
  });
49
47
 
50
48
  test("environment configuration supports Gateway routing and provider-neutral names", async () => {
51
49
  const calls = [];
52
50
  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
- },
51
+ 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
52
  fetch: async (url, init) => { calls.push({ url, body: JSON.parse(init.body) }); return response("ok"); },
62
53
  });
63
54
 
@@ -66,38 +57,48 @@ test("environment configuration supports Gateway routing and provider-neutral na
66
57
  assert.equal(calls[0].body.model, "gpt-5.4-mini");
67
58
  });
68
59
 
69
- test("Gateway supports Cloudflare-stored provider keys", async () => {
60
+ test("the universal URL selects direct routing or Cloudflare Gateway routing", async () => {
70
61
  const calls = [];
71
62
  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"); },
63
+ env: { LLM_API_URL: "https://api.openai.com/v1/responses", LLM_API_TOKEN: "openai-key" },
64
+ fetch: async (url) => { calls.push(url); return response("ok"); },
74
65
  });
75
66
 
76
67
  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");
68
+ assert.equal(calls[0], "https://api.openai.com/v1/responses");
79
69
  });
80
70
 
81
- test("gateway false preserves direct OpenAI routing", async () => {
71
+ test("universal URL, token, and model variables support OpenAI-compatible endpoints", async () => {
82
72
  const calls = [];
83
73
  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"); },
74
+ env: { LLM_API_URL: "https://openrelay.example/v1/responses", LLM_API_TOKEN: "relay-key", LLM_MODEL: "relay-model" },
75
+ fetch: async (url, init) => { calls.push({ url, headers: init.headers, body: JSON.parse(init.body) }); return response("ok"); },
87
76
  });
88
77
 
89
78
  assert.equal(await llm.generate("hello"), "ok");
90
- assert.equal(calls[0], "https://api.openai.com/v1/responses");
79
+ assert.equal(calls[0].url, "https://openrelay.example/v1/responses");
80
+ assert.equal(calls[0].headers.Authorization, "Bearer relay-key");
81
+ assert.equal(calls[0].body.model, "relay-model");
91
82
  });
92
83
 
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/);
84
+ test("auto model selection uses the compatible endpoint's model list", async () => {
85
+ const calls = [];
86
+ const llm = createLLM({
87
+ env: { LLM_API_URL: "https://openrelay.example/v1/responses", LLM_API_TOKEN: "relay-key", LLM_MODEL: "auto" },
88
+ fetch: async (url, init) => {
89
+ calls.push({ url, method: init.method, body: init.body && JSON.parse(init.body) });
90
+ return init.method === "GET" ? new Response(JSON.stringify({ data: [{ id: "auto-model" }] }), { status: 200 }) : response("ok");
91
+ },
92
+ });
93
+
94
+ assert.equal(await llm.generate("hello"), "ok");
95
+ assert.equal(calls[0].url, "https://openrelay.example/v1/models");
96
+ assert.equal(calls[1].body.model, "auto-model");
96
97
  });
97
98
 
98
99
  test("generateMulti starts parallel typed requests and reviewMulti preserves order", async () => {
99
100
  let active = 0; let peak = 0;
100
- 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" })); } });
101
+ 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
102
  const results = await llm.generateMulti(["one", "two"], schema);
102
103
  assert.deepEqual(results, [{ answer: "one" }, { answer: "two" }]); assert.equal(peak, 2);
103
104
  assert.deepEqual(await llm.reviewMulti("source", ["first", "second"], schema), [{ answer: "one" }, { answer: "one" }]);
@@ -105,8 +106,8 @@ test("generateMulti starts parallel typed requests and reviewMulti preserves ord
105
106
 
106
107
  test("invalid typed output gets one repair, then exposes the raw model response", async () => {
107
108
  let count = 0;
108
- const llm = createLLM({ apiKey: "test", fetch: async () => { count++; return response(count === 1 ? "{\"wrong\":true}" : "{\"answer\":\"fixed\"}"); } });
109
+ const llm = createLLM({ env: { LLM_API_TOKEN: "test" }, fetch: async () => { count++; return response(count === 1 ? "{\"wrong\":true}" : "{\"answer\":\"fixed\"}"); } });
109
110
  assert.deepEqual(await llm.generate("repair me", schema), { answer: "fixed" }); assert.equal(count, 2);
110
- const failing = createLLM({ apiKey: "test", fetch: async () => response("{\"wrong\":true}") });
111
+ const failing = createLLM({ env: { LLM_API_TOKEN: "test" }, fetch: async () => response("{\"wrong\":true}") });
111
112
  await assert.rejects(() => failing.generate("fail", schema), (error) => error instanceof LLMResponseError && error.responseFailed && error.llmResponse.output_text === "{\"wrong\":true}");
112
113
  });