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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -680,18 +680,20 @@ 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. |
| `JEV_MCP_REQUEST_TIMEOUT_MS` | `60000` | Whole-request deadline in milliseconds, covering every attempt, on the fetch-based transports. |
| `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/<id>/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

Expand All @@ -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:
Expand Down
84 changes: 80 additions & 4 deletions src/provider.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ?? "") &&
Expand Down Expand Up @@ -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}`;
Expand Down
144 changes: 144 additions & 0 deletions test/mock.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down