diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 625ea21e0..f97cd6eb3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -25,6 +25,9 @@ ### Fixed - Fixed `omp plugin install` of legacy pi extensions failing Bun's static named-export validation on `isContextOverflow` (e.g. `pi-blackhole`). The `@oh-my-pi/pi-ai` root barrel dropped upstream `@earendil-works/pi-ai`'s root re-export, so `legacy-pi-ai-shim.ts` (which backs both on-disk and `omp-legacy-pi-bundled:` virtual resolution) never surfaced it. The shim now bridges every upstream-root runtime symbol that still exists in omp — `isContextOverflow` (from `@oh-my-pi/pi-ai/error`) and `parseJsonWithRepair`/`parseStreamingJson`/`repairJson` (from `@oh-my-pi/pi-utils`) ([#6859](https://github.com/can1357/oh-my-pi/issues/6859)). +### Fixed + +- Fixed legacy extension plugin validation failing with `Export named 'isRetryableAssistantError' not found in module '.../legacy-pi-ai-shim.ts'` when an extension imports `isRetryableAssistantError` from `@earendil-works/pi-ai` / `@oh-my-pi/pi-ai` (e.g. `@router-for-me/pi-cliproxyapi-provider` >= 1.4.9). Historical pi-ai exports this transient-error classifier from its package root, but OMP's legacy `pi-ai` root shim never bridged it; the shim now ships a compatibility implementation matching the upstream provider-error wording tables. ([#6847](https://github.com/can1357/oh-my-pi/issues/6847)) ## [17.1.7] - 2026-07-27 diff --git a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts index 5b482eb83..6d8d67dc1 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts @@ -19,7 +19,7 @@ * `types.ts` via the `export *` below — pi-ai still exports both as types, * only the runtime `Type` builder and `StringEnum()` helper were removed. */ -import type { Api, Model } from "@oh-my-pi/pi-ai"; +import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import type { Effort } from "@oh-my-pi/pi-catalog/effort"; import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; import { @@ -80,6 +80,33 @@ export function clampThinkingLevel(model: Model, level: return clampThinkingLevelForModel(model, level) ?? "off"; } +/** + * Provider-error classification patterns ported verbatim from historical pi-ai + * (`@earendil-works/pi-ai` `utils/retry.ts`). Legacy extensions call + * {@link isRetryableAssistantError} to decide whether to restart a failed + * assistant turn, so the wording tables must match the upstream semantics they + * were authored against rather than OMP's own `Error`-based classifiers. + */ +const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = + /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i; +const RETRYABLE_PROVIDER_ERROR_PATTERN = + /overloaded|rate.?limit|too many requests|429|500|502|503|504|524|service.?unavailable|server.?error|internal.?error|provider.?returned.?error|network.?error|connection.?error|connection.?refused|connection.?lost|other side closed|fetch failed|getaddrinfo|ENOTFOUND|EAI_AGAIN|upstream.?connect|reset before headers|socket hang up|socket connection was closed|timed? out|timeout|terminated|websocket.?closed|websocket.?error|ended without|stream ended before message_stop|stream ended before a terminal response event|http2 request did not get a response|retry delay|you can retry your request|try your request again|please retry your request|ResourceExhausted/i; + +/** + * Compatibility implementation of historical pi-ai's `isRetryableAssistantError`. + * + * Classifies whether a failed assistant message looks like a transient provider + * or transport error so legacy extensions can decide if the last assistant turn + * should be restarted. Account/quota limits are treated as non-retryable. This + * does not implement any retry policy; callers own budget, backoff, and reporting. + */ +export function isRetryableAssistantError(message: AssistantMessage): boolean { + if (message.stopReason !== "error" || !message.errorMessage) return false; + const errorMessage = message.errorMessage; + if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false; + return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage); +} + export * from "@oh-my-pi/pi-ai"; /** * Compatibility re-exports for catalog symbols that pi-ai historically exposed diff --git a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts index 4eb64ddbd..dfc8fa984 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts @@ -220,6 +220,29 @@ describe("legacy-pi @(scope)/pi-ai root `Type` remap (issue #1437)", () => { expect(loaded.schema.safeParse("blue").success).toBe(false); expect(loaded.schema.toJSON?.()?.description).toBe("primary colors"); }); + + it("exports isRetryableAssistantError for legacy retry classification (issue #6847)", async () => { + // `@earendil-works/pi-ai@0.82.x` exports isRetryableAssistantError from its + // package root (utils/retry.js). Plugins such as + // `@router-for-me/pi-cliproxyapi-provider` (>=1.4.9) import it, so a missing + // shim export surfaced as a plain + // `Export named 'isRetryableAssistantError' not found` at validation time. + const loaded = (await loadLegacyPiModule( + await writeFixtureExtension( + [ + 'import { isRetryableAssistantError } from "@earendil-works/pi-ai";', + 'const err = errorMessage => ({ role: "assistant", stopReason: "error", errorMessage });', + 'export const transient = isRetryableAssistantError(err("upstream connect error"));', + 'export const quota = isRetryableAssistantError(err("insufficient_quota"));', + 'export const ok = isRetryableAssistantError({ role: "assistant", stopReason: "stop" });', + ].join("\n"), + ), + )) as { transient: boolean; quota: boolean; ok: boolean }; + + expect(loaded.transient).toBe(true); + expect(loaded.quota).toBe(false); + expect(loaded.ok).toBe(false); + }); }); describe("legacy pi package root remaps (issue #1474)", () => {