@oh-my-pi/pi-ai 18.1.13 → 18.1.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
@@ -2,12 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.14] - 2026-09-07
6
+
7
+ ### Fixed
8
+
9
+ - Fixed reasoning-off requests (e.g. GitHub Copilot `gpt-6-astra`) surfacing `400 Unsupported value: 'none' … Supported values are: …` instead of retrying at the lowest allowed effort: the reasoning-effort fallback now recognizes `Supported values` phrasing ([#11128](https://github.com/can1357/oh-my-pi/pull/11128) by [@H4vC](https://github.com/H4vC)).
10
+ - Fixed Cursor GPT off-tier requests sending raw `-none` sibling ids (e.g. `gpt-5.6-sol-none-fast`), which the Run endpoint rejects; they now normalize to the base model id with no reasoning parameter, matching every other effort tier ([#11128](https://github.com/can1357/oh-my-pi/pull/11128) by [@H4vC](https://github.com/H4vC)).
11
+
5
12
  ## [18.1.12] - 2026-09-06
6
13
 
7
14
  ### Added
8
15
 
9
16
  - Added Muse Code subscription sign-in, credential refresh, inference, and quota reporting in `/usage`, with durable rate-limit backoff so quota refresh recovers instead of repeatedly retrying.
10
17
 
18
+ ### Fixed
19
+
20
+ - Fixed Codex compaction timeouts triggering prolonged retries instead of advancing to the next compaction method.
21
+
11
22
  ## [18.1.11] - 2026-09-05
12
23
 
13
24
  ### Fixed
@@ -223,7 +223,7 @@ type CodexToolPayload = {
223
223
  /** @internal Exported for tests. */
224
224
  export declare function convertOpenAICodexResponsesTools(tools: Tool[], model: Model<"openai-codex-responses">): CodexToolPayload[];
225
225
  export declare class CodexWebSocketTransportError extends Error {
226
- constructor(detail: string);
226
+ constructor(detail: string, options?: ErrorOptions);
227
227
  }
228
228
  declare class CodexProviderStreamError extends Error {
229
229
  readonly retryable: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.1.13",
3
+ "version": "18.1.14",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -124,11 +124,11 @@
124
124
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
125
125
  },
126
126
  "dependencies": {
127
- "@oh-my-pi/omptype": "18.1.13",
128
- "@oh-my-pi/pi-catalog": "18.1.13",
129
- "@oh-my-pi/pi-natives": "18.1.13",
130
- "@oh-my-pi/pi-utils": "18.1.13",
131
- "@oh-my-pi/pi-wire": "18.1.13"
127
+ "@oh-my-pi/omptype": "18.1.14",
128
+ "@oh-my-pi/pi-catalog": "18.1.14",
129
+ "@oh-my-pi/pi-natives": "18.1.14",
130
+ "@oh-my-pi/pi-utils": "18.1.14",
131
+ "@oh-my-pi/pi-wire": "18.1.14"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/bun": "^1.3.14"
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import http2 from "node:http2";
4
- import { classifyModel } from "@oh-my-pi/pi-catalog/compat/taxonomy";
4
+ import { classifyModel, collapseVariantId } from "@oh-my-pi/pi-catalog/compat/taxonomy";
5
5
  import type {
6
6
  ConversationStep,
7
7
  CursorRule,
@@ -5236,8 +5236,11 @@ function extractImages(content: (TextContent | ImageContent)[]) {
5236
5236
  * The Run endpoint rejects a sibling slug as the wire `model_id` with
5237
5237
  * `resource_exhausted` (errorId 528384); the official `cursor-agent` splits the
5238
5238
  * slug into its base model id plus a `reasoning` effort parameter. Mirror that
5239
- * for OpenAI-family ids: strip a trailing effort tier and emit
5240
- * `{ id: "reasoning", value: <effort> }`.
5239
+ * for OpenAI-family ids: split the tier with the compiled catalog policy
5240
+ * (`collapseVariantId`, KDL suffix/lane rules) and emit
5241
+ * `{ id: "reasoning", value: <effort> }`. The off tier (`-none`) is a sibling
5242
+ * slug too, so it normalizes to the bare (lane-preserving) base with no
5243
+ * reasoning parameter instead of going out raw.
5241
5244
  *
5242
5245
  * Non-OpenAI ids pass through unchanged — Cursor-native ids (`composer-*`,
5243
5246
  * `cursor-grok-*`, `default`) carry no effort suffix, and Claude/other siblings
@@ -5255,21 +5258,23 @@ function resolveCursorWireModel(
5255
5258
  } {
5256
5259
  const wireModelId = requestModelId ?? model.requestModelId ?? model.id;
5257
5260
  if (wireMode === "discovered") return { modelId: wireModelId, parameters: [] };
5258
- // Cursor's fast lane follows the effort token (`-high-fast`), while the
5259
- // standard lane ends at it (`-high`). Preserve the lane in the base id.
5260
- const match = /^(.*)-(minimal|low|medium|high|xhigh|max)(-fast)?$/.exec(wireModelId);
5261
- const base = match?.[1];
5262
- const effort = match?.[2];
5263
- if (
5264
- base &&
5265
- effort &&
5266
- (THINKING_EFFORTS as readonly string[]).includes(effort) &&
5267
- classifyModel("cursor", base).class === "openai"
5268
- ) {
5269
- return {
5270
- modelId: `${base}${match[3] ?? ""}`,
5271
- parameters: [create(RequestedModel_ModelParameterbytesSchema, { id: "reasoning", value: effort })],
5272
- };
5261
+ // `collapseVariantId` keeps the lane in the logical id (`-high-fast`
5262
+ // base `-fast`) and decodes the KDL effort (`-none` `off`).
5263
+ const collapsed = collapseVariantId("cursor", wireModelId);
5264
+ const effort = collapsed.effort;
5265
+ const base = effort !== undefined ? collapsed.logicalId : undefined;
5266
+ if (effort !== undefined && base && classifyModel("cursor", base).class === "openai") {
5267
+ if (effort === "off") {
5268
+ return { modelId: base, parameters: [] };
5269
+ }
5270
+ if ((THINKING_EFFORTS as readonly string[]).includes(effort)) {
5271
+ return {
5272
+ modelId: base,
5273
+ parameters: [
5274
+ create(RequestedModel_ModelParameterbytesSchema, { id: "reasoning", value: collapsed.effort }),
5275
+ ],
5276
+ };
5277
+ }
5273
5278
  }
5274
5279
  // A bare `composer-2.5` id resolves to the Fast variant server-side
5275
5280
  // (can1357/oh-my-pi#9012). Pin the Standard tier explicitly; `-fast`
@@ -3734,12 +3734,12 @@ class CodexWebSocketConnection {
3734
3734
  if (signal) signal.removeEventListener("abort", onAbort);
3735
3735
  };
3736
3736
  const onAbort = () => {
3737
- this.close("aborted");
3738
3737
  if (!settled) {
3739
3738
  settled = true;
3740
3739
  clearPending();
3741
- reject(new CodexWebSocketTransportError(`request was aborted`));
3740
+ reject(new CodexWebSocketTransportError(`request was aborted`, { cause: signal?.reason }));
3742
3741
  }
3742
+ this.close("aborted");
3743
3743
  };
3744
3744
  if (signal) {
3745
3745
  if (signal.aborted) {
@@ -3844,7 +3844,7 @@ class CodexWebSocketConnection {
3844
3844
  throw new CodexWebSocketTransportError(`websocket request already in progress`);
3845
3845
  }
3846
3846
  if (signal?.aborted) {
3847
- throw new CodexWebSocketTransportError(`request was aborted`);
3847
+ throw new CodexWebSocketTransportError(`request was aborted`, { cause: signal.reason });
3848
3848
  }
3849
3849
  this.#activeRequest = true;
3850
3850
  this.#streamObserver = onSseEvent;
@@ -3860,8 +3860,9 @@ class CodexWebSocketConnection {
3860
3860
  // the death signal instead of writing into a dead socket.
3861
3861
  this.#dropStaleFrames();
3862
3862
  const onAbort = () => {
3863
+ this.#push(new CodexWebSocketTransportError(`request was aborted`, { cause: signal?.reason }));
3864
+ // Closing can synchronously enqueue a generic onclose error.
3863
3865
  this.close("aborted");
3864
- this.#push(new CodexWebSocketTransportError(`request was aborted`));
3865
3866
  };
3866
3867
  if (signal) signal.addEventListener("abort", onAbort, { once: true });
3867
3868
 
@@ -3882,8 +3883,15 @@ class CodexWebSocketConnection {
3882
3883
  const requestPayload = JSON.stringify(request);
3883
3884
  notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
3884
3885
  // Re-check liveness: the debug-session await above can outlive the socket.
3886
+ // Preserve the abort cause: onAbort already queued a caused error, but this
3887
+ // throw would otherwise mask it before #nextMessage() drains the queue.
3885
3888
  const socket = this.#socket;
3886
3889
  if (!socket || socket.readyState !== WebSocket.OPEN) {
3890
+ if (signal?.aborted) {
3891
+ throw new CodexWebSocketTransportError(`websocket connection is unavailable`, {
3892
+ cause: signal.reason,
3893
+ });
3894
+ }
3887
3895
  throw new CodexWebSocketTransportError(`websocket connection is unavailable`);
3888
3896
  }
3889
3897
  try {
@@ -4752,8 +4760,8 @@ export function convertOpenAICodexResponsesTools(
4752
4760
  }
4753
4761
 
4754
4762
  export class CodexWebSocketTransportError extends Error {
4755
- constructor(detail: string) {
4756
- super(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${detail}`);
4763
+ constructor(detail: string, options?: ErrorOptions) {
4764
+ super(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${detail}`, options);
4757
4765
  this.name = "CodexWebSocketTransportError";
4758
4766
  }
4759
4767
  }
@@ -816,10 +816,15 @@ const streamOpenAICompletionsOnce = (
816
816
  openaiStream = await createCompletionsStream();
817
817
  } catch (error) {
818
818
  const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
819
+ // A caller disable with a retained effort preference is still an
820
+ // explicit disable: without this, a fieldless rejection of the
821
+ // resulting `none` resolves to a delete-effort retry that gets
822
+ // cached and strips later enabled turns.
823
+ const isExplicitDisable = options?.disableReasoning === true;
819
824
  const reasoningEffortFallback =
820
825
  activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
821
826
  ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
822
- explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
827
+ explicitDisable: isExplicitDisable,
823
828
  })
824
829
  : undefined;
825
830
  if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
@@ -828,11 +833,16 @@ const streamOpenAICompletionsOnce = (
828
833
  attemptedReasoningEffortFallbacks.add(retryMarker);
829
834
  requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
830
835
  openaiStream = await createCompletionsStream();
831
- rememberOpenAIReasoningEffortFallback(
832
- providerSessionState,
833
- activeReasoningEffortFallbackKey,
834
- reasoningEffortFallback,
835
- );
836
+ // Explicit-disable fallbacks stay per-request so a reasoning-off
837
+ // side request cannot downgrade later normal turns sharing
838
+ // the session state.
839
+ if (!isExplicitDisable) {
840
+ rememberOpenAIReasoningEffortFallback(
841
+ providerSessionState,
842
+ activeReasoningEffortFallbackKey,
843
+ reasoningEffortFallback,
844
+ );
845
+ }
836
846
  } else if (
837
847
  model.compat.retryWithoutStrictOnGrammarError &&
838
848
  !disableStrictTools &&
@@ -183,9 +183,11 @@ function collectMessageParts(error: unknown, captured: CapturedHttpErrorResponse
183
183
  * Text that identifies a 400 as being about the reasoning-effort field.
184
184
  * OpenAI-compatible gateways (cliproxy, …) never name the field — they reject
185
185
  * the value alone with `level "none" not supported, valid levels: low, …` — so
186
- * the allowed-level phrasing counts as a mention too.
186
+ * the allowed-level phrasing counts as a mention too. GitHub Copilot phrases
187
+ * the same rejection with `Supported values are: …`, so value lists count too.
187
188
  */
188
- const REASONING_EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value|(?:valid|supported|allowed) levels?/i;
189
+ const REASONING_EFFORT_FIELD_PATTERN =
190
+ /reasoning[_. ]effort|reasoning value|(?:valid|supported|allowed) (?:levels?|values?)/i;
189
191
 
190
192
  function mentionsReasoningEffort(error: unknown, captured: CapturedHttpErrorResponse | undefined): boolean {
191
193
  const param = capturedStringField(captured, "param");
@@ -200,6 +202,68 @@ function mentionsReasoningEffort(error: unknown, captured: CapturedHttpErrorResp
200
202
  );
201
203
  }
202
204
 
205
+ /** Which request field a rejection attributes itself to, parsed once. */
206
+ type EffortRejectionField = "reasoning-effort" | "other" | "unknown";
207
+
208
+ /**
209
+ * Parsed attribution of a 400/422, in precedence order: the structured error
210
+ * field first, the message verdict second, bare vocabulary last. New server
211
+ * wordings extend these parsers; the decision in
212
+ * {@link isInvalidReasoningEffortError} stays fixed.
213
+ */
214
+ interface EffortRejectionSignal {
215
+ /** Authoritative attribution: explicit `param`, else message content, else unknown. */
216
+ field: EffortRejectionField;
217
+ /** The message names the reasoning-effort option without a verdict. */
218
+ namesFieldInMessage: boolean;
219
+ /** The message carries a fielded rejection verdict (any word order). */
220
+ messageVerdict: boolean;
221
+ /** The message lists allowed tiers in gateway levels vocabulary. */
222
+ listsLevels: boolean;
223
+ /** The rejected effort is quoted next to a rejection verdict. */
224
+ rejectedMatches: boolean;
225
+ }
226
+
227
+ const EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value/i;
228
+ const ALLOWED_LEVELS_PATTERN = /(?:valid|supported|allowed) levels?/i;
229
+
230
+ /** Fielded rejection verdicts in any word order: verdict-first, field-first, or bare mention plus verdict. */
231
+ function messageCarriesEffortVerdict(message: string): boolean {
232
+ return (
233
+ /invalid[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message) ||
234
+ /(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|not permitted|must be|expected|unknown|unexpected|unrecognized)/i.test(
235
+ message,
236
+ ) ||
237
+ /(?:unsupported|not supported|not permitted|unknown|unexpected|unrecognized|extra)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(
238
+ message,
239
+ )
240
+ );
241
+ }
242
+
243
+ function parseEffortRejectionSignal(
244
+ message: string,
245
+ captured: CapturedHttpErrorResponse | undefined,
246
+ currentEffort: string,
247
+ ): EffortRejectionSignal {
248
+ const namesFieldInMessage = EFFORT_FIELD_PATTERN.test(message);
249
+ const param = capturedStringField(captured, "param") ?? "";
250
+ const quoted = `["'\`]${escapeRegExp(currentEffort)}["'\`]`;
251
+ return {
252
+ field:
253
+ namesFieldInMessage || EFFORT_FIELD_PATTERN.test(param)
254
+ ? "reasoning-effort"
255
+ : param.trim() !== ""
256
+ ? "other"
257
+ : "unknown",
258
+ namesFieldInMessage,
259
+ messageVerdict: messageCarriesEffortVerdict(message),
260
+ listsLevels: ALLOWED_LEVELS_PATTERN.test(message),
261
+ rejectedMatches:
262
+ new RegExp(`(?:invalid|unsupported|not supported)[^\\n]*${quoted}`, "i").test(message) ||
263
+ new RegExp(`${quoted}[^\\n]*(?:invalid|unsupported|not supported)`, "i").test(message),
264
+ };
265
+ }
266
+
203
267
  function isInvalidReasoningEffortError(
204
268
  error: unknown,
205
269
  captured: CapturedHttpErrorResponse | undefined,
@@ -210,28 +274,15 @@ function isInvalidReasoningEffortError(
210
274
  if (!mentionsReasoningEffort(error, captured)) return false;
211
275
  const message = collectMessageParts(error, captured);
212
276
  if (/reasoning[_ ]content/i.test(message) && !REASONING_EFFORT_FIELD_PATTERN.test(message)) return false;
213
- if (/invalid[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) return true;
214
- if (
215
- /(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|not permitted|must be|expected|unknown|unexpected|unrecognized)/i.test(
216
- message,
217
- )
218
- ) {
219
- return true;
220
- }
221
- if (
222
- /(?:unsupported|not supported|not permitted|unknown|unexpected|unrecognized|extra)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(
223
- message,
224
- )
225
- ) {
226
- return true;
227
- }
228
- // Gateways put the rejected value first (`level "none" not supported`), the
229
- // official API puts the verdict first (`Unsupported value: 'none'`).
230
- const quoted = `["'\`]${escapeRegExp(currentEffort)}["'\`]`;
231
- return (
232
- new RegExp(`(?:invalid|unsupported|not supported)[^\\n]*${quoted}`, "i").test(message) ||
233
- new RegExp(`${quoted}[^\\n]*(?:invalid|unsupported|not supported)`, "i").test(message)
234
- );
277
+ const signal = parseEffortRejectionSignal(message, captured, currentEffort);
278
+ // Precedence is fixed: an explicit foreign field defeats the heuristic,
279
+ // a fielded verdict always qualifies, and fieldless values-lists are
280
+ // trusted only for the reasoning-off value (levels vocabulary is
281
+ // gateway-effort dialect, so it stays trusted for every tier).
282
+ if (signal.field === "other" && !signal.namesFieldInMessage) return false;
283
+ if (signal.messageVerdict) return true;
284
+ if (currentEffort.toLowerCase() !== "none" && !signal.namesFieldInMessage && !signal.listsLevels) return false;
285
+ return signal.rejectedMatches;
235
286
  }
236
287
 
237
288
  function escapeRegExp(value: string): string {
@@ -591,11 +591,21 @@ const streamOpenAIResponsesOnce = (
591
591
  try {
592
592
  openaiStream = await openResponsesStream(chained.params);
593
593
  if (pendingReasoningEffortFallback) {
594
- rememberOpenAIReasoningEffortFallback(
595
- providerSessionState,
596
- pendingReasoningEffortFallback.key,
597
- pendingReasoningEffortFallback.fallback,
598
- );
594
+ // Explicit-disable fallbacks (none -> lowest allowed) are
595
+ // per-request: persisting them under the model key would
596
+ // silently downgrade later normal turns sharing the
597
+ // session state. A retained effort preference does not
598
+ // make the disable less explicit. Keep them in the
599
+ // per-request map only.
600
+ const isExplicitDisable =
601
+ options?.forceReasoningOff === true || options?.disableReasoning === true;
602
+ if (!isExplicitDisable) {
603
+ rememberOpenAIReasoningEffortFallback(
604
+ providerSessionState,
605
+ pendingReasoningEffortFallback.key,
606
+ pendingReasoningEffortFallback.fallback,
607
+ );
608
+ }
599
609
  pendingReasoningEffortFallback = undefined;
600
610
  }
601
611
  break;
@@ -605,8 +615,7 @@ const streamOpenAIResponsesOnce = (
605
615
  activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
606
616
  ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
607
617
  explicitDisable:
608
- options?.forceReasoningOff === true ||
609
- (options?.disableReasoning === true && options.reasoning === undefined),
618
+ options?.forceReasoningOff === true || options?.disableReasoning === true,
610
619
  })
611
620
  : undefined;
612
621
  if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {