@monotykamary/pi-better-openai 0.2.7 → 0.2.8

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 ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.2.8
4
+
5
+ - Add GPT-6.1 Sol fallback metadata and Fast support for the GPT-6 model family.
6
+ - Add `/openai-tier` for Standard, Fast, and capability-gated API Astra Ultrafast, with explicit pricing warnings, persistence, diagnostics, and footer labels.
7
+ - Add opt-in `openai_decide` and `/openai-decisions` through Pi's classifier API, with typed validation, cancellation, safe errors, and usage accounting.
8
+ - Include classifier tool usage in footer totals and preserve legacy Fast settings and customized model lists.
9
+ - Document remaining native OpenAI Decisions, Codex subscription Ultrafast, and hosted multi-agent integration gates; no undocumented endpoint or chat fallback is assumed.
10
+
11
+ ## 0.2.7
12
+
13
+ - Validate against Pi 0.99.0, including an offline real-host package-loading probe.
14
+ - Declare imported host packages as wildcard peers and pin development dependencies to Pi 0.99.0.
package/README.md CHANGED
@@ -30,8 +30,9 @@ Usage display, image generation, and live voice require pi's `openai-codex` OAut
30
30
 
31
31
  ## Features
32
32
 
33
- - GPT-6 Astra and Daybreak Blue/Red model fallbacks for the built-in `openai-codex` provider.
34
- - Fast mode for supported OpenAI models, toggled with `/fast` or in `/openai-settings`.
33
+ - GPT-6.1 Sol, GPT-6 Astra, and Daybreak Blue/Red model fallbacks for the built-in `openai-codex` provider.
34
+ - Standard, Fast, and capability-gated Ultrafast service tiers via `/openai-tier` or `/openai-settings`; `/fast` remains a quick Fast toggle.
35
+ - Opt-in typed decisions through `openai_decide`, using an explicitly selected Pi classifier provider (including Jev). Native OpenAI Decisions remains gated on a published adapter.
35
36
  - OpenAI subscription usage display via `/openai-usage` and the footer.
36
37
  - Interactive settings picker via `/openai-settings`.
37
38
  - Footer customization for model, thinking, fast mode, usage, and token/cost context.
@@ -40,7 +41,9 @@ Usage display, image generation, and live voice require pi's `openai-codex` OAut
40
41
  - Codex-backed realtime voice through `/live`, with an animated microphone waveform and coding-task delegation into the active pi session.
41
42
  - Animated Codex custom pets rendered in the Better OpenAI footer.
42
43
  - Commands:
43
- - `/fast` toggles fast mode.
44
+ - `/fast` toggles Fast and Standard; it never enables Ultrafast.
45
+ - `/openai-tier [standard|fast|ultrafast]` shows or selects the requested service tier.
46
+ - `/openai-decisions [models|use provider/model|off]` inspects or configures typed decisions.
44
47
  - `/openai-image <prompt>` generates an image directly.
45
48
  - `/openai-websearch <query>` searches the web and inserts the cited answer into the session.
46
49
  - `/live` starts or stops realtime voice mode. `Ctrl+Shift+L` is the keyboard toggle.
@@ -72,7 +75,14 @@ Default supported models:
72
75
  [
73
76
  "openai/gpt-5.4",
74
77
  "openai/gpt-5.5",
78
+ "openai/gpt-6-astra",
79
+ "openai/gpt-6.1-sol",
80
+ "openai/gpt-6-sol",
81
+ "openai/gpt-6-luna",
75
82
  "openai-codex/gpt-6-astra",
83
+ "openai-codex/gpt-6.1-sol",
84
+ "openai-codex/gpt-6-sol",
85
+ "openai-codex/gpt-6-luna",
76
86
  "openai-codex/gpt-5.6-sol",
77
87
  "openai-codex/gpt-5.6-terra",
78
88
  "openai-codex/gpt-5.6-luna",
@@ -87,8 +97,12 @@ Example config:
87
97
  {
88
98
  "persistState": true,
89
99
  "notifyOnModelSwitch": true,
90
- "desiredActive": false,
91
- "supportedModels": ["openai/gpt-5.5", "openai-codex/gpt-5.5"],
100
+ "serviceTier": "standard",
101
+ "decisions": {
102
+ "enabled": false,
103
+ "model": "",
104
+ "timeoutMs": 10000
105
+ },
92
106
  "usage": {
93
107
  "enabled": true,
94
108
  "refreshIntervalMs": 60000,
@@ -125,9 +139,56 @@ Example config:
125
139
  }
126
140
  ```
127
141
 
142
+ ## Service tiers
143
+
144
+ `/openai-tier fast` requests `service_tier: "priority"`. `/openai-tier standard` explicitly requests `"default"`, clearing an inherited Fast/Ultrafast request tier on OpenAI providers. Legacy disabled configurations without an explicit tier leave payloads untouched. `/fast` and the `--fast` flag never select Ultrafast.
145
+
146
+ `/openai-tier ultrafast` explicitly opts into **6x Standard token prices** for `openai/gpt-6-astra` over the Responses API. Only the documented global (`https://api.openai.com/v1`) and US (`https://us.api.openai.com/v1`) endpoints are enabled. EU/regional endpoints, custom proxies, other models, and Codex subscription Ultrafast are not enabled without verified support. This does not promise account entitlement or available rate limits. Unsupported selections remain requested but inactive; the extension does not inject a lower-tier fallback or retry a rejected request.
147
+
148
+ The footer shows `fast` or `ultrafast` only when supported by the current model. Diagnostics distinguish the requested tier and last injected payload from server-confirmed service or billing. **Pi's host cost estimates may omit the Ultrafast premium**; use OpenAI billing for actual charges. The host's native transport is preserved; WebSockets are recommended by OpenAI but HTTP is also supported. See [Ultrafast documentation](https://developers.openai.com/api/docs/guides/ultrafast-mode) and [pricing](https://developers.openai.com/api/docs/pricing?latest-pricing=ultrafast).
149
+
150
+ `serviceTier` takes precedence over legacy `desiredActive`/`active` within each config layer; project state still overrides global state. `persistState: false` keeps tier changes session-only. `supportedModels` overrides the Fast allowlist, not Ultrafast capabilities. Unknown config fields and customized model lists are preserved.
151
+
152
+ ## Typed decisions
153
+
154
+ The `openai_decide` tool returns typed judgments through Pi's classifier API, **not chat completions**. It is disabled by default and never chooses a provider automatically. To use an existing Jev classifier explicitly:
155
+
156
+ ```text
157
+ /openai-decisions models
158
+ /openai-decisions use typesafe/jev-latest
159
+ ```
160
+
161
+ Configure that provider's credentials through Pi (for example, `TYPESAFE_API_KEY` for TypeSafe). The model list shows registered classifiers, not guaranteed credentials or entitlement. `/openai-decisions use` saves the selected model and enables decisions in the active project/global config; `/openai-decisions off` disables requests. These settings are independent of service-tier persistence. Provider-qualified IDs containing further slashes, such as `openrouter/typesafe/jev-1.13`, are supported when registered by the host. Prefer pinned versions for stable evaluations.
162
+
163
+ **Native OpenAI Decisions is not yet implemented:** as of the September 29, 2026 research checkpoint, no verifiable public endpoint/schema or SDK adapter was found. The bridge accepts OpenAI only once a classifier adapter is registered in Pi; it does not invent an endpoint, forward Codex OAuth to another service, or silently substitute a chat model. See the [integration status and remaining gates](plans/devday-integration.md).
164
+
165
+ Example tool input:
166
+
167
+ ```json
168
+ {
169
+ "state": { "testFailure": "connection to local test database timed out" },
170
+ "questions": {
171
+ "route": {
172
+ "type": "choice",
173
+ "instructions": "Classify the failure for human review.",
174
+ "criteria": { "environment": "Environment problem", "code": "Code defect" }
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ - `state`: JSON object; send only the necessary context, never credentials or the entire session.
181
+ - `questions`: 1–32 named questions. `choice` uses 2–64 labeled criteria; `bool` uses `true`/`false` criteria; `score` uses 2–64 ordered criteria. Pi maps boolean questions to Jev's `noul` representation.
182
+ - Total input is capped at 64 KiB. `decisions.timeoutMs` defaults to 10000 and is clamped to 1000–60000. Cancellation/deadlines abort the provider request; there are no automatic retries. A timed-out upstream request may still incur charges.
183
+ - Results have `status: "ok"`, provider/model provenance, and typed `answers`; errors have `status: "error"` and mark the tool failed. Structured output is available to programmatic callers. Provider error text is withheld to prevent credential/state leakage.
184
+ - Probabilities and confidence remain uncertain judgments; scores retain their provider-specific scale. No claim of cross-provider calibration is made. Decisions never authorize tools, execute commands, change the active model, or start background polling.
185
+ - Reported classifier token usage/cost is included in tool results and the Better OpenAI footer. Missing usage or catalog pricing is not evidence that a request was free.
186
+
128
187
  ## Codex model fallbacks
129
188
 
130
- The extension adds `gpt-6-astra`, `gpt-daybreak-blue-latest`, and `gpt-daybreak-red-latest` to the built-in `openai-codex` provider without requiring local `models.json` entries. Existing built-in models remain available, and metadata from pi's live catalog takes precedence when pi publishes an official entry with the same ID.
189
+ The extension adds `gpt-6.1-sol`, `gpt-6-astra`, `gpt-daybreak-blue-latest`, and `gpt-daybreak-red-latest` to the built-in `openai-codex` provider without requiring local `models.json` entries. Existing built-in models remain available, and metadata from pi's live catalog takes precedence when pi publishes an official entry with the same ID.
190
+
191
+ The GPT-6.1 Sol fallback uses Codex's conservative 272K context default, 128K output limit, and published short/long-context pricing. It maps Pi's `minimal` level to `low` and disables `off`; the model does not accept `none` or `minimal` reasoning efforts. See [model documentation](https://developers.openai.com/api/docs/models/gpt-6.1-sol) and the [upstream Codex catalog change](https://github.com/openai/codex/commit/b1e72963c3b71a9265a551e54beff078384efed9).
131
192
 
132
193
  Daybreak models require separate OpenAI approval and provisioning. pi currently exposes reasoning levels through `max`; Codex's `ultra` automatic-delegation mode is not a pi thinking level.
133
194
 
package/index.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Better OpenAI for pi.
3
3
  *
4
- * Adds `service_tier: "priority"` to OpenAI provider payloads while fast mode is
5
- * enabled and the selected model is in the configured allow-list.
4
+ * Adds capability-gated OpenAI service tiers and opt-in native typed decisions.
6
5
  */
7
6
  import {
8
7
  type ExtensionAPI,
@@ -17,6 +16,7 @@ import {
17
16
  SettingsList,
18
17
  type SettingsListTheme,
19
18
  } from "@earendil-works/pi-tui";
19
+ import type { Usage } from "@earendil-works/pi-ai";
20
20
  import { registerOpenAICodexModels } from "./src/codex-models.ts";
21
21
  import { CONFIG_BASENAME, STATUS_KEY } from "./src/identity.ts";
22
22
  import {
@@ -49,6 +49,8 @@ import {
49
49
  LIVE_SETTING_DESCRIPTORS,
50
50
  PET_SETTING_DESCRIPTORS,
51
51
  FAST_SETTING_DESCRIPTORS,
52
+ DECISIONS_SETTING_DESCRIPTORS,
53
+ isServiceTier,
52
54
  type SettingsOptionDescriptor,
53
55
  configPaths,
54
56
  type ResolvedConfig,
@@ -80,6 +82,7 @@ import { ResetController } from "./src/reset-controller.ts";
80
82
  import { registerOpenAIImage, _imageTest } from "./src/image.ts";
81
83
  import { registerOpenAIWebSearch, _websearchTest } from "./src/websearch.ts";
82
84
  import { registerOpenAILive } from "./src/live/index.ts";
85
+ import { registerOpenAIDecisions } from "./src/decisions.ts";
83
86
  import {
84
87
  type CodexPetPackage,
85
88
  codexHome,
@@ -91,7 +94,7 @@ import {
91
94
  registerOpenAIPets,
92
95
  _petsTest,
93
96
  } from "./src/pets.ts";
94
- import { FastController, modelList, supportsFast } from "./src/fast-controller.ts";
97
+ import { FastController, supportsFast } from "./src/fast-controller.ts";
95
98
  import { isOpenAISubscriptionModel, UsageController } from "./src/usage-controller.ts";
96
99
  import { PetFooterController } from "./src/pet-footer-controller.ts";
97
100
  import {
@@ -338,12 +341,14 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
338
341
  ...nextConfig,
339
342
  active: fastController.active,
340
343
  desiredActive: fastController.desiredActive,
344
+ serviceTier: fastController.desiredTier,
341
345
  };
342
346
  if (!nextConfig.persistState) return;
343
347
  writeConfig(nextConfig.configPath, {
344
348
  ...readRawConfig(nextConfig.configPath),
345
349
  active: fastController.active,
346
350
  desiredActive: fastController.desiredActive,
351
+ serviceTier: fastController.desiredTier,
347
352
  });
348
353
  }
349
354
 
@@ -368,15 +373,22 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
368
373
  ctx.ui.notify(fastController.stateText(ctx, nextConfig), "info");
369
374
  }
370
375
 
376
+ function addFooterUsage(usage: Usage | undefined): void {
377
+ if (!usage) return;
378
+ footerTotals.input += usage.input;
379
+ footerTotals.output += usage.output;
380
+ footerTotals.cacheRead += usage.cacheRead;
381
+ footerTotals.cacheWrite += usage.cacheWrite;
382
+ footerTotals.cost += usage.cost.total;
383
+ }
384
+
371
385
  function refreshFooterTotals(ctx: ExtensionContext): void {
372
386
  footerTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
373
387
  for (const entry of ctx.sessionManager.getEntries()) {
374
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
375
- footerTotals.input += entry.message.usage.input;
376
- footerTotals.output += entry.message.usage.output;
377
- footerTotals.cacheRead += entry.message.usage.cacheRead;
378
- footerTotals.cacheWrite += entry.message.usage.cacheWrite;
379
- footerTotals.cost += entry.message.usage.cost.total;
388
+ if (entry.type !== "message") continue;
389
+ if (entry.message.role === "assistant" || entry.message.role === "toolResult") {
390
+ addFooterUsage(entry.message.usage);
391
+ }
380
392
  }
381
393
  }
382
394
 
@@ -433,6 +445,9 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
433
445
  `Image default save: ${cfg.image.defaultSave}`,
434
446
  `Websearch enabled: ${cfg.websearch.enabled}`,
435
447
  `Websearch model: ${cfg.websearch.model} (${cfg.websearch.reasoningEffort}/${cfg.websearch.responseLength}, ${cfg.websearch.maxOutputTokens} tokens, ${cfg.websearch.timeoutMs}ms)`,
448
+ `Decisions enabled: ${cfg.decisions.enabled}`,
449
+ `Decision classifier: ${cfg.decisions.model || "not selected"} (${cfg.decisions.timeoutMs}ms)`,
450
+ "Native OpenAI Decisions requires a registered classifier adapter; no chat fallback.",
436
451
  `Live enabled: ${cfg.live.enabled}`,
437
452
  `Live voice: ${cfg.live.voice}`,
438
453
  `Pet enabled: ${cfg.pets.enabled}`,
@@ -531,6 +546,37 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
531
546
  },
532
547
  });
533
548
 
549
+ pi.registerCommand("openai-tier", {
550
+ description: "Select standard, fast, or ultrafast (API Astra; 6x Standard token prices)",
551
+ handler: async (args, ctx) => {
552
+ const tier = args.trim().toLowerCase();
553
+ if (!tier) {
554
+ ctx.ui.notify(
555
+ `Requested tier: ${fastController.desiredTier}. ${fastController.stateText(ctx, config(ctx))}`,
556
+ "info",
557
+ );
558
+ return;
559
+ }
560
+ if (!isServiceTier(tier)) {
561
+ ctx.ui.notify(
562
+ "Usage: /openai-tier [standard | fast | ultrafast]. Ultrafast explicitly opts into 6x Standard token prices.",
563
+ "error",
564
+ );
565
+ return;
566
+ }
567
+ const cfg = refresh(ctx);
568
+ fastController.setTier(ctx, cfg, tier);
569
+ persist(cfg);
570
+ updateFooter(ctx);
571
+ ctx.ui.notify(
572
+ fastController.stateText(ctx, cfg),
573
+ tier === "ultrafast" || (fastController.desiredActive && !fastController.active)
574
+ ? "warning"
575
+ : "info",
576
+ );
577
+ },
578
+ });
579
+
534
580
  pi.registerCommand(OPENAI_STATUS_COMMAND, {
535
581
  description: "Show OpenAI subscription usage status",
536
582
  handler: async (_args, ctx) => {
@@ -798,16 +844,9 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
798
844
  }
799
845
 
800
846
  function buildFastSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
801
- return [
802
- {
803
- id: "fast.enabled",
804
- label: "Fast mode",
805
- currentValue: String(fastController.desiredActive),
806
- values: ["true", "false"],
807
- description: `Request OpenAI fast mode. Activates for supported models: ${modelList(cfg.supportedModels)}.`,
808
- },
809
- ...settingsItemsFromDescriptors(FAST_SETTING_DESCRIPTORS, cfg),
810
- ];
847
+ return settingsItemsFromDescriptors(FAST_SETTING_DESCRIPTORS, cfg, {
848
+ serviceTier: { currentValue: fastController.desiredTier },
849
+ });
811
850
  }
812
851
 
813
852
  function buildFooterSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
@@ -870,12 +909,13 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
870
909
  return [
871
910
  {
872
911
  id: "section.fast",
873
- label: "Fast mode",
912
+ label: "Service tier",
874
913
  currentValue: fastSettingsSummary(ctx, cfg),
875
- description: "Configure OpenAI fast mode and persistence.",
914
+ description:
915
+ "Configure Standard, Fast, Ultrafast, and persistence. Ultrafast costs 6x Standard.",
876
916
  submenu: (_value, done) =>
877
917
  settingsSubmenu(
878
- "Fast mode settings",
918
+ "Service tier settings",
879
919
  () => buildFastSettingsItems(config(ctx)),
880
920
  ctx,
881
921
  () => done(fastSettingsSummary(ctx, config(ctx))),
@@ -933,6 +973,38 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
933
973
  () => done(websearchSettingsSummary(config(ctx))),
934
974
  ),
935
975
  },
976
+ {
977
+ id: "section.decisions",
978
+ label: "Typed decisions",
979
+ currentValue: cfg.decisions.enabled ? cfg.decisions.model || "model required" : "disabled",
980
+ description: "Opt-in native classifier requests; no chat fallback or automatic actions.",
981
+ submenu: (_value, done) =>
982
+ settingsSubmenu(
983
+ "Typed decision settings",
984
+ () => {
985
+ const next = config(ctx);
986
+ const models = ctx.modelRegistry.getModelsOfType("classifier");
987
+ return settingsItemsFromDescriptors(DECISIONS_SETTING_DESCRIPTORS, next, {
988
+ "decisions.model": {
989
+ values: [
990
+ ...new Set([
991
+ "",
992
+ next.decisions.model,
993
+ ...models.map((model) => `${model.provider}/${model.id}`),
994
+ ]),
995
+ ],
996
+ },
997
+ });
998
+ },
999
+ ctx,
1000
+ () =>
1001
+ done(
1002
+ config(ctx).decisions.enabled
1003
+ ? config(ctx).decisions.model || "model required"
1004
+ : "disabled",
1005
+ ),
1006
+ ),
1007
+ },
936
1008
  {
937
1009
  id: "section.live",
938
1010
  label: "Live voice",
@@ -998,14 +1070,29 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
998
1070
  const bool = rawValue === "true";
999
1071
  if (id === "fast.enabled") {
1000
1072
  fastController.setDesired(ctx, cfg, bool);
1073
+ } else if (id === "serviceTier") {
1074
+ if (!isServiceTier(rawValue)) return;
1075
+ fastController.setTier(ctx, cfg, rawValue);
1076
+ ctx.ui.notify(
1077
+ fastController.stateText(ctx, cfg),
1078
+ rawValue === "ultrafast" || (fastController.desiredActive && !fastController.active)
1079
+ ? "warning"
1080
+ : "info",
1081
+ );
1001
1082
  }
1002
1083
  const petKey = id.startsWith("pets.") ? id.slice("pets.".length) : undefined;
1003
1084
  const nextRawConfig = applySettingToRawConfig(current, id, rawValue, {
1004
1085
  persistState: cfg.persistState,
1005
1086
  active: fastController.active,
1006
1087
  desiredActive: fastController.desiredActive,
1088
+ serviceTier: fastController.desiredTier,
1007
1089
  petEmptyValue: PET_EMPTY_VALUE,
1008
1090
  });
1091
+ if (id === "persistState" && bool) {
1092
+ nextRawConfig.serviceTier = fastController.desiredTier;
1093
+ nextRawConfig.active = fastController.active;
1094
+ nextRawConfig.desiredActive = fastController.desiredActive;
1095
+ }
1009
1096
  if (petKey) {
1010
1097
  if (petKey === "enabled" || petKey === "sizeCells" || petKey === "slug")
1011
1098
  petController.invalidateLoadKey();
@@ -1102,6 +1189,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1102
1189
 
1103
1190
  registerOpenAIImage(pi, config);
1104
1191
  registerOpenAIWebSearch(pi, config);
1192
+ registerOpenAIDecisions(pi, config, refresh);
1105
1193
  registerOpenAILive(pi, config);
1106
1194
  registerOpenAIPets(pi, {
1107
1195
  wake: async (ctx, slug) => {
@@ -1270,8 +1358,9 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1270
1358
 
1271
1359
  const modelName = ctx.model?.id || "no-model";
1272
1360
  const thinkingLevel = pi.getThinkingLevel();
1273
- const fastSuffix =
1274
- fastController.active && supportsFast(ctx, cfg.supportedModels) ? " fast" : "";
1361
+ const fastSuffix = fastController.statusSegment(ctx, cfg)
1362
+ ? ` ${fastController.desiredTier}`
1363
+ : "";
1275
1364
  let rightWithoutProvider = modelName;
1276
1365
  if (ctx.model?.reasoning) {
1277
1366
  rightWithoutProvider =
@@ -1448,7 +1537,9 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1448
1537
  fastController.initializeForSession(ctx, nextConfig, pi.getFlag(FLAG) === true);
1449
1538
  if (
1450
1539
  fastController.desiredActive !== nextConfig.desiredActive ||
1451
- fastController.active !== nextConfig.active
1540
+ fastController.active !== nextConfig.active ||
1541
+ fastController.desiredTier !==
1542
+ (nextConfig.serviceTier ?? (nextConfig.desiredActive ? "fast" : "standard"))
1452
1543
  )
1453
1544
  persist(nextConfig);
1454
1545
  if (hasTerminalUI(ctx)) petController.installResizeGuard(ctx);
@@ -1489,11 +1580,8 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1489
1580
  pi.on("turn_end", (event, ctx) => {
1490
1581
  invalidateContextUsage();
1491
1582
  if (event.message?.role === "assistant") {
1492
- footerTotals.input += event.message.usage.input;
1493
- footerTotals.output += event.message.usage.output;
1494
- footerTotals.cacheRead += event.message.usage.cacheRead;
1495
- footerTotals.cacheWrite += event.message.usage.cacheWrite;
1496
- footerTotals.cost += event.message.usage.cost.total;
1583
+ addFooterUsage(event.message.usage);
1584
+ for (const result of event.toolResults ?? []) addFooterUsage(result.usage);
1497
1585
  } else refreshFooterTotals(ctx);
1498
1586
  updateFooter(ctx);
1499
1587
  void usageController.refresh(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-better-openai",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Improve OpenAI in pi with fast mode, usage stats, realtime voice, image generation, and footer polish.",
5
5
  "keywords": [
6
6
  "fast",
@@ -38,6 +38,8 @@
38
38
  "index.ts",
39
39
  "src/",
40
40
  "README.md",
41
+ "CHANGELOG.md",
42
+ "plans/devday-integration.md",
41
43
  "LICENSE",
42
44
  "THIRD_PARTY_NOTICES.md"
43
45
  ],
@@ -0,0 +1,48 @@
1
+ # DevDay integration status
2
+
3
+ Research checkpoint: September 29, 2026 (UTC). Announcements and account entitlements can change independently of source releases.
4
+
5
+ ## Implemented
6
+
7
+ - GPT-6.1 Sol fallback metadata, with native Pi transport/auth and live catalog precedence preserved.
8
+ - Fast-mode defaults for GPT-6 Astra, 6.1 Sol, Sol, and Luna on OpenAI/Codex.
9
+ - Explicit Standard/Fast/Ultrafast selection, legacy state migration, model/endpoint capability gates, persistence, diagnostics, cost disclosure, and footer labels.
10
+ - `openai_decide`: an opt-in bridge to Pi's classifier API. Explicit model selection, bounded typed input/output, cancellation/deadlines, usage reporting, and no chat/provider fallback.
11
+ - No autonomous actions, model routing, approval bypasses, or inference-driven polling. Classification is an advisory primitive; application code owns policy and execution.
12
+
13
+ ## Native OpenAI Decisions: blocked on a verifiable contract
14
+
15
+ The announcement is reported in [DevDay coverage](https://www.axios.com/2026/09/29/openai-dev-day-2026-dots-space-sol). At the research checkpoint, the public docs index and OpenAI Node SDK did not expose a verifiable Decisions contract; Codex search hits concerned existing approval decisions. This is not proof that private or later access is unavailable.
16
+
17
+ The tool works with explicitly selected registered classifiers, including Jev. It does not pretend this is OpenAI's new service. Once OpenAI publishes its contract:
18
+
19
+ 1. Verify model IDs, schema, error/refusal behavior, modalities, endpoint, auth audience, rate limits, pricing, and data residency.
20
+ 2. Prefer a native Pi classifier adapter rather than duplicating credentials and transport here. Register classifier models, not chat models; the current tool can then select them.
21
+ 3. Preserve provider semantics. Do not equate arbitrary numeric scores or self-reported confidence with calibrated probabilities, or silently translate incompatible question types.
22
+ 4. Keep API-key and Codex subscription access separate until subscription authorization is documented. Do not infer OAuth compatibility from a product announcement.
23
+ 5. Add fixture tests and an opt-in live probe before claiming support. No default model ID is guessed today.
24
+
25
+ ### Evaluation checklist
26
+
27
+ Before automated routing or background triage is enabled, compare pinned Jev/OpenAI classifier versions on the same consented, non-sensitive labeled cases:
28
+
29
+ - failure classification, candidate-tool ranking, task routing, and notification relevance;
30
+ - p50/p95 end-to-end latency, cost per useful decision, invalid-result/refusal/timeout rates;
31
+ - accuracy and abstention coverage; Brier score/calibration only for outputs documented as probabilities;
32
+ - low-confidence escalation and immutable policy/permission boundaries.
33
+
34
+ Do not automatically run paid benchmark requests. A typed result is not proof of truth or authorization. TypeSafe's [September 15 Jev announcement](https://typesafe.ai/blog/introducing-system-one-models-and-jev) establishes timing and the decision-oriented interface, not OpenAI implementation provenance.
35
+
36
+ ## Codex subscription Ultrafast: capability gate remains closed
37
+
38
+ The [public Ultrafast guide](https://developers.openai.com/api/docs/guides/ultrafast-mode) documents API Astra support. Codex has catalog-driven tier controls and WebSocket tier-switch tests, but its inspected bundled Astra/Sol catalog advertises Fast, not a subscription Ultrafast entitlement.
39
+
40
+ Enable subscription Ultrafast only after an authenticated model/account capability contract is established, with tests for account switching, capability withdrawal, and unsupported requests. Arbitrary `supportedModels` configuration must not bypass this gate. Global/US API Astra is the verified path today. Host cost reporting currently may omit its 6x premium; never describe local estimates as actual billing.
41
+
42
+ ## Hosted Responses multi-agent: deliberately deferred
43
+
44
+ [Responses multi-agent](https://developers.openai.com/api/docs/guides/responses-multi-agent) is a separate beta protocol, not a tier or ordinary thinking level. Adding only `multi_agent.enabled` is insufficient and could conflict with Fabric's orchestration.
45
+
46
+ A future opt-in integration must handle agent identities, hosted collaboration items versus client tool calls, interleaved streams, continuation routing, cancellation, concurrency and total-spend limits, and permission enforcement for every child. Do not assume `max_concurrent_subagents` limits total agents, tree depth, or total spend. Keep the existing local orchestration owner until these invariants are verified.
47
+
48
+ Dots, Space, and other consumer surfaces are not added without a documented integration contract.
@@ -59,6 +59,26 @@ export const CODEX_MODEL_FALLBACKS: CodexModel[] = [
59
59
  },
60
60
  compat: CODEX_COMPAT,
61
61
  },
62
+ {
63
+ id: "gpt-6.1-sol",
64
+ name: "GPT-6.1 Sol",
65
+ api: CODEX_API,
66
+ provider: CODEX_PROVIDER_ID,
67
+ baseUrl: CODEX_BASE_URL,
68
+ reasoning: true,
69
+ thinkingLevelMap: { off: null, minimal: "low", xhigh: "xhigh", max: "max" },
70
+ input: ["text", "image"],
71
+ contextWindow: 272000,
72
+ maxTokens: 128000,
73
+ cost: {
74
+ input: 2,
75
+ output: 10,
76
+ cacheRead: 0.1,
77
+ cacheWrite: 2.5,
78
+ tiers: [{ inputTokensAbove: 272000, input: 4, output: 15, cacheRead: 0.2, cacheWrite: 5 }],
79
+ },
80
+ compat: CODEX_COMPAT,
81
+ },
62
82
  {
63
83
  id: "gpt-daybreak-blue-latest",
64
84
  name: "Daybreak Blue",
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
+ }
@@ -1,7 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { ResolvedConfig, SupportedModel } from "./config.ts";
2
+ import type { ResolvedConfig, ServiceTier, SupportedModel } from "./config.ts";
3
3
  import { isRecord } from "./config.ts";
4
4
 
5
+ export const ULTRAFAST_NOTICE =
6
+ "Ultrafast uses 6x Standard token prices for API GPT-6 Astra (global/US only). Codex subscription access is not verified. Host cost estimates may exclude tier premiums.";
7
+
5
8
  export function currentModelKey(ctx: ExtensionContext): string {
6
9
  return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
7
10
  }
@@ -14,6 +17,41 @@ export function supportsFast(ctx: ExtensionContext, supportedModels: SupportedMo
14
17
  );
15
18
  }
16
19
 
20
+ export function supportsUltrafast(ctx: ExtensionContext): boolean {
21
+ const model = ctx.model;
22
+ if (
23
+ model?.provider !== "openai" ||
24
+ model.id !== "gpt-6-astra" ||
25
+ model.api !== "openai-responses"
26
+ )
27
+ return false;
28
+ try {
29
+ const url = new URL(model.baseUrl);
30
+ return (
31
+ url.protocol === "https:" &&
32
+ !url.username &&
33
+ !url.password &&
34
+ !url.port &&
35
+ ["api.openai.com", "us.api.openai.com"].includes(url.hostname) &&
36
+ /^\/v1\/?$/.test(url.pathname) &&
37
+ !url.search &&
38
+ !url.hash
39
+ );
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export function supportsServiceTier(
46
+ ctx: ExtensionContext,
47
+ cfg: ResolvedConfig,
48
+ tier: ServiceTier,
49
+ ): boolean {
50
+ if (tier === "ultrafast") return supportsUltrafast(ctx);
51
+ if (tier === "fast") return supportsFast(ctx, cfg.supportedModels);
52
+ return ctx.model?.provider === "openai" || ctx.model?.provider === "openai-codex";
53
+ }
54
+
17
55
  export function modelList(supportedModels: SupportedModel[]): string {
18
56
  return supportedModels.length > 0
19
57
  ? supportedModels.map((model) => `${model.provider}/${model.id}`).join(", ")
@@ -35,54 +73,80 @@ export function fastStateText(
35
73
  }
36
74
 
37
75
  export class FastController {
38
- desiredActive = false;
76
+ desiredTier: ServiceTier = "standard";
39
77
  active = false;
78
+ private explicitSelection = false;
40
79
  private lastInjectedAt: number | undefined;
41
80
  private lastInjectedModel: string | undefined;
42
81
  private lastInjectedTier: string | undefined;
43
- private readonly serviceTier: string;
44
82
 
45
- constructor(serviceTier: string) {
46
- this.serviceTier = serviceTier;
83
+ private readonly fastServiceTier: string;
84
+
85
+ constructor(fastServiceTier = "priority") {
86
+ this.fastServiceTier = fastServiceTier;
87
+ }
88
+
89
+ get desiredActive(): boolean {
90
+ return this.desiredTier !== "standard";
47
91
  }
48
92
 
49
93
  applyDesiredState(ctx: ExtensionContext, cfg: ResolvedConfig): void {
50
- this.active = this.desiredActive && supportsFast(ctx, cfg.supportedModels);
94
+ this.active = this.desiredActive && supportsServiceTier(ctx, cfg, this.desiredTier);
51
95
  }
52
96
 
53
97
  initializeForSession(ctx: ExtensionContext, cfg: ResolvedConfig, flagActive: boolean): void {
54
- this.desiredActive = cfg.persistState ? cfg.desiredActive : false;
55
- if (flagActive) this.desiredActive = true;
98
+ this.desiredTier = cfg.persistState
99
+ ? (cfg.serviceTier ?? (cfg.desiredActive ? "fast" : "standard"))
100
+ : "standard";
101
+ this.explicitSelection = cfg.persistState && cfg.serviceTier !== undefined;
102
+ if (flagActive) {
103
+ this.desiredTier = "fast";
104
+ this.explicitSelection = true;
105
+ }
56
106
  this.applyDesiredState(ctx, cfg);
57
107
  }
58
108
 
59
109
  setDesired(ctx: ExtensionContext, cfg: ResolvedConfig, next: boolean): void {
60
- this.desiredActive = next;
110
+ this.setTier(ctx, cfg, next ? "fast" : "standard");
111
+ }
112
+
113
+ setTier(ctx: ExtensionContext, cfg: ResolvedConfig, tier: ServiceTier): void {
114
+ this.desiredTier = tier;
115
+ this.explicitSelection = true;
61
116
  this.applyDesiredState(ctx, cfg);
62
117
  }
63
118
 
64
119
  stateText(ctx: ExtensionContext, cfg: ResolvedConfig): string {
120
+ if (this.desiredTier === "ultrafast") {
121
+ return this.active
122
+ ? `Ultrafast mode is on for ${currentModelKey(ctx)}. ${ULTRAFAST_NOTICE}`
123
+ : this.unsupportedRequestMessage(ctx, cfg);
124
+ }
65
125
  return fastStateText(ctx, this.desiredActive, this.active, cfg.supportedModels);
66
126
  }
67
127
 
68
128
  unsupportedRequestMessage(ctx: ExtensionContext, cfg: ResolvedConfig): string {
129
+ if (this.desiredTier === "ultrafast") {
130
+ return `Ultrafast requested, but inactive for ${currentModelKey(ctx)}. ${ULTRAFAST_NOTICE} No lower-tier fallback will be injected.`;
131
+ }
69
132
  return `Fast mode requested, but ${currentModelKey(ctx)} is unsupported. It will activate automatically when you switch to a supported model: ${modelList(cfg.supportedModels)}.`;
70
133
  }
71
134
 
72
135
  inactiveForModelMessage(ctx: ExtensionContext): string {
73
- return `Fast mode inactive for unsupported model ${currentModelKey(ctx)}.`;
136
+ const name = this.desiredTier === "ultrafast" ? "Ultrafast" : "Fast";
137
+ return `${name} mode inactive for unsupported model ${currentModelKey(ctx)}.`;
74
138
  }
75
139
 
76
140
  settingsSummary(ctx: ExtensionContext, cfg: ResolvedConfig): string {
77
- if (this.active) return "on";
78
- if (this.desiredActive)
79
- return supportsFast(ctx, cfg.supportedModels) ? "requested" : "requested inactive";
80
- return "off";
141
+ return (
142
+ this.desiredTier +
143
+ (this.desiredActive && !supportsServiceTier(ctx, cfg, this.desiredTier) ? " (inactive)" : "")
144
+ );
81
145
  }
82
146
 
83
147
  statusSegment(ctx: ExtensionContext, cfg: ResolvedConfig): string | undefined {
84
- return this.active && supportsFast(ctx, cfg.supportedModels)
85
- ? `${ctx.model?.id ?? "model"} fast`
148
+ return this.active && supportsServiceTier(ctx, cfg, this.desiredTier)
149
+ ? `${ctx.model?.id ?? "model"} ${this.desiredTier}`
86
150
  : undefined;
87
151
  }
88
152
 
@@ -91,22 +155,33 @@ export class FastController {
91
155
  ctx: ExtensionContext,
92
156
  cfg: ResolvedConfig,
93
157
  ): unknown {
94
- if (!this.active || !supportsFast(ctx, cfg.supportedModels) || !isRecord(event.payload))
158
+ if (!isRecord(event.payload) || !supportsServiceTier(ctx, cfg, this.desiredTier))
159
+ return undefined;
160
+ if (typeof event.payload.model === "string" && event.payload.model !== ctx.model?.id)
95
161
  return undefined;
162
+ if (this.desiredTier === "standard" && !this.explicitSelection) return undefined;
163
+ const tier =
164
+ this.desiredTier === "standard"
165
+ ? "default"
166
+ : this.desiredTier === "fast"
167
+ ? this.fastServiceTier
168
+ : "ultrafast";
96
169
  this.lastInjectedAt = Date.now();
97
170
  this.lastInjectedModel = currentModelKey(ctx);
98
- this.lastInjectedTier = this.serviceTier;
99
- return { ...event.payload, service_tier: this.serviceTier };
171
+ this.lastInjectedTier = tier;
172
+ return { ...event.payload, service_tier: tier };
100
173
  }
101
174
 
102
175
  debugLines(ctx: ExtensionContext, cfg: ResolvedConfig): string[] {
103
176
  return [
104
- `Fast desired: ${this.desiredActive}`,
105
- `Fast active: ${this.active}`,
177
+ `Requested service tier: ${this.desiredTier}`,
178
+ `Effective override: ${supportsServiceTier(ctx, cfg, this.desiredTier) ? (this.desiredTier === "standard" && !this.explicitSelection ? "none (provider default)" : this.desiredTier) : "inactive (unsupported)"}`,
106
179
  `Current model: ${currentModelKey(ctx)}`,
107
- `Supported model: ${supportsFast(ctx, cfg.supportedModels)}`,
108
- `Configured service_tier: ${this.serviceTier}`,
180
+ `Fast supported: ${supportsFast(ctx, cfg.supportedModels)}`,
181
+ `Ultrafast supported: ${supportsUltrafast(ctx)}`,
109
182
  `Last injected: ${this.lastInjectedAt ? `${new Date(this.lastInjectedAt).toLocaleTimeString()} (${this.lastInjectedModel}, ${this.lastInjectedTier})` : "never"}`,
183
+ "Injection records requests, not server-confirmed service tiers or billing.",
184
+ ULTRAFAST_NOTICE,
110
185
  ];
111
186
  }
112
187
  }