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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to jev-layer are recorded here. Entries describe public behavior and compatibility impact; provider-specific experiments are listed only when they affect a public contract.

## Unreleased

### Fixed

- Route `typesafe` through the official System One provider; share provider resolution with supervision and preserve fail-open decisions for unsupported providers.
- Apply configured provider environment names in CLI/MCP, including supervision and browser decisions; let Hermes/OMP honor project provider selection.
- Send OpenRouter's optional app title and record the configured model in provider metadata; allow the Hermes process to outlive the provider HTTP timeout.
- Cover demo, OpenRouter, and TypeSafe routing with offline provider and CLI/MCP/adapter regression checks; document setup and configuration precedence.

## [0.1.0]

First public-release candidate. This version is prepared but has not been pushed, released on GitHub, or published to npm.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export OPENROUTER_API_KEY='provided-by-your-secret-store'
jev doctor --project /path/to/workspace
```

All three modes (`demo`, `openrouter`, and direct `typesafe`), their endpoints, and configuration precedence are documented in the [provider guide](docs/PROVIDERS.md).

## Core surfaces

- **Routing:** `jev_route` selects one capability from the host-supplied candidate set. Selection is advisory; the host validates the id and permissions.
Expand Down
2 changes: 2 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export OPENROUTER_API_KEY='provided-by-your-secret-store'
jev doctor --project /path/to/workspace
```

Все три режима (`demo`, `openrouter` и прямой `typesafe`), их endpoints и порядок выбора конфигурации описаны в [руководстве по провайдерам](docs/PROVIDERS.md).

## Основные поверхности

- **Routing:** `jev_route` выбирает одну capability из набора, предоставленного host. Host повторно проверяет id и permissions.
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export OPENROUTER_API_KEY='provided-by-your-secret-store'
jev doctor --project /path/to/workspace
```

三种模式(`demo`、`openrouter` 和直连 `typesafe`)、端点及配置优先级见[提供商指南](docs/PROVIDERS.md)。

## 核心能力

- **Routing:** `jev_route` 从 host 提供的候选集合中选择一个 capability。host 会再次验证 id 和权限。
Expand Down
67 changes: 67 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Routing providers

CLI, stdio MCP, and the Hermes/OMP adapters use the same three providers. Routing, supervision, and browser decisions share provider resolution. The host still owns execution and approval.

| Provider | Default endpoint | Default model | Credentials |
| --- | --- | --- | --- |
| `demo` | None; deterministic word matching, no HTTP | `jev-demo` | None |
| `openrouter` | `https://openrouter.ai/api/alpha/decisions` | `typesafe/jev-1.13` | `OPENROUTER_API_KEY` |
| `typesafe` | `https://api.typesafe.ai/v1/systemone` | `jev-latest` | `TYPESAFE_API_KEY` |

Both remote providers send `{ model, state, questions }` with a Choice question and Bearer authentication. They use the [OpenRouter Decisions API](https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-questions-and-answers-request) and the [official TypeSafe API](https://docs.typesafe.ai/api), respectively.

## Select a provider

For a stdio MCP process, export the selected provider's API key through your secret store into the harness environment, then choose one command:

```sh
JEV_LAYER_PROVIDER=demo jev mcp
JEV_LAYER_PROVIDER=openrouter jev mcp
JEV_LAYER_PROVIDER=typesafe jev mcp
```

For a single CLI request from the repository root:

```sh
jev cli --provider demo --input examples/route-request.json
jev cli --provider openrouter --input examples/route-request.json
jev cli --provider typesafe --input examples/route-request.json
```

Selection precedence is: request `provider` → CLI `--provider` (CLI only) → `JEV_LAYER_PROVIDER` → `.jev/config.json` `provider` → `demo`. MCP launcher `env` entries count as process environment; update them when switching providers. Hermes and OMP delegate this choice to the CLI instead of forcing `demo`. The Hermes adapter's default process timeout is 10 seconds, allowing either provider's HTTP timeout to complete; `JEV_LAYER_TIMEOUT_S` overrides it.

`jev add <harness> --provider typesafe` generates configuration for TypeSafe in the target workspace. Use `--provider openrouter` or `--provider demo` for the other modes. Keep keys outside generated configuration.

## Endpoints, models, and custom environment names

- TypeSafe: `TYPESAFE_ENDPOINT`, `TYPESAFE_MODEL`.
- OpenRouter: `OPENROUTER_DECISIONS_ENDPOINT`, `OPENROUTER_DECISIONS_MODEL`; optional attribution headers use `OPENROUTER_HTTP_REFERER` and `OPENROUTER_APP_TITLE`.
- Endpoints are complete request URLs, including `/v1/systemone` or `/api/alpha/decisions`.
- Default HTTP timeouts are 2 seconds for TypeSafe and 5 seconds for OpenRouter. There are no provider retries; failures return control to the host.

The CLI and MCP read `.jev/config.json` from their working directory. `JEV_CONFIG` selects another file. To use secret-store-specific environment names, set only their names in the config:

```json
{
"provider": "typesafe",
"providers": {
"typesafe": {
"api_key_env": "MY_TYPESAFE_KEY",
"endpoint_env": "MY_TYPESAFE_ENDPOINT",
"model_env": "MY_TYPESAFE_MODEL"
}
}
}
```

Omitted fields retain the default environment names. A missing configured key returns `fallback` without an HTTP call, even if `TYPESAFE_API_KEY` is set. The same mapping is supported under `providers.openrouter`.

## Verification

`jev doctor` checks configuration and key presence; it does not run inference or validate a key with the provider.

```sh
node --test test/providers.test.mjs test/provider-transports.test.mjs
```

These offline checks cover all three provider selections, CLI/MCP precedence, custom environment names, Hermes/OMP transport, and fail-open behavior. Remote-provider requests use injected responses or a loopback HTTP server with fixture credentials. No paid inference, agent model, or real browser is invoked. Live provider/account availability requires a separately configured environment.
4 changes: 2 additions & 2 deletions integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ def jev_route(args: dict, **kwargs) -> str:
"""Return a bounded decision as JSON; never execute the selected target."""
try:
completed = subprocess.run(
[os.environ.get("JEV_NODE", "node"), str(CLI), "--provider", os.environ.get("JEV_LAYER_PROVIDER", "demo")],
[os.environ.get("JEV_NODE", "node"), str(CLI)],
input=json.dumps(args) + "\n",
capture_output=True,
text=True,
timeout=float(os.environ.get("JEV_LAYER_TIMEOUT_S", "3")),
timeout=float(os.environ.get("JEV_LAYER_TIMEOUT_S", "10")),
check=False,
)
if completed.returncode != 0:
Expand Down
4 changes: 2 additions & 2 deletions integrations/omp/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async function runCore(payload, signal) {
}

async function runCoreWithBun(payload, signal) {
const child = Bun.spawn([process.env.JEV_NODE ?? "node", CORE, "--provider", process.env.JEV_LAYER_PROVIDER ?? "demo"], {
const child = Bun.spawn([process.env.JEV_NODE ?? "node", CORE], {
cwd: process.cwd(),
env: process.env,
stdin: "pipe",
Expand All @@ -58,7 +58,7 @@ async function runCoreWithBun(payload, signal) {

function runCoreWithNode(payload, signal) {
return new Promise((resolve) => {
const child = spawn(process.env.JEV_NODE ?? "node", [CORE, "--provider", process.env.JEV_LAYER_PROVIDER ?? "demo"], {
const child = spawn(process.env.JEV_NODE ?? "node", [CORE], {
cwd: process.cwd(),
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
Expand Down
3 changes: 2 additions & 1 deletion src/browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export async function decideBrowserStep(input, options = {}) {
}
const decision = await routeRequest(request, {
provider: options.provider ?? "demo",
config: options.config,
engine: options.engine ?? "native",
policy: options.policy,
maxContextBytes: options.maxContextBytes,
Expand Down Expand Up @@ -566,4 +567,4 @@ function elapsed(started) {

function message(error) {
return error instanceof Error ? error.message : String(error);
}
}
2 changes: 1 addition & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ for await (const line of lines) {
try {
const request = JSON.parse(line);
const { provider = args.provider ?? configuredProvider(config), engine = args.engine ?? "native", ...payload } = request;
const decision = await routeRequest(payload, { provider, engine });
const decision = await routeRequest(payload, { provider, engine, config });
process.stdout.write(`${JSON.stringify(decision)}\n`);
} catch (error) {
process.stdout.write(`${JSON.stringify({ status: "error", reason: error instanceof Error ? error.message : String(error), execution: { enabled: false, status: "not_started" } })}\n`);
Expand Down
12 changes: 11 additions & 1 deletion src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ export function configuredProvider(config, explicitProvider) {
return explicitProvider ?? process.env.JEV_LAYER_PROVIDER ?? config?.provider ?? DEFAULT_CONFIG.provider;
}

export function configuredProviderOptions(config, provider) {
if (!config?.providers?.[provider]) return {};
const names = { ...DEFAULT_CONFIG.providers[provider], ...config.providers[provider] };
return {
apiKey: process.env[names.api_key_env] ?? "",
endpoint: process.env[names.endpoint_env],
model: process.env[names.model_env],
};
}

export function configuredReplayPath(config) {
return process.env.JEV_REPLAY_CASES ?? config?.replay_cases ?? DEFAULT_CONFIG.replay_cases;
}
Expand All @@ -72,4 +82,4 @@ function mergeConfig(parsed) {
...(parsed.providers && typeof parsed.providers === "object" ? parsed.providers : {}),
},
};
}
}
3 changes: 3 additions & 0 deletions src/mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ async function route(args, id) {
const decision = await routeRequest(request, {
provider,
engine,
config,
contextFilterMode: request.policy?.context_filter_mode ?? config.features?.context_filter,
});
return persistDecision(request, decision, id);
Expand All @@ -133,6 +134,7 @@ async function route(args, id) {
async function browserStep(args, id) {
const { provider: requestedProvider, enabled, ...input } = args;
const routed = await decideBrowserStep(input, {
config,
enabled: enabled ?? config.features?.browser_fast_path,
provider: configuredProvider(config, requestedProvider),
});
Expand All @@ -152,6 +154,7 @@ async function supervise(args, id) {
const { provider: requestedProvider, enabled, ...input } = args;
const result = await superviseWork({
...input,
config,
enabled: enabled ?? config.features?.supervision,
provider: configuredProvider(config, requestedProvider),
receiptPath: CASES_PATH,
Expand Down
12 changes: 12 additions & 0 deletions src/providers/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { configuredProviderOptions } from "../config.mjs";
import { DemoProvider } from "./demo.mjs";
import { OpenRouterDecisionsProvider, TypeSafeProvider } from "./typesafe.mjs";

export function resolveProvider(provider, options = {}) {
if (provider && typeof provider === "object") return provider;
if (provider === "demo") return new DemoProvider();
const settings = { ...configuredProviderOptions(options.config, provider), ...options[provider] };
if (provider === "openrouter") return new OpenRouterDecisionsProvider(settings);
if (provider === "typesafe") return new TypeSafeProvider(settings);
throw new Error(`unsupported provider: ${provider}`);
}
12 changes: 6 additions & 6 deletions src/providers/typesafe.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { stableJson } from "../contract.mjs";

export class TypeSafeProvider {
name = "typesafe";

constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-latest", timeoutMs = 2_000 } = {}) {
constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-latest", timeoutMs = 2_000, fetchImpl = globalThis.fetch } = {}) {
this.apiKey = apiKey;
this.endpoint = endpoint;
this.model = model;
this.timeoutMs = timeoutMs;
this.fetchImpl = fetchImpl;
}

async decide({ state, candidates }) {
Expand All @@ -31,7 +30,8 @@ export class TypeSafeProvider {

async evaluate({ state, questions }) {
if (!this.apiKey) throw new Error("TYPESAFE_API_KEY is not configured");
const response = await fetch(this.endpoint, {
if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
const response = await this.fetchImpl(this.endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
Expand All @@ -57,8 +57,6 @@ export class TypeSafeProvider {
}

export class OpenRouterDecisionsProvider {
name = "openrouter:typesafe/jev-1.13";

constructor({
apiKey = process.env.OPENROUTER_API_KEY,
endpoint = process.env.OPENROUTER_DECISIONS_ENDPOINT ?? "https://openrouter.ai/api/alpha/decisions",
Expand All @@ -68,12 +66,14 @@ export class OpenRouterDecisionsProvider {
httpReferer = process.env.OPENROUTER_HTTP_REFERER,
appTitle = process.env.OPENROUTER_APP_TITLE,
} = {}) {
this.name = `openrouter:${model}`;
this.apiKey = apiKey;
this.endpoint = endpoint;
this.model = model;
this.timeoutMs = timeoutMs;
this.fetchImpl = fetchImpl;
this.httpReferer = httpReferer;
this.appTitle = appTitle;
}
async decide({ state, candidates }) {
const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
Expand Down
20 changes: 9 additions & 11 deletions src/route.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { byteLength, decisionEnvelope, normalizeRequest } from "./contract.mjs";
import { projectState } from "./context-filter.mjs";
import { discoverCapabilities } from "./discovery.mjs";
import { decisionCandidates, DEFAULT_POLICY, deterministicCandidate, filterCapabilities, normalizeCapabilities } from "./registry.mjs";
import { DemoProvider } from "./providers/demo.mjs";
import { OpenRouterDecisionsProvider, TypeSafeProvider } from "./providers/typesafe.mjs";
import { resolveProvider } from "./providers/index.mjs";

export async function routeRequest(input, options = {}) {
const discovered = options.discovery ? discoverCapabilities(options.discovery) : [];
Expand All @@ -15,9 +14,8 @@ export async function routeRequest(input, options = {}) {
const capabilities = normalizeCapabilities(request.capabilities);
const { policy, assessments, eligible } = filterCapabilities(capabilities, request, effectivePolicy);
const projection = projectState(request, eligible, options.maxContextBytes ?? 6_000, { mode: options.contextFilterMode });
const provider = resolveProvider(options.provider ?? "demo", options);
const base = {
provider: provider.name,
provider: typeof options.provider === "string" ? options.provider : options.provider?.name ?? "jev-demo",
candidateCount: eligible.length,
contextBytes: projection.context_bytes,
started,
Expand All @@ -26,6 +24,13 @@ export async function routeRequest(input, options = {}) {
if (process.env.JEV_LAYER_ENABLED === "0") {
return fallback(base, assessments, "disabled", "Jev layer disabled by JEV_LAYER_ENABLED=0", projection.state);
}
let provider;
try {
provider = resolveProvider(options.provider ?? "demo", options);
base.provider = provider.name;
} catch (error) {
return fallback(base, assessments, "provider_error", error instanceof Error ? error.message : String(error), projection.state);
}
if (eligible.length === 0) {
return decisionEnvelope({
status: "no_decision",
Expand Down Expand Up @@ -123,13 +128,6 @@ export async function routeRequest(input, options = {}) {
});
}

function resolveProvider(provider, options) {
if (provider && typeof provider === "object" && typeof provider.decide === "function") return provider;
if (provider === "demo") return new DemoProvider();
if (provider === "openrouter") return new OpenRouterDecisionsProvider(options.openrouter);
throw new Error(`unsupported provider: ${provider}`);
}

function readChoiceAnswer(raw) {
const answers = raw?.answers;
if (!answers || typeof answers !== "object") throw new Error("response has no answers object");
Expand Down
14 changes: 3 additions & 11 deletions src/supervision.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { stableJson } from "./contract.mjs";
import { DemoProvider } from "./providers/demo.mjs";
import { OpenRouterDecisionsProvider, TypeSafeProvider } from "./providers/typesafe.mjs";
import { resolveProvider } from "./providers/index.mjs";
import { appendSupervisionReceipt } from "./receipts.mjs";

export const SUPERVISION_ENV = "JEV_SUPERVISION";
Expand Down Expand Up @@ -104,6 +103,7 @@ export async function superviseWork({
actor_permissions,
policy,
provider = "demo",
config,
enabled,
attempts = 0,
receiptPath,
Expand All @@ -124,7 +124,7 @@ export async function superviseWork({
if (!supervisionEnabled({ enabled })) return finalize(base, started, "disabled", 0);
let resolved;
try {
resolved = resolveProvider(provider);
resolved = resolveProvider(provider, { config });
if (!resolved || typeof resolved.evaluate !== "function") throw new Error("provider does not support supervision evaluation");
} catch (error) {
return finalize(base, started, "provider_error", 1, error);
Expand Down Expand Up @@ -177,14 +177,6 @@ export async function superviseWork({
return result;
}

function resolveProvider(provider) {
if (provider && typeof provider === "object") return provider;
if (provider === "demo") return new DemoProvider();
if (provider === "openrouter") return new OpenRouterDecisionsProvider();
if (provider === "typesafe") return new TypeSafeProvider();
throw new Error(`unsupported provider: ${provider}`);
}

function finalize(result, started, reason, jevCalls, error = null) {
const message = error instanceof Error ? error.message : error ? String(error) : reason;
return {
Expand Down
Loading
Loading