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 @@ -4,6 +4,15 @@

<!-- Empty. Next release starts here. -->

## 0.8.0

### Added

- A `commandcode` backend for the same Jev decisions protocol: `createTypeSafe({ backend: "commandcode" })` sends judgments to `api.commandcode.ai` under `/provider/v1/systemone` with the key from `COMMANDCODE_API_KEY` and the model `typesafe/jev`; its public model list does not verify a key.
- `backend` accepts a caller-supplied endpoint object wherever a backend name is accepted (`createTypeSafe`, `keySituation`, `resolveApiKey`, `authState`, `ensureApiKey`, `safeError`): an endpoint names its own `label`, `host`, `keyEnv`, and optionally `path`, `defaultModel`, and model-list fields, is validated on every call, never reads `TYPESAFE_API_KEY` or the login store, and is never added to the registry. An invalid backend makes `authState` and `keySituation` throw `configuration`; validate user input with `resolveBackend` first.
- `resolveBackend(nameOrEndpoint)` resolves either form to the validated backend the client uses, and `backendHost(nameOrEndpoint)` reports the destination host for consent text, alongside the `BackendEndpoint`, `BackendSpec`, and `ResolvedBackend` types.
- `TypeSafeBackend` now includes `"commandcode"`; a consumer with an exhaustive `switch` over it sees a new member.

## 0.7.4

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ if (!answer.ok) return { skipped: answer.errorCode === "budget" }; // never thr

Your extension owns its own user consent and budget; `/typesafe enable` applies only to this package's tool. Check `authState()` rather than your own consent flag before you report that judgments are on.

Judgments can also go through OpenRouter: `createTypeSafe({ backend: "openrouter" })` sends them to `openrouter.ai` with the key from `OPENROUTER_API_KEY`. That backend has no login store, so `/typesafe login` does not apply to it. Pass the same `backend` to `authState`, `keySituation`, and `ensureApiKey`, or the status you report describes the TypeSafe key while the requests use another one. The `/typesafe` commands and the `typesafe_evaluate` tool always use the TypeSafe backend. Every export — the client, `ask`, batching, the usage ledger, auth state, and the `pi-typesafe/calibrate` and `pi-typesafe/ui` entry points — is in [docs/api.md](docs/api.md).
Judgments can also go through OpenRouter: `createTypeSafe({ backend: "openrouter" })` sends them to `openrouter.ai` with the key from `OPENROUTER_API_KEY`. Command Code serves the same protocol: `createTypeSafe({ backend: "commandcode" })` sends them to `api.commandcode.ai` with the key from `COMMANDCODE_API_KEY` and the model `typesafe/jev`. Or pass a `BackendEndpoint` object as `backend` to name any host that serves the protocol: it brings its own key variable, never reads `TYPESAFE_API_KEY`, and `backendHost()` reports its destination host for your consent text. These backends have no login store, so `/typesafe login` does not apply to them. Pass the same `backend` to `authState`, `keySituation`, and `ensureApiKey`, or the status you report describes the TypeSafe key while the requests use another one. The `/typesafe` commands and the `typesafe_evaluate` tool always use the TypeSafe backend. Every export — the client, `ask`, batching, the usage ledger, auth state, and the `pi-typesafe/calibrate` and `pi-typesafe/ui` entry points — is in [docs/api.md](docs/api.md).

## Development

Expand Down
30 changes: 24 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ result.answers.severity.score; // 0..2, may be fractional
| Option | Default | Meaning |
| --- | --- | --- |
| `apiKey` | the backend's key (below) | Never returned |
| `backend` | `typesafe` | `typesafe` or `openrouter`; picks the host, the request path, the default model, and the key |
| `model` | `jev-latest` (`typesafe/jev-1.13` on OpenRouter) | No model is inferred from submitted content |
| `backend` | `typesafe` | `typesafe`, `openrouter`, `commandcode`, or a caller-supplied endpoint object; picks the host, the request path, the default model, and the key |
| `model` | `jev-latest` (`typesafe/jev-1.13` on OpenRouter, `typesafe/jev` on Command Code) | No model is inferred from submitted content |
| `timeoutMs` | `15000` | Per request; no automatic retries |
| `maxInputBytes` | `65536` | UTF-8 JSON bytes, not tokens |
| `maxRequests` | `20` | Attempts per client instance, failures included |
Expand All @@ -34,11 +34,29 @@ result.answers.severity.score; // 0..2, may be fractional
| `ledger` | the store next to the key | Inject a ledger in tests |
| `fetch` | global fetch | Inject a transport for offline tests |

A `model` is mapped to the backend's own id form before it is sent: on OpenRouter a bare `jev-latest` goes as `~typesafe/jev-latest` and a bare `jev-1.13` (or `jev-1.13.0`) as `typesafe/jev-1.13`, while an id that already carries an author, such as `vendor/other`, passes unchanged, and TypeSafe sends ids as written. The same mapping applies to a per-request `model` inside `evaluate()`; the limits of 1–100 characters apply to your own id, before mapping.
A `model` is mapped to the backend's own id form before it is sent: on OpenRouter a bare `jev-latest` goes as `~typesafe/jev-latest` and a bare `jev-1.13` (or `jev-1.13.0`) as `typesafe/jev-1.13`, while an id that already carries an author, such as `vendor/other`, passes unchanged, and TypeSafe sends ids as written. The same mapping applies to a per-request `model` inside `evaluate()`; the limits of 1–100 characters apply to your own id, before mapping. A caller-supplied endpoint's model is never mapped: `defaultModel` and a per-request `model` go on the wire as the caller wrote them.

`DECISIONS_BACKENDS` is the registry behind `backend`: each entry carries `label`, `host`, `keyEnv`, and, when the service does not serve the SDK's own paths, `path` for the judgment request plus `modelsPath`, `modelsField`, and `modelsIdField` for the model list — OpenRouter's list arrives under `data` and is renamed to the `models` the SDK reads, with each entry's `id` promoted to the `name` that `listModels()` returns. `modelsVerifyKey: false` marks a backend whose model list is public, and therefore proves nothing about the key. `DEFAULT_BACKEND` is `"typesafe"`. The TypeSafe backend takes its key from `TYPESAFE_API_KEY`, then the `/typesafe login` store. Every other backend reads only its own environment variable (`OPENROUTER_API_KEY` for OpenRouter): the store holds a TypeSafe key, and a login verifies against api.typesafe.ai, so neither applies elsewhere. Pass the same `backend` to `authState`, `keySituation`, and `ensureApiKey` so what you report matches what you send.
`DECISIONS_BACKENDS` is the registry behind `backend`: `typesafe`, `openrouter`, and `commandcode`. Command Code serves the same Jev decisions protocol at `api.commandcode.ai` under `/provider/v1/systemone`, with the model id `typesafe/jev`; its model list is public, under `/provider/v1/models`, and arrives in `data` with ids in `id`. Each entry carries `label`, `host`, `keyEnv`, and, when the service does not serve the SDK's own paths, `path` for the judgment request plus `modelsPath`, `modelsField`, and `modelsIdField` for the model list — OpenRouter's and Command Code's lists are renamed to the `models` the SDK reads, with each entry's `id` promoted to the `name` that `listModels()` returns. `modelsVerifyKey: false` marks a backend whose model list is public, and therefore proves nothing about the key: both OpenRouter and Command Code serve theirs without checking one, so `listModels()` there leaves the auth state unverified while the answer check is unchanged and a malformed reply stays a `response` error. `DEFAULT_BACKEND` is `"typesafe"`.

`evaluate(request, { signal })` validates before sending and rejects with `TypeSafeIntegrationError`. `code` is one of `configuration`, `validation`, `budget`, `aborted`, `timeout`, `http`, `connection`, `response`; messages never contain upstream bodies, keys, or your submitted state, and no header value except a numeric `Retry-After` count in seconds (quoted by the 429 advice as `Retry after <n> seconds.`). The advice is backend-aware: a 401 says `Check TYPESAFE_API_KEY.` or `Check OPENROUTER_API_KEY.`, and a 402 says `Check your account balance.` except on OpenRouter, which says `Insufficient credits. Add credits at https://openrouter.ai/credits.` `listModels()` verifies the key without counting toward `maxRequests`, except on a backend whose model list is public (`modelsVerifyKey: false`), which accepts any key and leaves the auth state unverified.
`backend` also accepts a caller-supplied endpoint object (`BackendEndpoint`) wherever a backend name is accepted — `createTypeSafe`, `keySituation`, `resolveApiKey`, `authState`, `ensureApiKey`, and `safeError`. An endpoint names `label`, `host`, and `keyEnv`, and optionally `path`, `modelsPath`, `modelsField`, `modelsIdField`, `modelsVerifyKey`, and `defaultModel`; it is validated on every call, never added to the registry, and without `defaultModel` requires `model` on `createTypeSafe`. `resolveBackend(nameOrEndpoint)` resolves either form to the validated `ResolvedBackend` the client uses (registry `name` when there is one, `host` as origin only, explicit `keyEnv` and `modelsVerifyKey`), and `backendHost(nameOrEndpoint)` returns just the destination host — `api.commandcode.ai`, `gw.example.com:8443` — for consent text. Validation is in this order, and every failure is a `configuration` error whose message never quotes a caller value (a host can carry credentials in its user info), except the label after it is validated:

| Rule | Refusal |
| --- | --- |
| `label` is a string, trimmed nonempty, at most 60 characters | `Backend label must be a nonempty string of at most 60 characters.` |
| `host` is an absolute `https:` URL with no user info, path, query, or fragment (`http:` only for a loopback host) | `Backend host must be an absolute https: URL with no user info, path, query, or fragment …` |
| `path`, when present, starts with `"/"` and carries no `?` or `#` | `Backend path must be a string that starts with "/".` |
| `modelsPath`, same rule | `Backend modelsPath must be a string that starts with "/".` |
| `modelsField` / `modelsIdField`, when present, nonempty strings | `Backend modelsField must be a nonempty string.` / `Backend modelsIdField must be a nonempty string.` |
| `modelsVerifyKey`, when present, a boolean | `Backend modelsVerifyKey must be a boolean.` |
| `keyEnv` is a name of letters, digits, and underscores, not starting with a digit | `Backend keyEnv must name an environment variable: letters, digits, and underscores, not starting with a digit.` |
| `keyEnv` is not `TYPESAFE_API_KEY` in any case | `Backend keyEnv must not be TYPESAFE_API_KEY: the TypeSafe key is only sent to the typesafe backend. Give this endpoint its own variable.` |
| `defaultModel`, when present, trimmed nonempty, at most 100 characters | `Backend defaultModel must be a nonempty string of at most 100 characters.` |

Anything that is neither a registry name nor such an object is refused with `backend must be a registry name or a backend object.`

The TypeSafe backend takes its key from `TYPESAFE_API_KEY`, then the `/typesafe login` store. Every other backend — registry or endpoint — reads only its own `keyEnv` variable: the store holds a TypeSafe key, and a login verifies against api.typesafe.ai, so neither applies elsewhere, and the TypeSafe key is only ever sent to the typesafe backend. `createTypeSafe` with no `apiKey` and no key in the backend's variable fails with `No API key. Set <keyEnv> in the environment.` before any request is built. Pass the same `backend` to `authState`, `keySituation`, and `ensureApiKey` so what you report matches what you send.

`evaluate(request, { signal })` validates before sending and rejects with `TypeSafeIntegrationError`. `code` is one of `configuration`, `validation`, `budget`, `aborted`, `timeout`, `http`, `connection`, `response`; messages never contain upstream bodies, keys, or your submitted state, and no header value except a numeric `Retry-After` count in seconds (quoted by the 429 advice as `Retry after <n> seconds.`). The advice is backend-aware: a 401 names the backend's own key variable (`Check TYPESAFE_API_KEY.`, `Check OPENROUTER_API_KEY.`, `Check COMMANDCODE_API_KEY.`, or `Check <keyEnv>.` for an endpoint), and a 402 says `Check your account balance.` except on OpenRouter, which says `Insufficient credits. Add credits at https://openrouter.ai/credits.` `listModels()` verifies the key without counting toward `maxRequests`, except on a backend whose model list is public (`modelsVerifyKey: false`, or an endpoint that does not set `modelsVerifyKey: true`), which accepts any key and leaves the auth state unverified.

## Admission

Expand Down Expand Up @@ -75,7 +93,7 @@ The environment may lower an explicit cap, never raise it. A reached cap raises

## Auth state

`authState({ backend })` never throws. It reports `backend`, `kind` (`environment`, `stored`, `missing`, `unusable`), `keyName`, `path`, `reason`, `verified`, `verifiedAt`, `lastFailure`, and `usable` — `usable` is false when no key is present or the last authentication outcome was an HTTP 401/403 rejection. `backend` defaults to `typesafe`; name the backend you pass to `createTypeSafe`, or the report describes a key you do not send. The verification and failure record is one file shared by every backend, so after switching backends the last outcome stands until the next request.
`authState({ backend })` never throws for a valid backend. An invalid backend — an unknown name or an endpoint object that fails validation — throws the same `configuration` error as `resolveBackend`, so validate a user-supplied endpoint with `resolveBackend` first. It reports `backend` (the value you passed, `typesafe` by default — a name or your endpoint object), `kind` (`environment`, `stored`, `missing`, `unusable`), `keyName`, `path`, `reason`, `verified`, `verifiedAt`, `lastFailure`, and `usable` — `usable` is false when no key is present or the last authentication outcome was an HTTP 401/403 rejection. Name the backend you pass to `createTypeSafe`, or the report describes a key you do not send. The verification and failure record is one file shared by every backend, so after switching backends the last outcome stands until the next request: a 401 recorded while one backend is in use makes every backend's `authState` report `usable: false`.

`describeAuth(state)` turns that into `{ level: "ok" | "warning" | "error", text }` for a status line or a log. The extension calls both at session start and after a rejection, so an enabled-but-unusable setup is never reported as working.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pi-typesafe",
"version": "0.7.4",
"version": "0.8.0",
"description": "TypeSafe AI (Jev) decisions for Pi: batched Choice/Score/Noul evaluation tool, terminal playground, and a typed API other extensions build on.",
"type": "module",
"license": "MIT",
Expand Down
18 changes: 11 additions & 7 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { DEFAULT_BACKEND, TYPESAFE_KEY_ENV, backendConfig, usesTypesafeKey } from "./backends.js";
import type { TypeSafeBackend } from "./backends.js";
import { DEFAULT_BACKEND, TYPESAFE_KEY_ENV, resolveBackend, usesTypesafeKey } from "./backends.js";
import type { BackendSpec } from "./backends.js";
import { credentialsPath, keySituation, keySourceLabel, piTypesafeDir } from "./credentials.js";
import type { KeySource } from "./credentials.js";
import { TypeSafeIntegrationError } from "./errors.js";
Expand All @@ -28,8 +28,8 @@ export interface AuthFailure {
* that judgments will happen — an enabled extension with no key used to look identical to a working one.
*/
export interface AuthState {
/** The judgment backend this state describes; each backend has its own key. */
readonly backend: TypeSafeBackend;
/** The judgment backend this state describes; each backend has its own key. Holds the value the caller passed. */
readonly backend: BackendSpec;
/** Same kinds as KeySituation: where the key in effect comes from. */
readonly kind: "environment" | "stored" | "missing" | "unusable";
readonly source?: KeySource;
Expand Down Expand Up @@ -89,8 +89,12 @@ function writeState(path: string, state: { verifiedAt?: string; lastFailure?: Au
}
}

/** What the key situation, the last outcome, and the clock add up to for one backend. Never throws. */
export function authState(options: { path?: string; backend?: TypeSafeBackend } = {}): AuthState {
/**
* What the key situation, the last outcome, and the clock add up to for a valid backend; it never throws for one. An
* invalid backend throws the same `configuration` error as resolveBackend(), so validate a user-supplied endpoint
* with resolveBackend() first.
*/
export function authState(options: { path?: string; backend?: BackendSpec } = {}): AuthState {
const path = options.path ?? authStatePath();
const backend = options.backend ?? DEFAULT_BACKEND;
const situation = keySituation(backend);
Expand Down Expand Up @@ -146,7 +150,7 @@ export interface AuthReport {
* state instead of reporting "enabled".
*/
export function describeAuth(state: AuthState = authState()): AuthReport {
const config = backendConfig(state.backend ?? DEFAULT_BACKEND);
const config = resolveBackend(state.backend);
const label = `${config.label} key`;
const since = state.lastFailure ? ` Last failure: ${state.lastFailure.message}${state.lastFailure.at ? ` (${state.lastFailure.at})` : ""}` : "";
if (state.kind === "missing") {
Expand Down
Loading
Loading