@elyracode/jev-tools 0.9.39 → 0.9.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,8 +14,15 @@ elyra install npm:@elyracode/jev-tools
14
14
 
15
15
  ## Requirements
16
16
 
17
- An OpenRouter API key. Either log in with `/login` and pick OpenRouter, or set `OPENROUTER_API_KEY`.
18
- The extension reuses whatever key Elyra already has for the `openrouter` provider.
17
+ One of two API keys:
18
+
19
+ - **TypeSafe direct** (preferred): a key from [typesafe.ai](https://typesafe.ai). Set `TYPESAFE_API_KEY`.
20
+ Requests go to `https://api.typesafe.ai/v1/systemone` with model `jev-latest`.
21
+ - **OpenRouter**: log in with `/login` and pick OpenRouter, or set `OPENROUTER_API_KEY`. Requests go to
22
+ OpenRouter's alpha decisions endpoint with model `~typesafe/jev-latest`.
23
+
24
+ When both are present, TypeSafe direct is used. Force one with `JEV_BACKEND=typesafe|openrouter`.
25
+ The request and response shape is identical on both; only endpoint, model slug, and billing differ.
19
26
 
20
27
  ## Tools
21
28
 
@@ -25,7 +32,7 @@ The extension reuses whatever key Elyra already has for the `openrouter` provide
25
32
 
26
33
  ## Commands
27
34
 
28
- - `/jev` -- show model, endpoint, key status, and bash gate state
35
+ - `/jev` -- show active backend, key status, and bash gate state
29
36
  - `/jev gate on|off` -- toggle the bash gate for the current session
30
37
 
31
38
  ## Bash gate (opt-in)
@@ -85,15 +92,19 @@ Jev reads criteria literally and does not infer intent beyond what is stated.
85
92
 
86
93
  | Variable | Default | Purpose |
87
94
  |----------|---------|---------|
88
- | `OPENROUTER_API_KEY` | -- | API key, if not stored via `/login` |
89
- | `JEV_MODEL` | `~typesafe/jev-latest` | Model slug sent to the endpoint |
90
- | `JEV_BASE_URL` | `https://openrouter.ai/api/alpha/decisions` | Override if OpenRouter moves the endpoint out of alpha |
95
+ | `TYPESAFE_API_KEY` | -- | TypeSafe direct API key |
96
+ | `OPENROUTER_API_KEY` | -- | OpenRouter API key, if not stored via `/login` |
97
+ | `JEV_BACKEND` | auto | Force `typesafe` or `openrouter` instead of picking by available key |
98
+ | `JEV_MODEL` | `jev-latest` / `~typesafe/jev-latest` | Override the model slug for the chosen backend |
99
+ | `JEV_BASE_URL` | per backend | Override the endpoint URL (proxies, or if OpenRouter moves out of alpha) |
91
100
  | `JEV_BASH_GATE` | off | Set to `1` to enable the bash gate at startup |
92
101
  | `JEV_BASH_GATE_THRESHOLD` | `0.7` | Probability (0-1) at or above which the gate intervenes |
93
102
 
94
103
  ## Notes
95
104
 
96
- - The decisions endpoint is an OpenRouter alpha API. The request contract here is derived from public
97
- community clients, not from an official specification, and may change.
98
- - Retries with backoff on 429/5xx, up to three attempts. Requests time out after 60 seconds.
105
+ - The request contract follows the [TypeSafe API reference](https://docs.typesafe.ai/api). OpenRouter's
106
+ decisions endpoint is an alpha API mirroring the same shape and may change.
107
+ - Retries with backoff on 429/529/5xx, up to three attempts. Requests time out after 60 seconds.
108
+ - Question design guidance: see the [primitives docs](https://docs.typesafe.ai/primitives). A Noul near 0.5
109
+ means yes and no are equally likely, not "medium"; use a Score for degree.
99
110
  - Unofficial community integration. Not affiliated with TypeSafe or OpenRouter.
@@ -1,14 +1,50 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@elyracode/coding-agent";
2
2
  import { type Static, Type } from "typebox";
3
3
 
4
- // TypeSafe Jev is a decision model, not a chat model. It is reached through
5
- // OpenRouter's decisions endpoint (alpha), which has its own request shape:
6
- // a `state` to judge plus a map of typed `questions`. It never generates text.
7
- const DEFAULT_BASE_URL = "https://openrouter.ai/api/alpha/decisions";
8
- const DEFAULT_MODEL = "~typesafe/jev-latest";
4
+ // TypeSafe Jev is a decision model, not a chat model. It takes a `state` to
5
+ // judge plus a map of typed `questions` and returns probabilities; it never
6
+ // generates text. The same request shape is served by two backends:
7
+ // - TypeSafe direct: POST https://api.typesafe.ai/v1/systemone (model "jev-latest")
8
+ // - OpenRouter alpha: POST https://openrouter.ai/api/alpha/decisions (model "~typesafe/jev-latest")
9
+ // Direct is preferred when a TypeSafe key is available; OpenRouter is the fallback.
10
+ type BackendId = "typesafe" | "openrouter";
11
+
12
+ interface Backend {
13
+ id: BackendId;
14
+ label: string;
15
+ baseUrl: string;
16
+ model: string;
17
+ apiKey: string;
18
+ }
19
+
20
+ interface BackendSpec {
21
+ id: BackendId;
22
+ label: string;
23
+ baseUrl: string;
24
+ model: string;
25
+ envKey: string;
26
+ }
27
+
28
+ const BACKENDS: Record<BackendId, BackendSpec> = {
29
+ typesafe: {
30
+ id: "typesafe",
31
+ label: "TypeSafe (direct)",
32
+ baseUrl: "https://api.typesafe.ai/v1/systemone",
33
+ model: "jev-latest",
34
+ envKey: "TYPESAFE_API_KEY",
35
+ },
36
+ openrouter: {
37
+ id: "openrouter",
38
+ label: "OpenRouter",
39
+ baseUrl: "https://openrouter.ai/api/alpha/decisions",
40
+ model: "~typesafe/jev-latest",
41
+ envKey: "OPENROUTER_API_KEY",
42
+ },
43
+ };
44
+
45
+ const ENV_BACKEND = "JEV_BACKEND";
9
46
  const ENV_BASE_URL = "JEV_BASE_URL";
10
47
  const ENV_MODEL = "JEV_MODEL";
11
- const PROVIDER = "openrouter";
12
48
  const MAX_ATTEMPTS = 3;
13
49
  const REQUEST_TIMEOUT_MS = 60_000;
14
50
 
@@ -153,11 +189,11 @@ export default function (elyra: ExtensionAPI): void {
153
189
  execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
154
190
  validateQuestions(params.questions);
155
191
 
156
- const apiKey = await requireApiKey(ctx);
192
+ const backend = await requireBackend(ctx);
157
193
  const state = parseState(params.state);
158
194
  const questions = toApiQuestions(params.questions);
159
195
  const warning = sizeWarning(state, questions);
160
- const response = await requestDecision(apiKey, state, questions, signal);
196
+ const response = await requestDecision(backend, state, questions, signal);
161
197
  const text = formatResponse(params.questions, response);
162
198
 
163
199
  return {
@@ -188,16 +224,16 @@ export default function (elyra: ExtensionAPI): void {
188
224
  const command = event.input.command;
189
225
  if (typeof command !== "string" || !command.trim()) return undefined;
190
226
 
191
- const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
192
- if (!apiKey) {
193
- ctx.ui.notify("Jev bash gate: no OpenRouter API key, skipping check", "warning");
227
+ const backend = await resolveBackend(ctx);
228
+ if (!backend) {
229
+ ctx.ui.notify("Jev bash gate: no TypeSafe or OpenRouter API key, skipping check", "warning");
194
230
  return undefined;
195
231
  }
196
232
 
197
233
  let probability: number;
198
234
  try {
199
235
  const response = await requestDecision(
200
- apiKey,
236
+ backend,
201
237
  { command, cwd: ctx.cwd },
202
238
  { [GATE_QUESTION_NAME]: GATE_QUESTION },
203
239
  ctx.signal,
@@ -246,14 +282,17 @@ export default function (elyra: ExtensionAPI): void {
246
282
  return;
247
283
  }
248
284
 
249
- const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
285
+ const typesafeKey = await resolveApiKey(ctx, "typesafe");
286
+ const openrouterKey = await resolveApiKey(ctx, "openrouter");
287
+ const backend = await resolveBackend(ctx);
250
288
  const lines = [
251
- `Model: ${process.env[ENV_MODEL] || DEFAULT_MODEL}`,
252
- `Endpoint: ${process.env[ENV_BASE_URL] || DEFAULT_BASE_URL}`,
253
- `OpenRouter key: ${apiKey ? "configured" : "missing (run /login or set OPENROUTER_API_KEY)"}`,
289
+ `Active backend: ${backend ? `${backend.label} (${backend.model} at ${backend.baseUrl})` : "none"}`,
290
+ `TypeSafe key: ${typesafeKey ? "configured" : "missing (set TYPESAFE_API_KEY)"}`,
291
+ `OpenRouter key: ${openrouterKey ? "configured" : "missing (run /login or set OPENROUTER_API_KEY)"}`,
292
+ `Backend override: ${process.env[ENV_BACKEND] || "auto (TypeSafe if keyed, else OpenRouter)"}`,
254
293
  `Bash gate: ${gateEnabled ? "on" : "off"} (threshold ${gateThreshold}, toggle with /jev gate on|off)`,
255
294
  ];
256
- ctx.ui.notify(lines.join("\n"), apiKey ? "info" : "warning");
295
+ ctx.ui.notify(lines.join("\n"), backend ? "info" : "warning");
257
296
  },
258
297
  });
259
298
  }
@@ -272,12 +311,56 @@ function parseThreshold(value: string | undefined): number {
272
311
  return parsed;
273
312
  }
274
313
 
275
- async function requireApiKey(ctx: ExtensionContext): Promise<string> {
276
- const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
277
- if (!apiKey) {
278
- throw new Error("No OpenRouter API key found. Run /login and pick OpenRouter, or set OPENROUTER_API_KEY.");
314
+ // ── Backend resolution ──
315
+
316
+ function parseBackendId(value: string | undefined): BackendId | undefined {
317
+ const normalized = value?.trim().toLowerCase();
318
+ return normalized === "typesafe" || normalized === "openrouter" ? normalized : undefined;
319
+ }
320
+
321
+ /** Env var first, then Elyra's auth storage (/login or auth.json entry under the backend id). */
322
+ async function resolveApiKey(ctx: ExtensionContext, id: BackendId): Promise<string | undefined> {
323
+ const fromEnv = process.env[BACKENDS[id].envKey]?.trim();
324
+ if (fromEnv) return fromEnv;
325
+ return ctx.modelRegistry.getApiKeyForProvider(id);
326
+ }
327
+
328
+ /**
329
+ * Pick the backend. JEV_BACKEND forces one; otherwise TypeSafe direct is used when
330
+ * a key exists, falling back to OpenRouter. JEV_BASE_URL / JEV_MODEL override the
331
+ * chosen backend's endpoint and model slug.
332
+ */
333
+ async function resolveBackend(ctx: ExtensionContext): Promise<Backend | undefined> {
334
+ const forced = parseBackendId(process.env[ENV_BACKEND]);
335
+ const order: BackendId[] = forced ? [forced] : ["typesafe", "openrouter"];
336
+
337
+ for (const id of order) {
338
+ const apiKey = await resolveApiKey(ctx, id);
339
+ if (!apiKey) continue;
340
+ const spec = BACKENDS[id];
341
+ return {
342
+ id,
343
+ label: spec.label,
344
+ apiKey,
345
+ baseUrl: process.env[ENV_BASE_URL] || spec.baseUrl,
346
+ model: process.env[ENV_MODEL] || spec.model,
347
+ };
348
+ }
349
+ return undefined;
350
+ }
351
+
352
+ async function requireBackend(ctx: ExtensionContext): Promise<Backend> {
353
+ const backend = await resolveBackend(ctx);
354
+ if (!backend) {
355
+ const forced = parseBackendId(process.env[ENV_BACKEND]);
356
+ if (forced) {
357
+ throw new Error(`JEV_BACKEND=${forced} but no ${BACKENDS[forced].envKey} is set.`);
358
+ }
359
+ throw new Error(
360
+ "No Jev API key found. Set TYPESAFE_API_KEY for direct access, or run /login and pick OpenRouter (or set OPENROUTER_API_KEY).",
361
+ );
279
362
  }
280
- return apiKey;
363
+ return backend;
281
364
  }
282
365
 
283
366
  // ── Validation ──
@@ -366,27 +449,23 @@ function sizeWarning(state: unknown, questions: Record<string, ApiQuestion>): st
366
449
 
367
450
  // ── HTTP ──
368
451
 
369
- function requestDecision(
370
- apiKey: string,
452
+ async function requestDecision(
453
+ backend: Backend,
371
454
  state: unknown,
372
455
  questions: Record<string, ApiQuestion>,
373
456
  signal: AbortSignal | undefined,
374
457
  ): Promise<DecisionsResponse> {
375
- return callDecisions(apiKey, { model: process.env[ENV_MODEL] || DEFAULT_MODEL, state, questions }, signal);
376
- }
377
-
378
- async function callDecisions(
379
- apiKey: string,
380
- payload: Record<string, unknown>,
381
- signal: AbortSignal | undefined,
382
- ): Promise<DecisionsResponse> {
383
- const url = process.env[ENV_BASE_URL] || DEFAULT_BASE_URL;
384
- const headers = {
385
- Authorization: `Bearer ${apiKey}`,
458
+ const payload = { model: backend.model, state, questions };
459
+ const url = backend.baseUrl;
460
+ const headers: Record<string, string> = {
461
+ Authorization: `Bearer ${backend.apiKey}`,
386
462
  "Content-Type": "application/json",
387
- "X-Title": "elyra-jev-tools",
388
- "HTTP-Referer": "https://github.com/kwhorne/elyra",
389
463
  };
464
+ if (backend.id === "openrouter") {
465
+ // OpenRouter attribution headers; not part of the TypeSafe API.
466
+ headers["X-Title"] = "elyra-jev-tools";
467
+ headers["HTTP-Referer"] = "https://github.com/kwhorne/elyra";
468
+ }
390
469
 
391
470
  for (let attempt = 1; ; attempt++) {
392
471
  signal?.throwIfAborted();
@@ -485,7 +564,7 @@ function formatResponse(questions: QuestionType[], response: DecisionsResponse):
485
564
  if (usage) {
486
565
  const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
487
566
  const cost = usage.cost !== undefined ? ` · $${usage.cost.toFixed(6)}` : "";
488
- lines.push("", `[${response.model ?? DEFAULT_MODEL} · ${tokens} tok${cost}]`);
567
+ lines.push("", `[${response.model ?? "jev"} · ${tokens} tok${cost}]`);
489
568
  }
490
569
  return lines.join("\n");
491
570
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elyracode/jev-tools",
3
- "version": "0.9.39",
3
+ "version": "0.9.40",
4
4
  "description": "Elyra extension for TypeSafe Jev decisions via OpenRouter -- calibrated yes/no, choice, and score judgements",
5
5
  "type": "module",
6
6
  "keywords": [