Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,17 @@ Auto-selection tries them in this order:
- **OpenRouter** (`OPENROUTER_API_KEY`, begins `sk-or-`): maps `jev-latest` to `typesafe/jev-1.13`.
- **Cloudflare** (`JEV_CLOUDFLARE_API_TOKEN` preferred over `CLOUDFLARE_API_TOKEN`, plus `CLOUDFLARE_ACCOUNT_ID`): maps `jev-latest` to `typesafe/jev`.
- **Vercel AI Gateway** (`AI_GATEWAY_API_KEY`): selects `typesafe-ai/jev` unless given a `typesafe-ai/` model.
- **SiliconFlow** (`SILICONFLOW_API_KEY`): maps `jev-latest` to `semif`, the always-current alias on its System One endpoint.

Set `JEV_PROVIDER` to `typesafe`, `openrouter`, `cloudflare`, `vercel`, or `auto` to select strictly. Unknown names and missing credentials fail rather than falling through. With no provider configured, the diagnostic names every supported credential variable. `config.env` accepts an injectable environment record, and `config.transport` accepts a run-bound transport for callers that own one.
Set `JEV_PROVIDER` to `typesafe`, `openrouter`, `cloudflare`, `vercel`, `siliconflow`, or `auto` to select strictly. Unknown names and missing credentials fail rather than falling through. With no provider configured, the diagnostic names every supported credential variable. `config.env` accepts an injectable environment record, and `config.transport` accepts a run-bound transport for callers that own one.

## Validation

Every reply is checked before you see it. Validation requires exactly the requested answer IDs and criterion IDs, finite probabilities in [0,1] summing to within 0.01 of 1, and a selected Choice maximum within a 0.001 tie tolerance. Noul values must be in [0,1], confidence finite or null, usage counters non-negative safe integers, and the effective model nonempty. An invalid answer yields `ok: false` before any usage is credited, so a malformed response can never become a decision.

## Adding a provider

The built-ins stay limited to major, well-known providers. TypeSafe, OpenRouter, Cloudflare, and Vercel are in. Pull requests for other major providers are welcome; small or regional carriers are not merged as built-ins. They have two supported paths below, and good third-party packages get linked from the READMEs of this package, [jev-browser](https://github.com/jkudish/jev-browser), and [jev-mcp](https://github.com/jkudish/jev-mcp).
The built-ins stay limited to major, well-known providers. TypeSafe, OpenRouter, Cloudflare, Vercel, and SiliconFlow are in. Pull requests for other major providers are welcome; small or regional carriers are not merged as built-ins. They have two supported paths below, and good third-party packages get linked from the READMEs of this package, [jev-browser](https://github.com/jkudish/jev-browser), and [jev-mcp](https://github.com/jkudish/jev-mcp).

### No code: inject a transport

Expand Down
11 changes: 6 additions & 5 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { typesafe } from "./transports/typesafe.js";
import { openrouter } from "./transports/openrouter.js";
import { cloudflare } from "./transports/cloudflare.js";
import { vercel } from "./transports/vercel.js";
import { siliconflow } from "./transports/siliconflow.js";

export interface JevTransportInput {
state: unknown;
Expand All @@ -22,7 +23,7 @@ export interface JevTransport {
}

export interface BuiltinDriver {
readonly name: "typesafe" | "openrouter" | "cloudflare" | "vercel";
readonly name: "typesafe" | "openrouter" | "cloudflare" | "vercel" | "siliconflow";
isConfigured(env: Env): boolean;
assertConfigured(env: Env): void;
create(env: Env): JevTransport;
Expand All @@ -43,13 +44,13 @@ export type AskResult =

export interface AskConfig { env?: Env; transport?: JevTransport }

const drivers: readonly BuiltinDriver[] = [typesafe, openrouter, cloudflare, vercel];
const drivers: readonly BuiltinDriver[] = [typesafe, openrouter, cloudflare, vercel, siliconflow];

export function resolveTransport(env: Env = process.env): JevTransport {
const explicit = (env.JEV_PROVIDER ?? "auto").toLowerCase();
if (explicit !== "auto") {
const driver = drivers.find((candidate) => candidate.name === explicit);
if (!driver) throw new Error("Unknown JEV_PROVIDER; choose typesafe, openrouter, cloudflare, vercel, or auto.");
if (!driver) throw new Error("Unknown JEV_PROVIDER; choose typesafe, openrouter, cloudflare, vercel, siliconflow, or auto.");
try {
driver.assertConfigured(env);
} catch (error) {
Expand All @@ -59,7 +60,7 @@ export function resolveTransport(env: Env = process.env): JevTransport {
}
const driver = drivers.find((candidate) => candidate.isConfigured(env));
if (!driver) {
throw new Error("No TYPESAFE_API_KEY, OPENROUTER_API_KEY (sk-or-), Cloudflare token (CLOUDFLARE_API_TOKEN or JEV_CLOUDFLARE_API_TOKEN) + CLOUDFLARE_ACCOUNT_ID, or AI_GATEWAY_API_KEY found. Set one, or JEV_PROVIDER to choose explicitly.");
throw new Error("No TYPESAFE_API_KEY, OPENROUTER_API_KEY (sk-or-), Cloudflare token (CLOUDFLARE_API_TOKEN or JEV_CLOUDFLARE_API_TOKEN) + CLOUDFLARE_ACCOUNT_ID, AI_GATEWAY_API_KEY, or SILICONFLOW_API_KEY found. Set one, or JEV_PROVIDER to choose explicitly.");
}
return driver.create(env);
}
Expand Down Expand Up @@ -108,7 +109,7 @@ export async function ask(input: JevTransportInput, config: AskConfig = {}): Pro
return { ok: false, code: "configuration_error", message };
}
// Injected transport names are untrusted and never included in error text.
const provider = typeof transport.name === "string" && /^(typesafe|openrouter|cloudflare|vercel|fixture)$/.test(transport.name) ? transport.name : "unknown";
const provider = typeof transport.name === "string" && /^(typesafe|openrouter|cloudflare|vercel|siliconflow|fixture)$/.test(transport.name) ? transport.name : "unknown";
let reply: JevTransportReply;
try {
reply = await transport.ask(input);
Expand Down
53 changes: 53 additions & 0 deletions src/transports/siliconflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { BuiltinDriver } from "../provider.js";

export const siliconflow: BuiltinDriver = {
name: "siliconflow",
isConfigured: (env) => Boolean(env.SILICONFLOW_API_KEY),
assertConfigured(env) {
if (!this.isConfigured(env)) throw new Error("SILICONFLOW_API_KEY is not set.");
},
create(env) {
this.assertConfigured(env);
const key = env.SILICONFLOW_API_KEY!;
return {
name: this.name,
async ask({ state, questions, model, signal }) {
const effective = model === "jev-latest" ? "semif" : model;
const response = await fetch("https://api.siliconflow.cn/v1/systemone", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: effective, state, questions }),
signal,
}).catch(() => {
if (signal.aborted) throw signal.reason;
throw new Error("SiliconFlow system one API HTTP unavailable (network error; 0 response bytes)");
});
const raw = await response.text().catch(() => {
if (signal.aborted) throw signal.reason;
throw new Error(`SiliconFlow system one API HTTP ${response.status} (body read error; 0 response bytes)`);
});
const bytes = Buffer.byteLength(raw);
if (!response.ok) throw new Error(`SiliconFlow system one API HTTP ${response.status} (request failed; ${bytes} response bytes)`);
let body: any;
try {
body = JSON.parse(raw);
} catch {
throw new Error(`SiliconFlow system one API HTTP ${response.status} (invalid JSON; ${bytes} response bytes)`);
}
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error(`SiliconFlow system one API HTTP ${response.status} (invalid envelope; ${bytes} response bytes)`);
const usage = body.usage;
if (usage !== undefined && (typeof usage !== "object" || usage === null || Array.isArray(usage))) {
throw new Error(`SiliconFlow system one API HTTP ${response.status} (invalid usage; ${bytes} response bytes)`);
}
return {
answers: body.answers,
usage: {
input_tokens: usage && Object.hasOwn(usage, "input_tokens") ? usage.input_tokens : 0,
output_tokens: usage && Object.hasOwn(usage, "output_tokens") ? usage.output_tokens : 0,
},
model: typeof body.model === "string" && body.model ? body.model : effective,
};
},
};
},
};
44 changes: 39 additions & 5 deletions test/transports.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { ask, resolveTransport } from "../dist/index.js";
import { typesafe } from "../dist/transports/typesafe.js";
import { openrouter } from "../dist/transports/openrouter.js";
import { cloudflare } from "../dist/transports/cloudflare.js";
import { siliconflow } from "../dist/transports/siliconflow.js";
import { createVercelDriver, adaptVercelAnswers } from "../dist/transports/vercel.js";

const askJev = (transport, input) => ask(input, { transport });
const rejected = (result, code, pattern) => {
assert.equal(result.ok, false);
assert.equal(result.code, code);
assert.match(result.message, pattern);
assert.doesNotMatch(result.message, /body-secret|ts-secret|sk-or-secret|high-secret|low-secret|ai-secret/);
assert.doesNotMatch(result.message, /body-secret|ts-secret|sk-or-secret|high-secret|low-secret|ai-secret|sf-secret/);
};

const signal = new AbortController().signal;
Expand All @@ -28,30 +29,31 @@ async function withFetch(fn, run) {
}

test("registry auto-detects in precedence order and explicit names select only themselves", () => {
const all = { TYPESAFE_API_KEY: "ts-secret", OPENROUTER_API_KEY: "sk-or-secret", CLOUDFLARE_API_TOKEN: "cf-secret", CLOUDFLARE_ACCOUNT_ID: "account", AI_GATEWAY_API_KEY: "ai-secret" };
const all = { TYPESAFE_API_KEY: "ts-secret", OPENROUTER_API_KEY: "sk-or-secret", CLOUDFLARE_API_TOKEN: "cf-secret", CLOUDFLARE_ACCOUNT_ID: "account", AI_GATEWAY_API_KEY: "ai-secret", SILICONFLOW_API_KEY: "sf-secret" };
for (const [removed, expected] of [
[[], "typesafe"],
[["TYPESAFE_API_KEY"], "openrouter"],
[["TYPESAFE_API_KEY", "OPENROUTER_API_KEY"], "cloudflare"],
[["TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "CLOUDFLARE_API_TOKEN"], "vercel"],
[["TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "CLOUDFLARE_API_TOKEN", "AI_GATEWAY_API_KEY"], "siliconflow"],
]) {
const env = { ...all };
for (const key of removed) delete env[key];
assert.equal(resolveTransport(env).name, expected);
}
for (const name of ["typesafe", "openrouter", "cloudflare", "vercel"]) {
for (const name of ["typesafe", "openrouter", "cloudflare", "vercel", "siliconflow"]) {
assert.equal(resolveTransport({ ...all, JEV_PROVIDER: name.toUpperCase() }).name, name);
}
assert.equal(resolveTransport({ JEV_CLOUDFLARE_API_TOKEN: "pref", CLOUDFLARE_ACCOUNT_ID: "account" }).name, "cloudflare");
assert.throws(() => resolveTransport({ ...all, JEV_PROVIDER: "typo" }), /Unknown JEV_PROVIDER/);
assert.throws(() => resolveTransport({}), (error) => ["TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "CLOUDFLARE_API_TOKEN", "JEV_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "AI_GATEWAY_API_KEY"].every((name) => error.message.includes(name)));
assert.throws(() => resolveTransport({}), (error) => ["TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "CLOUDFLARE_API_TOKEN", "JEV_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "AI_GATEWAY_API_KEY", "SILICONFLOW_API_KEY"].every((name) => error.message.includes(name)));
});

test("public ask returns configuration errors and redacts thrown transport details", async () => {
rejected(await ask(input, { env: { JEV_PROVIDER: "typo" } }), "configuration_error", /Unknown JEV_PROVIDER/);
const absent = await ask(input, { env: {} });
rejected(absent, "configuration_error", /No TYPESAFE_API_KEY/);
for (const name of ["TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "CLOUDFLARE_API_TOKEN", "JEV_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "AI_GATEWAY_API_KEY"]) {
for (const name of ["TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "CLOUDFLARE_API_TOKEN", "JEV_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "AI_GATEWAY_API_KEY", "SILICONFLOW_API_KEY"]) {
assert.ok(absent.message.includes(name));
}
rejected(await ask(input, { transport: { name: "body-secret", ask: async () => { throw Error("body-secret ts-secret"); } } }), "request_failed", /provider unknown: request failed/);
Expand All @@ -67,6 +69,7 @@ test("forced credentials reject every missing or invalid variant without leaking
["cloudflare", { CLOUDFLARE_ACCOUNT_ID: "account-secret" }, /CLOUDFLARE_API_TOKEN/],
["cloudflare", { JEV_CLOUDFLARE_API_TOKEN: "cf-secret" }, /CLOUDFLARE_ACCOUNT_ID/],
["vercel", {}, /AI_GATEWAY_API_KEY/],
["siliconflow", {}, /SILICONFLOW_API_KEY/],
];
for (const [name, env, pattern] of cases) {
assert.throws(() => resolveTransport({ ...env, JEV_PROVIDER: name }), (error) => {
Expand Down Expand Up @@ -257,6 +260,36 @@ test("Cloudflare token priority, double envelope, state, usage, and safe errors"
});
});

test("SiliconFlow maps latest to semif, sends exact envelope and redacts HTTP errors", async () => {
const calls = [];
await withFetch(async (url, init) => {
calls.push({ url, init });
return Response.json({ answers, usage, model: JSON.parse(init.body).model });
}, async () => {
const transport = siliconflow.create({ SILICONFLOW_API_KEY: "sf-secret" });
assert.equal((await askJev(transport, input)).model, "semif");
assert.equal((await askJev(transport, { ...input, model: "diffusiongemma" })).model, "diffusiongemma");
assert.equal((await transport.ask({ ...input, model: "kev-4b" })).model, "kev-4b");
assert.equal(calls[0].url, "https://api.siliconflow.cn/v1/systemone");
assert.equal(calls[0].init.method, "POST");
assert.deepEqual(calls[0].init.headers, { Authorization: "Bearer sf-secret", "Content-Type": "application/json" });
assert.equal(calls[0].init.signal, signal);
assert.deepEqual(JSON.parse(calls[0].init.body), { model: "semif", state: input.state, questions });
assert.deepEqual(JSON.parse(calls[2].init.body).model, "kev-4b");
});
for (const response of [new Response("body-secret", { status: 401 }), new Response("body-secret", { status: 200 })]) {
await withFetch(async () => response, async () => {
rejected(await askJev(siliconflow.create({ SILICONFLOW_API_KEY: "sf-secret" }), input), "request_failed", /request failed/);
});
}
await withFetch(async () => Response.json({ usage }), async () => {
rejected(await askJev(siliconflow.create({ SILICONFLOW_API_KEY: "sf-secret" }), input), "malformed_answer", /question <response>.*answers/);
});
await withFetch(async () => { throw new Error("body-secret sf-secret"); }, async () => {
rejected(await askJev(siliconflow.create({ SILICONFLOW_API_KEY: "sf-secret" }), input), "request_failed", /request failed/);
});
});

test("Vercel factory forwards evaluate request and pure adaptation preserves confidence", async () => {
let call;
const raw = { item: { type: "choice", choice: "beta", probabilities: { alpha: 0.2, beta: 0.8 } }, yes: { type: "boolean", probability: 0.7 } };
Expand Down Expand Up @@ -305,6 +338,7 @@ test("all adapters distinguish absent usage from malformed containers and presen
["openrouter", "input_tokens", (wire) => withFetch(async () => Response.json({ answers, ...wire }), () => askJev(openrouter.create({ OPENROUTER_API_KEY: "sk-or-secret" }), input))],
["cloudflare", "input_tokens", (wire) => withFetch(async () => Response.json({ result: { state: "Completed", result: { answers, ...wire } } }), () => askJev(cloudflare.create({ CLOUDFLARE_API_TOKEN: "cf-secret", CLOUDFLARE_ACCOUNT_ID: "account" }), input))],
["vercel", "inputTokens", (wire) => askJev(createVercelDriver(async () => ({ answers: vercelAnswers, ...wire })).create({ AI_GATEWAY_API_KEY: "ai-secret" }), input)],
["siliconflow", "input_tokens", (wire) => withFetch(async () => Response.json({ answers, ...wire }), () => askJev(siliconflow.create({ SILICONFLOW_API_KEY: "sf-secret" }), input))],
]) {
assert.deepEqual((await run({})).usage, { input_tokens: 0, output_tokens: 0 }, name);
assert.deepEqual((await run({ usage: {} })).usage, { input_tokens: 0, output_tokens: 0 }, name);
Expand Down