@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/CHANGELOG.md +20 -0
- package/README.md +67 -6
- package/index.ts +118 -30
- package/package.json +8 -6
- package/plans/devday-integration.md +48 -0
- package/src/codex-models.ts +20 -0
- package/src/config.ts +143 -6
- package/src/decisions.ts +405 -0
- package/src/fast-controller.ts +98 -23
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
|
|
322
|
+
label: "Persist service tier",
|
|
270
323
|
currentValue: (cfg) => String(cfg.persistState),
|
|
271
324
|
values: ["true", "false"],
|
|
272
|
-
description: "Remember
|
|
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
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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,
|
package/src/decisions.ts
ADDED
|
@@ -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
|
+
}
|