Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
env:
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
- name: TypeSafe live smoke (optional)
if: env.TYPESAFE_API_KEY != ''
run: npm run test:live
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Changelog

## 0.1.0

- Extract four run-bound Jev judgment carriers and strict provider selection from jev-browser.
- Return validated answers or typed rejections through a non-throwing Result API, with hermetic tests and optional live TypeSafe smoke.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Joey Kudish

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,29 @@
# jev-agent-tools
Shared Jev wire layer: judgment carriers and fail-closed validation used by jev-browser and jev-mcp
# @jkudish/jev-agent-tools

Shared Jev judgment wire layer for [jev-browser](https://github.com/jkudish/jev-browser) and [jev-mcp](https://github.com/jkudish/jev-mcp). This package only selects a carrier, sends a judgment, and validates its answer. It has no runtime dependencies, Playwright, MCP SDK, or AI SDK.

## Result contract

`ask(input, config?)` returns `Promise<{ ok: true, answer, usage, model, provider } | { ok: false, code, message }>` and never throws a verdict. Transport failures return `request_failed`; invalid responses return specific codes such as `answer_id_mismatch`, `invalid_distribution`, `invalid_noul`, `invalid_usage`, and `invalid_model`. Configuration errors use `configuration_error`. Messages do not include response bodies or credentials. The two consumers need different error behavior: jev-browser can map `!ok` to its own exception, while jev-mcp can map `!ok` to `invalid_response`.

```js
import { ask } from "@jkudish/jev-agent-tools";

const result = await ask({
state: "A customer asks for a refund of a duplicate charge.",
questions: { refund: { type: "noul", instructions: "Is a refund requested?" } },
model: "jev-latest",
signal: new AbortController().signal,
});

if (!result.ok) console.error(result.code, result.message);
else console.log(result.answer.refund.noul, result.usage, result.model);
```

## Providers

Auto-selection precedence: TypeSafe (`TYPESAFE_API_KEY`, optional `TYPESAFE_BASE_URL`), OpenRouter (`OPENROUTER_API_KEY` beginning `sk-or-`), Cloudflare (`JEV_CLOUDFLARE_API_TOKEN` preferred over `CLOUDFLARE_API_TOKEN`, plus `CLOUDFLARE_ACCOUNT_ID`), then Vercel AI Gateway (`AI_GATEWAY_API_KEY`). Set `JEV_PROVIDER` to `typesafe`, `openrouter`, `cloudflare`, `vercel`, or `auto` to select strictly. Unknown names and missing credentials for forced providers fail rather than falling through. Without a provider, 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.

Models retain the browser driver's mappings: OpenRouter maps `jev-latest` to `typesafe/jev-1.13`; Cloudflare maps it to `typesafe/jev`; Vercel selects `typesafe-ai/jev` unless given a `typesafe-ai/` model. The direct TypeSafe driver sends the supplied model (usually `jev-latest`).

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. Invalid responses yield `ok: false` before any result usage is credited.
51 changes: 51 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@jkudish/jev-agent-tools",
"version": "0.1.0",
"description": "Shared Jev judgment transports and fail-closed Result validation",
"type": "module",
"license": "MIT",
"author": "Joey Kudish",
"repository": { "type": "git", "url": "git+https://github.com/jkudish/jev-agent-tools.git" },
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
"files": ["dist", "README.md", "LICENSE", "CHANGELOG.md"],
"engines": { "node": ">=22" },
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"test": "node --test test/transports.test.mjs",
"test:live": "node --test test/live.test.mjs"
},
"devDependencies": { "typescript": "^5.6.0", "@types/node": "^22.0.0" },
"publishConfig": { "access": "public" }
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ask, resolveTransport } from "./provider.js";
export type { AskConfig, AskResult, Env, JevAnswer, JevTransport, JevTransportInput, JevTransportReply, RejectionCode } from "./provider.js";
149 changes: 149 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { typesafe } from "./transports/typesafe.js";
import { openrouter } from "./transports/openrouter.js";
import { cloudflare } from "./transports/cloudflare.js";
import { vercel } from "./transports/vercel.js";

export interface JevTransportInput {
state: unknown;
questions: Record<string, unknown>;
model: string;
signal: AbortSignal;
}

export interface JevTransportReply {
answers: unknown;
usage: { input_tokens: number; output_tokens: number };
model: string;
}

export interface JevTransport {
readonly name: string;
ask(input: JevTransportInput): Promise<JevTransportReply>;
}

export interface BuiltinDriver {
readonly name: "typesafe" | "openrouter" | "cloudflare" | "vercel";
isConfigured(env: Env): boolean;
assertConfigured(env: Env): void;
create(env: Env): JevTransport;
}

export type Env = Record<string, string | undefined>;

export type JevAnswer =
| { type: "noul"; noul: number }
| { type: "choice"; choice: string; probabilities: Record<string, number>; confidence: number | null }
| { type: "score"; score: number; probabilities: Record<string, number>; confidence: number | null };

export type RejectionCode = "request_failed" | "configuration_error" | "malformed_answer" | "answer_id_mismatch" | "invalid_criteria" | "invalid_distribution" | "invalid_choice" | "invalid_noul" | "invalid_confidence" | "invalid_usage" | "invalid_model";

export type AskResult =
| { ok: true; answer: Record<string, JevAnswer>; usage: JevTransportReply["usage"]; model: string; provider: string }
| { ok: false; code: RejectionCode; message: string };

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

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

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.");
try {
driver.assertConfigured(env);
} catch (error) {
throw new Error(`JEV_PROVIDER=${driver.name} but ${(error as Error).message}`);
}
return driver.create(env);
}
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.");
}
return driver.create(env);
}

function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function distribution(value: unknown, keys: string[]): { ok: true; probabilities: Record<string, number> } | { ok: false; reason: string } {
if (!record(value) || Object.keys(value).length !== keys.length || keys.some((key) => !Object.hasOwn(value, key))) {
return { ok: false, reason: "distribution must contain exactly the criteria keys" };
}
let sum = 0;
const entries: [string, number][] = [];
for (const key of keys) {
const probability = value[key];
if (typeof probability !== "number" || !Number.isFinite(probability) || probability < 0 || probability > 1) {
return { ok: false, reason: "distribution must contain finite probabilities in [0,1]" };
}
entries.push([key, probability]);
sum += probability as number;
}
// Upstream distributions are rounded; permit a one-percent sum drift and
// a 0.001 selection tie, but not a different winner.
if (Math.abs(sum - 1) > 0.01) return { ok: false, reason: "distribution must sum to approximately 1" };
return { ok: true, probabilities: Object.fromEntries(entries) };
}

export async function ask(input: JevTransportInput, config: AskConfig = {}): Promise<AskResult> {
let transport: JevTransport;
try {
transport = config.transport ?? resolveTransport(config.env);
} catch (error) {
// Registry errors are fixed strings; never echo arbitrary driver exceptions.
const message = error instanceof Error && /^(Unknown JEV_PROVIDER|No TYPESAFE_API_KEY|JEV_PROVIDER=)/.test(error.message)
? error.message : "Jev provider configuration failed";
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";
let reply: JevTransportReply;
try {
reply = await transport.ask(input);
} catch {
return { ok: false, code: "request_failed", message: `Jev provider ${provider}: request failed` };
}
const fail = (id: string, code: RejectionCode, reason: string): AskResult => ({ ok: false, code, message: `Jev provider ${provider} question ${id}: ${reason}` });
if (!record(input.questions)) return fail("<response>", "invalid_criteria", "questions must be an object");
if (!record(reply) || !record(reply.answers)) return fail("<response>", "malformed_answer", "answers must be an object");
const answers = reply.answers as Record<string, unknown>;
const ids = Object.keys(input.questions);
for (const id of ids) if (!Object.hasOwn(answers, id)) return fail(id, "answer_id_mismatch", "missing answer");
if (Object.keys(answers).length !== ids.length) return fail("<response>", "answer_id_mismatch", "unexpected answer ID");
const validated: [string, JevAnswer][] = [];
for (const id of ids) {
const question = input.questions[id];
const answer = answers[id];
if (!record(question) || !record(answer) || answer.type !== question.type) return fail(id, "malformed_answer", "missing answer or wrong type");
if (question.type === "noul") {
if (typeof answer.noul !== "number" || !Number.isFinite(answer.noul) || answer.noul < 0 || answer.noul > 1) return fail(id, "invalid_noul", "noul must be finite in [0,1]");
validated.push([id, { type: "noul", noul: answer.noul as number }]);
continue;
}
const keys = question.type === "score" && Array.isArray(question.criteria)
? question.criteria.map((_, index) => String(index))
: question.type === "choice" && record(question.criteria) ? Object.keys(question.criteria) : null;
if (!keys?.length) return fail(id, "invalid_criteria", "invalid question criteria");
const checked = distribution(answer.probabilities, keys);
if (!checked.ok) return fail(id, "invalid_distribution", checked.reason);
const probabilities = checked.probabilities;
const confidence = answer.confidence === undefined ? null : answer.confidence;
if (confidence !== null && (typeof confidence !== "number" || !Number.isFinite(confidence))) return fail(id, "invalid_confidence", "confidence must be finite or null");
if (question.type === "choice") {
if (typeof answer.choice !== "string" || !keys.includes(answer.choice)) return fail(id, "invalid_choice", "choice is outside criteria");
if (probabilities[answer.choice as string] + 0.001 < Math.max(...Object.values(probabilities))) return fail(id, "invalid_choice", "choice is not a distribution maximum");
validated.push([id, { type: "choice", choice: answer.choice as string, probabilities, confidence: confidence as number | null }]);
} else if (question.type === "score") {
if (typeof answer.score !== "number" || !Number.isInteger(answer.score) || !keys.includes(String(answer.score))) return fail(id, "invalid_choice", "score is outside criteria levels");
validated.push([id, { type: "score", score: answer.score as number, probabilities, confidence: confidence as number | null }]);
} else return fail(id, "malformed_answer", "unsupported question type");
}
if (!record(reply.usage) || !Number.isSafeInteger(reply.usage.input_tokens) || (reply.usage.input_tokens as number) < 0 || !Number.isSafeInteger(reply.usage.output_tokens) || (reply.usage.output_tokens as number) < 0) {
return fail("<response>", "invalid_usage", "usage counters must be non-negative safe integers");
}
if (typeof reply.model !== "string" || !reply.model.trim()) return fail("<response>", "invalid_model", "effective model must be nonempty");
return { ok: true, answer: Object.fromEntries(validated), usage: reply.usage as JevTransportReply["usage"], provider, model: reply.model as string };
}
61 changes: 61 additions & 0 deletions src/transports/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { BuiltinDriver } from "../provider.js";

export const cloudflare: BuiltinDriver = {
name: "cloudflare",
isConfigured: (env) => Boolean((env.JEV_CLOUDFLARE_API_TOKEN || env.CLOUDFLARE_API_TOKEN) && env.CLOUDFLARE_ACCOUNT_ID),
assertConfigured(env) {
if (!this.isConfigured(env)) throw new Error("a Cloudflare API token (CLOUDFLARE_API_TOKEN or JEV_CLOUDFLARE_API_TOKEN) and CLOUDFLARE_ACCOUNT_ID are not both set.");
},
create(env) {
this.assertConfigured(env);
const token = env.JEV_CLOUDFLARE_API_TOKEN || env.CLOUDFLARE_API_TOKEN;
const account = env.CLOUDFLARE_ACCOUNT_ID!;
return {
name: this.name,
async ask({ state, questions, model, signal }) {
const slug = model.startsWith("typesafe/") ? model : `typesafe/${model === "jev-latest" ? "jev" : model}`;
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${account}/ai/run`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: slug, input: { state, questions } }),
signal,
}).catch(() => {
if (signal.aborted) throw signal.reason;
throw new Error("Cloudflare AI run HTTP unavailable (network error; 0 response bytes)");
});
const raw = await response.text().catch(() => {
if (signal.aborted) throw signal.reason;
throw new Error(`Cloudflare AI run HTTP ${response.status} (body read error; 0 response bytes)`);
});
const bytes = Buffer.byteLength(raw);
if (!response.ok) throw new Error(`Cloudflare AI run HTTP ${response.status} (request failed; ${bytes} response bytes)`);
let body: any;
try {
body = JSON.parse(raw);
} catch {
throw new Error(`Cloudflare AI run HTTP ${response.status} (invalid JSON; ${bytes} response bytes)`);
}
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error(`Cloudflare AI run HTTP ${response.status} (invalid envelope; ${bytes} response bytes)`);
if (body.success === false) throw new Error(`Cloudflare AI run HTTP ${response.status} (API unsuccessful; ${bytes} response bytes)`);
// The v4 envelope double-nests the model output under result.result.
const outer = body.result;
if (outer && typeof outer.state === "string" && outer.state !== "Completed") {
throw new Error(`Cloudflare AI run HTTP ${response.status} (non-Completed state; ${bytes} response bytes)`);
}
const payload = outer?.result ?? outer ?? body;
const usage = payload?.usage;
if (usage !== undefined && (typeof usage !== "object" || usage === null || Array.isArray(usage))) {
throw new Error(`Cloudflare AI run HTTP ${response.status} (invalid usage; ${bytes} response bytes)`);
}
return {
answers: payload?.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: payload?.model ?? slug,
};
},
};
},
};
Loading
Loading