@monotykamary/pi-better-openai 0.2.12 → 0.2.14

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.13
4
+
5
+ - Add a native OpenAI Decisions compatibility classifier for hosts without upstream support, preserving OpenAI chat/auth and deferring to the host adapter when available.
6
+ - Support typed predicates, choices, scores, inline images, cancellation, hooks, HTTP retries, and input-only usage accounting through native codemode and pi-fabric.
7
+ - Keep the bounded decision tool opt-in, explicitly disable its provider retries, and verify real-host codemode with offline HTTP fixtures.
8
+ - Upgrade sharp and pin patched source-map-js/tinypool dependencies to resolve high/critical security advisories.
9
+
3
10
  ## 0.2.12
4
11
 
5
12
  - Validate against Pi 1.0.2, where `openai` supports ChatGPT subscription OAuth and `openai-codex` is labeled legacy; preserve the native OpenAI provider and transport.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Pi labels `openai-codex` **legacy**, but this extension's usage polling, banked
34
34
 
35
35
  - GPT-6.1 Sol, GPT-6 Astra, and Daybreak Blue/Red model fallbacks for the built-in `openai-codex` provider.
36
36
  - Standard, Fast, and capability-gated Ultrafast service tiers via `/openai-tier` or `/openai-settings`; `/fast` remains a quick Fast toggle.
37
- - 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.
37
+ - Native OpenAI Decisions (`openai/gpt-6-luna`) through Pi's classifier API, codemode, and pi-fabric; optional bounded `openai_decide` tool also supports Jev.
38
38
  - OpenAI subscription usage display via `/openai-usage` and the footer.
39
39
  - Interactive settings picker via `/openai-settings`.
40
40
  - Footer customization for model, thinking, fast mode, usage, and token/cost context.
@@ -155,16 +155,48 @@ The footer shows `fast` or `ultrafast` only when supported by the current model.
155
155
 
156
156
  ## Typed decisions
157
157
 
158
- 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:
158
+ OpenAI's [Decisions API](https://developers.openai.com/api/docs/guides/decisions) is available as the native classifier `openai/gpt-6-luna`, **not a chat-completion fallback**. Pi 1.1.0 ships the adapter, including native codemode image classification. This extension supplies a compatibility implementation only when the host lacks one; a built-in Decisions adapter always wins. Native OpenAI chat models, authentication, and transport are preserved.
159
+
160
+ Use an **OpenAI API key** via `/login openai` or `OPENAI_API_KEY`. ChatGPT/Codex OAuth does not grant Decisions access. If `/login openai` stores OAuth, it takes precedence over the environment key: select the API-key login instead. No credentials are copied between providers. Catalog presence does not guarantee account entitlement.
161
+
162
+ The optional `openai_decide` tool is disabled by default and never chooses a provider automatically. To enable it:
159
163
 
160
164
  ```text
161
165
  /openai-decisions models
162
- /openai-decisions use typesafe/jev-latest
166
+ /openai-decisions use openai/gpt-6-luna
167
+ ```
168
+
169
+ Jev also works: select `typesafe/jev-latest` and configure `TYPESAFE_API_KEY` through Pi. 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.
170
+
171
+ ### Native codemode and pi-fabric
172
+
173
+ The same classifier is available through `models` in Pi's codemode and pi-fabric's `fabric_exec` (`tools.models` is a Fabric alias). These are direct native-model calls: they do **not** depend on `decisions.enabled` or use the optional tool's size/deadline limits. Disabling `openai_decide` does not disable the classifier catalog or direct model calls. Enable Pi codemode with `"defaultTools": ["+codemode"]`; Fabric users can use their existing execution tool.
174
+
175
+ ```js
176
+ const available = await models.getAvailableOfType("classifier", "openai");
177
+ const model = available.find((m) => m.id === "gpt-6-luna");
178
+ if (!model) return "Configure an OpenAI API key with Decisions access.";
179
+ const result = await models.classify(model, {
180
+ state: { message: "The deployment worked." },
181
+ questions: {
182
+ approved: {
183
+ type: "bool",
184
+ instructions: "Does the message approve the result?",
185
+ criteria: { true: "Approval", false: "No approval" },
186
+ },
187
+ },
188
+ });
189
+ if (result.stopReason !== "stop") return result.errorMessage;
190
+ return result.answers;
163
191
  ```
164
192
 
165
- 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.
193
+ Native requests also accept `images: [{ type: "image", data: "<base64>", mimeType: "image/png" }]` beside `state` and `questions` (at most 128). The adapter performs no file reads or remote-image fetches; supply inline PNG/JPEG/GIF/WebP data. Older Fabric builds must support `NativeClassifierContext.images`; otherwise use text or upgrade Fabric. The bounded `openai_decide` wrapper remains JSON-state-only.
194
+
195
+ Pi maps `bool` to OpenAI `predicate`, labeled `choice` criteria to options, and ordered `score` criteria to levels. Scores are expected zero-based level indices, not probabilities. Refusals fail with `stopReason: "error"`; billed usage is retained. Native calls follow provider HTTP retries (default two, never retrying 504); the optional tool explicitly disables retries and applies its own deadline.
196
+
197
+ Decisions pricing starts at **$0.10 per million input tokens**, with no output/cache charges. Catalog long-context pricing doubles input above 272K tokens; regional premiums and actual billing may differ. See the [integration status](plans/devday-integration.md).
166
198
 
167
- **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).
199
+ ### Optional bounded tool
168
200
 
169
201
  Example tool input:
170
202
 
@@ -182,7 +214,7 @@ Example tool input:
182
214
  ```
183
215
 
184
216
  - `state`: JSON object; send only the necessary context, never credentials or the entire session.
185
- - `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.
217
+ - `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 OpenAI's `predicate` or Jev's `noul` representation.
186
218
  - 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.
187
219
  - 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.
188
220
  - 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.
@@ -190,7 +222,7 @@ Example tool input:
190
222
 
191
223
  ## Codex model fallbacks
192
224
 
193
- These fallbacks remain scoped to the legacy provider. The native `openai` provider and its catalog are not replaced or redirected to Codex.
225
+ These chat fallbacks remain scoped to the legacy provider. Native `openai` chat models and transport are not replaced or redirected to Codex; only the missing Decisions classifier is added on older hosts.
194
226
 
195
227
  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.
196
228
 
@@ -1,5 +1,31 @@
1
1
  # Third-party notices
2
2
 
3
+ ## Pi
4
+
5
+ `src/openai-decisions.ts` adapts the wire mapping, classifier contract, model metadata, and HTTP behavior from [`earendil-works/pi`](https://github.com/earendil-works/pi), notably `packages/ai/src/api/openai-decisions.ts`, `classifier-shared.ts`, and the classifier tests.
6
+
7
+ MIT License
8
+
9
+ Copyright (c) 2025 Mario Zechner
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
3
29
  ## oh-my-pi
4
30
 
5
31
  Portions of `src/live/` are adapted from [`can1357/oh-my-pi`](https://github.com/can1357/oh-my-pi), including its live protocol, transport, session orchestration, attestation, and terminal visualizer design.
package/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from "@earendil-works/pi-tui";
19
19
  import type { Usage } from "@earendil-works/pi-ai";
20
20
  import { registerOpenAICodexModels } from "./src/codex-models.ts";
21
+ import { registerOpenAIDecisionsProvider } from "./src/openai-decisions.ts";
21
22
  import { CONFIG_BASENAME, STATUS_KEY } from "./src/identity.ts";
22
23
  import {
23
24
  CODEX_PROVIDER_ID,
@@ -278,6 +279,7 @@ function textPanel(title: string, lines: string[], done: () => void) {
278
279
 
279
280
  export default function betterOpenAI(pi: ExtensionAPI): void {
280
281
  registerOpenAICodexModels(pi);
282
+ registerOpenAIDecisionsProvider(pi);
281
283
 
282
284
  const fastController = new FastController(SERVICE_TIER);
283
285
  let cachedConfig: ResolvedConfig | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-better-openai",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
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",
@@ -60,14 +60,14 @@
60
60
  "dependencies": {
61
61
  "https-proxy-agent": "9.1.0",
62
62
  "proxy-from-env": "2.1.0",
63
- "sharp": "0.35.4",
63
+ "sharp": "0.35.5",
64
64
  "undici": "8.10.2",
65
65
  "ws": "8.21.2"
66
66
  },
67
67
  "devDependencies": {
68
- "@earendil-works/pi-ai": "1.0.2",
69
- "@earendil-works/pi-coding-agent": "1.0.2",
70
- "@earendil-works/pi-tui": "1.0.2",
68
+ "@earendil-works/pi-ai": "1.1.0",
69
+ "@earendil-works/pi-coding-agent": "1.1.0",
70
+ "@earendil-works/pi-tui": "1.1.0",
71
71
  "@types/node": "~22.19.0",
72
72
  "@types/proxy-from-env": "1.0.4",
73
73
  "@types/ws": "8.18.1",
@@ -89,12 +89,14 @@
89
89
  "@oh-my-pi/pi-natives-win32-x64": "17.2.9"
90
90
  },
91
91
  "overrides": {
92
- "@earendil-works/pi-agent-core": "1.0.2",
93
- "@earendil-works/pi-ai": "1.0.2",
94
- "brace-expansion": "5.0.11",
92
+ "@earendil-works/pi-agent-core": "1.1.0",
93
+ "@earendil-works/pi-ai": "1.1.0",
94
+ "brace-expansion": "5.0.12",
95
95
  "nanoid": "3.3.18",
96
96
  "postcss": "8.5.25",
97
97
  "protobufjs": "7.6.5",
98
+ "source-map-js": "1.2.2",
99
+ "tinypool": "2.1.2",
98
100
  "undici": "8.10.2"
99
101
  },
100
102
  "engines": {
@@ -10,17 +10,17 @@ Research checkpoint: September 29, 2026 (UTC). Announcements and account entitle
10
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
11
  - No autonomous actions, model routing, approval bypasses, or inference-driven polling. Classification is an advisory primitive; application code owns policy and execution.
12
12
 
13
- ## Native OpenAI Decisions: blocked on a verifiable contract
13
+ ## Native OpenAI Decisions: compatibility adapter available
14
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.
15
+ The [public Decisions guide](https://developers.openai.com/api/docs/guides/decisions) now documents `POST /v1/decisions`, `gpt-6-luna`, predicates/choices/scores, inline image inputs, refusals, and input-only pricing. This supersedes the original research checkpoint above.
16
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:
17
+ Pi 1.1.0, now pinned for development, ships `packages/ai/src/api/openai-decisions.ts` and the `openai` classifier registration. `src/openai-decisions.ts` provides compatibility through the same native classifier contract; it skips registration entirely when the host provides Decisions. Remove the shim once the minimum supported host ships the adapter.
18
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.
19
+ - OpenAI chat transport, catalog, and native credential resolution are retained. Only the missing classifier operation is added. OAuth discovery excludes Decisions; API-key access is required, with no Codex credential substitution or chat fallback.
20
+ - Native codemode and Fabric call `modelRegistry.classify`; no separate decision tool protocol is required. Native requests support optional inline `images`. The bounded `openai_decide` wrapper remains JSON-state-only and opt-in.
21
+ - Typed result mapping preserves predicates, choice distributions, zero-based expected scores, error/aborted stop reasons, and billed usage, including refusals. Provider hooks, header overrides, cancellation, HTTP retry budgets, and long-context costs are covered by offline fixtures.
22
+ - `bun run test:pi` exercises real Pi loading, credential isolation, and codemode text/image requests with synthetic HTTP. Fabric's native-codemode tests cover the same context shape in QuickJS, Node, and Bun. No paid live request or account entitlement is implied by these tests.
23
+ - The provider's default HTTP retry policy is independent of the optional wrapper, which sends `maxRetries: 0` and enforces its own deadline. Neither interface authorizes actions.
24
24
 
25
25
  ### Evaluation checklist
26
26
 
package/src/config.ts CHANGED
@@ -678,7 +678,7 @@ export const DECISIONS_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[]
678
678
  currentValue: (cfg) => cfg.decisions.model,
679
679
  parse: stringSetting,
680
680
  description:
681
- "Select with /openai-decisions use provider/model. OpenAI requires a published native classifier; Jev can be selected explicitly today.",
681
+ "Select with /openai-decisions use provider/model. OpenAI Decisions: openai/gpt-6-luna (API key only). Jev is also supported.",
682
682
  },
683
683
  {
684
684
  id: "decisions.timeoutMs",
package/src/decisions.ts CHANGED
@@ -264,7 +264,7 @@ export async function evaluateDecision(
264
264
  const model = ctx.modelRegistry.getModelOfType("classifier", key.provider, key.id);
265
265
  requireDecision(
266
266
  model,
267
- "The configured native classifier is unavailable. No OpenAI endpoint or chat fallback is assumed; inspect /openai-decisions models.",
267
+ "The configured native classifier is unavailable. Inspect /openai-decisions models; no chat fallback is used.",
268
268
  );
269
269
  const request = validateDecisionRequest(input);
270
270
  requireDecision(!controller.signal.aborted, "Decision request aborted.");
@@ -279,7 +279,7 @@ export async function evaluateDecision(
279
279
  });
280
280
  timer = setTimeout(abort, cfg.decisions.timeoutMs);
281
281
  result = await Promise.race([
282
- ctx.modelRegistry.classify(model, request, { signal: controller.signal }),
282
+ ctx.modelRegistry.classify(model, request, { signal: controller.signal, maxRetries: 0 }),
283
283
  cancelled,
284
284
  ]);
285
285
  requireDecision(
@@ -360,7 +360,7 @@ export function registerOpenAIDecisions(
360
360
  models.length
361
361
  ? models.map((m) => `${m.provider}/${m.id}`).join("\n") +
362
362
  "\nCatalog entries do not guarantee credentials or entitlement."
363
- : "No native classifiers registered. No OpenAI Decisions endpoint is assumed.",
363
+ : "No native classifiers registered.",
364
364
  "info",
365
365
  );
366
366
  } else if (arg.startsWith("use ")) {
@@ -381,7 +381,7 @@ export function registerOpenAIDecisions(
381
381
  } else if (!arg) {
382
382
  const cfg = getConfig(ctx).decisions;
383
383
  ctx.ui.notify(
384
- `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
+ `Decisions: ${cfg.enabled ? "enabled" : "disabled"}; model: ${cfg.model || "not selected"}; timeout: ${cfg.timeoutMs}ms.\n/openai-decisions models | use provider/model | off\nOpenAI Decisions: openai/gpt-6-luna requires an OpenAI API key (not ChatGPT/Codex OAuth).`,
385
385
  "info",
386
386
  );
387
387
  } else {
@@ -393,7 +393,7 @@ export function registerOpenAIDecisions(
393
393
  name: OPENAI_DECIDE_TOOL,
394
394
  label: "Typed decision",
395
395
  description:
396
- "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
+ "Answer bounded choice, bool, or score questions using the user's explicitly configured native classifier. Disabled until opt-in. Supports OpenAI Decisions (openai/gpt-6-luna, API key only) and other Pi classifiers including Jev. Never uses a chat fallback or executes decisions.",
397
397
  parameters: DECISION_PARAMETERS,
398
398
  outputSchema: DECISION_OUTPUT,
399
399
  promptGuidelines: [
@@ -0,0 +1,379 @@
1
+ // Compatibility implementation of Pi's upstream OpenAI Decisions classifier.
2
+ // Remove once the minimum supported Pi ships it. See THIRD_PARTY_NOTICES.md.
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import {
5
+ calculateCost,
6
+ type ClassifierContext,
7
+ type ClassifierModel,
8
+ type ClassifierOptions,
9
+ type ClassifierResult,
10
+ type ImageContent,
11
+ type Provider,
12
+ type ProviderHeaders,
13
+ type Usage,
14
+ } from "@earendil-works/pi-ai";
15
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { isRecord } from "./config.ts";
18
+
19
+ export const OPENAI_DECISIONS_API = "openai-decisions";
20
+ export const OPENAI_DECISIONS_MODEL: ClassifierModel<typeof OPENAI_DECISIONS_API> = {
21
+ type: "classifier",
22
+ provider: "openai",
23
+ id: "gpt-6-luna",
24
+ name: "GPT-6 Luna (Decisions)",
25
+ api: OPENAI_DECISIONS_API,
26
+ baseUrl: "https://api.openai.com/v1",
27
+ input: ["text", "image"],
28
+ contextWindow: 922000,
29
+ cost: {
30
+ input: 0.1,
31
+ output: 0,
32
+ cacheRead: 0,
33
+ cacheWrite: 0,
34
+ tiers: [{ inputTokensAbove: 272000, input: 0.2, output: 0, cacheRead: 0, cacheWrite: 0 }],
35
+ },
36
+ };
37
+
38
+ // Structural compatibility with Pi's upcoming ClassifierContext.images field.
39
+ export type OpenAIDecisionsContext = ClassifierContext & { images?: ImageContent[] };
40
+
41
+ class DecisionsError extends Error {}
42
+
43
+ function requireValue(condition: unknown, message: string): asserts condition {
44
+ if (!condition) throw new DecisionsError(message);
45
+ }
46
+
47
+ function number(value: unknown, field: string, maximum = 1): number {
48
+ requireValue(
49
+ typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= maximum,
50
+ `OpenAI Decisions returned invalid ${field}.`,
51
+ );
52
+ return value;
53
+ }
54
+
55
+ function wireInput(context: OpenAIDecisionsContext): unknown {
56
+ const state = JSON.stringify(context.state);
57
+ const images = context.images ?? [];
58
+ requireValue(
59
+ Array.isArray(images) && images.length <= 128,
60
+ "Decisions accepts at most 128 images.",
61
+ );
62
+ if (!images.length) return state;
63
+ return [
64
+ {
65
+ role: "user",
66
+ content: [
67
+ { type: "input_text", text: state },
68
+ ...images.map((image) => {
69
+ requireValue(
70
+ isRecord(image) &&
71
+ image.type === "image" &&
72
+ typeof image.mimeType === "string" &&
73
+ /^image\/(png|jpeg|gif|webp)$/.test(image.mimeType) &&
74
+ typeof image.data === "string" &&
75
+ image.data.length > 0 &&
76
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(image.data),
77
+ "Decisions images must be inline base64 PNG, JPEG, GIF, or WebP blocks.",
78
+ );
79
+ return { type: "input_image", image_url: `data:${image.mimeType};base64,${image.data}` };
80
+ }),
81
+ ],
82
+ },
83
+ ];
84
+ }
85
+
86
+ function wireQuestions(context: ClassifierContext): unknown[] {
87
+ return Object.entries(context.questions).map(([name, q]) => {
88
+ if (q.type === "choice")
89
+ return {
90
+ type: "choice",
91
+ name,
92
+ instructions: q.instructions,
93
+ choices: Object.entries(q.criteria).map(([value, description]) =>
94
+ description ? { value, description } : { value },
95
+ ),
96
+ };
97
+ if (q.type === "score")
98
+ return {
99
+ type: "score",
100
+ name,
101
+ instructions: q.instructions,
102
+ levels: q.criteria.map((label) => ({ label })),
103
+ };
104
+ requireValue(q.type === "bool", "Unsupported Decisions question type.");
105
+ const meanings = [
106
+ q.criteria.true && `True means: ${q.criteria.true}`,
107
+ q.criteria.false && `False means: ${q.criteria.false}`,
108
+ ].filter(Boolean);
109
+ return {
110
+ type: "predicate",
111
+ name,
112
+ instructions: meanings.length
113
+ ? `${q.instructions}\n\n${meanings.join("\n")}`
114
+ : q.instructions,
115
+ };
116
+ });
117
+ }
118
+
119
+ function parseAnswers(value: unknown, context: ClassifierContext): ClassifierResult["answers"] {
120
+ requireValue(Array.isArray(value), "OpenAI Decisions returned invalid answers.");
121
+ const byName = new Map<string, Record<string, unknown>>();
122
+ for (const answer of value) {
123
+ requireValue(
124
+ isRecord(answer) && typeof answer.name === "string" && !byName.has(answer.name),
125
+ "OpenAI Decisions returned invalid answer names.",
126
+ );
127
+ byName.set(answer.name, answer);
128
+ }
129
+ requireValue(
130
+ byName.size === Object.keys(context.questions).length,
131
+ "OpenAI Decisions returned incomplete answers.",
132
+ );
133
+ return Object.fromEntries(
134
+ Object.entries(context.questions).map(([id, q]) => {
135
+ const answer = byName.get(id);
136
+ requireValue(answer, "OpenAI Decisions omitted an answer.");
137
+ requireValue(answer.type !== "refusal", "OpenAI Decisions refused a question.");
138
+ if (q.type === "bool") {
139
+ requireValue(
140
+ answer.type === "predicate",
141
+ "OpenAI Decisions returned an invalid predicate.",
142
+ );
143
+ return [id, { type: "bool", probability: number(answer.probability, "probability") }];
144
+ }
145
+ requireValue(answer.type === q.type, "OpenAI Decisions returned an invalid answer type.");
146
+ const confidence = number(answer.confidence, "confidence");
147
+ if (q.type === "score")
148
+ return [
149
+ id,
150
+ {
151
+ type: "score",
152
+ score: number(answer.score, "score", q.criteria.length - 1),
153
+ confidence,
154
+ },
155
+ ];
156
+ requireValue(
157
+ typeof answer.choice === "string" &&
158
+ Object.hasOwn(q.criteria, answer.choice) &&
159
+ Array.isArray(answer.probabilities),
160
+ "OpenAI Decisions returned an invalid choice.",
161
+ );
162
+ const probabilities: Record<string, number> = Object.create(null);
163
+ for (const entry of answer.probabilities) {
164
+ requireValue(
165
+ isRecord(entry) &&
166
+ typeof entry.value === "string" &&
167
+ Object.hasOwn(q.criteria, entry.value) &&
168
+ !Object.hasOwn(probabilities, entry.value),
169
+ "OpenAI Decisions returned invalid choice probabilities.",
170
+ );
171
+ probabilities[entry.value] = number(entry.probability, "probability");
172
+ }
173
+ requireValue(
174
+ Object.keys(probabilities).length === Object.keys(q.criteria).length &&
175
+ Math.abs(Object.values(probabilities).reduce((sum, p) => sum + p, 0) - 1) <= 0.001,
176
+ "OpenAI Decisions returned incomplete choice probabilities.",
177
+ );
178
+ return [id, { type: "choice", choice: answer.choice, probabilities, confidence }];
179
+ }),
180
+ );
181
+ }
182
+
183
+ function parseUsage(value: unknown, model: ClassifierModel<string>): Usage | undefined {
184
+ if (!isRecord(value) || (value.input_tokens === undefined && value.output_tokens === undefined))
185
+ return undefined;
186
+ const count = (n: unknown) => (typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0);
187
+ const input = count(value.input_tokens),
188
+ output = count(value.output_tokens);
189
+ const usage: Usage = {
190
+ input,
191
+ output,
192
+ cacheRead: 0,
193
+ cacheWrite: 0,
194
+ totalTokens: input + output,
195
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
196
+ };
197
+ calculateCost(model, usage);
198
+ return usage;
199
+ }
200
+
201
+ function headers(...sources: (ProviderHeaders | undefined)[]): Headers {
202
+ const result = new Headers();
203
+ for (const source of sources)
204
+ for (const [key, value] of Object.entries(source ?? {})) {
205
+ if (value === null) result.delete(key);
206
+ else if (value !== undefined) result.set(key, value);
207
+ }
208
+ return result;
209
+ }
210
+
211
+ async function post(
212
+ model: ClassifierModel<string>,
213
+ context: OpenAIDecisionsContext,
214
+ options: ClassifierOptions,
215
+ ): Promise<unknown> {
216
+ requireValue(
217
+ options.apiKey,
218
+ "OpenAI Decisions requires an OpenAI API key, not a ChatGPT subscription.",
219
+ );
220
+ let payload: unknown = {
221
+ model: model.id,
222
+ input: wireInput(context),
223
+ questions: wireQuestions(context),
224
+ };
225
+ const transformed = await options.onPayload?.(payload, model);
226
+ if (transformed !== undefined) payload = transformed;
227
+ const body = JSON.stringify(payload);
228
+ const url = new URL("decisions", `${model.baseUrl.replace(/\/+$/u, "")}/`);
229
+ const requestHeaders = headers(
230
+ { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
231
+ model.headers,
232
+ options.headers,
233
+ );
234
+ for (let attempt = 0; ; attempt++) {
235
+ options.signal?.throwIfAborted();
236
+ const timeout =
237
+ options.timeoutMs === undefined ? undefined : AbortSignal.timeout(options.timeoutMs);
238
+ const signal =
239
+ options.signal && timeout
240
+ ? AbortSignal.any([options.signal, timeout])
241
+ : (options.signal ?? timeout);
242
+ let response: Response;
243
+ try {
244
+ response = await (options.fetch ?? globalThis.fetch)(url, {
245
+ method: "POST",
246
+ headers: requestHeaders,
247
+ body,
248
+ signal,
249
+ });
250
+ await options.onResponse?.(
251
+ { status: response.status, headers: Object.fromEntries(response.headers) },
252
+ model,
253
+ );
254
+ if (response.ok) return await response.json();
255
+ } catch {
256
+ throw new DecisionsError(
257
+ timeout?.aborted
258
+ ? "OpenAI Decisions request timed out."
259
+ : "OpenAI Decisions request failed.",
260
+ );
261
+ }
262
+ // Do not surface provider bodies, which can echo credentials or submitted state.
263
+ await response.body?.cancel();
264
+ const hint = response.headers.get("x-should-retry");
265
+ const retryable =
266
+ hint === "true" ||
267
+ (hint !== "false" && ([408, 409, 429].includes(response.status) || response.status >= 500));
268
+ // Like upstream Pi, never retry gateway timeouts caused by very large inputs.
269
+ requireValue(
270
+ response.status !== 504,
271
+ "OpenAI Decisions gateway timeout (504). Reduce the input size.",
272
+ );
273
+ requireValue(
274
+ retryable && attempt < (options.maxRetries ?? 2),
275
+ `OpenAI Decisions HTTP ${response.status}. Check API-key access and request limits.`,
276
+ );
277
+ const afterMs = response.headers.get("retry-after-ms"),
278
+ after = response.headers.get("retry-after");
279
+ let delay =
280
+ afterMs !== null
281
+ ? Number(afterMs)
282
+ : after !== null
283
+ ? Number.isNaN(Number(after))
284
+ ? Date.parse(after) - Date.now()
285
+ : Number(after) * 1000
286
+ : NaN;
287
+ if (!Number.isFinite(delay))
288
+ delay = Math.min(500 * 2 ** attempt, 8000) * (1 - Math.random() * 0.25);
289
+ const cap = options.maxRetryDelayMs ?? 60000;
290
+ requireValue(
291
+ cap <= 0 || delay <= cap,
292
+ "OpenAI Decisions retry delay exceeds the configured limit.",
293
+ );
294
+ await sleep(Math.max(0, delay), undefined, { signal: options.signal });
295
+ }
296
+ }
297
+
298
+ export async function classifyOpenAIDecisions(
299
+ model: ClassifierModel<string>,
300
+ context: OpenAIDecisionsContext,
301
+ options: ClassifierOptions = {},
302
+ ): Promise<ClassifierResult> {
303
+ const result: ClassifierResult = {
304
+ api: model.api,
305
+ provider: model.provider,
306
+ model: model.id,
307
+ answers: {},
308
+ stopReason: "stop",
309
+ timestamp: Date.now(),
310
+ };
311
+ try {
312
+ requireValue(model.api === OPENAI_DECISIONS_API, "Unsupported OpenAI classifier API.");
313
+ const body = await post(model, context, options);
314
+ requireValue(isRecord(body), "OpenAI Decisions returned an invalid response.");
315
+ result.usage = parseUsage(body.usage, model);
316
+ result.answers = parseAnswers(body.answers, context);
317
+ } catch (error) {
318
+ result.stopReason = options.signal?.aborted ? "aborted" : "error";
319
+ result.errorMessage =
320
+ result.stopReason === "aborted"
321
+ ? "OpenAI Decisions request aborted."
322
+ : error instanceof DecisionsError
323
+ ? error.message
324
+ : "OpenAI Decisions request failed.";
325
+ }
326
+ return result;
327
+ }
328
+
329
+ export function withOpenAIDecisions(builtIn: Provider): Provider {
330
+ const catalog = () => builtIn.getAllModels?.() ?? builtIn.getModels();
331
+ // Never replace a classifier implementation shipped by the host.
332
+ if (
333
+ builtIn.classify &&
334
+ catalog().some((m) => m.type === "classifier" && m.api === OPENAI_DECISIONS_API)
335
+ )
336
+ return builtIn;
337
+ return {
338
+ ...builtIn,
339
+ getAllModels: () => [
340
+ ...catalog().filter((m) => !(m.type === "classifier" && m.id === OPENAI_DECISIONS_MODEL.id)),
341
+ OPENAI_DECISIONS_MODEL,
342
+ ],
343
+ filterAllModels: (models, credential) => {
344
+ const available =
345
+ builtIn.filterAllModels?.(models, credential) ??
346
+ models.filter(
347
+ (m) =>
348
+ m.type === "classifier" ||
349
+ m.type === "image" ||
350
+ !builtIn.filterModels ||
351
+ builtIn.filterModels([m], credential).length > 0,
352
+ );
353
+ return credential?.type === "oauth"
354
+ ? available.filter((m) => m.api !== OPENAI_DECISIONS_API)
355
+ : available;
356
+ },
357
+ classify: (model, context, options) =>
358
+ model.api === OPENAI_DECISIONS_API
359
+ ? classifyOpenAIDecisions(model, context, options)
360
+ : builtIn.classify
361
+ ? builtIn.classify(model, context, options)
362
+ : Promise.resolve({
363
+ api: model.api,
364
+ provider: model.provider,
365
+ model: model.id,
366
+ timestamp: Date.now(),
367
+ answers: {},
368
+ stopReason: "error",
369
+ errorMessage: "Unsupported classifier API.",
370
+ }),
371
+ };
372
+ }
373
+
374
+ export function registerOpenAIDecisionsProvider(pi: ExtensionAPI): void {
375
+ const builtIn = builtinProviders().find((provider) => provider.id === "openai");
376
+ if (!builtIn) return;
377
+ const provider = withOpenAIDecisions(builtIn);
378
+ if (provider !== builtIn) pi.registerProvider(provider);
379
+ }