specpi 0.26.0 → 0.28.0
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/CHANGELOG.md +80 -0
- package/README.md +37 -3
- package/SECURITY_MODEL.md +44 -4
- package/THIRD_PARTY.md +9 -1
- package/extensions/jev-advisor/broker.mjs +277 -0
- package/extensions/jev-advisor/client.mjs +172 -0
- package/extensions/jev-advisor/config.mjs +270 -0
- package/extensions/jev-advisor/consent.mjs +133 -0
- package/extensions/jev-advisor/gate.mjs +263 -0
- package/extensions/jev-advisor/index.ts +999 -0
- package/extensions/jev-advisor/key-source.mjs +252 -0
- package/extensions/jev-advisor/layer.mjs +169 -0
- package/extensions/jev-advisor/ledger.mjs +138 -0
- package/extensions/jev-advisor/questions/capabilities.mjs +124 -0
- package/extensions/jev-advisor/questions/compaction.mjs +153 -0
- package/extensions/jev-advisor/questions/gap.mjs +140 -0
- package/extensions/jev-advisor/questions/guard.mjs +168 -0
- package/extensions/jev-advisor/questions/progress.mjs +195 -0
- package/extensions/jev-advisor/questions/retention.mjs +188 -0
- package/extensions/jev-advisor/questions/sources.mjs +91 -0
- package/extensions/jev-advisor/questions/untrusted.mjs +69 -0
- package/extensions/jev-advisor/risk.mjs +442 -0
- package/extensions/jev-advisor/sanitize.mjs +0 -0
- package/extensions/jev-advisor/usage.mjs +92 -0
- package/extensions/tool-wishlist/authoring-tools.mjs +42 -0
- package/extensions/tool-wishlist/index.ts +11 -0
- package/extensions/workflow-controls/capabilities.mjs +26 -0
- package/extensions/workflow-controls/index.ts +2 -2
- package/package.json +1 -1
- package/scripts/packages.mjs +56 -0
- package/scripts/specpi.mjs +73 -4
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// The only thing in this extension that touches the network. Systems are questions plus a gate;
|
|
2
|
+
// they never hold a client, so two systems firing at one hook cost one call rather than two.
|
|
3
|
+
//
|
|
4
|
+
// Every gate is checked here, in one order, before anything leaves the process: master switch,
|
|
5
|
+
// per-system switch, human consent, per-system budget, session total budget, then the byte budget
|
|
6
|
+
// inside sanitize.
|
|
7
|
+
//
|
|
8
|
+
// AWAIT ONLY THE SYSTEMS THAT MUTATE WHAT THEY INSPECT. Retention must be awaited, because its
|
|
9
|
+
// answer replaces the tool result it was asked about; so must compaction, the branch hook and the
|
|
10
|
+
// two tool_call systems, which return a patch or edit `event.input` in place. A system that acts on
|
|
11
|
+
// a later turn must not be awaited: at roughly 300ms a call, a turn-level system firing thirty
|
|
12
|
+
// times would add nine seconds to an attempt that takes a hundred and thirty, to deliver advice
|
|
13
|
+
// that was never going to change the turn it was asked during.
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { SYSTEM_NAMES, loadSettings } from "./config.mjs";
|
|
17
|
+
import { ensureConsent } from "./consent.mjs";
|
|
18
|
+
import { buildState } from "./sanitize.mjs";
|
|
19
|
+
import { ask } from "./client.mjs";
|
|
20
|
+
import { payloadDigest, record } from "./ledger.mjs";
|
|
21
|
+
import { writeUsage } from "./usage.mjs";
|
|
22
|
+
|
|
23
|
+
export const SYSTEM_LABELS = Object.freeze({
|
|
24
|
+
retention: "Tool-result retention",
|
|
25
|
+
compaction: "Compaction guidance",
|
|
26
|
+
gap: "Capability-gap triage",
|
|
27
|
+
sources: "Delegation source ranking",
|
|
28
|
+
progress: "Progress and thrash detection",
|
|
29
|
+
untrusted: "Untrusted-content classification",
|
|
30
|
+
capability: "Turn-zero capability arming",
|
|
31
|
+
guard: "Command guard",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export function createBroker(options = {}) {
|
|
35
|
+
// Injected in tests so master-off can be proven as "the transport was never reached" rather
|
|
36
|
+
// than "no socket was observed".
|
|
37
|
+
const transport = options.ask ?? ask;
|
|
38
|
+
const readSettings = options.loadSettings ?? loadSettings;
|
|
39
|
+
const resolveConsent = options.ensureConsent ?? ensureConsent;
|
|
40
|
+
const write = options.record ?? record;
|
|
41
|
+
// Separate from `record` because it answers a different question and is read by a different
|
|
42
|
+
// reader. The ledger is an audit trail for a person; this is a live counter for SpecPi Chat,
|
|
43
|
+
// which runs in another process and cannot see `callsUsed`.
|
|
44
|
+
const publish = options.recordUsage ?? writeUsage;
|
|
45
|
+
|
|
46
|
+
let callsUsed = 0;
|
|
47
|
+
let generation = 0;
|
|
48
|
+
let session = "";
|
|
49
|
+
let startedAt = "";
|
|
50
|
+
const usedBySystem = new Map();
|
|
51
|
+
const effects = new Map();
|
|
52
|
+
const warned = new Set();
|
|
53
|
+
|
|
54
|
+
/** Zero counts for every system, so a reader never has to distinguish absent from unused. */
|
|
55
|
+
const snapshot = (active) => {
|
|
56
|
+
const settings = readSettings();
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
schema: 1,
|
|
60
|
+
session,
|
|
61
|
+
startedAt,
|
|
62
|
+
updatedAt: new Date().toISOString(),
|
|
63
|
+
active,
|
|
64
|
+
calls: callsUsed,
|
|
65
|
+
budgets: settings.budgets,
|
|
66
|
+
systems: Object.fromEntries(
|
|
67
|
+
SYSTEM_NAMES.map((name) => [
|
|
68
|
+
name,
|
|
69
|
+
{
|
|
70
|
+
calls: usedBySystem.get(name) ?? 0,
|
|
71
|
+
applied: effects.get(name)?.applied ?? 0,
|
|
72
|
+
failed: effects.get(name)?.failed ?? 0,
|
|
73
|
+
savedBytes: effects.get(name)?.savedBytes ?? 0,
|
|
74
|
+
},
|
|
75
|
+
]),
|
|
76
|
+
),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const clear = () => {
|
|
81
|
+
callsUsed = 0;
|
|
82
|
+
usedBySystem.clear();
|
|
83
|
+
effects.clear();
|
|
84
|
+
warned.clear();
|
|
85
|
+
// Bumped before anything else so an answer still in flight from the previous session is
|
|
86
|
+
// discarded rather than counted against the new one.
|
|
87
|
+
generation += 1;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Publishing is itself gated on the master switch. With the layer off this extension is meant
|
|
92
|
+
* to leave no trace at all, and a counts file appearing in every Pi session on every machine
|
|
93
|
+
* that merely has SpecPi installed is a trace. Once a call has been made there is something
|
|
94
|
+
* worth saying, so the count keeps being published for the rest of the session even if the
|
|
95
|
+
* master switch is turned back off.
|
|
96
|
+
*/
|
|
97
|
+
const publishIf = (active) => {
|
|
98
|
+
if (callsUsed > 0 || readSettings().master === true) {
|
|
99
|
+
publish(snapshot(active));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const reset = () => {
|
|
104
|
+
clear();
|
|
105
|
+
session = randomUUID();
|
|
106
|
+
startedAt = new Date().toISOString();
|
|
107
|
+
publishIf(true);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* End of session. The counts are published one last time with `active` false rather than
|
|
112
|
+
* cleared, because a reader that found no file could not tell "this layer has never run" from
|
|
113
|
+
* "the session that just ended spent its whole budget", and the second is the more useful
|
|
114
|
+
* thing to be able to see after the fact.
|
|
115
|
+
*/
|
|
116
|
+
const finish = () => {
|
|
117
|
+
publishIf(false);
|
|
118
|
+
clear();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Running out of budget used to be indistinguishable from a system that had nothing to say.
|
|
123
|
+
* Both produce silence, and silence is this layer's normal state, so a session could spend an
|
|
124
|
+
* hour with retention switched on and quietly dead without anything ever saying so. The notice
|
|
125
|
+
* fires once per system per session -- repeating it every turn would be its own nuisance -- and
|
|
126
|
+
* only where there is a human to read it.
|
|
127
|
+
*/
|
|
128
|
+
const warnExhausted = (system, ctx, scope) => {
|
|
129
|
+
if (warned.has(system) || !ctx?.hasUI || typeof ctx?.ui?.notify !== "function") {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
warned.add(system);
|
|
134
|
+
try {
|
|
135
|
+
ctx.ui.notify(
|
|
136
|
+
scope === "system"
|
|
137
|
+
? `Jev: the ${SYSTEM_LABELS[system] ?? system} budget for this session is spent, so that system is now off until the session ends. Raise it in SpecPi Chat under package settings, or in the Jev layer's own settings file.`
|
|
138
|
+
: `Jev: this session's total call budget is spent, so the whole advisor is now quiet until the session ends. Raise it in SpecPi Chat under package settings, or in the Jev layer's own settings file.`,
|
|
139
|
+
"info",
|
|
140
|
+
);
|
|
141
|
+
} catch {
|
|
142
|
+
// A notice that cannot be delivered must not fail the call it was reporting on.
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const status = () => {
|
|
147
|
+
const settings = readSettings();
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
master: settings.master,
|
|
151
|
+
systems: settings.systems,
|
|
152
|
+
session,
|
|
153
|
+
callsUsed,
|
|
154
|
+
budgets: settings.budgets,
|
|
155
|
+
usedBySystem: Object.fromEntries(usedBySystem),
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Ask one batch for one system. Returns `{ ok: false, reason }` for every refusal so a caller
|
|
161
|
+
* can log why it got no advice without having to distinguish "switched off" from "timed out".
|
|
162
|
+
*
|
|
163
|
+
* `decide` is how the ledger learns what the advice did. The ledger recorded bytes sent and
|
|
164
|
+
* never whether the answer was taken, so a system's effect could only be inferred from a cost
|
|
165
|
+
* delta it may not have caused. The callback runs here, before the ledger write, because that
|
|
166
|
+
* is the only point where the answers and the audit line exist together; its `decision` is
|
|
167
|
+
* handed back so the caller does not gate the same answers twice.
|
|
168
|
+
*/
|
|
169
|
+
const request = async ({ system, state, questions, ctx, root, maxBytes, timeoutMs, signal, decide }) => {
|
|
170
|
+
const settings = readSettings();
|
|
171
|
+
if (!settings.master) {
|
|
172
|
+
return { ok: false, reason: "master-off", answers: {} };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (settings.systems[system] !== true) {
|
|
176
|
+
return { ok: false, reason: "system-off", answers: {} };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Per-system first, so an exhausted turn-level system reports its own exhaustion rather
|
|
180
|
+
// than looking like the session as a whole ran out.
|
|
181
|
+
if ((usedBySystem.get(system) ?? 0) >= (settings.budgets?.[system] ?? 0)) {
|
|
182
|
+
warnExhausted(system, ctx, "system");
|
|
183
|
+
|
|
184
|
+
return { ok: false, reason: "system-budget-exhausted", answers: {} };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (callsUsed >= (settings.budgets?.total ?? 0)) {
|
|
188
|
+
warnExhausted("total", ctx, "total");
|
|
189
|
+
|
|
190
|
+
return { ok: false, reason: "budget-exhausted", answers: {} };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const consented = await resolveConsent(ctx, SYSTEM_LABELS[system] ?? system);
|
|
194
|
+
if (!consented) {
|
|
195
|
+
return { ok: false, reason: "no-consent", answers: {} };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Settings can change while the dialog is open, and a session can end under it.
|
|
199
|
+
const current = readSettings();
|
|
200
|
+
if (!current.master || current.systems[system] !== true) {
|
|
201
|
+
return { ok: false, reason: "master-off", answers: {} };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const built = buildState(state, { root, maxBytes });
|
|
205
|
+
const questionKeys = Object.keys(questions);
|
|
206
|
+
callsUsed += 1;
|
|
207
|
+
usedBySystem.set(system, (usedBySystem.get(system) ?? 0) + 1);
|
|
208
|
+
const startedGeneration = generation;
|
|
209
|
+
const result = await transport(built.state, questions, { timeoutMs, signal });
|
|
210
|
+
// The session can end under a call that was never awaited, which is the normal shape of a
|
|
211
|
+
// turn-level system: the payload has already left the machine, and the answer now belongs
|
|
212
|
+
// to a session that no longer exists. It must not be acted on. It must still be recorded --
|
|
213
|
+
// the ledger's whole claim is that every transmission appears in it, and a run that sent 44
|
|
214
|
+
// and logged 43 is how this was found. So the line is written either way and says which.
|
|
215
|
+
const stale = startedGeneration !== generation;
|
|
216
|
+
|
|
217
|
+
// A gate that throws must not turn into a failed call: the caller's own catch would have
|
|
218
|
+
// swallowed it anyway, and recording it as unapplied is the truthful line.
|
|
219
|
+
let outcome = { applied: false };
|
|
220
|
+
if (!stale && result.ok && typeof decide === "function") {
|
|
221
|
+
try {
|
|
222
|
+
outcome = decide(result.answers) ?? { applied: false };
|
|
223
|
+
} catch {
|
|
224
|
+
outcome = { applied: false, gateThrew: true };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
write({
|
|
229
|
+
system,
|
|
230
|
+
questionKeys,
|
|
231
|
+
stateBytes: built.bytes,
|
|
232
|
+
stateTruncated: built.truncated,
|
|
233
|
+
payloadSha256: payloadDigest({ state: built.state, questions }),
|
|
234
|
+
ok: result.ok,
|
|
235
|
+
reason: result.ok ? undefined : result.reason,
|
|
236
|
+
// A sent payload whose answer arrived too late to use. Distinguished from a refusal,
|
|
237
|
+
// because nothing was refused: it was asked, answered, and discarded.
|
|
238
|
+
discarded: stale ? true : undefined,
|
|
239
|
+
// Whether the advice changed anything, and what it saved when the change was a
|
|
240
|
+
// shortening. Zero is a real answer here and means "asked, and kept the result whole".
|
|
241
|
+
applied: outcome.applied === true,
|
|
242
|
+
// And why not, when nothing changed. Without this a system that asks and never acts is
|
|
243
|
+
// indistinguishable from one whose gate can never be satisfied, which is the exact
|
|
244
|
+
// failure the calibration pass had to go looking for by hand.
|
|
245
|
+
outcome: stale ? "session-changed" : typeof outcome.reason === "string" ? outcome.reason : undefined,
|
|
246
|
+
savedBytes: Number.isFinite(outcome.savedBytes) ? Math.max(0, Math.round(outcome.savedBytes)) : 0,
|
|
247
|
+
gateThrew: outcome.gateThrew === true ? true : undefined,
|
|
248
|
+
latencyMs: result.latencyMs,
|
|
249
|
+
model: result.model,
|
|
250
|
+
// Values only, never the state that produced them: enough to plot a calibration curve.
|
|
251
|
+
answers: Object.fromEntries(
|
|
252
|
+
Object.entries(result.answers ?? {}).map(([name, answer]) => [
|
|
253
|
+
name,
|
|
254
|
+
{ kind: answer.kind, value: answer.value, confidence: answer.confidence },
|
|
255
|
+
]),
|
|
256
|
+
),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (stale) {
|
|
260
|
+
// Counted against the session it was made in, which has already been published and
|
|
261
|
+
// cleared. Adding it to the new session's running total would attribute one session's
|
|
262
|
+
// spend to the next one.
|
|
263
|
+
return { ok: false, reason: "session-changed", answers: {} };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const effect = effects.get(system) ?? { applied: 0, failed: 0, savedBytes: 0 };
|
|
267
|
+
effect.applied += outcome.applied === true ? 1 : 0;
|
|
268
|
+
effect.failed += result.ok ? 0 : 1;
|
|
269
|
+
effect.savedBytes += Number.isFinite(outcome.savedBytes) ? Math.max(0, Math.round(outcome.savedBytes)) : 0;
|
|
270
|
+
effects.set(system, effect);
|
|
271
|
+
publishIf(true);
|
|
272
|
+
|
|
273
|
+
return { ...result, decision: outcome.decision };
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return { request, reset, finish, status };
|
|
277
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// One POST, no SDK. AGENTS.md forbids adding executable dependencies without need, and a single
|
|
2
|
+
// JSON request does not need one.
|
|
3
|
+
//
|
|
4
|
+
// Every failure mode returns `unavailable` rather than throwing: a timeout, an HTTP error, a
|
|
5
|
+
// missing key, a malformed body or an unparseable answer all mean "no advice", and the caller runs
|
|
6
|
+
// the path it would have run before this extension existed. That is fail-silent, not fail-closed —
|
|
7
|
+
// nothing here is ever the reason a tool is blocked.
|
|
8
|
+
|
|
9
|
+
import { backend, resolveKey } from "./key-source.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where a key comes from is resolved in key-source.mjs, which follows Pi's own order: the
|
|
13
|
+
* `auth.json` entry `/login openrouter` writes, then the environment variable.
|
|
14
|
+
*
|
|
15
|
+
* Every one of these defaults its route to `backend()` rather than to the string "openrouter", so a
|
|
16
|
+
* no-arg call resolves against the backend actually in force. That matters because it briefly did
|
|
17
|
+
* not: when `apiKey` was first replaced by a re-export of a function whose parameter defaulted to a
|
|
18
|
+
* literal, `apiKey()` returned a stored OpenRouter key under `JEV_BACKEND=typesafe` -- a script's
|
|
19
|
+
* `if (!apiKey())` guard passed and it spent a run, while every request underneath came back
|
|
20
|
+
* `no-key`. The fix belongs at the definition, which is where it now is; a wrapper here would only
|
|
21
|
+
* have hidden that three sibling exports had the same defect.
|
|
22
|
+
*/
|
|
23
|
+
export { backend, keyEnvName, keyPresent, keySource, keySources, resolveKey as apiKey } from "./key-source.mjs";
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_MODEL = "jev-1.13.0";
|
|
26
|
+
export const OPENROUTER_MODEL = "typesafe/jev-1.13";
|
|
27
|
+
// Measured round trip is ~250-400ms through OpenRouter. 800ms left no headroom for a slow call,
|
|
28
|
+
// and a timeout costs the advice without saving the latency already spent, so the budget is set
|
|
29
|
+
// above the observed spread rather than at it.
|
|
30
|
+
export const DEFAULT_TIMEOUT_MS = 1500;
|
|
31
|
+
const MAX_TIMEOUT_MS = 5000;
|
|
32
|
+
|
|
33
|
+
/** Overridable so tests never reach the network and the eval proxy can price the traffic. */
|
|
34
|
+
export function baseUrl() {
|
|
35
|
+
const configured = process.env.TYPESAFE_BASE_URL;
|
|
36
|
+
if (configured && configured.trim().length > 0) {
|
|
37
|
+
return configured.trim().replace(/\/+$/u, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return backend() === "openrouter" ? "https://openrouter.ai" : "https://api.typesafe.ai";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The two services expose the same state/questions body under different paths. Keeping both
|
|
44
|
+
// suffixes distinct is also what lets the eval proxy tell the traffic apart and forward it on.
|
|
45
|
+
export function endpoint() {
|
|
46
|
+
return `${baseUrl()}${backend() === "openrouter" ? "/api/alpha/decisions" : "/v1/systemone"}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function defaultModel() {
|
|
50
|
+
return backend() === "openrouter" ? OPENROUTER_MODEL : DEFAULT_MODEL;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Choose one option from a set. Up to 255 options; output is free, so rich enums cost nothing. */
|
|
54
|
+
export function choice(instructions, criteria) {
|
|
55
|
+
return { type: "choice", instructions, criteria };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Rate against ordered levels. Two to ten; the returned score may be fractional. */
|
|
59
|
+
export function score(instructions, criteria) {
|
|
60
|
+
return { type: "score", instructions, criteria };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Yes or no as a probability. Returns a bare number with no confidence field. */
|
|
64
|
+
export function noul(instructions) {
|
|
65
|
+
return { type: "noul", instructions };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unavailable(reason) {
|
|
69
|
+
return { ok: false, reason, answers: {} };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Answers are normalized to a single shape so gates never branch on which primitive produced them.
|
|
74
|
+
* A Noul has no confidence, and inventing one would let a caller gate on a number the model never
|
|
75
|
+
* reported, so it stays undefined.
|
|
76
|
+
*/
|
|
77
|
+
function normalizeAnswer(raw) {
|
|
78
|
+
if (!raw || typeof raw !== "object") {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof raw.noul === "number") {
|
|
83
|
+
return { kind: "noul", value: raw.noul, probabilities: undefined, confidence: undefined };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof raw.choice === "string") {
|
|
87
|
+
return {
|
|
88
|
+
kind: "choice",
|
|
89
|
+
value: raw.choice,
|
|
90
|
+
probabilities: raw.probabilities && typeof raw.probabilities === "object" ? raw.probabilities : undefined,
|
|
91
|
+
confidence: typeof raw.confidence === "number" ? raw.confidence : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof raw.score === "number") {
|
|
96
|
+
return {
|
|
97
|
+
kind: "score",
|
|
98
|
+
value: raw.score,
|
|
99
|
+
probabilities: Array.isArray(raw.probabilities) ? raw.probabilities : undefined,
|
|
100
|
+
confidence: typeof raw.confidence === "number" ? raw.confidence : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ask one batch. Questions are evaluated in parallel against one state, so callers should send
|
|
109
|
+
* every question that state can answer rather than paying for the state again.
|
|
110
|
+
*/
|
|
111
|
+
export async function ask(state, questions, options = {}) {
|
|
112
|
+
const key = resolveKey(backend());
|
|
113
|
+
if (!key) {
|
|
114
|
+
return unavailable("no-key");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!questions || Object.keys(questions).length === 0) {
|
|
118
|
+
return unavailable("no-questions");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const timeoutMs = Math.min(Math.max(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, 50), MAX_TIMEOUT_MS);
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
124
|
+
const startedAt = Date.now();
|
|
125
|
+
const payload = { model: options.model ?? defaultModel(), state, questions };
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(endpoint(), {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
"content-type": "application/json",
|
|
131
|
+
authorization: `Bearer ${key}`,
|
|
132
|
+
// OpenRouter attributes traffic by these; they are ignored by the direct API.
|
|
133
|
+
"HTTP-Referer": "https://pi.dev",
|
|
134
|
+
"X-Title": "specpi-jev-advisor",
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify(payload),
|
|
137
|
+
signal: options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal,
|
|
138
|
+
});
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
return { ...unavailable(`http-${response.status}`), latencyMs: Date.now() - startedAt };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const body = await response.json();
|
|
144
|
+
const answers = {};
|
|
145
|
+
for (const [name, raw] of Object.entries(body?.answers ?? {})) {
|
|
146
|
+
const normalized = normalizeAnswer(raw);
|
|
147
|
+
if (normalized) {
|
|
148
|
+
answers[name] = normalized;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (Object.keys(answers).length === 0) {
|
|
153
|
+
return { ...unavailable("empty-answers"), latencyMs: Date.now() - startedAt };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
answers,
|
|
159
|
+
model: typeof body?.model === "string" ? body.model : undefined,
|
|
160
|
+
// Reported by OpenRouter, absent on the direct API. Preferred over an estimate wherever
|
|
161
|
+
// it exists, so the eval cost column rests on logged usage rather than a guess.
|
|
162
|
+
usage: body?.usage && typeof body.usage === "object" ? body.usage : undefined,
|
|
163
|
+
latencyMs: Date.now() - startedAt,
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const reason = error?.name === "AbortError" ? "timeout" : "network";
|
|
167
|
+
|
|
168
|
+
return { ...unavailable(reason), latencyMs: Date.now() - startedAt };
|
|
169
|
+
} finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
}
|
|
172
|
+
}
|