pi-pignon 0.1.1

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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Model presets: the four built-in model names (`fast`, `balanced`,
3
+ * `reasoner`, `agent`) filled from one provider. A config file selects one
4
+ * with `"extends": "<name>"`; `/pignon init <name>` copies one into a new
5
+ * config file.
6
+ *
7
+ * Ids are Pi model ids (`pi --list-models`). They are starting points, not
8
+ * recommendations: check prices and quality for your own work.
9
+ */
10
+
11
+ import type { ModelSpec } from "../types.js";
12
+
13
+ export interface Preset {
14
+ description: string;
15
+ models: Readonly<Record<"fast" | "balanced" | "reasoner" | "agent", ModelSpec>>;
16
+ }
17
+
18
+ export const PRESETS = {
19
+ openrouter: {
20
+ description: "OpenRouter: DeepSeek flash models, GLM for reasoning, HY4 as agent (the built-in table)",
21
+ models: {
22
+ fast: { provider: "openrouter", modelId: "deepseek/deepseek-v4-flash-0731", thinking: "off" },
23
+ balanced: { provider: "openrouter", modelId: "deepseek/deepseek-v4.1-flash", thinking: "low" },
24
+ reasoner: { provider: "openrouter", modelId: "z-ai/glm-5.3", thinking: "high" },
25
+ agent: { provider: "openrouter", modelId: "tencent/hy4-preview", thinking: "low" },
26
+ },
27
+ },
28
+ anthropic: {
29
+ description: "Anthropic: Haiku for small edits, Sonnet for the rest, Opus for hard reasoning",
30
+ models: {
31
+ fast: { provider: "anthropic", modelId: "claude-haiku-4-5", thinking: "off" },
32
+ balanced: { provider: "anthropic", modelId: "claude-sonnet-5", thinking: "low" },
33
+ reasoner: { provider: "anthropic", modelId: "claude-opus-5-5", thinking: "high" },
34
+ agent: { provider: "anthropic", modelId: "claude-sonnet-5", thinking: "medium" },
35
+ },
36
+ },
37
+ openai: {
38
+ description: "OpenAI: GPT-6 Luna for small edits, GPT-5.6 Terra for the rest, GPT-6 Sol for reasoning, Codex as agent",
39
+ models: {
40
+ fast: { provider: "openai", modelId: "gpt-6-luna", thinking: "off" },
41
+ balanced: { provider: "openai", modelId: "gpt-5.6-terra", thinking: "low" },
42
+ reasoner: { provider: "openai", modelId: "gpt-6-sol", thinking: "high" },
43
+ agent: { provider: "openai", modelId: "gpt-5.3-codex", thinking: "medium" },
44
+ },
45
+ },
46
+ } as const satisfies Record<string, Preset>;
47
+
48
+ export type PresetName = keyof typeof PRESETS;
49
+
50
+ export const PRESET_NAMES = Object.keys(PRESETS) as PresetName[];
51
+
52
+ export function isPresetName(value: unknown): value is PresetName {
53
+ return typeof value === "string" && value in PRESETS;
54
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Shape of the config file (`~/.pi/agent/pignon.json`), as a TypeBox schema.
3
+ *
4
+ * TypeBox schemas are plain JSON Schema, so this one module gives the file's
5
+ * TS types, the runtime checks (`load.ts`) and `schema/config.schema.json`
6
+ * for editor autocompletion.
7
+ */
8
+
9
+ import Type, { type Static } from "typebox";
10
+
11
+ import { THINKING_LEVELS } from "../types.js";
12
+ import { PRESETS, PRESET_NAMES } from "./presets.js";
13
+
14
+ const ThinkingSchema = Type.Enum([...THINKING_LEVELS], {
15
+ description: "Pi thinking level to set with the model. Pi clamps it to what the model supports.",
16
+ });
17
+
18
+ export const ModelSpecSchema = Type.Object(
19
+ {
20
+ provider: Type.String({ minLength: 1, description: "Pi provider id, e.g. `openrouter` or `anthropic`." }),
21
+ modelId: Type.String({ minLength: 1, description: "Model id within the provider. Check with `pi --list-models`." }),
22
+ thinking: ThinkingSchema,
23
+ },
24
+ { additionalProperties: false },
25
+ );
26
+
27
+ /** A model: the name of an entry in `models`, or an inline model. */
28
+ const modelRef = (description: string) =>
29
+ Type.Union([Type.String({ minLength: 1, description: "Name of an entry in `models`." }), ModelSpecSchema], {
30
+ description,
31
+ });
32
+
33
+ export const ModelRefSchema = modelRef("A name from `models`, or an inline { provider, modelId, thinking }.");
34
+
35
+ export const TierFileSchema = Type.Object(
36
+ {
37
+ id: Type.String({
38
+ pattern: "^[a-z][a-z0-9_-]*$",
39
+ description: "Tier name, shown on decision cards. Lowercase letters, digits, `-` and `_`.",
40
+ }),
41
+ criterion: Type.String({
42
+ minLength: 1,
43
+ description: "How to recognize a task of this tier. This text is what the decision model reads.",
44
+ }),
45
+ model: Type.Optional(modelRef("Model for every task of this tier. Use it, or both `direct` and `exploration`.")),
46
+ direct: Type.Optional(modelRef("Model for tasks that can be done without exploring the codebase.")),
47
+ exploration: Type.Optional(modelRef("Model for tasks that need to explore the codebase first.")),
48
+ explorationAllowed: Type.Optional(
49
+ Type.Boolean({
50
+ description: "Set false to send tasks that need exploration to the next tier up. Default true.",
51
+ }),
52
+ ),
53
+ },
54
+ { additionalProperties: false },
55
+ );
56
+
57
+ export const TiersSchema = Type.Array(TierFileSchema, {
58
+ minItems: 2,
59
+ maxItems: 8,
60
+ description: "Difficulty tiers, easiest first. Replaces the default list as a whole.",
61
+ });
62
+
63
+ export const QuestionsSchema = Type.Object(
64
+ {
65
+ version: Type.Optional(
66
+ Type.String({
67
+ minLength: 1,
68
+ description: "Label stored with each decision. Change it whenever you edit the wording or the tier criteria.",
69
+ }),
70
+ ),
71
+ tierInstructions: Type.Optional(Type.String({ minLength: 1 })),
72
+ explorationInstructions: Type.Optional(Type.String({ minLength: 1 })),
73
+ explorationCriteria: Type.Optional(
74
+ Type.Object(
75
+ { yes: Type.Optional(Type.String({ minLength: 1 })), no: Type.Optional(Type.String({ minLength: 1 })) },
76
+ { additionalProperties: false },
77
+ ),
78
+ ),
79
+ },
80
+ { additionalProperties: false, description: "Wording of the questions sent to the decision model." },
81
+ );
82
+
83
+ const threshold = (description: string) => Type.Optional(Type.Number({ minimum: 0, description }));
84
+
85
+ export const ThresholdsSchema = Type.Object(
86
+ {
87
+ minConfidenceDowngrade: threshold("Confidence needed to downgrade, or to move in from a model outside the table."),
88
+ minConfidenceUpgrade: threshold("Confidence needed to upgrade."),
89
+ minConfidenceForm: threshold("Confidence needed to call a task direct rather than exploration."),
90
+ cacheGuardTokens: threshold("Context size above which lateral switches (and downgrades, when prices are unknown) are refused."),
91
+ minPromptsBetweenSwitches: threshold("Prompts to wait after a switch before the next downgrade or lateral switch."),
92
+ maxPaybackRequests: threshold("A downgrade must recoup its cache-miss cost within this many LLM requests."),
93
+ assumedOutputTokensPerRequest: threshold("Output per request assumed when estimating what a downgrade saves."),
94
+ layaTimeoutMs: threshold("Timeout for one decision from the local Laya worker, in milliseconds."),
95
+ },
96
+ { additionalProperties: false },
97
+ );
98
+
99
+ export const ConfidenceSourceSchema = Type.Enum(["reported", "top-probability"], {
100
+ description:
101
+ "`reported`: the decision model's confidence. `top-probability`: the probability of the chosen answer, for checkpoints whose confidence is uncalibrated.",
102
+ });
103
+
104
+ const timeoutMs = (description: string) => Type.Optional(Type.Number({ exclusiveMinimum: 0, description }));
105
+
106
+ export const LayaLocalDeciderSchema = Type.Object(
107
+ {
108
+ type: Type.Literal("laya-local", {
109
+ description: "Experimental: pignon's own Laya worker (Apple Silicon, laya-mlx), installed from the repository's worker/ directory.",
110
+ }),
111
+ timeoutMs: timeoutMs("Timeout for one decision, in milliseconds. Default: thresholds.layaTimeoutMs."),
112
+ command: Type.Optional(
113
+ Type.Array(Type.String({ minLength: 1 }), {
114
+ minItems: 1,
115
+ description:
116
+ "Command that starts the worker, e.g. [\"uv\", \"run\", \"--project\", \"/path/to/pignon/worker\", \"pignon-laya\"]. Default: found automatically (LAYA_PYTHON, a source checkout, then pignon-laya on PATH).",
117
+ }),
118
+ ),
119
+ },
120
+ { additionalProperties: false },
121
+ );
122
+
123
+ export const LayaServeDeciderSchema = Type.Object(
124
+ {
125
+ type: Type.Literal("laya-serve", {
126
+ description: "A Laya model served by the official laya-serve (`pip install \"laya[serve]\"`). Runs on your machine unless url points elsewhere.",
127
+ }),
128
+ url: Type.Optional(
129
+ Type.String({ pattern: "^https?://", description: "Server root. Default: http://127.0.0.1:8000 (laya-serve's default port)." }),
130
+ ),
131
+ model: Type.Optional(
132
+ Type.String({ minLength: 1, description: "Laya checkpoint: `english`, `multilingual` or `typed-decisions`. Default: chosen by the server from the prompt's language." }),
133
+ ),
134
+ apiKeyEnv: Type.Optional(
135
+ Type.String({ minLength: 1, description: "Environment variable holding the server's key, when it was started with LAYA_API_KEY. Default: no key." }),
136
+ ),
137
+ timeoutMs: timeoutMs("Timeout for one decision, in milliseconds. Default: 1500."),
138
+ },
139
+ { additionalProperties: false },
140
+ );
141
+
142
+ export const JevDeciderSchema = Type.Object(
143
+ {
144
+ type: Type.Literal("jev", { description: "TypeSafe's hosted Jev model. Sends each routed prompt (first 4000 characters) to the API." }),
145
+ model: Type.Optional(Type.String({ minLength: 1, description: "Jev model to pin, e.g. `jev-1.13.0`. Default: jev-latest." })),
146
+ baseURL: Type.Optional(
147
+ Type.String({ minLength: 1, description: "API root. `https://openrouter.ai/api` goes through OpenRouter. Default: TypeSafe." }),
148
+ ),
149
+ apiKeyEnv: Type.Optional(
150
+ Type.String({ minLength: 1, description: "Environment variable holding the API key. Default: TYPESAFE_API_KEY." }),
151
+ ),
152
+ timeoutMs: timeoutMs("Timeout for one decision, in milliseconds. Default: 1500."),
153
+ maxRetries: Type.Optional(Type.Integer({ minimum: 0, maximum: 3, description: "Retries after a failed attempt. Default: 0." })),
154
+ },
155
+ { additionalProperties: false },
156
+ );
157
+
158
+ export const DECIDER_SCHEMAS = {
159
+ "laya-serve": LayaServeDeciderSchema,
160
+ "laya-local": LayaLocalDeciderSchema,
161
+ jev: JevDeciderSchema,
162
+ } as const;
163
+
164
+ export const DecidersSchema = Type.Array(Type.Union([LayaServeDeciderSchema, LayaLocalDeciderSchema, JevDeciderSchema]), {
165
+ minItems: 1,
166
+ maxItems: 4,
167
+ description:
168
+ "Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set. /pignon init adds laya-serve when it is running.",
169
+ });
170
+
171
+ export const StrategySchema = Type.Object(
172
+ {
173
+ mode: Type.Optional(
174
+ Type.Enum(["sequential", "parallel"], {
175
+ description:
176
+ "`sequential` (default): ask the deciders in order until one is confident enough. `parallel`: ask them all at once, e.g. to compare them.",
177
+ }),
178
+ ),
179
+ escalateBelow: Type.Optional(
180
+ Type.Number({ minimum: 0, maximum: 1, description: "Sequential: ask the next decider when tier confidence is below this. Default 0.75." }),
181
+ ),
182
+ pick: Type.Optional(
183
+ Type.Enum(["most-confident", "first"], {
184
+ description:
185
+ "Parallel: route on the most confident answer (default), or on the first decider in the list that answered (the others are only recorded).",
186
+ }),
187
+ ),
188
+ budgetMs: Type.Optional(
189
+ Type.Number({ exclusiveMinimum: 0, description: "Wall-time limit for one decision, all deciders included. Default 3000." }),
190
+ ),
191
+ },
192
+ { additionalProperties: false, description: "How several deciders are combined." },
193
+ );
194
+
195
+ export const ConfigFileSchema = Type.Object(
196
+ {
197
+ $schema: Type.Optional(Type.String()),
198
+ version: Type.Optional(Type.Literal(2, { description: "Config format version." })),
199
+ extends: Type.Optional(
200
+ Type.Enum([...PRESET_NAMES], {
201
+ description: `Model preset for the built-in names, applied before \`models\`: ${PRESET_NAMES.map((n) => `\`${n}\` (${PRESETS[n].description})`).join("; ")}.`,
202
+ }),
203
+ ),
204
+ deciders: Type.Optional(DecidersSchema),
205
+ strategy: Type.Optional(StrategySchema),
206
+ models: Type.Optional(
207
+ Type.Record(Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" }), ModelSpecSchema, {
208
+ description: "Named models, referenced from `tiers`. Merged over the built-in names (fast, balanced, reasoner, agent).",
209
+ }),
210
+ ),
211
+ tiers: Type.Optional(TiersSchema),
212
+ questions: Type.Optional(QuestionsSchema),
213
+ confidenceSource: Type.Optional(ConfidenceSourceSchema),
214
+ thresholds: Type.Optional(ThresholdsSchema),
215
+ },
216
+ { additionalProperties: false, title: "pignon configuration" },
217
+ );
218
+
219
+ export type ModelRef = Static<typeof ModelRefSchema>;
220
+ export type TierFile = Static<typeof TierFileSchema>;
221
+ export type ConfigFile = Static<typeof ConfigFileSchema>;
222
+
223
+ /** Where the published JSON Schema lives, for the `$schema` key of config files. */
224
+ export const CONFIG_SCHEMA_URL = "https://raw.githubusercontent.com/siiick/pi-pignon/main/schema/config.schema.json";
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Build the decider described by the config.
3
+ *
4
+ * With no `deciders` section, pignon picks one: the experimental Laya worker
5
+ * when it is installed, else Jev when its API key is set. laya-serve is never
6
+ * picked here, since that would need a network probe at startup; `/pignon init`
7
+ * probes for it and writes it into the config. When neither is possible it
8
+ * returns an `UnavailableDecider` whose warmup error says what to install.
9
+ */
10
+
11
+ import type { DeciderSpec, RouterConfig } from "../types.js";
12
+ import { DEFAULT_API_KEY_ENV, JevDecider } from "./jev.js";
13
+ import { LayaWorker, layaRuntimeStatus } from "./laya-local.js";
14
+ import { createLayaServeDecider } from "./laya-serve.js";
15
+ import { parseDecision } from "./parse.js";
16
+ import { StrategyDecider } from "./strategy.js";
17
+ import { type Decider, type DeciderResult, DeciderError } from "./types.js";
18
+
19
+ export interface CreatedDecider {
20
+ decider: Decider;
21
+ /** Things to tell the user once, at session start. */
22
+ notes: string[];
23
+ }
24
+
25
+ export interface CreateDeciderDeps {
26
+ env?: NodeJS.ProcessEnv;
27
+ /** Whether the local worker can run here (tests replace the platform check). */
28
+ layaStatus?: typeof layaRuntimeStatus;
29
+ }
30
+
31
+ export function createDecider(config: RouterConfig, deps: CreateDeciderDeps = {}): CreatedDecider {
32
+ const env = deps.env ?? process.env;
33
+ const notes: string[] = [];
34
+ let specs = config.deciders;
35
+
36
+ if (specs === null) {
37
+ const laya = (deps.layaStatus ?? layaRuntimeStatus)(env);
38
+ if (laya.ok) {
39
+ specs = [{ type: "laya-local" }];
40
+ } else if (env[DEFAULT_API_KEY_ENV]?.trim()) {
41
+ specs = [{ type: "jev" }];
42
+ } else {
43
+ return {
44
+ decider: new UnavailableDecider(
45
+ `no decider configured: start laya-serve (see pignon's README) and run /pignon init, or set ${DEFAULT_API_KEY_ENV} for Jev`,
46
+ ),
47
+ notes,
48
+ };
49
+ }
50
+ }
51
+
52
+ const deciders = specs.map((spec) => build(spec, config, env));
53
+ if (deciders.length === 1) return { decider: deciders[0]!, notes };
54
+ return {
55
+ decider: new StrategyDecider(deciders, config.strategy, (answers, latencyMs) => parseDecision(answers, latencyMs, config)),
56
+ notes,
57
+ };
58
+ }
59
+
60
+ function build(spec: DeciderSpec, config: RouterConfig, env: NodeJS.ProcessEnv): Decider {
61
+ switch (spec.type) {
62
+ case "laya-serve":
63
+ return createLayaServeDecider(spec, env);
64
+ case "laya-local":
65
+ return new LayaWorker({
66
+ timeoutMs: spec.timeoutMs ?? config.thresholds.layaTimeoutMs,
67
+ ...(spec.command ? { launchCommand: spec.command } : {}),
68
+ });
69
+ case "jev":
70
+ return new JevDecider({
71
+ env,
72
+ ...(spec.model !== undefined ? { model: spec.model } : {}),
73
+ ...(spec.baseURL !== undefined ? { baseURL: spec.baseURL } : {}),
74
+ ...(spec.apiKeyEnv !== undefined ? { apiKeyEnv: spec.apiKeyEnv } : {}),
75
+ ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
76
+ ...(spec.maxRetries !== undefined ? { maxRetries: spec.maxRetries } : {}),
77
+ });
78
+ }
79
+ }
80
+
81
+ /** Stands in when no decider can run: never ready, and says why. */
82
+ export class UnavailableDecider implements Decider {
83
+ readonly id = "none";
84
+ readonly remote = false;
85
+ readonly isReady = false;
86
+ readonly model = undefined;
87
+ readonly recentLogs: readonly string[];
88
+
89
+ constructor(private readonly reason: string) {
90
+ this.recentLogs = [reason];
91
+ }
92
+
93
+ async warmup(): Promise<void> {
94
+ throw new DeciderError(this.reason);
95
+ }
96
+
97
+ async decide(): Promise<DeciderResult> {
98
+ throw new DeciderError(this.reason);
99
+ }
100
+
101
+ stop(): void {}
102
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `jev` decider: TypeSafe's hosted Jev System-One model, through the official
3
+ * TypeScript SDK (`@typesafe-ai/sdk`).
4
+ *
5
+ * Remote: the prompt (already capped by the router) leaves the machine. The
6
+ * SDK is hardened for use inside Pi's TUI:
7
+ * - its logger writes to `recentLogs` (for `/pignon log`), never the console,
8
+ * and never at `debug` level, which would log request bodies (prompts);
9
+ * - retries are off by default: the SDK's timeout applies per attempt with no
10
+ * total budget, and a routing decision that arrives late is useless.
11
+ *
12
+ * The same client serves `laya-serve` (see `laya-serve.ts`), which speaks the
13
+ * Jev API; the options below let it run without a TypeSafe key.
14
+ */
15
+
16
+ import {
17
+ type Fetch,
18
+ type Logger,
19
+ type Question,
20
+ APIConnectionError,
21
+ APIError,
22
+ APITimeoutError,
23
+ APIUserAbortError,
24
+ AuthenticationError,
25
+ PermissionDeniedError,
26
+ RateLimitError,
27
+ TypeSafeClient,
28
+ TypeSafeError,
29
+ } from "@typesafe-ai/sdk";
30
+
31
+ import type { LayaQuestion } from "../types.js";
32
+ import { type Decider, type DeciderResult, type DecisionRequest, DeciderError } from "./types.js";
33
+
34
+ /** Environment variable holding the API key unless `apiKeyEnv` names another. */
35
+ export const DEFAULT_API_KEY_ENV = "TYPESAFE_API_KEY";
36
+
37
+ /** Jev answers in 70–500 ms; a decision later than this is not worth waiting for. */
38
+ export const DEFAULT_JEV_TIMEOUT_MS = 1_500;
39
+
40
+ const LOG_CAPACITY = 200;
41
+
42
+ export interface JevDeciderOptions {
43
+ /** Decider id in logs and stats. Defaults to `jev`. */
44
+ id?: string;
45
+ /**
46
+ * Whether an API key is needed to be ready. When false and no key is set, a
47
+ * placeholder is sent, never the SDK's TYPESAFE_API_KEY fallback. Default: true.
48
+ */
49
+ requireApiKey?: boolean;
50
+ /** Said after "cannot reach the API" (e.g. how to start a local server). */
51
+ unreachableHint?: string;
52
+ /** Environment variable that holds the API key. Defaults to TYPESAFE_API_KEY. */
53
+ apiKeyEnv?: string;
54
+ /** API root, e.g. `https://openrouter.ai/api` to go through OpenRouter. Defaults to the SDK's (TypeSafe). */
55
+ baseURL?: string;
56
+ /** Jev model to pin, e.g. `jev-1.13.0`. Defaults to the SDK's (`jev-latest`). */
57
+ model?: string;
58
+ /** Timeout for one decision, in milliseconds. */
59
+ timeoutMs?: number;
60
+ /** Retries after a failed attempt. Each gets the full timeout. */
61
+ maxRetries?: number;
62
+ /** Where to read the API key and SDK settings. Defaults to `process.env`. */
63
+ env?: NodeJS.ProcessEnv;
64
+ /** HTTP implementation (tests). */
65
+ fetch?: Fetch;
66
+ }
67
+
68
+ /** Sent when no key is needed: an explicit value keeps the SDK from reading TYPESAFE_API_KEY. */
69
+ const NO_API_KEY = "none";
70
+
71
+ export class JevDecider implements Decider {
72
+ readonly id: string;
73
+ /** False when `baseURL` is this machine: the prompt does not leave it. */
74
+ readonly remote: boolean;
75
+
76
+ private readonly options: JevDeciderOptions;
77
+ private readonly apiKeyEnv: string;
78
+ private readonly timeoutMs: number;
79
+ private readonly logLines: string[] = [];
80
+ private client?: TypeSafeClient;
81
+ private lastModel?: string;
82
+ private stopped = false;
83
+
84
+ constructor(options: JevDeciderOptions = {}) {
85
+ this.options = options;
86
+ this.id = options.id ?? "jev";
87
+ this.remote = !(options.baseURL !== undefined && isLoopbackURL(options.baseURL));
88
+ this.apiKeyEnv = options.apiKeyEnv ?? DEFAULT_API_KEY_ENV;
89
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_JEV_TIMEOUT_MS;
90
+ }
91
+
92
+ /** Ready as soon as an API key is available (or none is needed); there is nothing to load. */
93
+ get isReady(): boolean {
94
+ return !this.stopped && (this.apiKey() !== undefined || this.options.requireApiKey === false);
95
+ }
96
+
97
+ /** The model that last answered, else the one requests will name. */
98
+ get model(): string | undefined {
99
+ if (this.lastModel) return this.lastModel;
100
+ if (this.options.requireApiKey === false) return this.options.model;
101
+ return this.client?.defaultModel ?? this.options.model ?? this.env().TYPESAFE_DEFAULT_MODEL ?? "jev-latest";
102
+ }
103
+
104
+ get recentLogs(): readonly string[] {
105
+ return this.logLines;
106
+ }
107
+
108
+ /** Check the API key and build the client. No network call. */
109
+ async warmup(): Promise<void> {
110
+ this.ensureClient();
111
+ }
112
+
113
+ async decide(request: DecisionRequest, signal?: AbortSignal): Promise<DeciderResult> {
114
+ const client = this.ensureClient();
115
+ const started = Date.now();
116
+ try {
117
+ const result = await client.systemOne(
118
+ { state: request.text, questions: toSdkQuestions(request.questions) },
119
+ { signal, timeout: this.timeoutMs, retry: { maxRetries: this.options.maxRetries ?? 0 } },
120
+ );
121
+ // The API reports the price at runtime; the SDK does not declare it yet.
122
+ const cost = (result.usage as { cost?: unknown } | undefined)?.cost;
123
+ this.lastModel = result.model;
124
+ return {
125
+ deciderId: this.id,
126
+ model: result.model,
127
+ answers: result.answers,
128
+ latencyMs: Date.now() - started,
129
+ ...(typeof cost === "number" && Number.isFinite(cost) ? { costUsd: cost } : {}),
130
+ };
131
+ } catch (err) {
132
+ let error = describeError(err, this.timeoutMs);
133
+ if (err instanceof APIConnectionError && this.options.unreachableHint) error += `; ${this.options.unreachableHint}`;
134
+ this.log(`decide failed: ${error}`);
135
+ throw new DeciderError(`${this.id}: ${error}`, err);
136
+ }
137
+ }
138
+
139
+ stop(): void {
140
+ this.stopped = true;
141
+ this.client = undefined;
142
+ }
143
+
144
+ // -------------------------------------------------------------------------
145
+
146
+ private env(): NodeJS.ProcessEnv {
147
+ return this.options.env ?? process.env;
148
+ }
149
+
150
+ private apiKey(): string | undefined {
151
+ const key = this.env()[this.apiKeyEnv]?.trim();
152
+ return key ? key : undefined;
153
+ }
154
+
155
+ private ensureClient(): TypeSafeClient {
156
+ if (this.stopped) throw new DeciderError(`${this.id}: decider is stopped`);
157
+ if (this.client) return this.client;
158
+ const apiKey = this.apiKey() ?? (this.options.requireApiKey === false ? NO_API_KEY : undefined);
159
+ if (!apiKey) throw new DeciderError(`${this.id}: ${this.apiKeyEnv} is not set`);
160
+
161
+ const env = this.env();
162
+ try {
163
+ this.client = new TypeSafeClient({
164
+ apiKey,
165
+ baseURL: this.options.baseURL ?? env.TYPESAFE_BASE_URL,
166
+ defaultModel: this.options.model ?? env.TYPESAFE_DEFAULT_MODEL,
167
+ logger: this.logger(),
168
+ // `debug` logs request bodies, which hold the prompt.
169
+ logLevel: "warn",
170
+ timeout: this.timeoutMs,
171
+ ...(this.options.fetch ? { fetch: this.options.fetch } : {}),
172
+ });
173
+ } catch (err) {
174
+ throw new DeciderError(`${this.id}: ${err instanceof Error ? err.message : String(err)}`, err);
175
+ }
176
+ return this.client;
177
+ }
178
+
179
+ private logger(): Logger {
180
+ const write = (level: string) => (message: string) => this.log(`${level}: ${message}`);
181
+ return { debug: write("debug"), info: write("info"), warn: write("warn"), error: write("error") };
182
+ }
183
+
184
+ private log(line: string): void {
185
+ this.logLines.push(line);
186
+ if (this.logLines.length > LOG_CAPACITY) this.logLines.shift();
187
+ }
188
+ }
189
+
190
+ /** Whether a URL points at this machine. */
191
+ export function isLoopbackURL(url: string): boolean {
192
+ let host: string;
193
+ try {
194
+ host = new URL(url).hostname;
195
+ } catch {
196
+ return false;
197
+ }
198
+ return host === "localhost" || host === "[::1]" || host === "::1" || /^127(\.\d{1,3}){3}$/.test(host);
199
+ }
200
+
201
+ /** Our question shapes are the Jev wire format; only the score tuple type differs. */
202
+ function toSdkQuestions(questions: DecisionRequest["questions"]): Record<string, Question> {
203
+ return Object.fromEntries(Object.entries(questions).map(([name, q]) => [name, toSdkQuestion(q)]));
204
+ }
205
+
206
+ function toSdkQuestion(question: LayaQuestion): Question {
207
+ if (question.type === "score") {
208
+ if (question.criteria.length < 2) throw new DeciderError("jev: a score question needs at least two criteria");
209
+ return { ...question, criteria: question.criteria as unknown as readonly [string, string, ...string[]] };
210
+ }
211
+ return question;
212
+ }
213
+
214
+ /** One short line per failure, for the status bar and decision card. */
215
+ export function describeError(err: unknown, timeoutMs: number): string {
216
+ if (err instanceof AuthenticationError || err instanceof PermissionDeniedError) {
217
+ return `API key rejected (HTTP ${err.status})`;
218
+ }
219
+ if (err instanceof RateLimitError) return "rate limited (HTTP 429)";
220
+ if (err instanceof APIError) return `HTTP ${err.status}${err.requestId ? ` (request ${err.requestId})` : ""}`;
221
+ if (err instanceof APITimeoutError) return `timed out after ${timeoutMs} ms`;
222
+ if (err instanceof APIUserAbortError) return "aborted";
223
+ if (err instanceof APIConnectionError) return `cannot reach the API: ${err.message}`;
224
+ if (err instanceof TypeSafeError || err instanceof Error) return err.message;
225
+ return String(err);
226
+ }