diff --git a/README.md b/README.md index b3507d0..f38327b 100644 --- a/README.md +++ b/README.md @@ -670,7 +670,7 @@ Built-in provider selection runs through the shared [@jkudish/jev-agent-tools](h - **Cloudflare Workers AI** (`CLOUDFLARE_API_TOKEN` or `JEV_CLOUDFLARE_API_TOKEN`, plus `CLOUDFLARE_ACCOUNT_ID`). - **Vercel AI Gateway** (`AI_GATEWAY_API_KEY`). -`JEV_PROVIDER` forces one, or `compatible` for any System One-compatible endpoint. Unknown names and missing credentials are configuration errors, never silent fallbacks. For resilience reasons the OpenRouter, Cloudflare, and compatible transports are implemented locally; see [Transport resilience](#transport-resilience). +`JEV_PROVIDER` forces one (`typesafe`, `openrouter`, `cloudflare`, `vercel`, `siliconflow`), or `compatible` for any System One-compatible endpoint. Unknown names and missing credentials are configuration errors, never silent fallbacks. For resilience reasons the OpenRouter, Cloudflare, SiliconFlow, and compatible transports are implemented locally; see [Transport resilience](#transport-resilience). The built-ins stay limited to major providers. The no-code extension path here is the [compatible endpoint](#jev-compatible-endpoints); the [add-a-provider guide](https://github.com/jkudish/jev-agent-tools#adding-a-provider) in the shared package covers transport injection and third-party driver packages. Published driver packages get linked here on request. @@ -680,7 +680,8 @@ The built-ins stay limited to major providers. The no-code extension path here i | `OPENROUTER_API_KEY` | none | OpenRouter `sk-or-` key; used when `TYPESAFE_API_KEY` is absent. | | `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` | none | Cloudflare Workers AI; used when no other provider key is present. `JEV_CLOUDFLARE_API_TOKEN` is honored first for separate credentials. | | `AI_GATEWAY_API_KEY` | none | Vercel AI Gateway; used when no other provider key is present. | -| `JEV_PROVIDER` | `auto` | Force `typesafe`, `openrouter`, `cloudflare`, `vercel`, or `compatible` instead of auto-detection. | +| `SILICONFLOW_API_KEY` | none | [SiliconFlow](https://www.siliconflow.cn) (硅基流动); used when no other provider key is present. Useful for callers where TypeSafe or OpenRouter is hard to reach. | +| `JEV_PROVIDER` | `auto` | Force `typesafe`, `openrouter`, `cloudflare`, `vercel`, `siliconflow`, or `compatible` instead of auto-detection. | | `JEV_MCP_MODEL` | `jev-latest` | Pin a Jev version, e.g. `jev-1.12`, or `typesafe/jev-1.13` on OpenRouter. | | `TYPESAFE_BASE_URL` | none | Custom direct endpoint (origin only; the SDK appends its route). | | `JEV_API_BASE_URL` + `JEV_API_KEY` | none | Jev-compatible System One endpoint and Bearer token; use with `JEV_PROVIDER=compatible`. `JEV_API_BASE_URL` is the full POST URL including the `/v1/systemone` path. | @@ -688,10 +689,11 @@ The built-ins stay limited to major providers. The no-code extension path here i | `JEV_MCP_MAX_ATTEMPTS` | `3` | Total attempts per request (clamped 1..6) on the fetch-based transports; retries happen only on 408, 409, 429, and 500 through 599. | | `JEV_OPENROUTER_BASE_URL` | `https://openrouter.ai/api` | Override the OpenRouter API root (the `/alpha/decisions` path is appended; a trailing slash is tolerated). | | `JEV_CLOUDFLARE_BASE_URL` | `https://api.cloudflare.com/client/v4` | Override the Cloudflare API root (`/accounts//ai/run` is appended; a trailing slash is tolerated). | +| `JEV_SILICONFLOW_BASE_URL` | `https://api.siliconflow.cn` | Override the SiliconFlow API root (the `/v1/systemone` path is appended; a trailing slash is tolerated). | ### Transport resilience -The fetch-based transports (OpenRouter, Cloudflare, and the Jev-compatible endpoint) retry only on the standard not-processed status set (408, 409, 429, and 500 through 599), with jittered exponential backoff, at most `JEV_MCP_MAX_ATTEMPTS` total attempts, all inside one `JEV_MCP_REQUEST_TIMEOUT_MS` deadline. A status cannot prove the request was not processed, but that allowlist is the conservative retry trigger; ambiguous network-level failures (connection reset, TLS errors) are never retried, because without an idempotency key a re-send can double-process a paid call. Everything else fails immediately: caller cancellations, deadline expiry, non-retryable statuses, unparseable bodies, and responses over 1,000,000 bytes, a ceiling enforced while the body streams rather than after buffering. A cancelled MCP call aborts the in-flight HTTP request, cuts any backoff sleep short, and is never re-sent. Error bodies on those transports are redacted, so a reflecting endpoint can never echo a configured key into MCP-visible errors. Direct TypeSafe and Vercel calls use `@jkudish/jev-agent-tools`, whose direct fetch path avoids the SDK cancellation crash ([typesafe-sdk-js#2](https://github.com/typesafe-ai/typesafe-sdk-js/issues/2)); no retry or deadline uniformity is claimed for those two. Research and the original report: [issue #23](https://github.com/jkudish/jev-mcp/issues/23) by oppih. +The fetch-based transports (OpenRouter, Cloudflare, SiliconFlow, and the Jev-compatible endpoint) retry only on the standard not-processed status set (408, 409, 429, and 500 through 599), with jittered exponential backoff, at most `JEV_MCP_MAX_ATTEMPTS` total attempts, all inside one `JEV_MCP_REQUEST_TIMEOUT_MS` deadline. A status cannot prove the request was not processed, but that allowlist is the conservative retry trigger; ambiguous network-level failures (connection reset, TLS errors) are never retried, because without an idempotency key a re-send can double-process a paid call. Everything else fails immediately: caller cancellations, deadline expiry, non-retryable statuses, unparseable bodies, and responses over 1,000,000 bytes, a ceiling enforced while the body streams rather than after buffering. A cancelled MCP call aborts the in-flight HTTP request, cuts any backoff sleep short, and is never re-sent. Error bodies on those transports are redacted, so a reflecting endpoint can never echo a configured key into MCP-visible errors. Direct TypeSafe and Vercel calls use `@jkudish/jev-agent-tools`, whose direct fetch path avoids the SDK cancellation crash ([typesafe-sdk-js#2](https://github.com/typesafe-ai/typesafe-sdk-js/issues/2)); no retry or deadline uniformity is claimed for those two. Research and the original report: [issue #23](https://github.com/jkudish/jev-mcp/issues/23) by oppih. ### Vercel @@ -705,6 +707,15 @@ With `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` set (and no other provid If you already have an OpenRouter key, that is all you need: with no `TYPESAFE_API_KEY` present, every call goes through OpenRouter's Decisions API at identical pricing. The endpoint is alpha and adds a hop, and OpenRouter serves pinned versions rather than a `latest` alias, so the default `jev-latest` maps to `typesafe/jev-1.13` there. Direct TypeSafe remains the recommended default when you have both keys. +### SiliconFlow + +With `SILICONFLOW_API_KEY` set (and no other provider key), judgments run through SiliconFlow's System One endpoint at `https://api.siliconflow.cn/v1/systemone`. This is the practical carrier where TypeSafe and OpenRouter are hard to reach; the key comes from the [SiliconFlow console](https://www.siliconflow.cn). The default `jev-latest` maps to SiliconFlow's current alias `semif`; pass `JEV_MCP_MODEL` to pin another model the endpoint serves — unknown model names are sent verbatim and rejected by the endpoint rather than rewritten. Auto-detection outranks the compatible fallback when both are configured; force it with `JEV_PROVIDER=siliconflow`. + +```bash +export JEV_PROVIDER=siliconflow +export SILICONFLOW_API_KEY=your-siliconflow-key +``` + ### Jev-compatible endpoints For a service that implements the same System One request and response contract, set the provider explicitly: diff --git a/src/provider.ts b/src/provider.ts index 968a1f5..d639af2 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,12 +1,13 @@ // Jev transport: TypeSafe direct (default), OpenRouter Decisions, Cloudflare -// Workers AI, or a caller-supplied Jev-compatible System One endpoint. All -// speak the {state, questions} / answers contract; URL, auth, and model slugs -// differ. Proxies add hops, so direct TypeSafe remains the recommended default. +// Workers AI, SiliconFlow, or a caller-supplied Jev-compatible System One +// endpoint. All speak the {state, questions} / answers contract; URL, auth, +// and model slugs differ. Proxies add hops, so direct TypeSafe remains the +// recommended default. import { ask, resolveTransport, type JevTransport, type JevTransportReply } from "@jkudish/jev-agent-tools"; import { isRecord } from "./lib.js"; -export type JevProvider = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "compatible"; +export type JevProvider = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "compatible" | "siliconflow"; export interface AskResult { answers: Record; @@ -218,6 +219,16 @@ async function fetchWithResilience(url: string, init: RequestInit, deadline: Dea function resolve(env: NodeJS.ProcessEnv): JevProvider { const explicit = (env.JEV_PROVIDER ?? "auto").toLowerCase(); const hasCompatible = Boolean(env.JEV_API_KEY && env.JEV_API_BASE_URL); + const hasSiliconFlow = Boolean(env.SILICONFLOW_API_KEY); + if (explicit === "siliconflow") { + if (!env.SILICONFLOW_API_KEY) { + throw new Error( + "JEV_PROVIDER=siliconflow but SILICONFLOW_API_KEY is not set. " + + "JEV_MCP_MODEL is optional and defaults to semif.", + ); + } + return "siliconflow"; + } if (explicit === "compatible") { const missing = ["JEV_API_KEY", "JEV_API_BASE_URL"].filter((name) => !env[name]); if (missing.length > 0) { @@ -228,6 +239,12 @@ function resolve(env: NodeJS.ProcessEnv): JevProvider { } return "compatible"; } + // A dedicated provider credential outranks the generic compatible fallback: + // SiliconFlow auto-selects when its key is the only one configured. + if ((explicit === "auto" || explicit === "") && hasSiliconFlow && + !env.TYPESAFE_API_KEY && !/^sk-or-/.test(env.OPENROUTER_API_KEY ?? "") && + !((env.JEV_CLOUDFLARE_API_TOKEN || env.CLOUDFLARE_API_TOKEN) && env.CLOUDFLARE_ACCOUNT_ID) && + !env.AI_GATEWAY_API_KEY) return "siliconflow"; // The published package owns the four built-in credential rules and order. if ((explicit === "auto" || explicit === "") && hasCompatible && !env.TYPESAFE_API_KEY && !/^sk-or-/.test(env.OPENROUTER_API_KEY ?? "") && @@ -388,6 +405,65 @@ export async function askJev( } } + if (provider === "siliconflow") { + // SiliconFlow serves the System One contract at a fixed path. The root is + // overridable like the OpenRouter and Cloudflare roots so tests can point + // it at a local mock; unknown models go to the wire verbatim and the + // endpoint owns rejecting them. + const SILICONFLOW_LATEST = "semif"; + const effective = model === "jev-latest" ? SILICONFLOW_LATEST : model; + const deadline = deadlineSignal(signal, REQUEST_TIMEOUT_MS); + try { + const response = await fetchWithResilience(apiUrl(process.env.JEV_SILICONFLOW_BASE_URL || "https://api.siliconflow.cn", "/v1/systemone"), { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.SILICONFLOW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ model: effective, state, questions }), + }, deadline); + const bodyText = await readBodyBounded(response, deadline); + if (!response.ok) { + // Client-visible errors stay fixed-string: provider name and numeric + // status only — a reflecting endpoint can echo nothing back through them. + throw new Error(`SiliconFlow API ${response.status}`); + } + let body: unknown; + try { + body = JSON.parse(bodyText); + } catch { + body = null; // parse failures never retry; surface as invalid response + } + const invalid = (why: string) => new Error(`SiliconFlow API returned an invalid response: ${why}`); + if (!isRecord(body)) throw invalid("expected a JSON object."); + if (!isRecord(body.answers)) throw invalid("expected an answers object."); + // Envelope shape is validated here; per-question answer validity is the + // tools' job. Each tool fails closed under its invalid_response contract, + // so a missing or malformed answer can never reach tool-level defaults. + let inputTokens = 0; + let outputTokens = 0; + if (body.usage !== undefined && body.usage !== null) { + const tokenCount = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0; + if (!isRecord(body.usage) || !tokenCount(body.usage.input_tokens) || !tokenCount(body.usage.output_tokens)) { + throw invalid("usage must report finite non-negative input_tokens and output_tokens."); + } + inputTokens = body.usage.input_tokens; + outputTokens = body.usage.output_tokens; + } + if (body.model !== undefined && body.model !== null && typeof body.model !== "string") { + throw invalid("model must be absent or a string."); + } + return { + answers: body.answers, + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + provider, + model: typeof body.model === "string" ? body.model : effective, + }; + } finally { + deadline.dispose(); + } + } + // Cloudflare Workers AI wraps the same contract in {model, input} and the // v4 {result, success} envelope. Single alias; no version pinning. const cfSlug = model.startsWith("typesafe/") ? model : `typesafe/${model === "jev-latest" ? "jev" : model}`; diff --git a/test/mock.test.mjs b/test/mock.test.mjs index 595c91e..c4b58d2 100644 --- a/test/mock.test.mjs +++ b/test/mock.test.mjs @@ -460,6 +460,150 @@ test("the retry allowlist is 408, 409, 429, and 500 through 599", async () => { ); }); +const siliconflowEnv = (port, overrides = {}) => ({ + JEV_PROVIDER: "siliconflow", + SILICONFLOW_API_KEY: "siliconflow-test-key", + JEV_SILICONFLOW_BASE_URL: `http://127.0.0.1:${port}`, + ...overrides, +}); + +test("siliconflow provider sends the standard request to the configured endpoint", async () => { + await withMock( + (request) => ({ relation_claim0: pick("supports", Object.keys(request.questions.relation_claim0.criteria)) }), + async (client, requests) => { + const result = await client.callTool({ + name: "jev_verify", + arguments: { claims: ["The patch is ready"], evidence: "The tests pass" }, + }); + const body = payload(result); + assert.equal(body.tool, "jev_verify"); + assert.equal(body.provider, "siliconflow"); + assert.equal(requests.length, 1); + assert.equal(requests[0].method, "POST"); + assert.equal(requests[0].path, "/v1/systemone"); + assert.equal(requests[0].headers.authorization, "Bearer siliconflow-test-key"); + assert.match(requests[0].headers["content-type"], /^application\/json/); + assert.equal(requests[0].body.model, "semif"); + assert.deepEqual(requests[0].body.state.claims, [{ text: "The patch is ready", id: "claim0" }]); + }, + siliconflowEnv, + { model: "semif" }, + ); +}); + +test("siliconflow provider maps the jev-latest alias to semif on the wire", async () => { + // JEV_MCP_MODEL is unset: the global jev-latest default must not reach the + // SiliconFlow endpoint verbatim; the transport maps it to the current alias. + await withMock( + (request) => ({ relation_claim0: pick("supports", Object.keys(request.questions.relation_claim0.criteria)) }), + async (client, requests) => { + const result = await client.callTool({ + name: "jev_verify", + arguments: { claims: ["The patch is ready"], evidence: "The tests pass" }, + }); + const body = payload(result); + assert.equal(body.provider, "siliconflow"); + assert.equal(body.model, "semif"); + assert.equal(requests[0].body.model, "semif"); + }, + (port) => siliconflowEnv(port), + ); +}); + +test("siliconflow provider auto-selects when its key is the only configured provider", async () => { + await withMock( + (request) => ({ relation_claim0: pick("supports", Object.keys(request.questions.relation_claim0.criteria)) }), + async (client) => { + const body = payload(await client.callTool({ + name: "jev_verify", + arguments: { claims: ["The patch is ready"], evidence: "The tests pass" }, + })); + assert.equal(body.provider, "siliconflow"); + assert.equal(body.results[0].verdict, "verified"); + }, + (port) => + siliconflowEnv(port, { + JEV_PROVIDER: "", + TYPESAFE_API_KEY: "", + TYPESAFE_BASE_URL: "", + }), + ); +}); + +test("siliconflow provider reports non-2xx status without upstream body text", async () => { + // The 401 body echoes the Authorization header; nothing from the upstream + // body may reach the MCP-visible error — fixed provider name and status only. + await withMock( + {}, + async (client) => { + const result = await client.callTool({ + name: "jev_verify", + arguments: { claims: ["The patch is ready"], evidence: "The tests pass" }, + }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /SiliconFlow API 401/); + assert.ok(!result.content[0].text.includes("Unauthorized client")); + assert.ok(!result.content[0].text.includes("siliconflow-test-key")); + }, + siliconflowEnv, + { status: 401, raw: JSON.stringify({ error: "Unauthorized client for Bearer siliconflow-test-key" }) }, + ); +}); + +test("siliconflow provider rejects a malformed response", async () => { + await withMock( + null, + async (client) => { + const result = await client.callTool({ + name: "jev_verify", + arguments: { claims: ["The patch is ready"], evidence: "The tests pass" }, + }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /invalid response/i); + }, + siliconflowEnv, + { raw: "not json at all" }, + ); +}); + +test("siliconflow provider without a key is a configuration error, not a silent fallback", async () => { + await withMock( + {}, + async (client) => { + const result = await client.callTool({ + name: "jev_verify", + arguments: { claims: ["The patch is ready"], evidence: "The tests pass" }, + }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /SILICONFLOW_API_KEY is not set/); + }, + (port) => siliconflowEnv(port, { SILICONFLOW_API_KEY: "" }), + ); +}); + +test("siliconflow auto-selection outranks a complete compatible credential pair", async () => { + // Both a dedicated provider key and a compatible endpoint are configured: + // the dedicated provider wins the auto-detection order. + await withMock( + (request) => ({ relation_claim0: pick("supports", Object.keys(request.questions.relation_claim0.criteria)) }), + async (client) => { + const body = payload(await client.callTool({ + name: "jev_verify", + arguments: { claims: ["The patch is ready"], evidence: "The tests pass" }, + })); + assert.equal(body.provider, "siliconflow"); + }, + (port) => + siliconflowEnv(port, { + JEV_PROVIDER: "auto", + TYPESAFE_API_KEY: "", + TYPESAFE_BASE_URL: "", + JEV_API_KEY: "compatible-test-key", + JEV_API_BASE_URL: `http://127.0.0.1:${port}/v1/systemone`, + }), + ); +}); + test("openrouter provider keeps a malformed 200 body out of client-visible errors", async () => { // Node's parse errors quote the malformed input; a 200 body reflecting the // key must not leak even a snippet. The error is fixed-string status only.