UNPKG

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
/** * 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; }