pi-advisor-flow 0.11.2-dev.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-advisor-flow",
3
- "version": "0.11.2-dev.2",
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.2",
55
- "@earendil-works/pi-coding-agent": "1.0.2",
56
- "@earendil-works/pi-tui": "1.0.2",
57
- "@oxlint/plugins": "1.86.0",
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.39.0",
62
+ "knip": "6.40.0",
63
63
  "lint-staged": "^17.6.0",
64
- "oxfmt": "0.71.0",
65
- "oxlint": "1.86.0",
66
- "typebox": "^1.3.34",
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.2"
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
- saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
161
+ if (!options.skipOutcomeLogging) {
162
+ saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
163
+ }
157
164
  } catch (error) {
158
165
  applyAdvisorSettings(previous);
159
166
  throw error;
@@ -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 = "auto" | "typesafe" | "openrouter";
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: number;
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
- /** One systemone client for both transports with a total wall deadline so
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
- this.#model = transport === "openrouter" ? openRouterModelId(model) : model;
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(body: string, signal: AbortSignal): Promise<AttemptOutcome> {
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
- return this.#endpoint === ENDPOINTS.openrouter ? "OpenRouter" : "TypeSafe";
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(response: Response): Promise<AttemptOutcome> {
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) || !parsed.answers || !isObjectLike(parsed.answers)) {
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
- // SAFETY: the response contract guarantees an answers object; the shape is re-validated per key by consumers.
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: (inputTokens / 1_000_000) * price,
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
+ };