diff --git a/README.md b/README.md index 46eaca4..ceda8e9 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ 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 @@ -50,7 +51,7 @@ Every reply is checked before you see it. Validation requires exactly the reques ## 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 diff --git a/src/provider.ts b/src/provider.ts index b6a9fb2..b231425 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -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; @@ -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; @@ -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) { @@ -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); } @@ -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); diff --git a/src/transports/siliconflow.ts b/src/transports/siliconflow.ts new file mode 100644 index 0000000..e346017 --- /dev/null +++ b/src/transports/siliconflow.ts @@ -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, + }; + }, + }; + }, +}; diff --git a/test/transports.test.mjs b/test/transports.test.mjs index 2159cf1..9266892 100644 --- a/test/transports.test.mjs +++ b/test/transports.test.mjs @@ -4,6 +4,7 @@ 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 }); @@ -11,7 +12,7 @@ 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; @@ -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/); @@ -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) => { @@ -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 .*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 } }; @@ -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);