pi-advisor-flow 0.11.2 → 0.11.3-dev.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.
- package/CHANGELOG.md +10 -0
- package/README.md +4 -2
- package/dist/index.js +863 -238
- package/package.json +10 -10
- package/src/commands/settings-commands.ts +43 -0
- package/src/commands/settings-persistence.ts +9 -2
- package/src/config/types.ts +6 -1
- package/src/jev/client.ts +88 -22
- package/src/jev/decisions-client.ts +124 -0
- package/src/jev/decisions-response.ts +198 -0
- package/src/jev/key-store.ts +1 -1
- package/src/jev/ledger.ts +28 -6
- package/src/jev/openai-key-store.ts +159 -0
- package/src/jev/transport.ts +103 -25
- package/src/tools/jev-filter.ts +2 -2
- package/src/tools/jev-turn-gate.ts +2 -2
- package/src/ui/jev-setup-submenu-view.ts +96 -0
- package/src/ui/jev-setup-submenu.ts +253 -235
- package/src/ui/jev-setup-support.ts +173 -0
- package/src/ui/settings-items.ts +28 -20
- package/src/ui/settings-mutations.ts +0 -5
- package/src/ui/settings-selector.ts +29 -0
- package/src/ui/types.ts +32 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-advisor-flow",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3-dev.1",
|
|
4
4
|
"description": "Advanced Executor/Advisor flow for Pi, fully configurable and extendable.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"advisor",
|
|
@@ -51,21 +51,21 @@
|
|
|
51
51
|
"format:check": "oxfmt --check"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-ai": "1.0.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "1.0.
|
|
56
|
-
"@earendil-works/pi-tui": "1.0.
|
|
57
|
-
"@oxlint/plugins": "1.
|
|
54
|
+
"@earendil-works/pi-ai": "1.0.4",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "1.0.4",
|
|
56
|
+
"@earendil-works/pi-tui": "1.0.4",
|
|
57
|
+
"@oxlint/plugins": "1.87.0",
|
|
58
58
|
"@types/node": "^26.6.4",
|
|
59
59
|
"@typesafe-ai/sdk": "^0.6.0",
|
|
60
60
|
"bun-types": "1.4.2",
|
|
61
61
|
"husky": "^9.1.7",
|
|
62
|
-
"knip": "6.
|
|
62
|
+
"knip": "6.40.0",
|
|
63
63
|
"lint-staged": "^17.6.0",
|
|
64
|
-
"oxfmt": "0.
|
|
65
|
-
"oxlint": "1.
|
|
66
|
-
"typebox": "^1.3.
|
|
64
|
+
"oxfmt": "0.72.0",
|
|
65
|
+
"oxlint": "1.87.0",
|
|
66
|
+
"typebox": "^1.3.36",
|
|
67
67
|
"typescript": "^7.0.2",
|
|
68
|
-
"ultracite": "7.12.
|
|
68
|
+
"ultracite": "7.12.4"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"@earendil-works/pi-ai": "^1.0.0",
|
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
setAlwaysOnRef,
|
|
6
6
|
} from "../config/state.ts";
|
|
7
7
|
import { saveConfig } from "../config/storage.ts";
|
|
8
|
+
import {
|
|
9
|
+
resolveJevTransport,
|
|
10
|
+
resolveJevTransportFor,
|
|
11
|
+
} from "../jev/transport.ts";
|
|
8
12
|
import { AdvisorSettingsSelector } from "../ui/settings-selector.ts";
|
|
9
13
|
import { loadCommandConfig } from "./activation-preparation.ts";
|
|
10
14
|
import {
|
|
@@ -30,6 +34,12 @@ export const registerSettingsCommands = (runtime: CommandRuntime) => {
|
|
|
30
34
|
new AdvisorSettingsSelector({
|
|
31
35
|
effortLevels: EFFORT_LEVELS,
|
|
32
36
|
initial,
|
|
37
|
+
jevSetupDeps: {
|
|
38
|
+
resolveTransport: (transport) =>
|
|
39
|
+
transport
|
|
40
|
+
? resolveJevTransportFor(transport, ctx)
|
|
41
|
+
: resolveJevTransport(ctx),
|
|
42
|
+
},
|
|
33
43
|
keybindings,
|
|
34
44
|
modelRefs: getConfiguredModelRefs(ctx),
|
|
35
45
|
onCancel: () => done(undefined),
|
|
@@ -47,6 +57,39 @@ export const registerSettingsCommands = (runtime: CommandRuntime) => {
|
|
|
47
57
|
);
|
|
48
58
|
}
|
|
49
59
|
},
|
|
60
|
+
onJevSetup: (selection, settings) => {
|
|
61
|
+
try {
|
|
62
|
+
saveAdvisorSettings(
|
|
63
|
+
ctx,
|
|
64
|
+
{
|
|
65
|
+
...settings,
|
|
66
|
+
jevFilterEnabled: selection.enabled,
|
|
67
|
+
jevTransport: selection.transport,
|
|
68
|
+
},
|
|
69
|
+
{ skipOutcomeLogging: true }
|
|
70
|
+
);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const message =
|
|
73
|
+
error instanceof Error ? error.message : String(error);
|
|
74
|
+
ctx.ui.notify(
|
|
75
|
+
`Could not save Advisor settings: ${message}`,
|
|
76
|
+
"error"
|
|
77
|
+
);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
runtime.updateSameModelNotice(ctx);
|
|
82
|
+
runtime.updateAdvisorUsageStatus(ctx);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message =
|
|
85
|
+
error instanceof Error ? error.message : String(error);
|
|
86
|
+
ctx.ui.notify(
|
|
87
|
+
`Advisor settings were saved, but the status refresh failed: ${message}`,
|
|
88
|
+
"warning"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
50
93
|
presets: CONTEXT_PRESETS,
|
|
51
94
|
theme,
|
|
52
95
|
tui,
|
|
@@ -141,9 +141,14 @@ const applyAdvisorSettings = (settings: AdvisorSettings) => {
|
|
|
141
141
|
applyDisclosureSettings(settings);
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
+
export interface SaveAdvisorSettingsOptions {
|
|
145
|
+
skipOutcomeLogging?: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
144
148
|
export const saveAdvisorSettings = (
|
|
145
149
|
ctx: ExtensionContext,
|
|
146
|
-
settings: AdvisorSettings
|
|
150
|
+
settings: AdvisorSettings,
|
|
151
|
+
options: SaveAdvisorSettingsOptions = {}
|
|
147
152
|
) => {
|
|
148
153
|
const previous = getAdvisorSettings();
|
|
149
154
|
try {
|
|
@@ -153,7 +158,9 @@ export const saveAdvisorSettings = (
|
|
|
153
158
|
persistAdvisor: Boolean(persisted.advisor),
|
|
154
159
|
persistExecutor: Boolean(persisted.executor),
|
|
155
160
|
});
|
|
156
|
-
|
|
161
|
+
if (!options.skipOutcomeLogging) {
|
|
162
|
+
saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
|
|
163
|
+
}
|
|
157
164
|
} catch (error) {
|
|
158
165
|
applyAdvisorSettings(previous);
|
|
159
166
|
throw error;
|
package/src/config/types.ts
CHANGED
|
@@ -32,12 +32,17 @@ export const DEFAULT_JEV_FILTER_NOUL_MARGIN = 0.35;
|
|
|
32
32
|
export const DEFAULT_JEV_FILTER_OVERRIDE_WINDOW = 10;
|
|
33
33
|
export const DEFAULT_JEV_TURN_GATE_EVERY_TURNS = 0;
|
|
34
34
|
export const DEFAULT_JEV_TURN_GATE_NOUL_THRESHOLD = 0.8;
|
|
35
|
-
export type JevTransport =
|
|
35
|
+
export type JevTransport =
|
|
36
|
+
| "auto"
|
|
37
|
+
| "typesafe"
|
|
38
|
+
| "openrouter"
|
|
39
|
+
| "openai-decisions";
|
|
36
40
|
export const DEFAULT_JEV_TRANSPORT: JevTransport = "auto";
|
|
37
41
|
export const JEV_TRANSPORTS: JevTransport[] = [
|
|
38
42
|
"auto",
|
|
39
43
|
"typesafe",
|
|
40
44
|
"openrouter",
|
|
45
|
+
"openai-decisions",
|
|
41
46
|
];
|
|
42
47
|
|
|
43
48
|
export type AdvisorToolPolicy = "full" | "summary" | "exclude";
|
package/src/jev/client.ts
CHANGED
|
@@ -8,6 +8,16 @@ import {
|
|
|
8
8
|
import { isNumber, isRecord, isRecordOf, isString } from "../content-utils.ts";
|
|
9
9
|
import type { JsonValue, RecordValue } from "../content-utils.ts";
|
|
10
10
|
import { redactSecrets } from "../redaction.ts";
|
|
11
|
+
import {
|
|
12
|
+
buildDecisionsRequest,
|
|
13
|
+
OPENAI_DECISIONS_ENDPOINT,
|
|
14
|
+
OPENAI_DECISIONS_MODEL,
|
|
15
|
+
} from "./decisions-client.ts";
|
|
16
|
+
import type { DecisionsQuestion } from "./decisions-client.ts";
|
|
17
|
+
import {
|
|
18
|
+
normalizeDecisionsResponse,
|
|
19
|
+
parseDecisionsApiResponse,
|
|
20
|
+
} from "./decisions-response.ts";
|
|
11
21
|
import { JevFailureError } from "./failure.ts";
|
|
12
22
|
import type { JevErrorCategory } from "./failure.ts";
|
|
13
23
|
import type { JevCredentials, JevTransportKind } from "./transport.ts";
|
|
@@ -16,7 +26,7 @@ export type { JevErrorCategory } from "./failure.ts";
|
|
|
16
26
|
export { JevFailureError as JevFailure } from "./failure.ts";
|
|
17
27
|
|
|
18
28
|
export interface JevUsage {
|
|
19
|
-
cost
|
|
29
|
+
cost?: number;
|
|
20
30
|
inputTokens: number;
|
|
21
31
|
outputTokens: number;
|
|
22
32
|
}
|
|
@@ -37,6 +47,7 @@ export interface JevClientOptions {
|
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
const ENDPOINTS: Record<JevTransportKind, string> = {
|
|
50
|
+
"openai-decisions": OPENAI_DECISIONS_ENDPOINT,
|
|
40
51
|
openrouter: "https://openrouter.ai/api/alpha/decisions",
|
|
41
52
|
typesafe: "https://api.typesafe.ai/v1/systemone",
|
|
42
53
|
};
|
|
@@ -71,12 +82,32 @@ const statusCategory = (status: number): JevErrorCategory => {
|
|
|
71
82
|
return "error";
|
|
72
83
|
};
|
|
73
84
|
|
|
74
|
-
const isObjectLike = <Value>(value: Value): value is Value & object =>
|
|
75
|
-
typeof value === "object";
|
|
76
|
-
|
|
77
85
|
const openRouterModelId = (model: string) =>
|
|
78
86
|
model.includes("/") ? model : `~typesafe/${model}`;
|
|
79
87
|
|
|
88
|
+
interface ClientRequest {
|
|
89
|
+
body: string;
|
|
90
|
+
decisionQuestions?: DecisionsQuestion[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const buildClientRequest = (
|
|
94
|
+
transport: JevTransportKind,
|
|
95
|
+
model: string,
|
|
96
|
+
state: EntryType,
|
|
97
|
+
questions: Questions
|
|
98
|
+
): ClientRequest => {
|
|
99
|
+
if (transport === "openai-decisions") {
|
|
100
|
+
const request = buildDecisionsRequest(state, questions);
|
|
101
|
+
return {
|
|
102
|
+
body: JSON.stringify(request),
|
|
103
|
+
decisionQuestions: request.questions,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
body: JSON.stringify({ model, questions, state }),
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
80
111
|
interface AttemptOutcome {
|
|
81
112
|
answers?: RecordValue;
|
|
82
113
|
failure?: JevFailureError;
|
|
@@ -116,9 +147,7 @@ const sleepWithAbort = (signal: AbortSignal, ms: number): Promise<void> => {
|
|
|
116
147
|
});
|
|
117
148
|
};
|
|
118
149
|
|
|
119
|
-
/**
|
|
120
|
-
* retries can never stall a tool call; errors are classified and redacted on
|
|
121
|
-
* every path. */
|
|
150
|
+
/** Shared Jev client with a total deadline, bounded retries, and redacted errors for every provider. */
|
|
122
151
|
export class JevClient {
|
|
123
152
|
readonly #apiKey: string;
|
|
124
153
|
readonly #endpoint: string;
|
|
@@ -126,6 +155,7 @@ export class JevClient {
|
|
|
126
155
|
readonly #model: string;
|
|
127
156
|
readonly #pricePerMtok: number | undefined;
|
|
128
157
|
readonly #timeoutMs: number;
|
|
158
|
+
readonly #transport: JevTransportKind;
|
|
129
159
|
|
|
130
160
|
constructor({
|
|
131
161
|
apiKey,
|
|
@@ -139,9 +169,16 @@ export class JevClient {
|
|
|
139
169
|
this.#endpoint = ENDPOINTS[transport];
|
|
140
170
|
// SAFETY: the bound global fetch satisfies the SDK Fetch signature; binding keeps the receiver correct.
|
|
141
171
|
this.#fetch = fetch ?? (globalThis.fetch.bind(globalThis) as Fetch);
|
|
142
|
-
|
|
172
|
+
if (transport === "openai-decisions") {
|
|
173
|
+
this.#model = OPENAI_DECISIONS_MODEL;
|
|
174
|
+
} else if (transport === "openrouter") {
|
|
175
|
+
this.#model = openRouterModelId(model);
|
|
176
|
+
} else {
|
|
177
|
+
this.#model = model;
|
|
178
|
+
}
|
|
143
179
|
this.#pricePerMtok = pricePerMtok;
|
|
144
180
|
this.#timeoutMs = timeoutMs;
|
|
181
|
+
this.#transport = transport;
|
|
145
182
|
}
|
|
146
183
|
|
|
147
184
|
async ask(
|
|
@@ -149,6 +186,12 @@ export class JevClient {
|
|
|
149
186
|
questions: Questions,
|
|
150
187
|
signal?: AbortSignal
|
|
151
188
|
): Promise<JevAskResult> {
|
|
189
|
+
const { body, decisionQuestions } = buildClientRequest(
|
|
190
|
+
this.#transport,
|
|
191
|
+
this.#model,
|
|
192
|
+
state,
|
|
193
|
+
questions
|
|
194
|
+
);
|
|
152
195
|
const deadline = new AbortController();
|
|
153
196
|
const abortFromCaller = () => deadline.abort(signal?.reason);
|
|
154
197
|
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
@@ -161,16 +204,11 @@ export class JevClient {
|
|
|
161
204
|
deadline.abort(new Error("Jev wall-time budget elapsed"));
|
|
162
205
|
}, this.#timeoutMs);
|
|
163
206
|
timer.unref?.();
|
|
164
|
-
const body = JSON.stringify({
|
|
165
|
-
model: this.#model,
|
|
166
|
-
questions,
|
|
167
|
-
state,
|
|
168
|
-
});
|
|
169
207
|
try {
|
|
170
208
|
let outcome: AttemptOutcome = {};
|
|
171
209
|
for (let attempt = 1; ; attempt += 1) {
|
|
172
210
|
// The retry loop is bounded to one backoff by the deadline controller.
|
|
173
|
-
outcome = await this.#attempt(body, deadline.signal);
|
|
211
|
+
outcome = await this.#attempt(body, deadline.signal, decisionQuestions);
|
|
174
212
|
if (
|
|
175
213
|
!outcome.retryable ||
|
|
176
214
|
attempt >= MAX_ATTEMPTS ||
|
|
@@ -229,7 +267,11 @@ export class JevClient {
|
|
|
229
267
|
throw new JevFailureError("error", "Jev call failed.");
|
|
230
268
|
}
|
|
231
269
|
|
|
232
|
-
async #attempt(
|
|
270
|
+
async #attempt(
|
|
271
|
+
body: string,
|
|
272
|
+
signal: AbortSignal,
|
|
273
|
+
decisionQuestions?: DecisionsQuestion[]
|
|
274
|
+
): Promise<AttemptOutcome> {
|
|
233
275
|
if (signal.aborted) {
|
|
234
276
|
return { retryable: false };
|
|
235
277
|
}
|
|
@@ -254,7 +296,7 @@ export class JevClient {
|
|
|
254
296
|
};
|
|
255
297
|
}
|
|
256
298
|
if (response.ok) {
|
|
257
|
-
return this.#parseSuccess(response);
|
|
299
|
+
return this.#parseSuccess(response, decisionQuestions);
|
|
258
300
|
}
|
|
259
301
|
const failure = await this.#failureFromStatus(response);
|
|
260
302
|
return {
|
|
@@ -279,10 +321,32 @@ export class JevClient {
|
|
|
279
321
|
}
|
|
280
322
|
|
|
281
323
|
#transportLabel(): string {
|
|
282
|
-
|
|
324
|
+
if (this.#transport === "openrouter") {
|
|
325
|
+
return "OpenRouter";
|
|
326
|
+
}
|
|
327
|
+
return this.#transport === "openai-decisions"
|
|
328
|
+
? "OpenAI Decisions"
|
|
329
|
+
: "TypeSafe";
|
|
283
330
|
}
|
|
284
331
|
|
|
285
|
-
async #parseSuccess(
|
|
332
|
+
async #parseSuccess(
|
|
333
|
+
response: Response,
|
|
334
|
+
decisionQuestions?: DecisionsQuestion[]
|
|
335
|
+
): Promise<AttemptOutcome> {
|
|
336
|
+
if (this.#transport === "openai-decisions") {
|
|
337
|
+
const normalized = normalizeDecisionsResponse(
|
|
338
|
+
await parseDecisionsApiResponse(response),
|
|
339
|
+
decisionQuestions ?? []
|
|
340
|
+
);
|
|
341
|
+
return {
|
|
342
|
+
answers: normalized.answers,
|
|
343
|
+
model: normalized.model,
|
|
344
|
+
usage: {
|
|
345
|
+
input_tokens: normalized.usage.inputTokens,
|
|
346
|
+
output_tokens: normalized.usage.outputTokens,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
286
350
|
let parsed: unknown;
|
|
287
351
|
try {
|
|
288
352
|
parsed = await response.json();
|
|
@@ -294,7 +358,7 @@ export class JevClient {
|
|
|
294
358
|
),
|
|
295
359
|
};
|
|
296
360
|
}
|
|
297
|
-
if (!isRecord(parsed) || !
|
|
361
|
+
if (!isRecord(parsed) || !isRecord(parsed.answers)) {
|
|
298
362
|
return {
|
|
299
363
|
failure: new JevFailureError(
|
|
300
364
|
"malformed",
|
|
@@ -303,8 +367,7 @@ export class JevClient {
|
|
|
303
367
|
};
|
|
304
368
|
}
|
|
305
369
|
return {
|
|
306
|
-
|
|
307
|
-
answers: parsed.answers as RecordValue,
|
|
370
|
+
answers: parsed.answers,
|
|
308
371
|
model: isString(parsed.model) ? parsed.model : this.#model,
|
|
309
372
|
usage: isRecordOf(parsed.usage)
|
|
310
373
|
? {
|
|
@@ -324,7 +387,10 @@ export class JevClient {
|
|
|
324
387
|
answers: outcome.answers ?? {},
|
|
325
388
|
model: outcome.model ?? this.#model,
|
|
326
389
|
usage: {
|
|
327
|
-
cost:
|
|
390
|
+
cost:
|
|
391
|
+
this.#transport === "openai-decisions"
|
|
392
|
+
? undefined
|
|
393
|
+
: (inputTokens / 1_000_000) * price,
|
|
328
394
|
inputTokens,
|
|
329
395
|
outputTokens,
|
|
330
396
|
},
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { EntryType, Questions } from "@typesafe-ai/sdk";
|
|
2
|
+
|
|
3
|
+
import { isRecordOf, isString } from "../content-utils.ts";
|
|
4
|
+
import { JevFailureError } from "./failure.ts";
|
|
5
|
+
|
|
6
|
+
export const OPENAI_DECISIONS_ENDPOINT = "https://api.openai.com/v1/decisions";
|
|
7
|
+
export const OPENAI_DECISIONS_MODEL = "gpt-6-luna";
|
|
8
|
+
|
|
9
|
+
export interface DecisionsPredicateQuestion {
|
|
10
|
+
instructions: string;
|
|
11
|
+
name: string;
|
|
12
|
+
type: "predicate";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DecisionsScoreLevel {
|
|
16
|
+
description: string;
|
|
17
|
+
label: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DecisionsScoreQuestion {
|
|
21
|
+
instructions: string;
|
|
22
|
+
levels: DecisionsScoreLevel[];
|
|
23
|
+
name: string;
|
|
24
|
+
type: "score";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type DecisionsQuestion =
|
|
28
|
+
| DecisionsPredicateQuestion
|
|
29
|
+
| DecisionsScoreQuestion;
|
|
30
|
+
|
|
31
|
+
export interface DecisionsRequest {
|
|
32
|
+
input: string;
|
|
33
|
+
model: typeof OPENAI_DECISIONS_MODEL;
|
|
34
|
+
questions: DecisionsQuestion[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const formatEntry = (entry: EntryType | undefined): string => {
|
|
38
|
+
if (isString(entry)) {
|
|
39
|
+
return entry;
|
|
40
|
+
}
|
|
41
|
+
return entry === undefined ? "" : JSON.stringify(entry);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const predicateInstructions = (question: Questions[string]): string => {
|
|
45
|
+
const parts = [formatEntry(question.instructions)];
|
|
46
|
+
if (question.type === "noul") {
|
|
47
|
+
if (question.criteria?.true !== undefined) {
|
|
48
|
+
parts.push(`A true answer means: ${formatEntry(question.criteria.true)}`);
|
|
49
|
+
}
|
|
50
|
+
if (question.criteria?.false !== undefined) {
|
|
51
|
+
parts.push(
|
|
52
|
+
`A false answer means: ${formatEntry(question.criteria.false)}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return parts.filter(Boolean).join("\n\n");
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const scoreLevelLabel = (description: EntryType, index: number): string => {
|
|
60
|
+
const text = formatEntry(description);
|
|
61
|
+
const colon = text.indexOf(":");
|
|
62
|
+
const label = (colon === -1 ? text : text.slice(0, colon)).trim();
|
|
63
|
+
return label || `Level ${index}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const decisionQuestion = (
|
|
67
|
+
name: string,
|
|
68
|
+
question: Questions[string]
|
|
69
|
+
): DecisionsQuestion => {
|
|
70
|
+
if (question.type === "noul") {
|
|
71
|
+
return {
|
|
72
|
+
instructions: predicateInstructions(question),
|
|
73
|
+
name,
|
|
74
|
+
type: "predicate",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (question.type === "score") {
|
|
78
|
+
const labels = new Set<string>();
|
|
79
|
+
const levels = question.criteria.map((description, index) => {
|
|
80
|
+
const label = scoreLevelLabel(description, index);
|
|
81
|
+
if (labels.has(label)) {
|
|
82
|
+
throw new JevFailureError(
|
|
83
|
+
"malformed",
|
|
84
|
+
`Jev score question ${name} has duplicate level labels.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
labels.add(label);
|
|
88
|
+
return { description: formatEntry(description), label };
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
instructions: formatEntry(question.instructions),
|
|
92
|
+
levels,
|
|
93
|
+
name,
|
|
94
|
+
type: "score",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
throw new JevFailureError(
|
|
98
|
+
"malformed",
|
|
99
|
+
`Jev question ${name} has an unsupported type.`
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const formatDecisionsInput = (state: EntryType): string => {
|
|
104
|
+
if (isString(state)) {
|
|
105
|
+
return state;
|
|
106
|
+
}
|
|
107
|
+
return JSON.stringify(state, null, 2);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const buildDecisionsRequest = (
|
|
111
|
+
state: EntryType,
|
|
112
|
+
questions: Questions
|
|
113
|
+
): DecisionsRequest => {
|
|
114
|
+
if (!isRecordOf(questions) || Object.keys(questions).length === 0) {
|
|
115
|
+
throw new JevFailureError("malformed", "Jev questions are empty.");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
input: formatDecisionsInput(state),
|
|
119
|
+
model: OPENAI_DECISIONS_MODEL,
|
|
120
|
+
questions: Object.entries(questions).map(([name, question]) =>
|
|
121
|
+
decisionQuestion(name, question)
|
|
122
|
+
),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { isNumber, isRecord, isString } from "../content-utils.ts";
|
|
2
|
+
import type { JsonValue, RecordValue } from "../content-utils.ts";
|
|
3
|
+
import { OPENAI_DECISIONS_MODEL } from "./decisions-client.ts";
|
|
4
|
+
import type {
|
|
5
|
+
DecisionsQuestion,
|
|
6
|
+
DecisionsScoreQuestion,
|
|
7
|
+
} from "./decisions-client.ts";
|
|
8
|
+
import { JevFailureError } from "./failure.ts";
|
|
9
|
+
|
|
10
|
+
export interface DecisionsUsage {
|
|
11
|
+
inputTokens: number;
|
|
12
|
+
outputTokens: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NormalizedDecisionsResponse {
|
|
16
|
+
answers: RecordValue;
|
|
17
|
+
model: string;
|
|
18
|
+
usage: DecisionsUsage;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DecisionsApiResponse {
|
|
22
|
+
answers: RecordValue[];
|
|
23
|
+
model?: string;
|
|
24
|
+
usage: RecordValue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const malformedResponse = (detail: string): JevFailureError =>
|
|
28
|
+
new JevFailureError("malformed", `OpenAI Decisions response ${detail}.`);
|
|
29
|
+
|
|
30
|
+
const boundedProbability = (
|
|
31
|
+
value: JsonValue | undefined
|
|
32
|
+
): number | undefined =>
|
|
33
|
+
isNumber(value) && Number.isFinite(value) && value >= 0 && value <= 1
|
|
34
|
+
? value
|
|
35
|
+
: undefined;
|
|
36
|
+
|
|
37
|
+
const normalizePredicateAnswer = (answer: RecordValue): RecordValue => {
|
|
38
|
+
const probability = boundedProbability(answer.probability);
|
|
39
|
+
if (probability === undefined) {
|
|
40
|
+
throw malformedResponse("has an invalid predicate probability");
|
|
41
|
+
}
|
|
42
|
+
return { noul: probability, type: "noul" };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const normalizeScoreAnswer = (
|
|
46
|
+
answer: RecordValue,
|
|
47
|
+
question: DecisionsScoreQuestion
|
|
48
|
+
): RecordValue => {
|
|
49
|
+
if (!Array.isArray(answer.probabilities)) {
|
|
50
|
+
throw malformedResponse("has no score probability array");
|
|
51
|
+
}
|
|
52
|
+
if (answer.probabilities.length !== question.levels.length) {
|
|
53
|
+
throw malformedResponse("has an incomplete score probability array");
|
|
54
|
+
}
|
|
55
|
+
const probabilities: Record<string, number> = {};
|
|
56
|
+
const legend: Record<string, string> = {};
|
|
57
|
+
const seenLabels = new Set<string>();
|
|
58
|
+
const seenValues = new Set<number>();
|
|
59
|
+
let probabilityTotal = 0;
|
|
60
|
+
for (const entry of answer.probabilities) {
|
|
61
|
+
if (!isRecord(entry)) {
|
|
62
|
+
throw malformedResponse("has an invalid score probability entry");
|
|
63
|
+
}
|
|
64
|
+
const { label, value } = entry;
|
|
65
|
+
const probability = boundedProbability(entry.probability);
|
|
66
|
+
if (
|
|
67
|
+
!isString(label) ||
|
|
68
|
+
!isNumber(value) ||
|
|
69
|
+
!Number.isSafeInteger(value) ||
|
|
70
|
+
value < 0 ||
|
|
71
|
+
value >= question.levels.length ||
|
|
72
|
+
probability === undefined ||
|
|
73
|
+
seenLabels.has(label) ||
|
|
74
|
+
seenValues.has(value)
|
|
75
|
+
) {
|
|
76
|
+
throw malformedResponse("has an invalid or duplicate score level");
|
|
77
|
+
}
|
|
78
|
+
const level = question.levels[value];
|
|
79
|
+
if (label !== level.label) {
|
|
80
|
+
throw malformedResponse("has an ambiguous score label/value pair");
|
|
81
|
+
}
|
|
82
|
+
seenLabels.add(label);
|
|
83
|
+
seenValues.add(value);
|
|
84
|
+
probabilities[String(value)] = probability;
|
|
85
|
+
legend[String(value)] = level.description;
|
|
86
|
+
probabilityTotal += probability;
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
Math.abs(probabilityTotal - 1) > 0.02 ||
|
|
90
|
+
question.levels.some(
|
|
91
|
+
(level, index) => !seenValues.has(index) || !seenLabels.has(level.label)
|
|
92
|
+
)
|
|
93
|
+
) {
|
|
94
|
+
throw malformedResponse(
|
|
95
|
+
"has an incomplete or ambiguous score distribution"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return { legend, probabilities, type: "score" };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const usageTokenCount = (value: JsonValue | undefined): number | undefined =>
|
|
102
|
+
isNumber(value) && Number.isSafeInteger(value) && value >= 0
|
|
103
|
+
? value
|
|
104
|
+
: undefined;
|
|
105
|
+
|
|
106
|
+
export const parseDecisionsApiResponse = async (
|
|
107
|
+
response: Response
|
|
108
|
+
): Promise<DecisionsApiResponse> => {
|
|
109
|
+
let value: unknown;
|
|
110
|
+
try {
|
|
111
|
+
value = await response.json();
|
|
112
|
+
} catch {
|
|
113
|
+
throw malformedResponse("was not valid JSON");
|
|
114
|
+
}
|
|
115
|
+
if (!isRecord(value)) {
|
|
116
|
+
throw malformedResponse("is not an object");
|
|
117
|
+
}
|
|
118
|
+
const { answers: rawAnswers, model, usage } = value;
|
|
119
|
+
if (!Array.isArray(rawAnswers)) {
|
|
120
|
+
throw malformedResponse("did not include an answers array");
|
|
121
|
+
}
|
|
122
|
+
if (!isRecord(usage)) {
|
|
123
|
+
throw malformedResponse("has malformed usage");
|
|
124
|
+
}
|
|
125
|
+
if (model !== undefined && !isString(model)) {
|
|
126
|
+
throw malformedResponse("has an invalid model name");
|
|
127
|
+
}
|
|
128
|
+
const answers: RecordValue[] = [];
|
|
129
|
+
for (const answer of rawAnswers) {
|
|
130
|
+
if (!isRecord(answer)) {
|
|
131
|
+
throw malformedResponse("contains an invalid answer");
|
|
132
|
+
}
|
|
133
|
+
answers.push(answer);
|
|
134
|
+
}
|
|
135
|
+
return { answers, model: isString(model) ? model : undefined, usage };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const normalizeAnswers = (
|
|
139
|
+
responseAnswers: RecordValue[],
|
|
140
|
+
questions: DecisionsQuestion[]
|
|
141
|
+
): RecordValue => {
|
|
142
|
+
const expected = new Map(
|
|
143
|
+
questions.map((question) => [question.name, question])
|
|
144
|
+
);
|
|
145
|
+
if (expected.size !== questions.length || expected.size === 0) {
|
|
146
|
+
throw malformedResponse(
|
|
147
|
+
"has duplicate or missing requested question names"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const received = new Map<string, RecordValue>();
|
|
151
|
+
for (const answer of responseAnswers) {
|
|
152
|
+
const { name } = answer;
|
|
153
|
+
if (!isString(name) || !name) {
|
|
154
|
+
throw malformedResponse("contains an unnamed answer");
|
|
155
|
+
}
|
|
156
|
+
if (!expected.has(name)) {
|
|
157
|
+
throw malformedResponse("contains an unexpected answer");
|
|
158
|
+
}
|
|
159
|
+
if (received.has(name)) {
|
|
160
|
+
throw malformedResponse("contains duplicate answer names");
|
|
161
|
+
}
|
|
162
|
+
if (answer.type === "refusal") {
|
|
163
|
+
throw malformedResponse("contains a refusal");
|
|
164
|
+
}
|
|
165
|
+
received.set(name, answer);
|
|
166
|
+
}
|
|
167
|
+
if (received.size !== expected.size) {
|
|
168
|
+
throw malformedResponse("is missing one or more named answers");
|
|
169
|
+
}
|
|
170
|
+
const answers: RecordValue = {};
|
|
171
|
+
for (const question of questions) {
|
|
172
|
+
const answer = received.get(question.name);
|
|
173
|
+
if (!answer || answer.type !== question.type) {
|
|
174
|
+
throw malformedResponse("has an answer with the wrong type");
|
|
175
|
+
}
|
|
176
|
+
answers[question.name] =
|
|
177
|
+
question.type === "predicate"
|
|
178
|
+
? normalizePredicateAnswer(answer)
|
|
179
|
+
: normalizeScoreAnswer(answer, question);
|
|
180
|
+
}
|
|
181
|
+
return answers;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const normalizeDecisionsResponse = (
|
|
185
|
+
response: DecisionsApiResponse,
|
|
186
|
+
questions: DecisionsQuestion[]
|
|
187
|
+
): NormalizedDecisionsResponse => {
|
|
188
|
+
const inputTokens = usageTokenCount(response.usage.input_tokens);
|
|
189
|
+
const outputTokens = usageTokenCount(response.usage.output_tokens);
|
|
190
|
+
if (inputTokens === undefined || outputTokens === undefined) {
|
|
191
|
+
throw malformedResponse("has malformed usage");
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
answers: normalizeAnswers(response.answers, questions),
|
|
195
|
+
model: response.model || OPENAI_DECISIONS_MODEL,
|
|
196
|
+
usage: { inputTokens, outputTokens },
|
|
197
|
+
};
|
|
198
|
+
};
|