@monotykamary/pi-better-openai 0.2.7 → 0.2.9

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/src/config.ts CHANGED
@@ -10,6 +10,13 @@ import {
10
10
  } from "./live/voices.ts";
11
11
  import { piAgentDir } from "./paths.ts";
12
12
 
13
+ export const SERVICE_TIERS = ["standard", "fast", "ultrafast"] as const;
14
+ export type ServiceTier = (typeof SERVICE_TIERS)[number];
15
+
16
+ export function isServiceTier(value: unknown): value is ServiceTier {
17
+ return typeof value === "string" && (SERVICE_TIERS as readonly string[]).includes(value);
18
+ }
19
+
13
20
  export const FOOTER_MODES = ["replace", "status", "off"] as const;
14
21
  export const IMAGE_SAVE_MODES = ["none", "project", "global", "custom"] as const;
15
22
  export const IMAGE_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const;
@@ -37,7 +44,14 @@ export const PET_STATES = [
37
44
  export const DEFAULT_SUPPORTED_MODELS = [
38
45
  "openai/gpt-5.4",
39
46
  "openai/gpt-5.5",
47
+ "openai/gpt-6-astra",
48
+ "openai/gpt-6.1-sol",
49
+ "openai/gpt-6-sol",
50
+ "openai/gpt-6-luna",
40
51
  "openai-codex/gpt-6-astra",
52
+ "openai-codex/gpt-6.1-sol",
53
+ "openai-codex/gpt-6-sol",
54
+ "openai-codex/gpt-6-luna",
41
55
  "openai-codex/gpt-5.6-sol",
42
56
  "openai-codex/gpt-5.6-terra",
43
57
  "openai-codex/gpt-5.6-luna",
@@ -49,6 +63,16 @@ export const DEFAULT_SUPPORTED_MODELS = [
49
63
  // default list. Treat those exact snapshots as unset so later additions to
50
64
  // DEFAULT_SUPPORTED_MODELS (such as gpt-6-astra) reach existing installs.
51
65
  const LEGACY_SEEDED_SUPPORTED_MODELS: readonly (readonly string[])[] = [
66
+ [
67
+ "openai/gpt-5.4",
68
+ "openai/gpt-5.5",
69
+ "openai-codex/gpt-6-astra",
70
+ "openai-codex/gpt-5.6-sol",
71
+ "openai-codex/gpt-5.6-terra",
72
+ "openai-codex/gpt-5.6-luna",
73
+ "openai-codex/gpt-5.4",
74
+ "openai-codex/gpt-5.5",
75
+ ],
52
76
  ["openai/gpt-5.4", "openai/gpt-5.5", "openai-codex/gpt-5.4", "openai-codex/gpt-5.5"],
53
77
  [
54
78
  "openai/gpt-5.4",
@@ -126,7 +150,15 @@ export type PetConfig = {
126
150
  sizeCells?: number;
127
151
  };
128
152
 
153
+ export type DecisionsConfig = {
154
+ enabled?: boolean;
155
+ model?: string;
156
+ timeoutMs?: number;
157
+ };
158
+
129
159
  export interface ConfigFile {
160
+ serviceTier?: ServiceTier;
161
+ decisions?: DecisionsConfig;
130
162
  persistState?: boolean;
131
163
  notifyOnModelSwitch?: boolean;
132
164
  active?: boolean;
@@ -146,6 +178,8 @@ export interface SupportedModel {
146
178
  }
147
179
 
148
180
  export interface ResolvedConfig {
181
+ serviceTier?: ServiceTier;
182
+ decisions: Required<DecisionsConfig>;
149
183
  configPath: string;
150
184
  projectConfigPath: string;
151
185
  globalConfigPath: string;
@@ -218,7 +252,14 @@ export const DEFAULT_PET_CONFIG: Required<PetConfig> = {
218
252
  sizeCells: 10,
219
253
  };
220
254
 
255
+ export const DEFAULT_DECISIONS_CONFIG: Required<DecisionsConfig> = {
256
+ enabled: false,
257
+ model: "",
258
+ timeoutMs: 10_000,
259
+ };
260
+
221
261
  export const DEFAULT_CONFIG: ConfigFile = {
262
+ decisions: DEFAULT_DECISIONS_CONFIG,
222
263
  // supportedModels is intentionally omitted so generated config files never pin
223
264
  // the default list; resolution falls back to DEFAULT_SUPPORTED_MODELS live.
224
265
  persistState: true,
@@ -239,6 +280,7 @@ export type SettingsOptionSection =
239
280
  | "footer"
240
281
  | "image"
241
282
  | "websearch"
283
+ | "decisions"
242
284
  | "live"
243
285
  | "pets";
244
286
 
@@ -262,14 +304,25 @@ const numberSetting = (rawValue: string): number => Number(rawValue);
262
304
  const stringSetting = (rawValue: string): string => rawValue;
263
305
 
264
306
  export const FAST_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
307
+ {
308
+ id: "serviceTier",
309
+ section: "root",
310
+ key: "serviceTier",
311
+ label: "Service tier",
312
+ currentValue: (cfg) => cfg.serviceTier ?? (cfg.desiredActive ? "fast" : "standard"),
313
+ values: SERVICE_TIERS,
314
+ description:
315
+ "Ultrafast explicitly opts into 6x Standard token prices for API Astra (global/US only). Codex subscription access is not verified. Host cost estimates may exclude tier premiums.",
316
+ parse: stringSetting,
317
+ },
265
318
  {
266
319
  id: "persistState",
267
320
  section: "root",
268
321
  key: "persistState",
269
- label: "Persist fast state",
322
+ label: "Persist service tier",
270
323
  currentValue: (cfg) => String(cfg.persistState),
271
324
  values: ["true", "false"],
272
- description: "Remember fast-mode state across sessions.",
325
+ description: "Remember the selected service tier across sessions.",
273
326
  parse: booleanSetting,
274
327
  },
275
328
  {
@@ -605,7 +658,42 @@ export const PET_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
605
658
  },
606
659
  ];
607
660
 
661
+ export const DECISIONS_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
662
+ {
663
+ id: "decisions.enabled",
664
+ section: "decisions",
665
+ key: "enabled",
666
+ label: "Enable decisions",
667
+ values: ["true", "false"],
668
+ currentValue: (cfg) => String(cfg.decisions.enabled),
669
+ parse: booleanSetting,
670
+ description:
671
+ "Opt in to sending bounded state to the explicitly selected native classifier. No chat fallback or automatic actions.",
672
+ },
673
+ {
674
+ id: "decisions.model",
675
+ section: "decisions",
676
+ key: "model",
677
+ label: "Classifier model",
678
+ currentValue: (cfg) => cfg.decisions.model,
679
+ parse: stringSetting,
680
+ description:
681
+ "Select with /openai-decisions use provider/model. OpenAI requires a published native classifier; Jev can be selected explicitly today.",
682
+ },
683
+ {
684
+ id: "decisions.timeoutMs",
685
+ section: "decisions",
686
+ key: "timeoutMs",
687
+ label: "Decision timeout (ms)",
688
+ values: ["5000", "10000", "30000"],
689
+ currentValue: (cfg) => String(cfg.decisions.timeoutMs),
690
+ parse: numberSetting,
691
+ description: "Maximum decision request duration; no automatic retries.",
692
+ },
693
+ ];
694
+
608
695
  export const SETTINGS_OPTION_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
696
+ ...DECISIONS_SETTING_DESCRIPTORS,
609
697
  ...FAST_SETTING_DESCRIPTORS,
610
698
  ...FOOTER_SETTING_DESCRIPTORS,
611
699
  ...USAGE_SETTING_DESCRIPTORS,
@@ -687,6 +775,23 @@ export function readConfig(path: string): ConfigFile | undefined {
687
775
  if (!existsSync(path)) return undefined;
688
776
  const parsed = readRawConfig(path);
689
777
  const config: ConfigFile = {};
778
+ if (isServiceTier(parsed.serviceTier)) config.serviceTier = parsed.serviceTier;
779
+ if (isRecord(parsed.decisions)) {
780
+ config.decisions = {};
781
+ if (Object.hasOwn(parsed.decisions, "enabled"))
782
+ config.decisions.enabled = parsed.decisions.enabled === true;
783
+ // Invalid explicit selections must not fall back to a different global provider.
784
+ if (Object.hasOwn(parsed.decisions, "model"))
785
+ config.decisions.model =
786
+ typeof parsed.decisions.model === "string" ? parsed.decisions.model.trim() : "";
787
+ if (
788
+ typeof parsed.decisions.timeoutMs === "number" &&
789
+ Number.isFinite(parsed.decisions.timeoutMs)
790
+ )
791
+ config.decisions.timeoutMs = parsed.decisions.timeoutMs;
792
+ } else if (Object.hasOwn(parsed, "decisions")) {
793
+ config.decisions = { enabled: false, model: "" };
794
+ }
690
795
  if (typeof parsed.persistState === "boolean") config.persistState = parsed.persistState;
691
796
  if (typeof parsed.notifyOnModelSwitch === "boolean")
692
797
  config.notifyOnModelSwitch = parsed.notifyOnModelSwitch;
@@ -798,6 +903,7 @@ export function readConfig(path: string): ConfigFile | undefined {
798
903
  }
799
904
 
800
905
  export type SettingPatchContext = SettingsValueContext & {
906
+ serviceTier?: ServiceTier;
801
907
  persistState?: boolean;
802
908
  active?: boolean;
803
909
  desiredActive?: boolean;
@@ -811,10 +917,14 @@ export function applySettingToRawConfig(
811
917
  ): Record<string, unknown> {
812
918
  const next: Record<string, unknown> = { ...current };
813
919
  const bool = rawValue === "true";
814
- if (id === "fast.enabled") {
920
+ if (id === "fast.enabled" || id === "serviceTier") {
921
+ if (id === "serviceTier" && !isServiceTier(rawValue)) return next;
815
922
  if (context.persistState) {
816
- next.active = context.active ?? bool;
817
- next.desiredActive = context.desiredActive ?? bool;
923
+ const tier =
924
+ context.serviceTier ?? (isServiceTier(rawValue) ? rawValue : bool ? "fast" : "standard");
925
+ next.serviceTier = tier;
926
+ next.active = context.active ?? tier !== "standard";
927
+ next.desiredActive = context.desiredActive ?? tier !== "standard";
818
928
  }
819
929
  } else {
820
930
  const descriptor = SETTINGS_OPTION_BY_ID.get(id);
@@ -856,10 +966,37 @@ export function resolveConfig(cwd: string): ResolvedConfig {
856
966
  const projectConfig = readConfig(paths.project) ?? {};
857
967
  const merged = { ...DEFAULT_CONFIG, ...globalConfig, ...projectConfig };
858
968
  const selectedPath = projectConfigExists ? paths.project : paths.global;
859
- const desiredActive = merged.desiredActive ?? merged.active ?? false;
969
+ // A project legacy boolean must also override a global explicit tier.
970
+ const stateConfig =
971
+ [projectConfig, globalConfig].find(
972
+ (cfg) =>
973
+ cfg.serviceTier !== undefined ||
974
+ cfg.desiredActive !== undefined ||
975
+ cfg.active !== undefined,
976
+ ) ?? {};
977
+ const serviceTier = stateConfig.serviceTier;
978
+ const desiredActive =
979
+ serviceTier !== undefined
980
+ ? serviceTier !== "standard"
981
+ : (stateConfig.desiredActive ?? stateConfig.active ?? false);
860
982
  const configuredSupportedModels = parseModels(merged.supportedModels);
861
983
 
862
984
  return {
985
+ serviceTier,
986
+ decisions: {
987
+ ...DEFAULT_DECISIONS_CONFIG,
988
+ ...globalConfig.decisions,
989
+ ...projectConfig.decisions,
990
+ timeoutMs: Math.max(
991
+ 1000,
992
+ Math.min(
993
+ 60_000,
994
+ projectConfig.decisions?.timeoutMs ??
995
+ globalConfig.decisions?.timeoutMs ??
996
+ DEFAULT_DECISIONS_CONFIG.timeoutMs,
997
+ ),
998
+ ),
999
+ },
863
1000
  configPath: selectedPath,
864
1001
  projectConfigPath: paths.project,
865
1002
  globalConfigPath: paths.global,
@@ -0,0 +1,405 @@
1
+ import {
2
+ Type,
3
+ type ClassifierContext,
4
+ type ClassifierResult,
5
+ type Static,
6
+ } from "@earendil-works/pi-ai";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ isRecord,
10
+ parseModelKey,
11
+ readRawConfig,
12
+ writeConfig,
13
+ type DecisionsConfig,
14
+ type ResolvedConfig,
15
+ } from "./config.ts";
16
+
17
+ export const OPENAI_DECIDE_TOOL = "openai_decide";
18
+ export const OPENAI_DECISIONS_COMMAND = "openai-decisions";
19
+ export const MAX_DECISION_BYTES = 64 * 1024;
20
+ const MAX_QUESTIONS = 32;
21
+ const MAX_CRITERIA = 64;
22
+ const text = Type.String({ minLength: 1, maxLength: 4096 });
23
+ const question = Type.Union([
24
+ Type.Object(
25
+ {
26
+ type: Type.Literal("choice"),
27
+ instructions: text,
28
+ criteria: Type.Record(Type.String(), text, { minProperties: 2, maxProperties: MAX_CRITERIA }),
29
+ },
30
+ { additionalProperties: false },
31
+ ),
32
+ Type.Object(
33
+ {
34
+ type: Type.Literal("bool"),
35
+ instructions: text,
36
+ criteria: Type.Object({ true: text, false: text }, { additionalProperties: false }),
37
+ },
38
+ { additionalProperties: false },
39
+ ),
40
+ Type.Object(
41
+ {
42
+ type: Type.Literal("score"),
43
+ instructions: text,
44
+ criteria: Type.Array(text, { minItems: 2, maxItems: MAX_CRITERIA }),
45
+ },
46
+ { additionalProperties: false },
47
+ ),
48
+ ]);
49
+ export const DECISION_PARAMETERS = Type.Object(
50
+ {
51
+ state: Type.Record(Type.String(), Type.Unknown(), {
52
+ description:
53
+ "Only the JSON state needed for this decision. Do not send secrets or the entire session.",
54
+ }),
55
+ questions: Type.Record(Type.String(), question, {
56
+ minProperties: 1,
57
+ maxProperties: MAX_QUESTIONS,
58
+ }),
59
+ },
60
+ { additionalProperties: false },
61
+ );
62
+ const probability = Type.Number({ minimum: 0, maximum: 1 });
63
+ export const DECISION_OUTPUT = Type.Union([
64
+ Type.Object(
65
+ {
66
+ status: Type.Literal("ok"),
67
+ provider: Type.String(),
68
+ model: Type.String(),
69
+ answers: Type.Record(
70
+ Type.String(),
71
+ Type.Union([
72
+ Type.Object(
73
+ {
74
+ type: Type.Literal("choice"),
75
+ choice: Type.String(),
76
+ probabilities: Type.Record(Type.String(), probability),
77
+ confidence: Type.Number(),
78
+ },
79
+ { additionalProperties: false },
80
+ ),
81
+ Type.Object({ type: Type.Literal("bool"), probability }, { additionalProperties: false }),
82
+ Type.Object(
83
+ { type: Type.Literal("score"), score: Type.Number(), confidence: Type.Number() },
84
+ { additionalProperties: false },
85
+ ),
86
+ ]),
87
+ ),
88
+ },
89
+ { additionalProperties: false },
90
+ ),
91
+ Type.Object(
92
+ { status: Type.Literal("error"), error: Type.String() },
93
+ { additionalProperties: false },
94
+ ),
95
+ ]);
96
+ type DecisionOutput = Static<typeof DECISION_OUTPUT>;
97
+
98
+ class DecisionError extends Error {}
99
+
100
+ function requireDecision(condition: unknown, message: string): asserts condition {
101
+ if (!condition) throw new DecisionError(message);
102
+ }
103
+
104
+ function validText(value: unknown): value is string {
105
+ return typeof value === "string" && value.trim().length > 0 && value.length <= 4096;
106
+ }
107
+
108
+ export function validateDecisionRequest(value: unknown): ClassifierContext {
109
+ requireDecision(
110
+ isRecord(value) && isRecord(value.state) && isRecord(value.questions),
111
+ "Decisions require a JSON state object and typed questions.",
112
+ );
113
+ let encoded: string;
114
+ try {
115
+ encoded = JSON.stringify(value, (_key, item: unknown) => {
116
+ if (typeof item === "number" && !Number.isFinite(item)) throw new Error();
117
+ if (["undefined", "function", "symbol", "bigint"].includes(typeof item)) throw new Error();
118
+ return item;
119
+ });
120
+ } catch {
121
+ throw new DecisionError("Decision input must contain only finite JSON values.");
122
+ }
123
+ requireDecision(
124
+ Buffer.byteLength(encoded, "utf8") <= MAX_DECISION_BYTES,
125
+ "Decision input exceeds the 64 KiB limit.",
126
+ );
127
+ requireDecision(
128
+ Object.keys(value).every((key) => key === "state" || key === "questions"),
129
+ "Unexpected decision input fields.",
130
+ );
131
+ const questions = Object.entries(value.questions);
132
+ requireDecision(
133
+ questions.length > 0 && questions.length <= MAX_QUESTIONS,
134
+ "Provide between 1 and 32 decision questions.",
135
+ );
136
+ for (const [key, item] of questions) {
137
+ requireDecision(
138
+ key.length > 0 &&
139
+ key.length <= 128 &&
140
+ !["__proto__", "constructor", "prototype"].includes(key),
141
+ "Invalid decision question key.",
142
+ );
143
+ requireDecision(
144
+ isRecord(item) && validText(item.instructions),
145
+ "Each question requires bounded, nonempty instructions.",
146
+ );
147
+ requireDecision(
148
+ Object.keys(item).every((field) => ["type", "instructions", "criteria"].includes(field)),
149
+ "Unexpected decision question fields.",
150
+ );
151
+ if (item.type === "score") {
152
+ requireDecision(
153
+ Array.isArray(item.criteria) &&
154
+ item.criteria.length >= 2 &&
155
+ item.criteria.length <= MAX_CRITERIA &&
156
+ item.criteria.every(validText),
157
+ "Scores require 2–64 ordered criteria.",
158
+ );
159
+ } else {
160
+ requireDecision(
161
+ (item.type === "choice" || item.type === "bool") && isRecord(item.criteria),
162
+ "Question type must be choice, bool, or score.",
163
+ );
164
+ const keys = Object.keys(item.criteria);
165
+ requireDecision(
166
+ keys.length >= 2 &&
167
+ keys.length <= MAX_CRITERIA &&
168
+ keys.every(
169
+ (label) =>
170
+ label.length > 0 &&
171
+ label.length <= 128 &&
172
+ !["__proto__", "constructor", "prototype"].includes(label),
173
+ ) &&
174
+ Object.values(item.criteria).every(validText),
175
+ "Provide 2–64 valid labeled criteria.",
176
+ );
177
+ if (item.type === "bool")
178
+ requireDecision(
179
+ keys.length === 2 && keys.includes("true") && keys.includes("false"),
180
+ "Boolean criteria must be true and false.",
181
+ );
182
+ }
183
+ }
184
+ return JSON.parse(encoded) as ClassifierContext;
185
+ }
186
+
187
+ const finite = (value: unknown): value is number =>
188
+ typeof value === "number" && Number.isFinite(value);
189
+ const unit = (value: unknown): value is number => finite(value) && value >= 0 && value <= 1;
190
+
191
+ export function validateDecisionAnswers(
192
+ answers: unknown,
193
+ request: ClassifierContext,
194
+ ): ClassifierResult["answers"] {
195
+ const invalid = "Classifier returned invalid typed answers.";
196
+ requireDecision(
197
+ isRecord(answers) && Object.keys(answers).length === Object.keys(request.questions).length,
198
+ invalid,
199
+ );
200
+ const clean: ClassifierResult["answers"] = {};
201
+ for (const [key, q] of Object.entries(request.questions)) {
202
+ const answer = answers[key];
203
+ requireDecision(isRecord(answer) && answer.type === q.type, invalid);
204
+ if (q.type === "bool") {
205
+ requireDecision(unit(answer.probability), invalid);
206
+ clean[key] = { type: "bool", probability: answer.probability };
207
+ } else if (q.type === "score") {
208
+ requireDecision(finite(answer.score) && finite(answer.confidence), invalid);
209
+ // Preserve the provider's score scale; do not relabel it as a probability.
210
+ clean[key] = { type: "score", score: answer.score, confidence: answer.confidence };
211
+ } else {
212
+ requireDecision(
213
+ typeof answer.choice === "string" &&
214
+ Object.hasOwn(q.criteria, answer.choice) &&
215
+ isRecord(answer.probabilities) &&
216
+ finite(answer.confidence),
217
+ invalid,
218
+ );
219
+ const entries = Object.entries(answer.probabilities);
220
+ requireDecision(
221
+ entries.length === Object.keys(q.criteria).length &&
222
+ entries.every(([label, p]) => Object.hasOwn(q.criteria, label) && unit(p)),
223
+ invalid,
224
+ );
225
+ requireDecision(
226
+ Math.abs(entries.reduce((sum, [, p]) => sum + (p as number), 0) - 1) <= 0.001,
227
+ invalid,
228
+ );
229
+ clean[key] = {
230
+ type: "choice",
231
+ choice: answer.choice,
232
+ confidence: answer.confidence,
233
+ probabilities: Object.fromEntries(entries) as Record<string, number>,
234
+ };
235
+ }
236
+ }
237
+ return clean;
238
+ }
239
+
240
+ export async function evaluateDecision(
241
+ ctx: ExtensionContext,
242
+ cfg: ResolvedConfig,
243
+ input: unknown,
244
+ signal?: AbortSignal,
245
+ ) {
246
+ let result: ClassifierResult | undefined;
247
+ let timer: ReturnType<typeof setTimeout> | undefined;
248
+ const controller = new AbortController();
249
+ const abort = () => controller.abort();
250
+ signal?.addEventListener("abort", abort, { once: true });
251
+ if (signal?.aborted) abort();
252
+ let abortListener: (() => void) | undefined;
253
+ try {
254
+ requireDecision(
255
+ cfg.decisions.enabled,
256
+ "Decisions are disabled. Select a native classifier with /openai-decisions use provider/model to opt in.",
257
+ );
258
+ const key = parseModelKey(cfg.decisions.model);
259
+ requireDecision(
260
+ key,
261
+ "Select an explicit native classifier with /openai-decisions use provider/model.",
262
+ );
263
+ const model = ctx.modelRegistry.getModelOfType("classifier", key.provider, key.id);
264
+ requireDecision(
265
+ model,
266
+ "The configured native classifier is unavailable. No OpenAI endpoint or chat fallback is assumed; inspect /openai-decisions models.",
267
+ );
268
+ const request = validateDecisionRequest(input);
269
+ requireDecision(!controller.signal.aborted, "Decision request aborted.");
270
+ const cancelled = new Promise<never>((_resolve, reject) => {
271
+ abortListener = () =>
272
+ reject(
273
+ new DecisionError(
274
+ signal?.aborted ? "Decision request aborted." : "Decision request timed out.",
275
+ ),
276
+ );
277
+ controller.signal.addEventListener("abort", abortListener, { once: true });
278
+ });
279
+ timer = setTimeout(abort, cfg.decisions.timeoutMs);
280
+ result = await Promise.race([
281
+ ctx.modelRegistry.classify(model, request, { signal: controller.signal }),
282
+ cancelled,
283
+ ]);
284
+ requireDecision(
285
+ !controller.signal.aborted,
286
+ signal?.aborted ? "Decision request aborted." : "Decision request timed out.",
287
+ );
288
+ requireDecision(
289
+ result.stopReason === "stop",
290
+ result.stopReason === "aborted"
291
+ ? "Decision request aborted."
292
+ : "Decision provider failed. Check the selected provider's credentials and availability in Pi.",
293
+ );
294
+ requireDecision(
295
+ result.provider === model.provider && result.model === model.id,
296
+ "Classifier response did not match the selected provider/model.",
297
+ );
298
+ const answers = validateDecisionAnswers(result.answers, request);
299
+ const output: DecisionOutput = {
300
+ status: "ok",
301
+ provider: result.provider,
302
+ model: result.model,
303
+ answers,
304
+ };
305
+ return {
306
+ content: [{ type: "text" as const, text: JSON.stringify(output) }],
307
+ details: output,
308
+ structuredContent: output,
309
+ usage: result.usage,
310
+ };
311
+ } catch (error) {
312
+ // Provider errors can echo submitted state or credentials; never expose their text.
313
+ const output: DecisionOutput = {
314
+ status: "error",
315
+ error:
316
+ error instanceof DecisionError
317
+ ? error.message
318
+ : "Decision provider failed. Check the selected provider's credentials and availability in Pi.",
319
+ };
320
+ return {
321
+ content: [{ type: "text" as const, text: output.error }],
322
+ details: output,
323
+ structuredContent: output,
324
+ isError: true,
325
+ usage: result?.usage,
326
+ };
327
+ } finally {
328
+ clearTimeout(timer);
329
+ signal?.removeEventListener("abort", abort);
330
+ if (abortListener) controller.signal.removeEventListener("abort", abortListener);
331
+ }
332
+ }
333
+
334
+ export function registerOpenAIDecisions(
335
+ pi: ExtensionAPI,
336
+ getConfig: (ctx: ExtensionContext) => ResolvedConfig,
337
+ refreshConfig: (ctx: ExtensionContext) => ResolvedConfig,
338
+ ): void {
339
+ function save(ctx: ExtensionContext, patch: DecisionsConfig): void {
340
+ const cfg = refreshConfig(ctx);
341
+ const raw = readRawConfig(cfg.configPath);
342
+ writeConfig(cfg.configPath, {
343
+ ...raw,
344
+ decisions: { ...(isRecord(raw.decisions) ? raw.decisions : {}), ...patch },
345
+ });
346
+ refreshConfig(ctx);
347
+ }
348
+
349
+ pi.registerCommand(OPENAI_DECISIONS_COMMAND, {
350
+ description: "Configure opt-in native decisions: models | use provider/model | off",
351
+ handler: async (args, ctx) => {
352
+ const arg = args.trim();
353
+ if (arg === "off") {
354
+ save(ctx, { enabled: false });
355
+ ctx.ui.notify("Decision requests disabled.", "info");
356
+ } else if (arg === "models") {
357
+ const models = ctx.modelRegistry.getModelsOfType("classifier");
358
+ ctx.ui.notify(
359
+ models.length
360
+ ? models.map((m) => `${m.provider}/${m.id}`).join("\n") +
361
+ "\nCatalog entries do not guarantee credentials or entitlement."
362
+ : "No native classifiers registered. No OpenAI Decisions endpoint is assumed.",
363
+ "info",
364
+ );
365
+ } else if (arg.startsWith("use ")) {
366
+ const key = parseModelKey(arg.slice(4));
367
+ if (!key || !ctx.modelRegistry.getModelOfType("classifier", key.provider, key.id)) {
368
+ ctx.ui.notify(
369
+ "Unknown native classifier. Use /openai-decisions models; chat models are not accepted.",
370
+ "error",
371
+ );
372
+ return;
373
+ }
374
+ const model = `${key.provider}/${key.id}`;
375
+ save(ctx, { enabled: true, model });
376
+ ctx.ui.notify(
377
+ `Decisions enabled for ${model}. Submitted state is sent to that provider and billed there. No automatic actions or provider fallback.`,
378
+ "warning",
379
+ );
380
+ } else if (!arg) {
381
+ const cfg = getConfig(ctx).decisions;
382
+ ctx.ui.notify(
383
+ `Decisions: ${cfg.enabled ? "enabled" : "disabled"}; model: ${cfg.model || "not selected"}; timeout: ${cfg.timeoutMs}ms.\n/openai-decisions models | use provider/model | off\nOpenAI native Decisions requires a published classifier adapter in Pi; no endpoint is guessed.`,
384
+ "info",
385
+ );
386
+ } else {
387
+ ctx.ui.notify("Usage: /openai-decisions [models | use provider/model | off]", "error");
388
+ }
389
+ },
390
+ });
391
+ pi.registerTool({
392
+ name: OPENAI_DECIDE_TOOL,
393
+ label: "Typed decision",
394
+ description:
395
+ "Answer bounded choice, bool, or score questions using the user's explicitly configured native classifier. Disabled until opt-in. Supports Pi classifier providers (including Jev); OpenAI requires a native adapter. Never uses a chat fallback or executes decisions.",
396
+ parameters: DECISION_PARAMETERS,
397
+ outputSchema: DECISION_OUTPUT,
398
+ promptGuidelines: [
399
+ "Use only for explicitly requested judgments after the user selects a classifier. Send minimal state, not secrets or the full session.",
400
+ "Treat probabilities as uncertain judgments, not permission to act. Preserve score semantics and escalate ambiguous results.",
401
+ ],
402
+ execute: (_id, params, signal, _onUpdate, ctx) =>
403
+ evaluateDecision(ctx, getConfig(ctx), params, signal),
404
+ });
405
+ }