pi-decider
Version:
Decision backends for pi and omp — TypeSafe Jev, OpenRouter's Decisions API, or an OpenAI-compatible chat proxy — exposed as one typed tool (noul / choice / score)
629 lines (574 loc) • 23.1 kB
text/typescript
/**
* Configuration for the `decide` pi extension.
*
* Shape: one top-level `backend` selector plus a self-contained block per backend, so a
* credential and its endpoint are always paired:
*
* typesafe -> TypeSafe System One /v1/systemone TYPESAFE_API_KEY
* openrouter -> OpenRouter Decisions API /alpha/decisions OPENROUTER_API_KEY
* llm -> any OpenAI-compatible chat /chat/completions DECIDER_LLM_API_KEY
*
* Precedence per field: config file -> environment -> built-in default. Config is re-read on
* every call, so edits need no `/reload`, and a string value may be `"$ENV_VAR"` to defer the
* lookup to request time.
*
* The config file is `<agentDir>/decider.json`, where `<agentDir>` defaults to `~/.pi/agent`
* and follows `PI_CODING_AGENT_DIR`. Callers pass the directory in, which keeps this module
* free of pi imports and unit-testable outside the pi runtime.
*/
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { JevError, messageOf } from "./errors.ts";
export const CONFIG_FILENAME = "decider.json";
export const BACKEND_IDS = ["typesafe", "openrouter", "llm"] as const;
export type BackendId = (typeof BACKEND_IDS)[number];
/** `decisions` backends take state + typed questions; `chat` backends are proxied chat completions. */
export type BackendKind = "decisions" | "chat";
export type BackendSelection = "auto" | BackendId;
export interface BackendDefaults {
label: string;
kind: BackendKind;
baseUrl: string;
path: string;
/** Model id used when neither the config file, the environment, nor the tool call sets one. */
model?: string;
costPerMTokInput: number;
costPerMTokOutput: number;
timeoutMs: number;
maxRetries: number;
}
export const BACKEND_DEFAULTS: Record<BackendId, BackendDefaults> = {
typesafe: {
label: "TypeSafe System One",
kind: "decisions",
baseUrl: "https://api.typesafe.ai",
path: "/v1/systemone",
model: "jev-latest",
costPerMTokInput: 0.042,
costPerMTokOutput: 0,
timeoutMs: 60_000,
maxRetries: 2,
},
openrouter: {
label: "OpenRouter Decisions API",
kind: "decisions",
baseUrl: "https://openrouter.ai/api",
path: "/alpha/decisions",
model: "~typesafe/jev-latest",
costPerMTokInput: 0.042,
costPerMTokOutput: 0,
timeoutMs: 60_000,
maxRetries: 2,
},
llm: {
label: "OpenAI-compatible chat proxy",
kind: "chat",
baseUrl: "https://openrouter.ai/api/v1",
path: "/chat/completions",
costPerMTokInput: 0,
costPerMTokOutput: 0,
timeoutMs: 60_000,
maxRetries: 0,
},
};
/** Well-known provider keys, tried after the backend-scoped ones. */
const PROVIDER_KEY_ENVS: Record<BackendId, string[]> = {
typesafe: ["TYPESAFE_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
llm: ["TYPESAFE_LLM_API_KEY", "OPENROUTER_API_KEY"],
};
/** Env var name `/decide setup` suggests for each backend. */
export const SUGGESTED_KEY_ENV: Record<BackendId, string> = {
typesafe: "TYPESAFE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
llm: "DECIDER_LLM_API_KEY",
};
export const DEFAULT_MODELS_PATH = "/v1/models";
export const DEFAULT_LLM_MAX_TOKENS = 2048;
export const DEFAULT_LLM_REPAIR_ATTEMPTS = 1;
export const BACKEND_ENV = "DECIDER_BACKEND";
export const MODELS_PATH_ENV = "DECIDER_MODELS_PATH";
/** OpenRouter serves Jev through its Decisions API; verified 2026-09-18. */
export const OPENROUTER_NOTE =
`OpenRouter's Decisions API routes Jev through ${BACKEND_DEFAULTS.openrouter.baseUrl}${BACKEND_DEFAULTS.openrouter.path} ` +
`with model ${BACKEND_DEFAULTS.openrouter.model} at $0.042/Mtok input and $0/Mtok output; ` +
`only the family alias exists there (pinned ids such as ~typesafe/jev-1.13.0 return 404) and it is absent from the public /v1/models catalogue.`;
export type JsonMode = "json_object" | "none";
const JSON_MODES: readonly JsonMode[] = ["json_object", "none"];
export interface BackendConfig {
id: BackendId;
kind: BackendKind;
label: string;
apiKey: string | undefined;
apiKeySource: string;
baseUrl: string;
baseUrlSource: string;
path: string;
pathSource: string;
model: string | undefined;
modelSource: string;
timeoutMs: number;
maxRetries: number;
costPerMTokInput: number;
costPerMTokOutput: number;
/** chat backends only */
temperature?: number;
maxTokens?: number;
jsonMode?: JsonMode;
repairAttempts?: number;
}
export interface DeciderConfig {
/** `auto`, or a pinned backend id. */
backend: BackendSelection;
backendSource: string;
backends: Record<BackendId, BackendConfig>;
modelsPath: string;
configPath: string;
notes: string[];
}
export interface ResolveConfigOptions {
/** Directory holding pi's agent state (`getAgentDir()`), usually `~/.pi/agent`. */
agentDir: string;
env?: NodeJS.ProcessEnv;
/** Override the config file location (tests). Defaults to `<agentDir>/decider.json`. */
configPath?: string;
/** Skip reading the config file entirely (tests). */
skipConfigFile?: boolean;
}
/** Read and parse the config file. A missing file is not an error. */
export function readConfigFile(path: string): Record<string, unknown> {
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw new JevError(`Cannot read config file ${path}: ${messageOf(error)}`);
}
const text = raw.replace(/^\uFEFF/, "").trim();
if (text === "") return {};
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
throw new JevError(`Config file ${path} is not valid JSON: ${messageOf(error)}`);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new JevError(`Config file ${path} must contain a JSON object.`);
}
return parsed as Record<string, unknown>;
}
export function resolveConfig(options: ResolveConfigOptions): DeciderConfig {
const env = options.env ?? process.env;
const configPath = options.configPath ?? join(options.agentDir, CONFIG_FILENAME);
const root = options.skipConfigFile ? {} : readConfigFile(configPath);
const notes: string[] = [];
const backends = {} as Record<BackendId, BackendConfig>;
for (const id of BACKEND_IDS) {
backends[id] = resolveBackend(id, readBackendBlock(root[id], configPath, id), env, notes);
}
const selection = readSelection(root.backend, configPath, env);
if (backends.openrouter.apiKey !== undefined && backends.typesafe.apiKey === undefined) notes.push(OPENROUTER_NOTE);
return {
backend: selection.value,
backendSource: selection.source,
backends,
modelsPath: optionalString(root.modelsPath, configPath, "modelsPath") ?? env[MODELS_PATH_ENV]?.trim() ?? DEFAULT_MODELS_PATH,
configPath,
notes,
};
}
/**
* Resolve the backend for a request. Explicit selections win; `auto` takes the first backend
* that is actually usable, preferring real Jev (TypeSafe, then OpenRouter) over the chat proxy.
*/
export function selectBackend(cfg: DeciderConfig, requested?: string): BackendConfig {
if (requested !== undefined) {
const backend = cfg.backends[requested as BackendId];
if (backend === undefined) {
throw new JevError(`Unknown backend "${requested}".`, { hint: describeBackends(cfg) });
}
requireUsable(backend, cfg);
return backend;
}
if (cfg.backend !== "auto") {
const backend = cfg.backends[cfg.backend];
requireUsable(backend, cfg);
return backend;
}
for (const id of BACKEND_IDS) {
const backend = cfg.backends[id];
if (isUsable(backend)) return backend;
}
throw new JevError("No backend is usable.", { hint: describeBackends(cfg) });
}
/** One line per backend: credential state, endpoint, model. Used by errors and `/decide status`. */
export function describeBackends(cfg: DeciderConfig): string {
return BACKEND_IDS.map((id) => {
const backend = cfg.backends[id];
const key = backend.apiKey === undefined ? "no key" : `${backend.apiKeySource} ${maskKey(backend.apiKey)}`;
const model = backend.model ?? "no model";
return `${id} (${backend.label}): ${key} · ${backend.baseUrl}${backend.path} · ${model}`;
}).join("\n");
}
function isUsable(backend: BackendConfig): boolean {
if (backend.apiKey === undefined) return false;
// A chat backend cannot answer without a model to call.
return backend.kind !== "chat" || (backend.model !== undefined && backend.model.trim() !== "");
}
function requireUsable(backend: BackendConfig, cfg: DeciderConfig): void {
if (isUsable(backend)) return;
const reason = backend.apiKey === undefined ? "has no API key" : "has no model";
throw new JevError(`Backend "${backend.id}" ${reason}.`, { hint: describeBackends(cfg) });
}
/** `sk-a…wxyz (48 chars)` — never returns the full key. */
export function maskKey(key: string | undefined): string {
if (!key) return "not set";
if (key.length <= 8) return `…${key.slice(-2)} (${key.length} chars)`;
return `${key.slice(0, 4)}…${key.slice(-4)} (${key.length} chars)`;
}
export function joinUrl(baseUrl: string, path: string): string {
return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
function resolveBackend(
id: BackendId,
block: Record<string, unknown>,
env: NodeJS.ProcessEnv,
notes: string[],
): BackendConfig {
const defaults = BACKEND_DEFAULTS[id];
const configPath = "config file";
const prefix = `DECIDER_${id.toUpperCase()}`;
const key = resolveString(block.apiKey, configPath, `${id}.apiKey`, env, notes);
let apiKey = key.value;
let apiKeySource = key.value === undefined ? "not set" : `${id}.apiKey`;
for (const name of [`${prefix}_API_KEY`, ...PROVIDER_KEY_ENVS[id]]) {
if (apiKey !== undefined) break;
if (env[name]?.trim()) {
apiKey = env[name]!.trim();
apiKeySource = `$${name}`;
}
}
const base = resolveString(block.baseUrl, configPath, `${id}.baseUrl`, env, notes);
let baseUrl = base.value;
let baseUrlSource = base.value === undefined ? "" : `${id}.baseUrl`;
if (baseUrl === undefined && env[`${prefix}_BASE_URL`]?.trim()) {
baseUrl = env[`${prefix}_BASE_URL`]!.trim();
baseUrlSource = `$${prefix}_BASE_URL`;
}
if (baseUrl === undefined) {
baseUrl = defaults.baseUrl;
baseUrlSource = "default";
}
const path = resolveString(block.path, configPath, `${id}.path`, env, notes);
let backendPath = path.value;
let pathSource = path.value === undefined ? "" : `${id}.path`;
if (backendPath === undefined && env[`${prefix}_PATH`]?.trim()) {
backendPath = env[`${prefix}_PATH`]!.trim();
pathSource = `$${prefix}_PATH`;
}
if (backendPath === undefined) {
backendPath = defaults.path;
pathSource = defaults.path === defaults.path ? "default" : "default";
}
const model = resolveString(block.model, configPath, `${id}.model`, env, notes);
let modelValue = model.value;
let modelSource = model.value === undefined ? "not set" : `${id}.model`;
if (modelValue === undefined && env[`${prefix}_MODEL`]?.trim()) {
modelValue = env[`${prefix}_MODEL`]!.trim();
modelSource = `$${prefix}_MODEL`;
}
if (modelValue === undefined && defaults.model !== undefined) {
modelValue = defaults.model;
modelSource = "default";
}
const config: BackendConfig = {
id,
kind: defaults.kind,
label: defaults.label,
apiKey,
apiKeySource,
baseUrl: baseUrl.replace(/\/+$/, ""),
baseUrlSource,
path: backendPath,
pathSource,
model: modelValue,
modelSource,
timeoutMs: readNumber(block.timeoutMs, configPath, `${id}.timeoutMs`, defaults.timeoutMs, {
integer: false,
min: 1,
}),
maxRetries: readNumber(block.maxRetries, configPath, `${id}.maxRetries`, defaults.maxRetries, {
integer: true,
min: 0,
}),
costPerMTokInput: readNumber(block.costPerMTokInput, configPath, `${id}.costPerMTokInput`, defaults.costPerMTokInput, {
integer: false,
min: 0,
}),
costPerMTokOutput: readNumber(
block.costPerMTokOutput,
configPath,
`${id}.costPerMTokOutput`,
defaults.costPerMTokOutput,
{ integer: false, min: 0 },
),
};
if (defaults.kind === "chat") {
config.temperature = readNumber(block.temperature, configPath, `${id}.temperature`, 0, { integer: false, min: 0 });
config.maxTokens = readNumber(block.maxTokens, configPath, `${id}.maxTokens`, DEFAULT_LLM_MAX_TOKENS, {
integer: true,
min: 1,
});
config.jsonMode = readEnum(block.jsonMode, JSON_MODES, configPath, `${id}.jsonMode`) ?? "json_object";
config.repairAttempts = readNumber(
block.repairAttempts,
configPath,
`${id}.repairAttempts`,
DEFAULT_LLM_REPAIR_ATTEMPTS,
{ integer: true, min: 0 },
);
}
return config;
}
function readSelection(
raw: unknown,
configPath: string,
env: NodeJS.ProcessEnv,
): { value: BackendSelection; source: string } {
const allowed: readonly BackendSelection[] = ["auto", ...BACKEND_IDS];
const fromFile = optionalString(raw, configPath, "backend");
if (fromFile !== undefined) {
if (!(allowed as readonly string[]).includes(fromFile)) {
throw new JevError(`${configPath}: backend must be one of ${allowed.join(", ")} (got "${fromFile}").`);
}
return { value: fromFile as BackendSelection, source: `${configPath}:backend` };
}
const fromEnv = env[BACKEND_ENV]?.trim();
if (fromEnv) {
if (!(allowed as readonly string[]).includes(fromEnv)) {
throw new JevError(`$${BACKEND_ENV} must be one of ${allowed.join(", ")} (got "${fromEnv}").`);
}
return { value: fromEnv as BackendSelection, source: `$${BACKEND_ENV}` };
}
return { value: "auto", source: "default" };
}
function readBackendBlock(raw: unknown, configPath: string, id: BackendId): Record<string, unknown> {
if (raw === undefined || raw === null) return {};
if (typeof raw !== "object" || Array.isArray(raw)) {
throw new JevError(`${configPath}: ${id} must be an object.`);
}
return raw as Record<string, unknown>;
}
/** What `/decide setup` writes: a top-level backend choice and/or fields inside one backend block. */
export interface ConfigPatch {
backend?: BackendSelection;
/** Other top-level fields, e.g. `modelsPath`. */
rootValues?: Record<string, unknown>;
backendBlock?: { id: BackendId; values: Record<string, unknown> };
}
export type FieldKind = "string" | "number" | "integer" | "enum";
export interface FieldSpec {
kind: FieldKind;
/** Allowed values for `enum` fields. */
values?: readonly string[];
/** Keys are credentials: mask them when echoing what was written. */
sensitive?: boolean;
}
const BACKEND_COMMON_FIELDS: Record<string, FieldSpec> = {
apiKey: { kind: "string", sensitive: true },
baseUrl: { kind: "string" },
path: { kind: "string" },
model: { kind: "string" },
timeoutMs: { kind: "integer" },
maxRetries: { kind: "integer" },
costPerMTokInput: { kind: "number" },
costPerMTokOutput: { kind: "number" },
};
const CHAT_ONLY_FIELDS: Record<string, FieldSpec> = {
temperature: { kind: "number" },
maxTokens: { kind: "integer" },
jsonMode: { kind: "enum", values: ["json_object", "none"] },
repairAttempts: { kind: "integer" },
};
/** Writable fields per target, driving both `/decide set|unset` and its error messages. */
export const SETTABLE_FIELDS: Record<BackendId | "root", Record<string, FieldSpec>> = {
root: {
backend: { kind: "enum", values: ["auto", ...BACKEND_IDS] },
modelsPath: { kind: "string" },
},
typesafe: { ...BACKEND_COMMON_FIELDS },
openrouter: { ...BACKEND_COMMON_FIELDS },
llm: { ...BACKEND_COMMON_FIELDS, ...CHAT_ONLY_FIELDS },
};
export const SETTABLE_TARGETS: readonly string[] = ["root", ...BACKEND_IDS];
/** `set mock-model` -> a masked preview for credentials, the raw value otherwise. */
export function formatSettingValue(field: string, target: string, value: unknown): string {
if (typeof value !== "string") return String(value);
if (SETTABLE_FIELDS[target as BackendId | "root"]?.[field]?.sensitive === true && !value.startsWith("$")) {
return maskKey(value);
}
return value;
}
function parseFieldValue(target: string, field: string, spec: FieldSpec, raw: string): unknown {
const value = raw.trim();
if (value === "") throw new JevError(`${target}.${field} needs a value.`);
switch (spec.kind) {
case "string":
return value;
case "enum":
if (!(spec.values ?? []).includes(value)) {
throw new JevError(`${target}.${field} must be one of ${(spec.values ?? []).join(", ")} (got "${value}").`);
}
return value;
case "number": {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new JevError(`${target}.${field} must be a finite number >= 0 (got "${value}").`);
}
return parsed;
}
case "integer": {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new JevError(`${target}.${field} must be a non-negative integer (got "${value}").`);
}
return parsed;
}
}
}
function fieldsFor(target: string): Record<string, FieldSpec> {
const table = SETTABLE_FIELDS[target as BackendId | "root"];
if (table === undefined) {
throw new JevError(`Unknown target "${target}". Targets: ${SETTABLE_TARGETS.join(", ")}.`);
}
return table;
}
/** `/decide set <target> <field> <value>` — validated, then merged into the existing config file. */
export function applySetting(
root: Record<string, unknown>,
target: string,
field: string,
rawValue: string | undefined,
): Record<string, unknown> {
// `set backend llm` names the value in the field slot; `set root backend llm` spells it out.
const selectorShortcut = target === "backend";
const resolved = selectorShortcut ? "root" : target;
const resolvedField = selectorShortcut ? "backend" : field;
const table = fieldsFor(resolved);
const spec = table[resolvedField];
if (spec === undefined) {
throw new JevError(`Unknown field "${resolvedField}" for ${resolved}. Writable: ${Object.keys(table).join(", ")}.`);
}
const value = parseFieldValue(resolved, resolvedField, spec, selectorShortcut ? field : (rawValue ?? ""));
if (resolved === "root") {
return resolvedField === "backend"
? mergeConfig(root, { backend: value as BackendSelection })
: mergeConfig(root, { rootValues: { [resolvedField]: value } });
}
return mergeConfig(root, { backendBlock: { id: resolved as BackendId, values: { [resolvedField]: value } } });
}
/** `/decide unset <target> [field]` — drop one field, or the whole block when no field is given. */
export function clearSetting(root: Record<string, unknown>, target: string, field?: string): Record<string, unknown> {
const resolved = target === "backend" ? "root" : target;
fieldsFor(resolved);
const next: Record<string, unknown> = { ...root };
if (resolved === "root") {
const key = target === "backend" ? "backend" : field;
if (key === undefined) throw new JevError("unset root needs a field name.", { hint: "Writable: backend, modelsPath." });
delete next[key];
return next;
}
if (field === undefined) {
delete next[resolved];
return next;
}
const existing = next[resolved];
if (existing === null || typeof existing !== "object" || Array.isArray(existing)) return next;
const block = { ...(existing as Record<string, unknown>) };
delete block[field];
if (Object.keys(block).length === 0) delete next[resolved];
else next[resolved] = block;
return next;
}
/** Merge a patch into a parsed config file without dropping unrelated keys. */
export function mergeConfig(root: Record<string, unknown>, patch: ConfigPatch): Record<string, unknown> {
const merged: Record<string, unknown> = { ...root };
if (patch.backend !== undefined) merged.backend = patch.backend;
if (patch.backendBlock !== undefined) {
const { id, values } = patch.backendBlock;
const existing = merged[id];
const block =
existing !== null && typeof existing === "object" && !Array.isArray(existing)
? { ...(existing as Record<string, unknown>) }
: {};
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete block[key];
else block[key] = value;
}
merged[id] = block;
}
return merged;
}
/** Persist a config file, creating the parent directory. Keys live here, so restrict permissions where the OS supports it. */
export function saveConfigFile(configPath: string, root: Record<string, unknown>): void {
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, `${JSON.stringify(root, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
// `mode` only applies at creation and is ignored on Windows; enforce it when the platform supports it.
if (process.platform !== "win32") chmodSync(configPath, 0o600);
}
interface ResolvedString {
value: string | undefined;
}
/** Resolve a string field, expanding a leading `$VAR` / `${VAR}` indirection. */
function resolveString(
raw: unknown,
configPath: string,
field: string,
env: NodeJS.ProcessEnv,
notes: string[],
): ResolvedString {
const value = optionalString(raw, configPath, field);
if (value === undefined) return { value: undefined };
const braced = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value);
const name = braced?.[1] ?? /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value)?.[1];
if (name === undefined) return { value };
const resolved = env[name];
if (resolved?.trim()) return { value: resolved.trim() };
notes.push(`${field} points at $${name}, which is not set.`);
return { value: undefined };
}
function optionalString(raw: unknown, configPath: string, field: string): string | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== "string") throw new JevError(`${configPath}: ${field} must be a string.`);
const trimmed = raw.trim();
return trimmed === "" ? undefined : trimmed;
}
function readEnum<T extends string>(
raw: unknown,
allowed: readonly T[],
configPath: string,
field: string,
): T | undefined {
const value = optionalString(raw, configPath, field);
if (value === undefined) return undefined;
if (!(allowed as readonly string[]).includes(value)) {
throw new JevError(`${configPath}: ${field} must be one of ${allowed.join(", ")} (got "${value}").`);
}
return value as T;
}
function readNumber(
raw: unknown,
configPath: string,
field: string,
fallback: number,
bounds: { integer: boolean; min: number },
): number {
if (raw === undefined || raw === null) return fallback;
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < bounds.min) {
throw new JevError(`${configPath}: ${field} must be a finite number >= ${bounds.min}.`);
}
if (bounds.integer && !Number.isInteger(raw)) {
throw new JevError(`${configPath}: ${field} must be an integer.`);
}
return raw;
}