@agilesyndrome/cf-genai-llm 4.1.0 → 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 +4 -0
- package/README.md +32 -0
- package/package.json +1 -1
- package/src/index.js +39 -16
- package/tests/feature.test.mjs +82 -6
package/CONTRACT.md
CHANGED
|
@@ -3,6 +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
|
+
- 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.
|
|
6
10
|
- A JSON Schema may be passed as the second argument or in `{ schema }`; invalid model data gets one repair request.
|
|
7
11
|
- Failed responses throw `LLMResponseError` with `code=response_failed`, `responseFailed=true`, and the raw `llmResponse`.
|
|
8
12
|
- 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,38 @@ 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
|
+
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.
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// Direct OpenAI API access remains the default.
|
|
22
|
+
const direct = createLLM({ env });
|
|
23
|
+
|
|
24
|
+
// Gateway is a routing/control layer; OpenAI still performs inference.
|
|
25
|
+
const gateway = createLLM({ env });
|
|
26
|
+
```
|
|
27
|
+
|
|
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 ...`.
|
|
32
|
+
|
|
33
|
+
Cloudflare Workers AI can be reached through an OpenAI-compatible Gateway URL;
|
|
34
|
+
the package does not use the Workers AI binding directly.
|
|
35
|
+
|
|
36
|
+
### Environment configuration
|
|
37
|
+
|
|
38
|
+
| Variable | Purpose |
|
|
39
|
+
| --- | --- |
|
|
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.
|
|
45
|
+
|
|
14
46
|
A feature exports an object with middleware(request, env, ctx, next, state).
|
|
15
47
|
Applications layer it into @agilesyndrome/cf-genai-base:
|
|
16
48
|
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"@agilesyndrome/cf-genai-llm","version":"4.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
|
-
|
|
4
|
-
const DEFAULT_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
|
|
5
3
|
export const PACKAGE_NAME = "@agilesyndrome/cf-genai-llm";
|
|
6
|
-
export const VERSION = "
|
|
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";
|
|
@@ -20,13 +18,11 @@ export class LLMResponseError extends Error {
|
|
|
20
18
|
export function createLLM(options = {}) {
|
|
21
19
|
const fetcher = options.fetch || globalThis.fetch;
|
|
22
20
|
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
21
|
const defaults = options.metadata || {};
|
|
26
22
|
const debugLogging = options.debugLogging === true;
|
|
27
23
|
const featureName = options.feature || options.featureName || "cf-genai-llm";
|
|
28
|
-
const breakerId = options.breakerId || featureName + ":
|
|
29
|
-
const healthcheckId = options.healthcheckId || featureName + ":
|
|
24
|
+
const breakerId = options.breakerId || featureName + ":llm-models";
|
|
25
|
+
const healthcheckId = options.healthcheckId || featureName + ":llm-models";
|
|
30
26
|
if (typeof fetcher !== "function") throw new TypeError("createLLM requires fetch");
|
|
31
27
|
|
|
32
28
|
const log = (level, event) => {
|
|
@@ -36,30 +32,30 @@ export function createLLM(options = {}) {
|
|
|
36
32
|
|
|
37
33
|
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
34
|
|
|
39
|
-
async function listModels(requestOptions = {}) { const env = requestOptions.env || options.env; const
|
|
35
|
+
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
36
|
|
|
41
37
|
async function request(prompt, schema, requestOptions = {}) { await assertAvailable(requestOptions);
|
|
42
38
|
const started = Date.now();
|
|
43
39
|
const requestId = requestOptions.requestId || crypto.randomUUID();
|
|
44
40
|
const env = requestOptions.env || options.env;
|
|
45
|
-
const
|
|
46
|
-
if (!apiKey) throw new Error("OPENAI_API_KEY is not configured");
|
|
41
|
+
const config = resolveConfig(options, requestOptions, env);
|
|
47
42
|
const metadata = { ...defaults, ...(requestOptions.metadata || {}) };
|
|
48
|
-
const
|
|
43
|
+
const model = await resolveModel(config, fetcher, requestOptions);
|
|
44
|
+
const body = { model, input: prompt, store: false };
|
|
49
45
|
if (Object.keys(metadata).length) body.metadata = metadata;
|
|
50
46
|
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) });
|
|
47
|
+
log("debug", { event: "llm.request", requestId, metadata, provider: config.provider, gateway: config.gateway, model: body.model, hasSchema: Boolean(schema) });
|
|
52
48
|
let response, payload;
|
|
53
49
|
try {
|
|
54
|
-
response = await fetcher(
|
|
50
|
+
response = await fetcher(config.endpoint, { method: "POST", headers: config.headers, body: JSON.stringify(body), signal: requestOptions.signal });
|
|
55
51
|
payload = await response.json();
|
|
56
52
|
} catch (error) {
|
|
57
|
-
log("error", { event: "llm.error", requestId, durationMs: Date.now() - started, error: error.message });
|
|
53
|
+
log("error", { event: "llm.error", requestId, durationMs: Date.now() - started, error: error.message, provider: config.provider, gateway: config.gateway, model: body.model });
|
|
58
54
|
throw error;
|
|
59
55
|
}
|
|
60
56
|
const usage = normalizeUsage(payload?.usage);
|
|
61
57
|
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 });
|
|
58
|
+
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
59
|
if (!response.ok) throw new LLMResponseError(`LLM request failed (${response.status})`, payload);
|
|
64
60
|
const text = extractText(payload);
|
|
65
61
|
if (!text) throw new LLMResponseError("LLM returned no text", payload);
|
|
@@ -116,6 +112,33 @@ function normalizeGenerateArgs(promptOrRequest, schemaOrOptions, maybeOptions) {
|
|
|
116
112
|
}
|
|
117
113
|
function normalizeReviewArgs(originalText, reviewPrompt, schemaOrOptions, maybeOptions) { const { schema, options } = normalizeSchemaOptions(schemaOrOptions, maybeOptions); return { originalText: String(originalText || ""), prompt: String(reviewPrompt || ""), schema, options }; }
|
|
118
114
|
function normalizeSchemaOptions(value, options) { if (value && value.type) return { schema: value, options: options || {} }; return { schema: value?.schema, options: { ...(value || {}), ...(options || {}) } }; }
|
|
115
|
+
function resolveValue(value, env) { return typeof value === "function" ? value(env) : value; }
|
|
116
|
+
function endpointFor(baseUrl, resource) {
|
|
117
|
+
const normalized = String(baseUrl).replace(/\/+$/, "");
|
|
118
|
+
return normalized.replace(/\/(responses|models)$/, "") + "/" + resource;
|
|
119
|
+
}
|
|
120
|
+
function resolveConfig(options, requestOptions, env) {
|
|
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);
|
|
129
|
+
const headers = { "Content-Type": "application/json", ...resolveValue(options.headers, env), ...requestOptions.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;
|
|
141
|
+
}
|
|
119
142
|
function extractText(payload) { return payload?.output_text || payload?.output?.flatMap((item) => item.content || []).find((item) => item.type === "output_text")?.text || ""; }
|
|
120
143
|
function parseBestEffort(text) { try { return JSON.parse(text); } catch { return text; } }
|
|
121
144
|
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 +156,4 @@ function validate(value, schema, path) {
|
|
|
133
156
|
}
|
|
134
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 }; }
|
|
135
158
|
|
|
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) => {
|
|
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(); } }; }
|
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.1");
|
|
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,15 +15,90 @@ 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({
|
|
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" } });
|
|
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-compatible");
|
|
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
|
+
env: { LLM_API_URL: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/", LLM_API_TOKEN: "gateway-key" },
|
|
33
|
+
fetch: async (url, init) => {
|
|
34
|
+
calls.push({ url, headers: init.headers });
|
|
35
|
+
return init.method === "GET"
|
|
36
|
+
? new Response(JSON.stringify({ data: [{ id: "gpt-5.4" }] }), { status: 200 })
|
|
37
|
+
: response("gateway response");
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
assert.equal(await llm.generate("hello"), "gateway response");
|
|
42
|
+
assert.deepEqual(await llm.listModels(), [{ id: "gpt-5.4" }]);
|
|
43
|
+
assert.equal(calls[0].url, "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/responses");
|
|
44
|
+
assert.equal(calls[1].url, "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/models");
|
|
45
|
+
assert.equal(calls[0].headers["cf-aig-authorization"], "Bearer gateway-key");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("environment configuration supports Gateway routing and provider-neutral names", async () => {
|
|
49
|
+
const calls = [];
|
|
50
|
+
const llm = createLLM({
|
|
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" },
|
|
52
|
+
fetch: async (url, init) => { calls.push({ url, body: JSON.parse(init.body) }); return response("ok"); },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
assert.equal(await llm.generate("hello"), "ok");
|
|
56
|
+
assert.equal(calls[0].url, "https://gateway.ai.cloudflare.com/v1/account/gateway/openai/responses");
|
|
57
|
+
assert.equal(calls[0].body.model, "gpt-5.4-mini");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("the universal URL selects direct routing or Cloudflare Gateway routing", async () => {
|
|
61
|
+
const calls = [];
|
|
62
|
+
const llm = createLLM({
|
|
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"); },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
assert.equal(await llm.generate("hello"), "ok");
|
|
68
|
+
assert.equal(calls[0], "https://api.openai.com/v1/responses");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("universal URL, token, and model variables support OpenAI-compatible endpoints", async () => {
|
|
72
|
+
const calls = [];
|
|
73
|
+
const llm = createLLM({
|
|
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"); },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.equal(await llm.generate("hello"), "ok");
|
|
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");
|
|
82
|
+
});
|
|
83
|
+
|
|
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");
|
|
21
97
|
});
|
|
22
98
|
|
|
23
99
|
test("generateMulti starts parallel typed requests and reviewMulti preserves order", async () => {
|
|
24
100
|
let active = 0; let peak = 0;
|
|
25
|
-
const llm = createLLM({
|
|
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" })); } });
|
|
26
102
|
const results = await llm.generateMulti(["one", "two"], schema);
|
|
27
103
|
assert.deepEqual(results, [{ answer: "one" }, { answer: "two" }]); assert.equal(peak, 2);
|
|
28
104
|
assert.deepEqual(await llm.reviewMulti("source", ["first", "second"], schema), [{ answer: "one" }, { answer: "one" }]);
|
|
@@ -30,8 +106,8 @@ test("generateMulti starts parallel typed requests and reviewMulti preserves ord
|
|
|
30
106
|
|
|
31
107
|
test("invalid typed output gets one repair, then exposes the raw model response", async () => {
|
|
32
108
|
let count = 0;
|
|
33
|
-
const llm = createLLM({
|
|
109
|
+
const llm = createLLM({ env: { LLM_API_TOKEN: "test" }, fetch: async () => { count++; return response(count === 1 ? "{\"wrong\":true}" : "{\"answer\":\"fixed\"}"); } });
|
|
34
110
|
assert.deepEqual(await llm.generate("repair me", schema), { answer: "fixed" }); assert.equal(count, 2);
|
|
35
|
-
const failing = createLLM({
|
|
111
|
+
const failing = createLLM({ env: { LLM_API_TOKEN: "test" }, fetch: async () => response("{\"wrong\":true}") });
|
|
36
112
|
await assert.rejects(() => failing.generate("fail", schema), (error) => error instanceof LLMResponseError && error.responseFailed && error.llmResponse.output_text === "{\"wrong\":true}");
|
|
37
113
|
});
|