diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 5579db16d..889be34e7 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -25,6 +25,7 @@ import { renderToolExamples, wrapInbandToolStream, } from "@oh-my-pi/pi-ai/dialect"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { createHarmonyAuditEvent, detectHarmonyLeakInAssistantMessage, @@ -1556,8 +1557,12 @@ function emitAbortedAssistantMessage( requestSignal: AbortSignal | undefined, ): AssistantMessage { const errorMessage = abortReasonText(requestSignal); + const errorId = + errorMessage === "Request was aborted" + ? AIError.create(AIError.Flag.Abort) + : AIError.classify(requestSignal?.reason) || undefined; const base: AssistantMessage = partialMessage - ? { ...partialMessage, stopReason: "aborted", errorMessage } + ? { ...partialMessage, stopReason: "aborted", errorMessage, errorId } : { role: "assistant", content: [], @@ -1574,6 +1579,7 @@ function emitAbortedAssistantMessage( }, stopReason: "aborted", errorMessage, + errorId, timestamp: Date.now(), }; // Only tool calls that reached `toolcall_end` survive abort/error replay. A diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index e263868ba..93d5ad50d 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -14,12 +14,12 @@ import { type Message, type MessageAttribution, type Model, - ProviderHttpError, type SimpleStreamOptions, type Tool, type Usage, withAuth, } from "@oh-my-pi/pi-ai"; +import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; import { preferredDialect } from "@oh-my-pi/pi-catalog/identity"; import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; import { logger, prompt } from "@oh-my-pi/pi-utils"; diff --git a/packages/agent/src/compaction/openai.ts b/packages/agent/src/compaction/openai.ts index 77e7fbc1f..74a1442ce 100644 --- a/packages/agent/src/compaction/openai.ts +++ b/packages/agent/src/compaction/openai.ts @@ -12,7 +12,7 @@ * with `{ summary, shortSummary? }`. */ -import { ProviderHttpError } from "@oh-my-pi/pi-ai/errors"; +import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; import { parseAzureDeploymentNameMap, parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared"; import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages"; import type { Api, AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types"; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0619aa130..d2bbe82fc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,10 +2,34 @@ ## [Unreleased] +### Added + +- Comprehensive error module with structured error classification system supporting multiple error types and providers +- `AIError.finalize()` function for standardized error finalization with status, id, and message generation +- `AIError.classifyGatewayError()` for gateway-level error classification into HTTP status codes +- OAuth-specific error types (`OAuthError`) with kind discrimination for different failure stages +- AWS credentials error types (`AwsCredentialsError`, `EventStreamFrameError`) with specific failure modes +- Provider-specific HTTP error classes (`AnthropicApiError`, `OpenAIHttpError`, `GeminiCliApiError`, etc.) +- Auth-specific errors (`MissingApiKeyError`, `LoginCancelledError`, `AuthBrokerError`) +- Structured error flags system for classifying errors by trait (timeout, transient, rate-limit, thinking-loop, etc.) +- Rate-limit utilities in error module including `RateLimitReason` classification and backoff calculation +- Stream-specific error types (`StreamTimeoutError`) with timeout + transient flag combination +- Validation and configuration error types with non-retryable classification +- Error retryability predicates including provider-specific transient detection hooks + ### Changed -- Enhanced cross-model reasoning recovery to support additional thinking dialects and leakage patterns +- Migrated error handling from legacy `errors.ts` and `utils/error-id.ts` into comprehensive `src/error/` module +- Reorganized `rate-limit-utils.ts` functions into `error/rate-limit.ts` with improved naming (`isUsageLimit`, `isUsageLimitOutcome`) +- Unified error classification via `AIError.classify()` and `AIError.classifyMessage()` replacing scattered `classifyError()` implementations +- All provider implementations now use structured `AIError.*` exceptions instead of generic `Error` or `ProviderHttpError` +- Error finalization refactored from inline `extractHttpStatusFromError()` + `errorIdFromError()` to single `AIError.finalize()` call +- Gateway error classification moved from `auth-gateway/server.ts` to `error/gateway.ts` with string-based input +- Exported error module as public API via package.json `"./error"` export path +- OAuth error constructors now accept structured options with `kind`, `provider`, `status`, and `cause` fields +- Registry login functions now use `AIError.OnPromptRequiredError` instead of generic errors +- Enhanced cross-model reasoning recovery to support additional thinking dialects and leakage patterns - Demote cross-vendor reasoning to plain text when the target does not natively support it - Refine cross-model reasoning preservation to prevent leaking inert context into structured fields - Rendered demoted cross-model reasoning blocks in the target model's canonical thinking dialect @@ -15,8 +39,20 @@ ### Removed +- Deleted legacy `src/errors.ts` (replaced by error module) +- Deleted `src/utils/error-id.ts` (migrated to `error/flags.ts`) +- Removed `rate-limit-utils.ts` from root (moved to `error/rate-limit.ts`) +- Removed generic `Error` constructor calls throughout codebase in favor of typed error classes + - Removed Pi dialect support and related serialization/parsing logic +### Fixed + +- Improved error message consistency across all providers with structured error formatting +- Corrected error classification for rate-limit vs transient failures in auth retry logic +- Fixed OAuth token refresh error handling with proper error type discrimination +- Enhanced thinking-loop error detection with flag-based classification + ## [16.2.0] - 2026-06-27 ### Breaking Changes diff --git a/packages/ai/package.json b/packages/ai/package.json index 97205c7a9..36fbdf6b3 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -61,6 +61,10 @@ "types": "./src/index.ts", "import": "./src/index.ts" }, + "./error": { + "types": "./src/error/index.ts", + "import": "./src/error/index.ts" + }, "./*": { "types": "./src/*.ts", "import": "./src/*.ts" diff --git a/packages/ai/src/api-registry.ts b/packages/ai/src/api-registry.ts index 5c5399d65..c201701f3 100644 --- a/packages/ai/src/api-registry.ts +++ b/packages/ai/src/api-registry.ts @@ -13,6 +13,7 @@ import type { SimpleStreamOptions, StreamOptions, } from "./types"; +import * as AIError from "./error"; const BUILTIN_API_IDS = [ "openai-completions", @@ -60,7 +61,7 @@ const customApiRegistry = new Map(); function assertCustomApiName(api: string): void { if (BUILTIN_APIS.has(api as KnownApi)) { - throw new Error(`Cannot register custom API "${api}": built-in API names are reserved.`); + throw new AIError.ConfigurationError(`Cannot register custom API "${api}": built-in API names are reserved.`); } } diff --git a/packages/ai/src/auth-broker/discover.ts b/packages/ai/src/auth-broker/discover.ts index 857fcc904..0f3d9ba2e 100644 --- a/packages/ai/src/auth-broker/discover.ts +++ b/packages/ai/src/auth-broker/discover.ts @@ -5,6 +5,7 @@ * credentials as the TUI. */ import * as path from "node:path"; +import * as AIError from "../error"; import { getAgentDbPath, getAgentDir, @@ -137,7 +138,8 @@ export async function resolveAuthBrokerConfig( const token = (envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined; if (!token) { - throw new Error( + throw new AIError.MissingApiKeyError( + undefined, `OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` + `Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`, ); @@ -190,7 +192,10 @@ export async function discoverAuthStorage(options: DiscoverAuthStorageOptions = } if (!initialSnapshot) { const initialResult = await client.fetchSnapshot(); - if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot"); + if (initialResult.status !== 200) + throw new AIError.AuthBrokerError("Auth broker returned no initial snapshot", { + status: initialResult.status, + }); initialSnapshot = initialResult.snapshot; persist?.(initialSnapshot); } diff --git a/packages/ai/src/auth-broker/remote-store.ts b/packages/ai/src/auth-broker/remote-store.ts index 749871dda..72b148afa 100644 --- a/packages/ai/src/auth-broker/remote-store.ts +++ b/packages/ai/src/auth-broker/remote-store.ts @@ -9,6 +9,7 @@ */ import { scheduler } from "node:timers/promises"; import { logger } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import { type AuthCredential, type AuthCredentialSnapshotEntry, @@ -313,26 +314,26 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore { async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise { const { entry } = await this.#client.refreshCredential(credentialId, opts.signal); if (entry.credential.type !== "oauth") { - throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`); + throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`); } this.#applyCredentialEntry(entry); this.#maybeRefreshSnapshot("suspect credential refresh"); } replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] { - throw new Error( + throw new AIError.AuthBrokerError( "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login ` to mutate credentials.", ); } upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] { - throw new Error( + throw new AIError.AuthBrokerError( "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login ` to mutate credentials.", ); } deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void { - throw new Error( + throw new AIError.AuthBrokerError( "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker logout ` to mutate credentials.", ); } @@ -487,7 +488,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore { }); } if (entry.credential.type !== "oauth") { - throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`); + throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`); } const refreshed = entry.credential; return { @@ -538,11 +539,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore { */ #raceWithSignal(promise: Promise, signal?: AbortSignal): Promise { if (!signal) return promise; - if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted")); + if (signal.aborted) return Promise.reject(new AIError.AbortError("auth-broker request aborted")); return new Promise((resolve, reject) => { const onAbort = (): void => { signal.removeEventListener("abort", onAbort); - reject(new Error("auth-broker request aborted")); + reject(new AIError.AbortError("auth-broker request aborted")); }; signal.addEventListener("abort", onAbort, { once: true }); promise.then( diff --git a/packages/ai/src/auth-gateway/server.ts b/packages/ai/src/auth-gateway/server.ts index ca0ab37c4..4d1a057c9 100644 --- a/packages/ai/src/auth-gateway/server.ts +++ b/packages/ai/src/auth-gateway/server.ts @@ -26,7 +26,8 @@ import * as anthropicMessages from "../providers/anthropic-messages-server"; import * as openaiChat from "../providers/openai-chat-server"; import * as openaiResponses from "../providers/openai-responses-server"; import * as piNative from "../providers/pi-native-server"; -import { isUsageLimitError, isUsageLimitOutcome } from "../rate-limit-utils"; +import { classifyGatewayError } from "../error/gateway"; +import { isUsageLimitOutcome } from "../error/rate-limit"; import { completeSimple, streamSimple } from "../stream"; import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types"; import { deterministicUuid } from "../utils/deterministic-id"; @@ -192,95 +193,6 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort return opts; } -/** - * Classify an upstream / gateway-internal error into a status code and a - * format-neutral type. The order is intentional: - * - * 1. Honour an explicit numeric `status` property on the thrown error. - * 2. Parse a status code embedded in the message string. Provider errors - * virtually always carry one (`Google API error (400): …`, `HTTP 429`, - * `status=503`) and the embedded value is authoritative. - * 3. Fall through to **word-boundaried** substring heuristics. The old - * `lower.includes("rate")` test famously matched - * `GenerateContentRequest`, surfacing every Google 400 as a 429 - * `rate_limit_error`. The patterns here all require boundaries so they - * don't collide with provider field names. - */ -export function classifyGatewayError(err: unknown): { status: number; type: string; message: string } { - const message = err instanceof Error ? err.message : String(err); - - // 1. Custom pi-ai errors may attach a numeric `status` property. - const statusProp = - typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number" - ? (err as { status: number }).status | 0 - : undefined; - if (statusProp !== undefined) return bucketStatus(statusProp, message); - - if (err instanceof Error && err.name === "AbortError") return { status: 499, type: "request_aborted", message }; - - // 2. Status code embedded in the message. Requires a contextual keyword - // (`HTTP`, `API error`, `status`, …) or a leading `(NNN)` token so we - // don't trip on incidental three-digit numbers ("took 200ms"). - const embedded = extractEmbeddedStatus(message); - if (embedded !== undefined) return bucketStatus(embedded, message); - - // 3. Word-boundaried substring heuristics. - if (/\baborted\b|\babort signal\b/i.test(message)) { - return { status: 499, type: "request_aborted", message }; - } - if ( - // Match rate-limit phrasings before auth wording: some providers - // describe throttling as "unauthorized due to rate limit". - // Keep boundaries so this does not collide with - // `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc. - /\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test( - message, - ) || - // Usage-limit phrasings emit no embedded status. Codex friendly text - // reads "You have hit your ChatGPT usage limit … Try again in ~158 - // min."; pi-ai's central `isUsageLimitError` already encodes every - // known provider variant, so reuse it instead of forking the regex. - // Without this branch the classifier falls through to the default - // 502/upstream_error, which is what callers were seeing when their - // account hit its cap. - isUsageLimitError(message) - ) { - return { status: 429, type: "rate_limit_error", message }; - } - if (/\b(?:unauthorized|forbidden)\b/i.test(message)) { - return { status: 401, type: "authentication_error", message }; - } - if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) { - return { status: 400, type: "invalid_request_error", message }; - } - return { status: 502, type: "upstream_error", message }; -} - -function bucketStatus(status: number, message: string): { status: number; type: string; message: string } { - if (status === 401 || status === 403) return { status, type: "authentication_error", message }; - if (status === 429) return { status, type: "rate_limit_error", message }; - if (status >= 400 && status < 500) return { status, type: "invalid_request_error", message }; - if (status >= 500) return { status, type: "upstream_error", message }; - return { status: 502, type: "upstream_error", message }; -} - -/** - * Pull a status code from common error-message shapes. Returns undefined when - * no contextual keyword is present, so we never guess at incidental numbers. - */ -function extractEmbeddedStatus(message: string): number | undefined { - // `Google API error (400)`, `OpenAI API error (429): …`, `(503)` - // `HTTP 429: too many requests` - // `status: 503`, `status_code=429`, `status=400` - const re = /(?:\bHTTP\b|\bAPI error\b|\bstatus(?:[- _]?code)?\b)\s*[:=]?\s*\(?\s*(\d{3})\b|\((\d{3})\)/i; - const m = message.match(re); - if (!m) return undefined; - const raw = m[1] ?? m[2]; - if (!raw) return undefined; - const code = Number.parseInt(raw, 10); - return Number.isFinite(code) && code >= 100 && code < 600 ? code : undefined; -} - /** * Hook fired by {@link streamSimple} when the upstream request fails in a * way that's rotatable — today that's HTTP 401 (credential is bad) and @@ -542,7 +454,7 @@ async function handleFormatEndpoint( if (message.stopReason === "aborted") { return route.module.formatError(499, "request_aborted", errorMessage); } - const classified = classifyGatewayError(new Error(errorMessage)); + const classified = classifyGatewayError(errorMessage); return route.module.formatError(classified.status, classified.type, errorMessage); } return json(200, route.module.encodeResponse(message, parsed.modelId)); @@ -717,7 +629,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe if (message.stopReason === "aborted") { return piNative.formatError(499, "request_aborted", errorMessage); } - const classified = classifyGatewayError(new Error(errorMessage)); + const classified = classifyGatewayError(errorMessage); return piNative.formatError(classified.status, classified.type, errorMessage); } return json(200, { message }); diff --git a/packages/ai/src/auth-retry.ts b/packages/ai/src/auth-retry.ts index d4a45bf87..dc35d6a1c 100644 --- a/packages/ai/src/auth-retry.ts +++ b/packages/ai/src/auth-retry.ts @@ -1,6 +1,6 @@ -import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import type { OAuthAccess } from "./auth-storage"; -import { isUsageLimitOutcome } from "./rate-limit-utils"; +import * as AIError from "./error"; +import { isAuthRetryableError } from "./error/auth-classify"; /** * Context passed to an {@link ApiKeyResolver} on each resolution attempt. @@ -70,23 +70,8 @@ export function seedApiKeyResolver(seed: string | undefined, resolver: ApiKeyRes }; } -/** - * Classifies whether an error should trigger a credential refresh/rotation - * retry: a hard `401`, body-classified usage limit (Codex - * `usage_limit_reached`, Anthropic account rate-limit, Google - * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429` - * whose payload did not preserve a richer quota code. Transient 429s - * (`Too many requests`, per-minute caps) classify as `RATE_LIMIT_EXCEEDED` - * via {@link parseRateLimitReason} and stay in the upstream-backoff lane. - */ -export function isAuthRetryableError(error: unknown): boolean { - const status = extractHttpStatusFromError(error); - if (status === 401) return true; - const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; - const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined; - if (embeddedStatus === 401) return true; - return isUsageLimitOutcome(status ?? embeddedStatus, message); -} +// Re-exported from the error module (its new home); see error/auth-classify.ts. +export { isAuthRetryableError }; /** * The ordered `lastChance` values for the retry steps after the initial @@ -130,7 +115,7 @@ export async function withAuth( opts?: { isAuthError?: (error: unknown) => boolean; signal?: AbortSignal; missingKeyMessage?: string }, ): Promise { const isAuthError = opts?.isAuthError ?? isAuthRetryableError; - const missingKey = (): Error => new Error(opts?.missingKeyMessage ?? "No API key available"); + const missingKey = (): Error => new AIError.MissingApiKeyError(undefined, opts?.missingKeyMessage); if (!isApiKeyResolver(key)) { if (key === undefined) throw missingKey(); @@ -225,7 +210,10 @@ export async function withOAuthAccess( let lastAccess = opts?.seed ?? (await storage.getOAuthAccess(provider, sessionId, { signal })); if (!lastAccess) { - throw new Error(opts?.missingAccessMessage ?? `No OAuth credential available for provider: ${provider}`); + throw new AIError.MissingApiKeyError( + provider, + opts?.missingAccessMessage ?? `No OAuth credential available for provider: ${provider}`, + ); } const resolveStep = async (lastChance: boolean, error: unknown): Promise => { diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 3d48709a9..9468d4ef8 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -10,9 +10,10 @@ import { Database, type Statement } from "bun:sqlite"; import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { extractHttpStatusFromError, getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; +import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; import type { ApiKeyResolver } from "./auth-retry"; -import { isUsageLimitOutcome } from "./rate-limit-utils"; +import * as AIError from "./error"; +import { isUsageLimitOutcome } from "./error/rate-limit"; import { getProviderDefinition, PASTE_CODE_LOGIN_PROVIDERS } from "./registry"; import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth"; import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types"; @@ -558,36 +559,9 @@ const OAUTH_REFRESH_SKEW_MS = 60_000; */ const MAX_PENDING_DISABLED_EVENTS = 32; -/** - * Classify an OAuth refresh error as a definitive credential failure (the - * refresh token is dead — re-login required) versus a transient blip - * (network/5xx — retry next sweep). - * - * Anchored at module scope so all three refresh sites — in-stream - * {@link AuthStorage.getApiKey}, the usage probe in - * {@link AuthStorage.fetchUsageReports}, and the auth-broker background - * refresher — disable rows on the same criteria. A drifting classifier - * between sites would let stale last-good usage reports surface indefinitely - * while streaming requests correctly tear the row down. - */ -const OAUTH_DEFINITIVE_FAILURE_REGEX = - /invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i; -// Transient: network blips, rate limits, gateway/5xx, and infra denials -// (WAF / egress 403, permission / account-verification) — block-and-retry, -// never tear the credential down for these. -const OAUTH_TRANSIENT_FAILURE_REGEX = - /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i; -// A bare 401 from an OAuth token endpoint means the stored grant/client is -// dead. 403 is deliberately excluded: it is overwhelmingly WAF / egress -// rate-limit / permission / account-verification — none of which mean the -// refresh token itself is invalid. -const OAUTH_HTTP_AUTH_REGEX = /\b401\b/; - -export function isDefinitiveOAuthFailure(errorMsg: string): boolean { - if (OAUTH_DEFINITIVE_FAILURE_REGEX.test(errorMsg)) return true; - if (OAUTH_HTTP_AUTH_REGEX.test(errorMsg) && !OAUTH_TRANSIENT_FAILURE_REGEX.test(errorMsg)) return true; - return false; -} +// Re-exported from the error module (its new home) to preserve the public +// `@oh-my-pi/pi-ai` entrypoint and the in-module call sites below. +export { isDefinitiveOAuthFailure } from "./error/auth-classify"; /** * Outcome of {@link AuthStorage.markUsageLimitReached}. @@ -811,11 +785,11 @@ function parseUsageCacheEntry(raw: string): UsageCacheEntry | undefined { */ function raceUsageWithSignal(promise: Promise, signal: AbortSignal | undefined): Promise { if (!signal) return promise; - if (signal.aborted) return Promise.reject(new Error("usage fetch aborted")); + if (signal.aborted) return Promise.reject(new AIError.AbortError("usage fetch aborted")); return new Promise((resolve, reject) => { const onAbort = (): void => { signal.removeEventListener("abort", onAbort); - reject(new Error("usage fetch aborted")); + reject(new AIError.AbortError("usage fetch aborted")); }; signal.addEventListener("abort", onAbort, { once: true }); promise.then( @@ -837,9 +811,9 @@ function raceCredentialRefreshWithSignal( message = "credential refresh aborted", ): Promise { if (!signal) return promise; - if (signal.aborted) return Promise.reject(new Error(message)); + if (signal.aborted) return Promise.reject(new AIError.AbortError(message)); const abort = Promise.withResolvers(); - const onAbort = (): void => abort.reject(new Error(message)); + const onAbort = (): void => abort.reject(new AIError.AbortError(message)); signal.addEventListener("abort", onAbort, { once: true }); return Promise.race([promise, abort.promise]).finally(() => { signal.removeEventListener("abort", onAbort); @@ -1901,7 +1875,7 @@ export class AuthStorage { // Built-in registry first, then runtime-registered extension providers. const def = getProviderDefinition(provider) ?? getOAuthProvider(provider); if (!def?.login) { - throw new Error(`Unknown OAuth provider: ${provider}`); + throw new AIError.ConfigurationError(`Unknown OAuth provider: ${provider}`); } const result = await def.login({ onAuth: ctrl.onAuth, @@ -2177,7 +2151,7 @@ export class AuthStorage { // (including its already-elapsed `resetsAt`). CAS-disable the row and // clear the cache so the credential drops out of the report instead of // freezing in place until the user notices and re-logs in. - if (isDefinitiveOAuthFailure(errorMsg)) { + if (AIError.isDefinitiveOAuthFailure(errorMsg)) { const credentialId = this.#findStoredCredentialIdForUsageCredential( request.provider, request.credential, @@ -3464,7 +3438,10 @@ export class AuthStorage { const customProvider = getOAuthProvider(provider); if (customProvider) { if (!customProvider.refreshToken) { - throw new Error(`OAuth provider "${provider}" does not support token refresh`); + throw new AIError.OAuthError(`OAuth provider "${provider}" does not support token refresh`, { + kind: "configuration", + provider, + }); } refreshPromise = customProvider.refreshToken(credential); } else { @@ -3478,14 +3455,20 @@ export class AuthStorage { let onAbort: (() => void) | undefined; const cancellation = Promise.withResolvers(); timeout = setTimeout( - () => cancellation.reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)), + () => + cancellation.reject( + new AIError.OAuthError(`OAuth token refresh timed out for provider: ${provider}`, { + kind: "timeout", + provider, + }), + ), DEFAULT_OAUTH_REFRESH_TIMEOUT_MS, ); if (signal) { if (signal.aborted) { - cancellation.reject(new Error("OAuth token refresh aborted by caller")); + cancellation.reject(new AIError.AbortError("OAuth token refresh aborted by caller")); } else { - onAbort = () => cancellation.reject(new Error("OAuth token refresh aborted by caller")); + onAbort = () => cancellation.reject(new AIError.AbortError("OAuth token refresh aborted by caller")); signal.addEventListener("abort", onAbort, { once: true }); } } @@ -3691,7 +3674,7 @@ export class AuthStorage { const errorMsg = String(error); // Only remove credentials for definitive auth failures // Keep credentials for transient errors (network, 5xx) and block temporarily - const isDefinitiveFailure = isDefinitiveOAuthFailure(errorMsg); + const isDefinitiveFailure = AIError.isDefinitiveOAuthFailure(errorMsg); logger.warn("OAuth token refresh failed", { provider, @@ -4251,7 +4234,7 @@ export class AuthStorage { if (!sessionCredential) return false; const error = options?.error; - const status = extractHttpStatusFromError(error); + const status = AIError.status(error); const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; if (isUsageLimitOutcome(status, message)) { return ( @@ -4388,7 +4371,9 @@ export class AuthStorage { if (index === -1) continue; const target = entries[index]; if (target.credential.type !== "oauth") { - throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`); + throw new AIError.ValidationError( + `Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`, + ); } // The exact credential we are about to refresh — captured before the // await so a definitive failure can CAS-disable the row against the @@ -4404,7 +4389,7 @@ export class AuthStorage { // A definitively-dead grant tears the row down here, where the // attempted credential is known. CAS on the persisted credential so a // peer/login rotation in flight leaves the freshly-rotated row intact. - if (isDefinitiveOAuthFailure(String(error))) { + if (AIError.isDefinitiveOAuthFailure(String(error))) { // CAS-loss (false) means a peer/login rotated the row mid-refresh, so // our #data copy is stale — reload so the next caller serves the // freshly-rotated credential rather than the dead token we attempted. @@ -4437,7 +4422,7 @@ export class AuthStorage { // -1 means the row was disabled/removed mid-refresh — surface that as a // miss rather than implying a live row the snapshot won't contain. if (this.#replaceCredentialById(provider, id, updated) === -1) { - throw new Error(`No credential with id=${id}`); + throw new AIError.ValidationError(`No credential with id=${id}`); } return { id, @@ -4446,7 +4431,7 @@ export class AuthStorage { identityKey: resolveCredentialIdentityKey(provider, updated), }; } - throw new Error(`No credential with id=${id}`); + throw new AIError.ValidationError(`No credential with id=${id}`); } /** @@ -4869,7 +4854,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { } } } - throw new Error( + throw new AIError.ConfigurationError( `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`, { cause: lastBusyError }, ); diff --git a/packages/ai/src/error/abort.ts b/packages/ai/src/error/abort.ts new file mode 100644 index 000000000..8d6b89942 --- /dev/null +++ b/packages/ai/src/error/abort.ts @@ -0,0 +1,18 @@ +import { attach, create, Flag } from "./flags"; + +/** + * A request was cancelled — by the caller's `AbortSignal` or a provider-local + * watchdog. Carries the {@link Flag.Abort} classification structurally so retry + * logic does not have to regex the message text. + * + * The default message is kept byte-identical to the historical + * `"Request was aborted"` string so any remaining text-based matchers keep + * working through the migration. + */ +export class AbortError extends Error { + constructor(message = "Request was aborted", options?: { cause?: unknown }) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = "AbortError"; + attach(this, create(Flag.Abort)); + } +} diff --git a/packages/ai/src/error/auth-classify.ts b/packages/ai/src/error/auth-classify.ts new file mode 100644 index 000000000..2245007fa --- /dev/null +++ b/packages/ai/src/error/auth-classify.ts @@ -0,0 +1,30 @@ +import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; +import { isOAuthExpiry } from "./flags"; +import { isUsageLimitOutcome } from "./rate-limit"; + +/** + * Whether an OAuth refresh failure is definitive (the credential must be + * disabled) versus transient. Thin alias over the {@link Flag.OAuthExpiry} + * text classifier {@link isOAuthExpiry}; retained as the public + * `@oh-my-pi/pi-ai` entrypoint name used by the coding agent and auth-broker. + */ +export function isDefinitiveOAuthFailure(errorMsg: string): boolean { + return isOAuthExpiry(errorMsg); +} + +/** + * Whether an upstream failure should rotate to a sibling credential: a hard + * `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic + * account rate-limit, Google `resource_exhausted`, OpenAI `insufficient_quota`, + * …), or a bare `429` whose payload did not preserve a richer quota code. + * Transient 429s (`Too many requests`, per-minute caps) stay in the + * upstream-backoff lane. + */ +export function isAuthRetryableError(error: unknown): boolean { + const httpStatus = extractHttpStatusFromError(error); + if (httpStatus === 401) return true; + const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; + const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined; + if (embeddedStatus === 401) return true; + return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message); +} diff --git a/packages/ai/src/error/auth.ts b/packages/ai/src/error/auth.ts new file mode 100644 index 000000000..e2b54800b --- /dev/null +++ b/packages/ai/src/error/auth.ts @@ -0,0 +1,48 @@ +import { attach, create, Flag } from "./flags"; + +/** + * No API key / credential was available to dispatch a request. + * + * The default message preserves the historical `"No API key for provider: X"` + * wording, which {@link Flag.AuthFailed}'s regex (`no api key`) keys off — but + * the flag is also attached structurally so classification never depends on the + * exact phrasing. + */ +export class MissingApiKeyError extends Error { + readonly provider: string | undefined; + + constructor(provider?: string, message?: string) { + super(message ?? (provider ? `No API key for provider: ${provider}` : "No API key available")); + this.name = "MissingApiKeyError"; + this.provider = provider; + attach(this, create(Flag.AuthFailed)); + } +} + +/** A user-facing login flow required an `onPrompt` callback that was not supplied. */ +export class OnPromptRequiredError extends Error { + constructor(providerLabel: string) { + super(`${providerLabel} login requires onPrompt callback`); + this.name = "OnPromptRequiredError"; + } +} + +/** An interactive login asked for an API key but the user supplied an empty value. */ +export class ApiKeyRequiredError extends Error { + constructor(message = "API key is required") { + super(message); + this.name = "ApiKeyRequiredError"; + } +} + +/** + * A user cancelled an interactive login / device flow. Classified as an abort + * so it is never surfaced as a retryable transient failure. + */ +export class LoginCancelledError extends Error { + constructor(message = "Login cancelled") { + super(message); + this.name = "LoginCancelledError"; + attach(this, create(Flag.Abort)); + } +} diff --git a/packages/ai/src/error/aws.ts b/packages/ai/src/error/aws.ts new file mode 100644 index 000000000..1fa0b0c48 --- /dev/null +++ b/packages/ai/src/error/aws.ts @@ -0,0 +1,31 @@ +/** Which AWS credential-resolution path failed. */ +export type AwsCredentialsErrorKind = + /** No usable credential source resolved (chain exhausted). */ + | "resolution" + /** SSO cache token missing (`aws sso login` not run). */ + | "sso-token-missing" + /** SSO cache token present but expired. */ + | "sso-token-expired" + /** SSO `GetRoleCredentials` call failed or returned no role. */ + | "sso-role" + /** External `credential_process` failed, timed out, or emitted bad output. */ + | "credential-process"; + +/** A failure resolving AWS credentials for the Bedrock provider. */ +export class AwsCredentialsError extends Error { + readonly kind: AwsCredentialsErrorKind; + + constructor(message: string, kind: AwsCredentialsErrorKind, options?: { cause?: unknown }) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = "AwsCredentialsError"; + this.kind = kind; + } +} + +/** A malformed AWS event-stream frame (bad length, CRC mismatch, unknown header type). */ +export class EventStreamFrameError extends Error { + constructor(detail: string) { + super(`eventstream: ${detail}`); + this.name = "EventStreamFrameError"; + } +} diff --git a/packages/ai/src/error/classes.ts b/packages/ai/src/error/classes.ts new file mode 100644 index 000000000..09b14f7f3 --- /dev/null +++ b/packages/ai/src/error/classes.ts @@ -0,0 +1,186 @@ +import type { CapturedHttpErrorResponse } from "../utils/http-inspector"; + +/** Prefix on errors raised when an Anthropic SSE stream envelope is malformed. */ +export const STREAM_ENVELOPE_ERROR_PREFIX = "Anthropic stream envelope error:"; + +/** Structured HTTP errors thrown by provider clients. */ +export interface ProviderHttpErrorOptions { + /** Response headers; enables `retry-after`/rate-limit extraction downstream. */ + headers?: Headers; + /** Machine-readable error code from the response body (`error.code` / `error.type`). */ + code?: string; + cause?: unknown; +} + +/** Non-2xx HTTP response from a provider. */ +export class ProviderHttpError extends Error { + readonly status: number; + readonly headers: Headers | undefined; + readonly code: string | undefined; + + constructor(message: string, status: number, options?: ProviderHttpErrorOptions) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = "ProviderHttpError"; + this.status = status; + this.headers = options?.headers; + this.code = options?.code; + } +} + +/** Non-2xx response from an OpenAI-wire endpoint, with the decoded body attached. */ +export class OpenAIHttpError extends ProviderHttpError { + readonly captured: CapturedHttpErrorResponse; + + constructor(message: string, captured: CapturedHttpErrorResponse, code?: string, cause?: unknown) { + super(message, captured.status, { headers: captured.headers, code, cause }); + this.name = "OpenAIHttpError"; + this.captured = captured; + } + + /** + * Pull a human-readable message and machine code out of an OpenAI-style error + * envelope (`{ error: { message, code, type } }`), tolerating the flat shapes + * compat hosts return (`{ error: "..." }`, `{ message: "..." }`) and falling + * back to the raw body text. + */ + static parseEnvelope( + bodyJson: unknown, + bodyText: string | undefined, + ): { detail: string | undefined; code: string | undefined } { + if (typeof bodyJson === "object" && bodyJson !== null) { + const envelope = bodyJson as { error?: unknown; message?: unknown }; + const error = envelope.error; + if (typeof error === "object" && error !== null) { + const { message, code, type } = error as { message?: unknown; code?: unknown; type?: unknown }; + return { + detail: typeof message === "string" && message.length > 0 ? message : bodyText, + code: typeof code === "string" ? code : typeof type === "string" ? type : undefined, + }; + } + if (typeof error === "string" && error.length > 0) { + return { detail: error, code: undefined }; + } + if (typeof envelope.message === "string" && envelope.message.length > 0) { + return { detail: envelope.message, code: undefined }; + } + } + return { detail: bodyText, code: undefined }; + } +} + +/** Non-2xx response from the Anthropic API. */ +export class AnthropicApiError extends ProviderHttpError { + declare readonly headers: Headers; + readonly requestId: string | null; + + constructor(status: number, message: string, headers: Headers) { + super(message, status, { headers }); + this.name = "AnthropicApiError"; + this.requestId = headers.get("request-id"); + } + + static async fromResponse(response: Response): Promise { + const body = await response.text().catch(() => ""); + const detail = body.trim() || "status code (no body)"; + return new AnthropicApiError(response.status, `${response.status} ${detail}`, response.headers); + } +} + +/** Network-level failure (DNS, TLS, socket reset) after retries were exhausted. */ +export class AnthropicConnectionError extends Error { + constructor(cause: unknown) { + super("Connection error.", { cause }); + this.name = "AnthropicConnectionError"; + } +} + +/** No response headers arrived within the configured request timeout. */ +export class AnthropicConnectionTimeoutError extends Error { + constructor() { + super("Request timed out."); + this.name = "AnthropicConnectionTimeoutError"; + } +} + +/** + * A malformed Anthropic SSE stream envelope — events arriving out of order + * (before `message_start`) or otherwise violating the message-event grammar. + * The message is prefixed with {@link STREAM_ENVELOPE_ERROR_PREFIX} so the + * shared envelope predicates classify it. + */ +export class AnthropicStreamEnvelopeError extends Error { + constructor(detail: string) { + super(`${STREAM_ENVELOPE_ERROR_PREFIX} ${detail}`); + this.name = "AnthropicStreamEnvelopeError"; + } +} + +/** Non-2xx response (or in-stream exception event) from the Bedrock runtime API. */ +export class BedrockApiError extends ProviderHttpError { + override readonly name = "BedrockApiError"; +} + +/** Non-2xx response (or in-stream error chunk) from the Cloud Code Assist API. */ +export class GeminiCliApiError extends ProviderHttpError { + override readonly name = "GeminiCliApiError"; +} + +/** Non-2xx response (or in-stream error chunk) from the Google Generative Language / Vertex API. */ +export class GoogleApiError extends ProviderHttpError { + override readonly name = "GoogleApiError"; +} + +/** Non-2xx response from the Ollama `/api/chat` endpoint. */ +export class OllamaApiError extends ProviderHttpError { + override readonly name = "OllamaApiError"; +} + +/** Auth gateway HTTP failure. */ +export class AuthGatewayError extends ProviderHttpError { + constructor(message: string, status: number, headers?: Headers, code?: string) { + super(message, status, { headers, code }); + this.name = "AuthGatewayError"; + } +} + +export class CodexWebSocketTransportError extends Error { + constructor(detail: string) { + super(`Codex websocket transport failure: ${detail}`); + this.name = "CodexWebSocketTransportError"; + } +} + +export class CodexWhitespaceToolCallLoopError extends Error { + constructor(message: string) { + super(message); + this.name = "CodexWhitespaceToolCallLoopError"; + } +} + +export class CodexProviderStreamError extends Error { + readonly retryable: boolean; + + constructor(message: string, options?: { retryable?: boolean; cause?: unknown }) { + super(message, { cause: options?.cause }); + this.name = "CodexProviderStreamError"; + this.retryable = options?.retryable !== false; + } +} + +export class AuthBrokerError extends Error { + readonly status: number | undefined; + readonly body: string | undefined; + constructor(message: string, opts: { status?: number; body?: string; cause?: unknown } = {}) { + super(message, { cause: opts.cause }); + this.name = "AuthBrokerError"; + this.status = opts.status; + this.body = opts.body; + } +} + +export class AuthBrokerStreamUnsupportedError extends AuthBrokerError { + constructor(message = "Auth broker does not support /v1/snapshot/stream") { + super(message, { status: 404 }); + this.name = "AuthBrokerStreamUnsupportedError"; + } +} diff --git a/packages/ai/src/error/finalize.ts b/packages/ai/src/error/finalize.ts new file mode 100644 index 000000000..bfecce6b7 --- /dev/null +++ b/packages/ai/src/error/finalize.ts @@ -0,0 +1,69 @@ +import type { Api } from "../types"; +import type { AbortSourceTracker } from "../utils/abort"; +import type { CapturedHttpErrorResponse, RawHttpRequestDump } from "../utils/http-inspector"; +import { classify, classifyMessage, status } from "./flags"; +import { formatMessage } from "./format"; + +/** Context a provider catch block hands to {@link finalize}. */ +export interface FinalizeOptions { + /** Wire API, for api-specific text classification (e.g. stale-responses items). */ + api?: Api; + /** Provider id; forwarded to the message formatter for copilot rewrites. */ + provider?: string; + /** Caller signal, for providers that don't run an abort tracker. */ + signal?: AbortSignal; + /** Abort tracker, preferred over `signal`: distinguishes caller vs. local aborts. */ + abortTracker?: AbortSourceTracker; + /** Raw request, dumped into the message for 400-class failures. */ + rawRequestDump?: RawHttpRequestDump; + /** Captured non-2xx response body, used for status fallback and message detail. */ + capturedErrorResponse?: CapturedHttpErrorResponse; +} + +/** The full bundle a provider assigns onto its `AssistantMessage` error fields. */ +export interface FinalizeResult { + /** Structured flag id from {@link classify}. */ + id: number; + /** HTTP status, from the error or the captured response. */ + status: number | undefined; + /** `"aborted"` when the caller cancelled, otherwise `"error"`. */ + stopReason: "aborted" | "error"; + /** User-facing message from {@link formatMessage}, or a local abort reason. */ + message: string; +} + +/** + * Build the complete error bundle for a provider catch block, replacing the + * `stopReason` / `errorStatus` / `errorId` / `errorMessage` boilerplate. + * + * `stopReason` comes from the abort tracker (caller intent dominates) or, when + * no tracker is supplied, the raw `signal.aborted`. A local abort reason (e.g. a + * first-event timeout) supersedes the formatted message. Message formatting is + * wrapped so a formatter throw can never skip the caller's `stream.end()`. + */ +export async function finalize(error: unknown, opts: FinalizeOptions = {}): Promise { + const aborted = opts.abortTracker ? opts.abortTracker.wasCallerAbort() : opts.signal?.aborted === true; + const currentStatus = status(error) ?? opts.capturedErrorResponse?.status; + + let message: string; + try { + const localReason = opts.abortTracker?.getLocalAbortReason(); + message = localReason?.message ?? (await formatMessage(error, opts)); + } catch { + message = error instanceof Error ? error.message : String(error); + } + + const id = classifyMessage({ + api: opts.api, + errorId: classify(error, opts.api), + errorMessage: message, + errorStatus: currentStatus, + }); + + return { + id, + status: currentStatus, + stopReason: aborted ? "aborted" : "error", + message, + }; +} diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts new file mode 100644 index 000000000..8d7893e3d --- /dev/null +++ b/packages/ai/src/error/flags.ts @@ -0,0 +1,487 @@ +import { isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils"; +import { matchesUsageLimitText, parseRateLimitReason } from "./rate-limit"; +import type { Api, AssistantMessage } from "../types"; +import { + AnthropicConnectionError, + AnthropicConnectionTimeoutError, + ProviderHttpError, + STREAM_ENVELOPE_ERROR_PREFIX, +} from "./classes"; + +export const Flag = { + Class: 0x1000, + ThinkingLoop: 0x0001_0000, + Transient: 0x0002_0000, + Timeout: 0x0004_0000, + UsageLimit: 0x0008_0000, + StaleResponsesItem: 0x0010_0000, + MalformedFunctionCall: 0x0020_0000, + ProviderFinishError: 0x0040_0000, + ContextOverflow: 0x0080_0000, + AuthFailed: 0x0100_0000, + SilentAbort: 0x0200_0000, + UserInterrupt: 0x0400_0000, + Abort: 0x0800_0000, + /** Anthropic strict-tool grammar too large / schema too complex to compile (400). */ + Grammar: 0x1000_0000, + /** Anthropic model/account does not support fast mode / the `speed` parameter. */ + FastModeUnsupported: 0x2000_0000, + /** OAuth refresh failed definitively — the stored grant is dead, re-login required. */ + OAuthExpiry: 0x4000_0000, +} as const; + +export type Flag = (typeof Flag)[keyof typeof Flag]; + +const KIND_MASK = + Flag.ThinkingLoop | + Flag.Transient | + Flag.Timeout | + Flag.UsageLimit | + Flag.StaleResponsesItem | + Flag.MalformedFunctionCall | + Flag.ProviderFinishError | + Flag.ContextOverflow | + Flag.AuthFailed | + Flag.SilentAbort | + Flag.UserInterrupt | + Flag.Abort | + Flag.Grammar | + Flag.FastModeUnsupported | + Flag.OAuthExpiry; + +const RETRIABLE_KINDS = + Flag.Transient | Flag.UsageLimit | Flag.ThinkingLoop | Flag.StaleResponsesItem | Flag.ProviderFinishError; + +const OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions & Responses API) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter (all backends) + /exceeds the limit of \d+/i, // GitHub Copilot + /exceeds the available context size/i, // llama.cpp server + /requested tokens?.*exceed.*context (window|length|size)/i, // llama.cpp / OpenAI-compatible local servers + /context (window|length|size).*(exceeded|overflow|too small)/i, // Generic local server variants + /(prompt|input).*(too long|too large).*(context|n_ctx)/i, // llama.cpp phrasing variants + /requested tokens?.*(exceeds?|greater than).*(n_ctx|context)/i, // llama.cpp n_ctx variants + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding + /context[_ ]length[_ ]exceeded/i, // Generic fallback + /too many tokens/i, // Generic fallback + /token limit exceeded/i, // Generic fallback + /request_too_large/i, // Anthropic 413 (request body too large) + /request exceeds the maximum size/i, // Anthropic 413 variant + /payload too large/i, // Generic HTTP 413 variant + /entity too large/i, // Generic HTTP 413 variant + /\b413\b.*\b(request|payload|entity)\b.*\btoo large\b/i, // "413 Request Entity Too Large" variants + /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text + /prompt filled the context window/i, // Ollama OpenAI-compatible empty length completion +]; + +const OVERFLOW_NO_BODY_PATTERN = /\b4(00|13)\s*(status code)?\s*\(no body\)/i; +const TIMEOUT_PATTERN = /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i; +const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i; +const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i; +const TRANSIENT_TRANSPORT_PATTERN = + /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i; +const AUTH_FAILURE_PATTERN = + /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i; +const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i; +const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i; +const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const; +const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i; + +// Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the +// error, also surfaced in text). Treated as transient — a retry usually lands +// on a backend that has the model. +const COPILOT_MODEL_NOT_SUPPORTED_PATTERN = /model_not_supported/i; +// Anthropic strict-tool grammar too large / schema too complex (400 invalid_request_error). +const GRAMMAR_TOO_LARGE_PATTERN = /compiled grammar/i; +const GRAMMAR_TOO_LARGE_DETAIL_PATTERN = /too large/i; +const SCHEMA_TOO_COMPLEX_PATTERN = /schema/i; +const SCHEMA_TOO_COMPLEX_DETAIL_PATTERN = /too complex/i; +const SCHEMA_COMPILE_PATTERN = /compil/i; +const INVALID_REQUEST_PATTERN = /invalid_request_error/i; +// Anthropic fast-mode unsupported: 400 rejecting `speed`, or 429 rate_limit_error +// because the account lacks the extra-usage entitlement fast mode requires. +const FAST_MODE_SPEED_PARAM_PATTERN = /\bspeed\b/i; +const FAST_MODE_NOT_SUPPORTED_PATTERN = /not support/i; +const FAST_MODE_RATE_LIMIT_PATTERN = /rate_limit_error/i; +const FAST_MODE_ENTITLEMENT_PATTERN = /fast mode/i; +// Definitive OAuth refresh failure — the stored grant/client is dead. +const OAUTH_DEFINITIVE_FAILURE_PATTERN = + /invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i; +const OAUTH_TRANSIENT_FAILURE_PATTERN = + /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i; +const OAUTH_HTTP_AUTH_PATTERN = /\b401\b/; + +function matchesGrammarTooLarge(message: string, errorStatus: number | undefined): boolean { + if (errorStatus !== 400) return false; + if (!INVALID_REQUEST_PATTERN.test(message)) return false; + const grammarTooLarge = GRAMMAR_TOO_LARGE_PATTERN.test(message) && GRAMMAR_TOO_LARGE_DETAIL_PATTERN.test(message); + const schemaTooComplex = + SCHEMA_TOO_COMPLEX_PATTERN.test(message) && + SCHEMA_TOO_COMPLEX_DETAIL_PATTERN.test(message) && + SCHEMA_COMPILE_PATTERN.test(message); + return grammarTooLarge || schemaTooComplex; +} + +function matchesFastModeUnsupported(message: string, errorStatus: number | undefined): boolean { + if (errorStatus !== 400 && errorStatus !== 429) return false; + if ( + errorStatus === 400 && + INVALID_REQUEST_PATTERN.test(message) && + FAST_MODE_SPEED_PARAM_PATTERN.test(message) && + FAST_MODE_NOT_SUPPORTED_PATTERN.test(message) + ) { + return true; + } + return errorStatus === 429 && FAST_MODE_RATE_LIMIT_PATTERN.test(message) && FAST_MODE_ENTITLEMENT_PATTERN.test(message); +} + +/** Whether an OAuth refresh error message means the grant is definitively dead. */ +export function isOAuthExpiry(errorMessage: string): boolean { + if (OAUTH_DEFINITIVE_FAILURE_PATTERN.test(errorMessage)) return true; + return OAUTH_HTTP_AUTH_PATTERN.test(errorMessage) && !OAUTH_TRANSIENT_FAILURE_PATTERN.test(errorMessage); +} + +const ERROR_KIND_LABELS: readonly [Flag, string][] = [ + [Flag.ThinkingLoop, "thinking-loop"], + [Flag.Transient, "transient"], + [Flag.Timeout, "timeout"], + [Flag.UsageLimit, "usage-limit"], + [Flag.StaleResponsesItem, "stale-responses-item"], + [Flag.MalformedFunctionCall, "malformed-function-call"], + [Flag.ProviderFinishError, "provider-finish-error"], + [Flag.ContextOverflow, "context-overflow"], + [Flag.AuthFailed, "auth-failed"], + [Flag.SilentAbort, "silent-abort"], + [Flag.UserInterrupt, "user-interrupt"], + [Flag.Abort, "abort"], +]; + +const STATUS_MESSAGE_PATTERNS = [ + /\bstatus(?:_code)?[:=]\s*(\d{3})\b/i, + /\bstatus\s+(\d{3})\b/i, + /\bHTTP\s+(\d{3})\b/i, + /\b(?:error|failed)\s+(\d{3})\b/i, + /(?:^|\s)(\d{3})\s+(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)/, +] as const; + +export function create(...flags: number[]): number { + let bits = 0; + for (const f of flags) bits |= f; + return bits | Flag.Class; +} + +export function is(id: number | undefined, flag: Flag): boolean { + return ((id ?? 0) & flag) !== 0; +} + +export function retriable(id: number | undefined, opts?: { replayUnsafe?: boolean }): boolean { + if (is(id, Flag.MalformedFunctionCall)) return true; + if (opts?.replayUnsafe) return false; + return ((id ?? 0) & RETRIABLE_KINDS) !== 0; +} + +function isClassified(id: number | undefined): boolean { + return ((id ?? 0) & Flag.Class) !== 0; +} + +function statusFromId(id: number | undefined): number | undefined { + return id && !isClassified(id) ? id : undefined; +} + +export function status(error: unknown): number | undefined { + return statusInternal(error, 0); +} + +function statusInternal(error: unknown, depth: number): number | undefined { + if (depth > 2 || error === undefined || error === null) return undefined; + if (typeof error === "object") { + const errObj = error as Record; + + if (typeof errObj.status === "number" && errObj.status >= 100 && errObj.status <= 599) { + return errObj.status; + } + if (typeof errObj.statusCode === "number" && errObj.statusCode >= 100 && errObj.statusCode <= 599) { + return errObj.statusCode; + } + if (typeof errObj.response === "object" && errObj.response !== null) { + const resp = errObj.response as Record; + if (typeof resp.status === "number" && resp.status >= 100 && resp.status <= 599) { + return resp.status; + } + } + + if ("cause" in errObj) { + const nested = statusInternal(errObj.cause, depth + 1); + if (nested !== undefined) return nested; + } + } + + if (error instanceof Error || (typeof error === "object" && error !== null && "message" in error)) { + const message = (error as { message: string }).message; + if (typeof message === "string") { + for (const pattern of STATUS_MESSAGE_PATTERNS) { + const match = pattern.exec(message); + if (match) { + const code = parseInt(match[1], 10); + if (code >= 100 && code <= 599) return code; + } + } + } + } + return undefined; +} + +function isTransientErrorText(text: string): boolean { + return ( + isUnexpectedSocketCloseMessage(text) || + (TRANSIENT_ENVELOPE_PATTERN.test(text) && TRANSIENT_ENVELOPE_BEFORE_START_PATTERN.test(text)) || + TRANSIENT_TRANSPORT_PATTERN.test(text) + ); +} + +function isTimeoutText(text: string): boolean { + return TIMEOUT_PATTERN.test(text); +} + +function isAuthFailureText(text: string): boolean { + return AUTH_FAILURE_PATTERN.test(text); +} + +function isStaleResponsesText(text: string): boolean { + return ( + STALE_RESPONSE_ITEM_PATTERNS[0].test(text) || + (STALE_RESPONSE_ITEM_PATTERNS[1].test(text) && STALE_RESPONSE_ITEM_DETAIL_PATTERN.test(text)) + ); +} + +function isMalformedFunctionCallText(text: string): boolean { + return MALFORMED_FUNCTION_CALL_PATTERN.test(text); +} + +function isProviderFinishErrorText(text: string): boolean { + return PROVIDER_FINISH_ERROR_PATTERN.test(text); +} + +function matchesOverflowText(text: string): boolean { + return OVERFLOW_PATTERNS.some(p => p.test(text)) || OVERFLOW_NO_BODY_PATTERN.test(text); +} + +function classifyText(errorMessage: string | undefined, errorStatus: number | undefined, api?: Api): number { + let kinds = 0; + if (errorMessage) { + if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow; + if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall; + if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError; + if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed; + + const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined); + const cleanMessage = errorMessage; + const cleaned = cleanMessage + .replace(/\b429\b/g, "") + .replace(/\b(?:http|https|status|error|code|response|message)\b/gi, ""); + const isOpaque = !/[a-z\d]{3,}/i.test(cleaned); + + const isLimitStatus = statusClean === 429; + if ( + matchesUsageLimitText(cleanMessage) || + (isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED")) + ) { + kinds |= Flag.UsageLimit; + } + + if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout; + else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient; + if ((api === "openai-responses" || api === "openai-codex-responses") && isStaleResponsesText(errorMessage)) { + kinds |= Flag.StaleResponsesItem; + } + + // Copilot per-client routing flap is transient. + if (statusClean === 400 && COPILOT_MODEL_NOT_SUPPORTED_PATTERN.test(cleanMessage)) kinds |= Flag.Transient; + if (matchesGrammarTooLarge(cleanMessage, statusClean)) kinds |= Flag.Grammar; + if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported; + } + if (kinds !== 0) return create(kinds); + const fallbackStatus = errorStatus ?? (errorMessage ? status({ message: errorMessage }) : undefined); + if (fallbackStatus === 401 || fallbackStatus === 403) return create(Flag.AuthFailed); + return fallbackStatus ?? 0; +} + +export function classify(error: unknown, api?: Api): number { + let kinds = 0; + const seen = new Set(); + let link: unknown = error; + while (link !== undefined && link !== null) { + if (typeof link === "object") { + if (seen.has(link)) break; + seen.add(link); + + if ("errorId" in link && typeof (link as { errorId: unknown }).errorId === "number") { + kinds |= (link as { errorId: number }).errorId & KIND_MASK; + } + } + + if (link instanceof AnthropicConnectionTimeoutError) { + kinds |= Flag.Timeout | Flag.Transient; + } else if (link instanceof AnthropicConnectionError) { + kinds |= Flag.Transient; + } else if ( + typeof link === "object" && + "name" in link && + (link as { name: string }).name === "CodexWebSocketTransportError" + ) { + kinds |= Flag.Transient; + } else if ( + link instanceof Error && + link.name === "CodexProviderStreamError" && + "retryable" in link && + (link as { retryable: unknown }).retryable === true + ) { + kinds |= Flag.Transient; + } else if (link instanceof ProviderHttpError) { + let linkKinds = 0; + const { status: codeStatus, code } = link; + if (code === "usage_limit_reached" || code === "insufficient_quota") { + linkKinds |= Flag.UsageLimit; + } + if (code === "overloaded_error" || code === "rate_limit_error") { + linkKinds |= Flag.Transient; + } + if (codeStatus === 401 || codeStatus === 403) { + linkKinds |= Flag.AuthFailed; + } else if (codeStatus === 429) { + if ((linkKinds & Flag.UsageLimit) === 0) { + linkKinds |= Flag.Transient; + } + } else if (codeStatus >= 500) { + linkKinds |= Flag.Transient; + } + kinds |= linkKinds; + } + + let linkMessage: string | undefined; + if (link instanceof Error) { + linkMessage = link.message; + } else if (typeof link === "string") { + linkMessage = link; + } else if ( + typeof link === "object" && + "message" in link && + typeof (link as { message: unknown }).message === "string" + ) { + linkMessage = (link as { message: string }).message; + } + + const textId = classifyText(linkMessage, status(link), api); + kinds |= textId & KIND_MASK; + + link = typeof link === "object" && "cause" in link ? (link as { cause: unknown }).cause : undefined; + } + + return kinds !== 0 ? create(kinds) : (status(error) ?? 0); +} + +/** + * Whether an error (or message string) classifies as an account usage/quota + * limit — the persistent, credential-rotation-worthy kind. This is the public + * accessor for {@link Flag.UsageLimit}; prefer it over re-running message + * regexes at call sites. + */ +export function isUsageLimit(error: unknown, api?: Api): boolean { + return is(classify(error, api), Flag.UsageLimit); +} + +/** + * Anthropic strict-tool grammar too large / schema too complex to compile. + * Accessor for {@link Flag.Grammar}. + */ +export function isGrammarError(error: unknown): boolean { + return is(classify(error), Flag.Grammar); +} + +/** + * Anthropic model/account does not support fast mode / the `speed` parameter. + * Accessor for {@link Flag.FastModeUnsupported}. + */ +export function isFastModeUnsupported(error: unknown): boolean { + return is(classify(error), Flag.FastModeUnsupported); +} + +/** + * GitHub Copilot 400 `model_not_supported` routing flap — transient. Reads the + * structural `code` (and falls back to {@link Flag.Transient} text classification). + */ +export function isCopilotTransientModelError(error: unknown): boolean { + if (status(error) === 400 && error && typeof error === "object") { + const info = error as { code?: unknown; error?: { code?: unknown } | null }; + const code = typeof info.code === "string" ? info.code : info.error?.code; + if (code === "model_not_supported") return true; + } + return false; +} + +export function classifyMessage(message: { + api?: Api; + errorId?: number; + errorMessage?: string; + errorStatus?: number; +}): number { + const existingId = message.errorId; + const currentStatus = message.errorStatus ?? statusFromId(existingId); + const textId = classifyText(message.errorMessage, currentStatus, message.api); + + const kinds = ((existingId ?? 0) | textId) & KIND_MASK; + const id = kinds !== 0 ? create(kinds) : (statusFromId(textId) ?? statusFromId(existingId) ?? currentStatus ?? 0); + + message.errorId = id; + return id; +} + +export function attach(error: E, id: number): E { + Object.defineProperty(error, "errorId", { value: id, enumerable: false, configurable: true }); + return error; +} + +export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { + if (is(message.errorId, Flag.ContextOverflow)) return true; + if (contextWindow) { + const inputTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite; + if (inputTokens > contextWindow) return true; + } + return message.stopReason === "error" && !!message.errorMessage && matchesOverflowText(message.errorMessage); +} + +export function stringify(id: number | undefined): string { + if (!id) return "none"; + if (!isClassified(id)) return `status:${id}`; + const labels = ERROR_KIND_LABELS.filter(([kind]) => is(id, kind)).map(([, label]) => label); + return labels.length > 0 ? labels.join("|") : `classified:0x${id.toString(16)}`; +} + +const STREAM_PARSE_TRUNCATION_PATTERN = + /unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i; +const STREAM_EVENT_ORDER_PATTERN = /stream event order|before message_start/i; + +/** Transient stream corruption where the response was truncated mid-JSON. */ +export function isTransientStreamParseError(error: unknown): boolean { + return error instanceof Error && STREAM_PARSE_TRUNCATION_PATTERN.test(error.message); +} + +/** Any malformed stream-envelope error (prefix-tagged or out-of-order events). */ +export function isStreamEnvelopeError(error: unknown): boolean { + return ( + error instanceof Error && + (error.message.includes(STREAM_ENVELOPE_ERROR_PREFIX) || STREAM_EVENT_ORDER_PATTERN.test(error.message)) + ); +} + +/** Stream-envelope errors safe to retry against the provider (event ordering only). */ +export function isRetryableStreamEnvelopeError(error: unknown): boolean { + return error instanceof Error && STREAM_EVENT_ORDER_PATTERN.test(error.message); +} diff --git a/packages/ai/src/error/format.ts b/packages/ai/src/error/format.ts new file mode 100644 index 000000000..7e9765c8c --- /dev/null +++ b/packages/ai/src/error/format.ts @@ -0,0 +1,36 @@ +import { + type CapturedHttpErrorResponse, + finalizeErrorMessage, + type RawHttpRequestDump, + rewriteCopilotError, +} from "../utils/http-inspector"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; + +/** Inputs that steer {@link formatMessage}'s formatter selection. */ +export interface FormatMessageOptions { + /** When present, the raw request is dumped into the message for 400-class failures. */ + rawRequestDump?: RawHttpRequestDump; + /** Captured non-2xx response body, appended to the message when available. */ + capturedErrorResponse?: CapturedHttpErrorResponse; + /** Provider id; `"github-copilot"` triggers the copilot message rewrite. */ + provider?: string; +} + +/** + * Format a provider error into a user-facing message, unifying the three + * formatters: lightweight retry-after extraction, the raw-dump finalizer, and + * the copilot rewrite. + * + * Selection is driven by inputs, not a mode flag: a `rawRequestDump` routes + * through {@link finalizeErrorMessage} (retry-after + raw dump + captured body), + * otherwise the lightweight {@link formatErrorMessageWithRetryAfter} is used. + */ +export async function formatMessage(error: unknown, opts: FormatMessageOptions = {}): Promise { + let message = opts.rawRequestDump + ? await finalizeErrorMessage(error, opts.rawRequestDump, opts.capturedErrorResponse) + : formatErrorMessageWithRetryAfter(error); + if (opts.provider === "github-copilot") { + message = rewriteCopilotError(message, error, opts.provider); + } + return message; +} diff --git a/packages/ai/src/error/gateway.ts b/packages/ai/src/error/gateway.ts new file mode 100644 index 000000000..b07bb1c81 --- /dev/null +++ b/packages/ai/src/error/gateway.ts @@ -0,0 +1,96 @@ +import { isUsageLimit } from "./flags"; + +/** A gateway-facing classification of an arbitrary upstream/internal error. */ +export interface GatewayErrorClassification { + status: number; + type: string; + message: string; +} + +/** + * Classify an upstream / gateway-internal error into a status code and a + * format-neutral type. The order is intentional: + * + * 1. Honour an explicit numeric `status` property on the thrown error. + * 2. Parse a status code embedded in the message string. Provider errors + * virtually always carry one (`Google API error (400): …`, `HTTP 429`, + * `status=503`) and the embedded value is authoritative. + * 3. Fall through to **word-boundaried** substring heuristics. The old + * `lower.includes("rate")` test famously matched `GenerateContentRequest`, + * surfacing every Google 400 as a 429 `rate_limit_error`. The patterns here + * all require boundaries so they don't collide with provider field names. + */ +export function classifyGatewayError(err: unknown): GatewayErrorClassification { + const message = err instanceof Error ? err.message : String(err); + + // 1. Custom pi-ai errors may attach a numeric `status` property. + const statusProp = + typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number" + ? (err as { status: number }).status | 0 + : undefined; + if (statusProp !== undefined) return bucketStatus(statusProp, message); + + if (err instanceof Error && err.name === "AbortError") return { status: 499, type: "request_aborted", message }; + + // 2. Status code embedded in the message. Requires a contextual keyword + // (`HTTP`, `API error`, `status`, …) or a leading `(NNN)` token so we + // don't trip on incidental three-digit numbers ("took 200ms"). + const embedded = extractEmbeddedStatus(message); + if (embedded !== undefined) return bucketStatus(embedded, message); + + // 3. Word-boundaried substring heuristics. + if (/\baborted\b|\babort signal\b/i.test(message)) { + return { status: 499, type: "request_aborted", message }; + } + if ( + // Match rate-limit phrasings before auth wording: some providers + // describe throttling as "unauthorized due to rate limit". + // Keep boundaries so this does not collide with + // `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc. + /\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test( + message, + ) || + // Usage-limit phrasings emit no embedded status. Codex friendly text + // reads "You have hit your ChatGPT usage limit … Try again in ~158 + // min."; the central usage-limit classifier already encodes every known + // provider variant, so reuse it instead of forking the regex. Without + // this branch the classifier falls through to the default + // 502/upstream_error, which is what callers saw when their account + // hit its cap. + isUsageLimit(message) + ) { + return { status: 429, type: "rate_limit_error", message }; + } + if (/\b(?:unauthorized|forbidden)\b/i.test(message)) { + return { status: 401, type: "authentication_error", message }; + } + if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) { + return { status: 400, type: "invalid_request_error", message }; + } + return { status: 502, type: "upstream_error", message }; +} + +function bucketStatus(status: number, message: string): GatewayErrorClassification { + if (status === 401 || status === 403) return { status, type: "authentication_error", message }; + if (status === 429) return { status, type: "rate_limit_error", message }; + if (status >= 400 && status < 500) return { status, type: "invalid_request_error", message }; + if (status >= 500) return { status, type: "upstream_error", message }; + return { status: 502, type: "upstream_error", message }; +} + +/** + * Pull a status code from common error-message shapes. Returns undefined when + * no contextual keyword is present, so we never guess at incidental numbers. + */ +function extractEmbeddedStatus(message: string): number | undefined { + // `Google API error (400)`, `OpenAI API error (429): …`, `(503)` + // `HTTP 429: too many requests` + // `status: 503`, `status_code=429`, `status=400` + const re = /(?:\bHTTP\b|\bAPI error\b|\bstatus(?:[- _]?code)?\b)\s*[:=]?\s*\(?\s*(\d{3})\b|\((\d{3})\)/i; + const m = message.match(re); + if (!m) return undefined; + const raw = m[1] ?? m[2]; + if (!raw) return undefined; + const code = Number.parseInt(raw, 10); + return Number.isFinite(code) && code >= 100 && code < 600 ? code : undefined; +} diff --git a/packages/ai/src/error/index.ts b/packages/ai/src/error/index.ts new file mode 100644 index 000000000..4cdb1c3f5 --- /dev/null +++ b/packages/ai/src/error/index.ts @@ -0,0 +1,13 @@ +export * from "./abort"; +export * from "./auth"; +export * from "./auth-classify"; +export * from "./aws"; +export * from "./classes"; +export * from "./finalize"; +export * from "./flags"; +export * from "./format"; +export * from "./gateway"; +export * from "./oauth"; +export * from "./provider"; +export * from "./retryable"; +export * from "./validation"; diff --git a/packages/ai/src/error/oauth.ts b/packages/ai/src/error/oauth.ts new file mode 100644 index 000000000..1b261587e --- /dev/null +++ b/packages/ai/src/error/oauth.ts @@ -0,0 +1,55 @@ +import { attach, create, Flag } from "./flags"; + +/** + * What stage of an OAuth / device-code login flow failed. Discriminates the + * single {@link OAuthError} class so login flows don't each mint a bespoke + * error type. + */ +export type OAuthErrorKind = + /** Token-exchange / refresh / discovery HTTP response was non-2xx or unparseable. */ + | "http" + /** Response body was missing required fields (token, account id, endpoints, …). */ + | "validation" + /** Authorization-code → token exchange failed. */ + | "token-exchange" + /** Refresh-token grant failed. */ + | "token-refresh" + /** Device-code / authorization polling failed (server error, too many retries). */ + | "polling" + /** The flow exceeded its deadline (device-code expiry, polling timeout). */ + | "timeout" + /** Device authorization was denied or cancelled by the user/provider. */ + | "device-auth" + /** Misconfiguration (bad redirect URI, missing projectId, callback bind, …). */ + | "configuration" + /** Cloud project provisioning / onboarding (loadCodeAssist, onboardUser). */ + | "provisioning" + /** OIDC / endpoint discovery failed. */ + | "discovery"; + +export interface OAuthErrorOptions { + kind?: OAuthErrorKind; + provider?: string; + status?: number; + cause?: unknown; +} + +/** + * A failure inside an interactive OAuth / device-code login flow. The `kind` + * pinpoints the stage. Timeout/polling are classified transient; everything + * else is a hard auth failure so the credential layer does not silently retry. + */ +export class OAuthError extends Error { + readonly kind: OAuthErrorKind; + readonly provider: string | undefined; + readonly status: number | undefined; + + constructor(message: string, options: OAuthErrorOptions = {}) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = "OAuthError"; + this.kind = options.kind ?? "http"; + this.provider = options.provider; + this.status = options.status; + attach(this, this.kind === "timeout" || this.kind === "polling" ? create(Flag.Transient) : create(Flag.AuthFailed)); + } +} diff --git a/packages/ai/src/error/provider.ts b/packages/ai/src/error/provider.ts new file mode 100644 index 000000000..8c9ad63de --- /dev/null +++ b/packages/ai/src/error/provider.ts @@ -0,0 +1,56 @@ +import { attach, create, Flag } from "./flags"; +import { ProviderHttpError } from "./classes"; + +/** Which part of a provider exchange produced a non-HTTP error. */ +export type ProviderResponseErrorKind = + /** Stream closed before a terminal completion/response event. */ + | "incomplete-stream" + /** Terminal event carried an error / unexpected stop reason. */ + | "output" + /** Response body was empty/missing when content was required. */ + | "empty-body" + /** Malformed wire envelope (unexpected message ordering / shape). */ + | "envelope" + /** Content was blocked by a provider safety filter. */ + | "content-blocked" + /** Runtime/namespace resolution or other provider-internal failure. */ + | "runtime"; + +export interface ProviderResponseErrorOptions { + provider?: string; + kind?: ProviderResponseErrorKind; + cause?: unknown; +} + +/** + * A non-HTTP provider failure: a truncated stream, an error stop reason, an + * empty body, a malformed envelope, or a runtime fault. For non-2xx HTTP + * responses use {@link ProviderHttpError} (or a provider subclass) instead. + */ +export class ProviderResponseError extends Error { + readonly provider: string | undefined; + readonly kind: ProviderResponseErrorKind; + + constructor(message: string, options: ProviderResponseErrorOptions = {}) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = "ProviderResponseError"; + this.provider = options.provider; + this.kind = options.kind ?? "output"; + if (this.kind === "content-blocked") attach(this, create(Flag.ProviderFinishError)); + } +} + +/** Non-2xx response from the Devin API. */ +export class DevinApiError extends ProviderHttpError { + override readonly name = "DevinApiError"; +} + +/** Non-2xx response from the GitLab Duo direct-access API. */ +export class GitLabDuoApiError extends ProviderHttpError { + override readonly name = "GitLabDuoApiError"; +} + +/** Non-2xx response from the GitLab Duo Workflow API. */ +export class GitLabDuoWorkflowApiError extends ProviderHttpError { + override readonly name = "GitLabDuoWorkflowApiError"; +} diff --git a/packages/ai/src/rate-limit-utils.ts b/packages/ai/src/error/rate-limit.ts similarity index 92% rename from packages/ai/src/rate-limit-utils.ts rename to packages/ai/src/error/rate-limit.ts index b2772cec1..f57680308 100644 --- a/packages/ai/src/rate-limit-utils.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -131,7 +131,7 @@ export function isUsageLimitStatus(status: number | undefined): boolean { * credentials. */ export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean { - if (message && isUsageLimitError(message)) return true; + if (message && matchesUsageLimitText(message)) return true; if (!isUsageLimitStatus(status)) return false; if (!message || isOpaqueStatusBody(message)) return true; return parseRateLimitReason(message) === "QUOTA_EXHAUSTED"; @@ -150,6 +150,12 @@ function isOpaqueStatusBody(message: string): boolean { return !/[a-z\d]{3,}/i.test(cleaned); } -export function isUsageLimitError(errorMessage: string): boolean { +/** + * Internal text matcher for usage/quota-limit phrasing. NOT part of the public + * API — callers classify through {@link import("./flags").isUsageLimit} (the + * flag accessor). `flags.ts` consumes this to populate `Flag.UsageLimit`, and + * {@link isUsageLimitOutcome} uses it for the account-rotation decision. + */ +export function matchesUsageLimitText(errorMessage: string): boolean { return USAGE_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage); } diff --git a/packages/ai/src/error/retryable.ts b/packages/ai/src/error/retryable.ts new file mode 100644 index 000000000..204485cd8 --- /dev/null +++ b/packages/ai/src/error/retryable.ts @@ -0,0 +1,48 @@ +import { isRetryableError, isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils"; +import { isRetryableStreamEnvelopeError, isTransientStreamParseError, isUsageLimit, status } from "./flags"; + +const PROVIDER_TRANSIENT_PATTERN = + /rate.?limit|too many requests|overloaded|service.?unavailable|internal_error|server_error|bad record mac|stream error.*received from peer|1302|timed?\s*out while waiting for the first event|timeout waiting for first/i; + +function isTransientTransportMessage(message: string): boolean { + return message.includes("tls: bad record mac") || message.includes("type=server_error"); +} + +/** Hook for provider-specific transient detection that the error module must not import directly. */ +export interface ProviderRetryableHooks { + /** Provider id of the failing request, used to gate provider-specific checks. */ + provider?: string; + /** Provider-specific transient predicate (e.g. Copilot `model_not_supported`). */ + isProviderTransient?: (error: Error) => boolean; +} + +/** + * Whether a provider stream error should be retried against the same credential. + * + * Account-level usage/quota limits are deliberately treated as **non**-retryable + * here — they are owned by the credential-rotation layer (auth-gateway / + * `streamSimple` a/b/c policy), not this seconds-scale provider backoff. + * + * Provider-specific transient cases are injected via {@link ProviderRetryableHooks} + * so this stays free of provider imports. + */ +export function isProviderRetryableError(error: unknown, hooks: ProviderRetryableHooks = {}): boolean { + if (!(error instanceof Error)) return false; + if (hooks.isProviderTransient?.(error)) return true; + if (isUsageLimit(error)) return false; + const httpStatus = status(error); + if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && httpStatus !== 408 && httpStatus !== 429) { + return false; + } + const msg = error.message.toLowerCase(); + if ( + isUnexpectedSocketCloseMessage(msg) || + isTransientTransportMessage(msg) || + PROVIDER_TRANSIENT_PATTERN.test(msg) || + isTransientStreamParseError(error) || + isRetryableStreamEnvelopeError(error) + ) { + return true; + } + return isRetryableError(error); +} diff --git a/packages/ai/src/error/validation.ts b/packages/ai/src/error/validation.ts new file mode 100644 index 000000000..5ea76c931 --- /dev/null +++ b/packages/ai/src/error/validation.ts @@ -0,0 +1,44 @@ +import { attach, create, Flag } from "./flags"; + +/** + * Caller-supplied input failed validation before/while building a provider + * request: bad request body, malformed tool arguments, unsupported content + * type, a schema that cannot be normalized, an unknown tool, etc. + * + * This is a programmer/config/contract error, not a transient provider fault — + * it is never retried. + */ +export class ValidationError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = "ValidationError"; + } +} + +/** A referenced tool was not found in the active tool set. */ +export class ToolNotFoundError extends ValidationError { + constructor(toolName: string) { + super(`Tool "${toolName}" not found`); + this.name = "ToolNotFoundError"; + } +} + +/** + * Provider/auth configuration was missing or malformed (env var pointing at a + * missing file, missing projectId, bad bind string, mTLS half-configured, …). + */ +export class ConfigurationError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = "ConfigurationError"; + } +} + +/** A request was abandoned because it exceeded a stream/idle/first-event deadline. */ +export class StreamTimeoutError extends Error { + constructor(message = "Request timed out.", options?: { cause?: unknown }) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = "StreamTimeoutError"; + attach(this, create(Flag.Transient, Flag.Timeout)); + } +} diff --git a/packages/ai/src/errors.ts b/packages/ai/src/errors.ts deleted file mode 100644 index 5f850d987..000000000 --- a/packages/ai/src/errors.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Structured HTTP errors thrown by provider clients. - * - * Downstream classification reads these fields structurally rather than via - * `instanceof`: `extractHttpStatusFromError` (pi-utils) reads `status`, - * `getHeadersFromError` (retry-after extraction) reads `headers`, and retry - * policies such as `isCopilotTransientModelError` read `code`. Per-provider - * subclasses exist so call sites can narrow with `instanceof` and logs carry - * a meaningful `error.name`. - */ -export interface ProviderHttpErrorOptions { - /** Response headers; enables `retry-after`/rate-limit extraction downstream. */ - headers?: Headers; - /** Machine-readable error code from the response body (`error.code` / `error.type`). */ - code?: string; - cause?: unknown; -} - -/** Non-2xx HTTP response from a provider endpoint. */ -export class ProviderHttpError extends Error { - readonly status: number; - readonly headers: Headers | undefined; - readonly code: string | undefined; - - constructor(message: string, status: number, options?: ProviderHttpErrorOptions) { - super(message, options?.cause === undefined ? undefined : { cause: options.cause }); - this.name = "ProviderHttpError"; - this.status = status; - this.headers = options?.headers; - this.code = options?.code; - } -} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 7b552d07f..8e0d7b03c 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -6,7 +6,6 @@ export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/serve export * from "./auth-gateway/types"; export * from "./auth-retry"; export * from "./auth-storage"; -export * from "./errors"; export * from "./provider-details"; export * from "./providers/anthropic"; export * from "./providers/anthropic-client"; @@ -24,7 +23,7 @@ export * from "./providers/openai-codex-responses"; export * from "./providers/openai-completions"; export * from "./providers/openai-responses"; export * from "./providers/synthetic"; -export * from "./rate-limit-utils"; +export * from "./error/rate-limit"; export * from "./registry"; export * from "./stream"; export * from "./types"; @@ -43,7 +42,6 @@ export * from "./usage/zai"; export * from "./utils/anthropic-auth"; export * from "./utils/event-stream"; export * from "./utils/openrouter-headers"; -export * from "./utils/overflow"; export * from "./utils/retry"; export * from "./utils/schema"; export * from "./utils/strip"; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 4c1817715..1e13480cf 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -10,16 +10,9 @@ import type { Effort } from "@oh-my-pi/pi-catalog/effort"; import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; -import { - $env, - $flag, - extractHttpStatusFromError, - fetchWithRetry, - parseStreamingJson, - parseStreamingJsonThrottled, -} from "@oh-my-pi/pi-utils"; +import { $env, $flag, fetchWithRetry, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils"; import { renderDemotedThinking } from "../dialect/demotion"; -import { ProviderHttpError } from "../errors"; +import * as AIError from "../error"; import type { Api, AssistantMessage, @@ -38,7 +31,7 @@ import type { } from "../types"; import { normalizeToolCallId, resolveCacheRetention } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump } from "../utils/http-inspector"; +import type { RawHttpRequestDump } from "../utils/http-inspector"; import { armPreResponseTimeout, getStreamFirstEventTimeoutMs } from "../utils/idle-iterator"; import { toolWireSchema } from "../utils/schema/wire"; import { stripVariant } from "../utils/strip"; @@ -47,11 +40,6 @@ import { decodeEventStream } from "./aws-eventstream"; import { signRequest } from "./aws-sigv4"; import { transformMessages } from "./transform-messages"; -/** Non-2xx response (or in-stream exception event) from the Bedrock runtime API. */ -export class BedrockApiError extends ProviderHttpError { - override readonly name = "BedrockApiError"; -} - export type BedrockThinkingDisplay = "summarized" | "omitted"; export interface BedrockOptions extends StreamOptions { @@ -423,11 +411,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( invalidateAwsCredentialCache({ profile: options.profile, region }); } const errBody = await response.text().catch(() => ""); - throw new BedrockApiError(`Bedrock HTTP ${response.status}: ${errBody.slice(0, 1000)}`, response.status, { - headers: response.headers, - }); + throw new AIError.BedrockApiError( + `Bedrock HTTP ${response.status}: ${errBody.slice(0, 1000)}`, + response.status, + { + headers: response.headers, + }, + ); } - if (!response.body) throw new Error("Bedrock response has no body"); + if (!response.body) throw new AIError.BedrockApiError("Bedrock response has no body", response.status); // Track first event for the abort/diagnostic path (currently informational). for await (const message of decodeEventStream(response.body)) { @@ -439,14 +431,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( const payload = safeParsePayload(message.payload) as { message?: string } | undefined; const errorMessage = payload?.message || new TextDecoder().decode(message.payload); const text = `${exceptionType}: ${errorMessage}`; - throw exceptionType === "validationException" - ? new BedrockApiError(text, 400, { code: exceptionType }) - : new Error(text); + throw new AIError.BedrockApiError(text, 400, { code: exceptionType }); } if (messageType === "error") { const code = message.headers[":error-code"] || "UnknownError"; const errorMessage = message.headers[":error-message"] || new TextDecoder().decode(message.payload); - throw new Error(`${code}: ${errorMessage}`); + throw new AIError.BedrockApiError(`${code}: ${errorMessage}`, 400, { code }); } if (messageType !== "event") continue; @@ -458,7 +448,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( // no-op: first event marker is implicit by stream entry. const ev = payload as MessageStartEvent; if (ev.role !== "assistant") { - throw new Error("Unexpected assistant message start but got user message start instead"); + throw new AIError.BedrockApiError( + "Unexpected assistant message start but got user message start instead", + 0, + ); } stream.push({ type: "start", partial: output }); break; @@ -498,10 +491,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( } } - if (options.signal?.aborted) throw new Error("Request was aborted"); + if (options.signal?.aborted) throw new AIError.AbortError(); if (output.stopReason === "error" || output.stopReason === "aborted") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); + throw new AIError.BedrockApiError(output.errorMessage ?? "An unknown error occurred", 0); } output.duration = performance.now() - startTime; @@ -513,8 +506,6 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( stripVariant(block, "index"); stripVariant(block, "partialJson"); } - output.stopReason = options.signal?.aborted ? "aborted" : "error"; - output.errorStatus = extractHttpStatusFromError(error); const baseMessage = error instanceof Error ? error.message : JSON.stringify(error); // Enrich error with thinking block diagnostics for signature-related failures let diagnostics = ""; @@ -536,7 +527,11 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( diagnostics = `\n[thinking-diag] ${JSON.stringify(thinkingBlocks)}`; } } - output.errorMessage = await appendRawHttpRequestDumpFor400(baseMessage + diagnostics, error, rawRequestDump); + const result = await AIError.finalize(error, { api: model.api, signal: options.signal, rawRequestDump }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message + diagnostics; output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); @@ -774,7 +769,7 @@ function convertMessages( contentBlocks.push({ image: createImageBlock(c.mimeType, c.data) }); break; default: - throw new Error("Unknown user content type"); + throw new AIError.ValidationError("Unknown user content type"); } } // Skip message if all blocks filtered out @@ -826,7 +821,7 @@ function convertMessages( } break; default: - throw new Error("Unknown assistant content type"); + throw new AIError.ValidationError("Unknown assistant content type"); } } // Skip if all content blocks were filtered out @@ -872,7 +867,7 @@ function convertMessages( break; } default: - throw new Error("Unknown message role"); + throw new AIError.ValidationError("Unknown message role"); } } @@ -1034,7 +1029,7 @@ function createImageBlock(mimeType: string, data: string): ImageBlockWire["image format = "webp"; break; default: - throw new Error(`Unknown image type: ${mimeType}`); + throw new AIError.ValidationError(`Unknown image type: ${mimeType}`); } return { source: { bytes: data }, format }; } diff --git a/packages/ai/src/providers/anthropic-client.ts b/packages/ai/src/providers/anthropic-client.ts index 336eeb35b..8d4be90e1 100644 --- a/packages/ai/src/providers/anthropic-client.ts +++ b/packages/ai/src/providers/anthropic-client.ts @@ -21,7 +21,11 @@ * with up to 25% jitter). */ import { scheduler } from "node:timers/promises"; -import { ProviderHttpError } from "../errors"; +import * as AIError from "../error"; +import { AnthropicApiError, AnthropicConnectionError, AnthropicConnectionTimeoutError } from "../error"; + +export { AnthropicApiError, AnthropicConnectionError, AnthropicConnectionTimeoutError }; + import type { FetchImpl } from "../types"; import type { MessageCreateParamsStreaming } from "./anthropic-wire"; @@ -77,42 +81,8 @@ export interface AnthropicClientOptions { fetchOptions?: AnthropicFetchOptions; } -/** Non-2xx response from the Anthropic API. */ -export class AnthropicApiError extends ProviderHttpError { - declare readonly headers: Headers; - readonly requestId: string | null; - - constructor(status: number, message: string, headers: Headers) { - super(message, status, { headers }); - this.name = "AnthropicApiError"; - this.requestId = headers.get("request-id"); - } - - static async fromResponse(response: Response): Promise { - const body = await response.text().catch(() => ""); - const detail = body.trim() || "status code (no body)"; - return new AnthropicApiError(response.status, `${response.status} ${detail}`, response.headers); - } -} - -/** Network-level failure (DNS, TLS, socket reset) after retries were exhausted. */ -export class AnthropicConnectionError extends Error { - constructor(cause: unknown) { - super("Connection error.", { cause }); - this.name = "AnthropicConnectionError"; - } -} - -/** No response headers arrived within the configured request timeout. */ -export class AnthropicConnectionTimeoutError extends Error { - constructor() { - super("Request timed out."); - this.name = "AnthropicConnectionTimeoutError"; - } -} - function createAbortError(): Error { - return new Error("Request was aborted."); + return new AIError.AbortError("Request was aborted."); } /** `x-should-retry` override, then 408/409/429/5xx. */ @@ -260,8 +230,8 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike { await this.#backoff(attempt, undefined, callerSignal); continue; } - if (error instanceof AnthropicConnectionTimeoutError) throw error; - throw new AnthropicConnectionError(error); + if (error instanceof AIError.AnthropicConnectionTimeoutError) throw error; + throw new AIError.AnthropicConnectionError(error); } if (response.ok) return response; @@ -271,7 +241,7 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike { await this.#backoff(attempt, response.headers, callerSignal); continue; } - throw await AnthropicApiError.fromResponse(response); + throw await AIError.AnthropicApiError.fromResponse(response); } } @@ -300,7 +270,7 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike { signal: controller.signal, }); } catch (error) { - if (timedOut && !callerSignal?.aborted) throw new AnthropicConnectionTimeoutError(); + if (timedOut && !callerSignal?.aborted) throw new AIError.AnthropicConnectionTimeoutError(); throw error; } finally { clearTimeout(timer); diff --git a/packages/ai/src/providers/anthropic-messages-server.ts b/packages/ai/src/providers/anthropic-messages-server.ts index 912f7596d..1133abf73 100644 --- a/packages/ai/src/providers/anthropic-messages-server.ts +++ b/packages/ai/src/providers/anthropic-messages-server.ts @@ -1,5 +1,6 @@ import { logger } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; +import * as AIError from "../error"; import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http"; import type { AssistantMessage, @@ -293,7 +294,7 @@ function deriveCacheRetention(data: { export function parseRequest(body: unknown, headers?: Headers): ParsedRequest { const data = anthropicMessagesRequestSchema(body); if (data instanceof type.errors) { - throw new Error(`anthropic-messages: ${data.summary}`); + throw new AIError.ValidationError(`anthropic-messages: ${data.summary}`); } const now = Date.now(); @@ -457,7 +458,10 @@ function encodeUsage(message: AssistantMessage): Record { export function encodeResponse(message: AssistantMessage, requestedModelId: string): Record { if (message.stopReason === "error" || message.stopReason === "aborted") { - throw new Error(message.errorMessage ?? `anthropic-messages: upstream ${message.stopReason}`); + throw new AIError.ProviderResponseError(message.errorMessage ?? `anthropic-messages: upstream ${message.stopReason}`, { + provider: "anthropic", + kind: "output", + }); } return { id: message.responseId ?? newMessageId(), diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 64f6555db..260fe628a 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -9,7 +9,6 @@ import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils"; import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot"; import { $env, - extractHttpStatusFromError, getInstallId, isEnoent, isRetryableError, @@ -20,7 +19,7 @@ import { readSseEvents, } from "@oh-my-pi/pi-utils"; import { renderDemotedThinking } from "../dialect/demotion"; -import { isUsageLimitError } from "../rate-limit-utils"; +import * as AIError from "../error"; import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream"; import type { Api, @@ -52,10 +51,9 @@ import { createAbortSourceTracker } from "../utils/abort"; import { withEmptyCompletionRetry } from "../utils/empty-completion-retry"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { isFoundryEnabled } from "../utils/foundry"; -import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector"; +import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator"; import { notifyProviderResponse } from "../utils/provider-response"; -import { isCopilotTransientModelError } from "../utils/retry"; import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema"; import { spillToDescription } from "../utils/schema/spill"; import { createSdkStreamRequestOptions } from "../utils/sdk-stream-timeout"; @@ -386,37 +384,6 @@ export function clearAnthropicFastModeFallback( } } -function isAnthropicStrictGrammarTooLargeError(error: unknown): boolean { - if (extractHttpStatusFromError(error) !== 400) return false; - const message = error instanceof Error ? error.message : String(error); - const isStrictGrammarTooLarge = /compiled grammar/i.test(message) && /too large/i.test(message); - const isSchemaCompilationTooComplex = - /schema/i.test(message) && /too complex/i.test(message) && /compil/i.test(message); - return /invalid_request_error/i.test(message) && (isStrictGrammarTooLarge || isSchemaCompilationTooComplex); -} - -export function isAnthropicFastModeUnsupportedError(error: unknown): boolean { - const status = extractHttpStatusFromError(error); - if (status !== 400 && status !== 429) return false; - const message = error instanceof Error ? error.message : String(error); - // 400 invalid_request_error — model doesn't accept `speed` at all. - // Observed: "'claude-opus-4-5-20251101' does not support the `speed` parameter." - // Stay tolerant of phrasing drift ("is not supported", quoted vs backticked field). - if ( - status === 400 && - /invalid_request_error/i.test(message) && - /\bspeed\b/i.test(message) && - /not support/i.test(message) - ) { - return true; - } - // 429 rate_limit_error — account lacks the extra-usage entitlement fast mode requires. - // Observed: "Extra usage is required for fast mode." - if (status === 429 && /rate_limit_error/i.test(message) && /fast mode/i.test(message)) { - return true; - } - return false; -} function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean { return params.tools?.some(tool => tool.strict === true) ?? false; @@ -1230,7 +1197,7 @@ function resolvePemValue(value: string | undefined, name: string): string | unde return fs.readFileSync(trimmed, "utf8"); } catch (error) { if (isEnoent(error)) { - throw new Error(`${name} path does not exist: ${trimmed}`); + throw new AIError.ValidationError(`${name} path does not exist: ${trimmed}`); } throw error; } @@ -1251,7 +1218,7 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl const key = resolvePemValue($env.CLAUDE_CODE_CLIENT_KEY, "CLAUDE_CODE_CLIENT_KEY"); if ((cert && !key) || (!cert && key)) { - throw new Error("Both CLAUDE_CODE_CLIENT_CERT and CLAUDE_CODE_CLIENT_KEY must be set for mTLS."); + throw new AIError.ConfigurationError("Both CLAUDE_CODE_CLIENT_CERT and CLAUDE_CODE_CLIENT_KEY must be set for mTLS."); } const options: FoundryTlsOptions = {}; @@ -1341,14 +1308,15 @@ function createAnthropicSseStreamError(data: string): Error { const errorType = typeof parsed?.error?.type === "string" ? parsed.error.type : undefined; const message = typeof parsed?.error?.message === "string" ? parsed.error.message : undefined; if (message) { - return new Error( + return new AIError.ProviderResponseError( errorType ? `Anthropic stream error (${errorType}): ${message}` : `Anthropic stream error: ${message}`, + { provider: "anthropic", kind: "output" }, ); } } catch { // Not a JSON envelope; fall through to the raw payload. } - return new Error(data); + return new AIError.ProviderResponseError(data, { provider: "anthropic", kind: "output" }); } async function* iterateAnthropicEvents( @@ -1357,7 +1325,7 @@ async function* iterateAnthropicEvents( onSseEvent?: AnthropicOptions["onSseEvent"], ): AsyncGenerator { if (!response.body) { - throw new Error("Attempted to iterate over an Anthropic response with no body"); + throw new AIError.AnthropicStreamEnvelopeError("Attempted to iterate over an Anthropic response with no body"); } let sawMessageStart = false; @@ -1446,7 +1414,7 @@ async function getAnthropicStreamResponse( const { data, response, request_id } = await request.withResponse(); return { events: data, response, requestId: request_id, recordsRawSseEvents: false }; } - throw new Error("Anthropic SDK request did not expose a stream response"); + throw new AIError.AnthropicStreamEnvelopeError("Anthropic SDK request did not expose a stream response"); } async function* observeDecodedAnthropicSdkEvents( @@ -1463,23 +1431,9 @@ async function* observeDecodedAnthropicSdkEvents( const PROVIDER_MAX_RETRIES = 10; -/** Transient stream corruption errors where the response was truncated mid-JSON. */ -function isTransientStreamParseError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return /unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i.test( - error.message, - ); -} - -const ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX = "Anthropic stream envelope error:"; - -function createAnthropicStreamEnvelopeError(message: string): Error { - return new Error(`${ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX} ${message}`); -} - /** * Log a malformed-stream-envelope anomaly without aborting the turn. The strict - * parser would `throw createAnthropicStreamEnvelopeError(...)` here; we instead + * parser would `throw new AnthropicStreamEnvelopeError(...)` here; we instead * surface a warning and let the caller skip the offending event (or finalize what * already streamed) so a non-conforming endpoint degrades to best-effort content * rather than failing the request. @@ -1494,49 +1448,18 @@ function shouldIgnoreAnthropicPreambleEvent(eventType: unknown): boolean { return !ANTHROPIC_MESSAGE_EVENTS.has(eventType); } -function isTransientStreamEnvelopeError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return ( - error.message.includes(ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX) || - /stream event order|before message_start/i.test(error.message) - ); -} - -function isProviderRetryableStreamEnvelopeError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return /stream event order|before message_start/i.test(error.message); -} - -function isAnthropicTransientTransportMessage(message: string): boolean { - return message.includes("tls: bad record mac") || message.includes("type=server_error"); -} - +/** + * Whether an Anthropic (or Copilot-over-Anthropic) stream error should be + * retried. The classification lives in {@link AIError.isProviderRetryableError}; + * this wrapper injects the Copilot-specific `model_not_supported` transient + * check, which the error module must not import directly. + */ export function isProviderRetryableError(error: unknown, provider?: string): boolean { - if (!(error instanceof Error)) return false; - if (provider === "github-copilot" && isCopilotTransientModelError(error)) return true; - // Account-level usage/quota limits ("usage_limit_reached", "exceed your - // account's rate limit", "quota exceeded") are persistent — the server - // parks the credential for minutes-to-hours (see the long `retry-after`). - // Retrying the same key with the provider's seconds-scale backoff never - // helps; these are owned by the credential-rotation layer (auth-gateway / - // `streamSimple` a/b/c policy), so surface them immediately instead of - // burning the retry budget here. - if (isUsageLimitError(error.message)) return false; - const status = extractHttpStatusFromError(error); - if (status !== undefined && status >= 400 && status < 500 && status !== 408 && status !== 429) return false; - const msg = error.message.toLowerCase(); - if ( - isUnexpectedSocketCloseMessage(msg) || - isAnthropicTransientTransportMessage(msg) || - /rate.?limit|too many requests|overloaded|service.?unavailable|internal_error|server_error|bad record mac|stream error.*received from peer|1302|timed?\s*out while waiting for the first event|timeout waiting for first/i.test( - msg, - ) || - isTransientStreamParseError(error) || - isProviderRetryableStreamEnvelopeError(error) - ) { - return true; - } - return isRetryableError(error); + return AIError.isProviderRetryableError(error, { + provider, + isProviderTransient: + provider === "github-copilot" ? (err): boolean => AIError.isCopilotTransientModelError(err) : undefined, + }); } const THINKING_ENVELOPE_OPEN = ""; @@ -1826,8 +1749,12 @@ const streamAnthropicOnce = ( // Provider-level transport/rate-limit failures: only before any streamed content starts. // Malformed envelopes/JSON: only before replay-unsafe text/tool events are visible on this stream. let providerRetryAttempt = 0; - const firstEventTimeoutAbortError = new Error("Anthropic stream timed out while waiting for the first event"); - const idleTimeoutAbortError = new Error("Anthropic stream stalled while waiting for the next event"); + const firstEventTimeoutAbortError = new AIError.StreamTimeoutError( + "Anthropic stream timed out while waiting for the first event", + ); + const idleTimeoutAbortError = new AIError.StreamTimeoutError( + "Anthropic stream stalled while waiting for the next event", + ); while (true) { activeAbortTracker = createAbortSourceTracker(options?.signal); const { requestSignal } = activeAbortTracker; @@ -1947,7 +1874,7 @@ const streamAnthropicOnce = ( if (shouldIgnoreAnthropicPreambleEvent(event.type)) { continue; } - throw createAnthropicStreamEnvelopeError(`received ${event.type} before message_start`); + throw new AIError.AnthropicStreamEnvelopeError(`received ${event.type} before message_start`); } if (event.type === "content_block_start") { @@ -2199,10 +2126,10 @@ const streamAnthropicOnce = ( throw firstEventTimeoutError; } if (activeAbortTracker.wasCallerAbort()) { - throw new Error("Request was aborted"); + throw new AIError.AbortError(); } if (!sawEvent || !sawMessageStart) { - throw createAnthropicStreamEnvelopeError("stream ended before message_start"); + throw new AIError.AnthropicStreamEnvelopeError("stream ended before message_start"); } if (!sawMessageStop) { reportAnthropicEnvelopeAnomaly("stream ended before message_stop"); @@ -2220,7 +2147,10 @@ const streamAnthropicOnce = ( } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); + throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", { + provider: model.provider, + kind: "output", + }); } break; } catch (streamError) { @@ -2229,7 +2159,7 @@ const streamAnthropicOnce = ( !disableStrictTools && firstTokenTime === undefined && hasStrictAnthropicTools(params) && - isAnthropicStrictGrammarTooLargeError(streamFailure) + AIError.isGrammarError(streamFailure) ) { // Log-only: the retried turn must not carry an errorMessage on // success (consumers treat its presence as failure). @@ -2256,7 +2186,7 @@ const streamAnthropicOnce = ( !dropFastMode && resolveServiceTier(options?.serviceTier, model.provider) === "priority" && firstTokenTime === undefined && - isAnthropicFastModeUnsupportedError(streamFailure) + AIError.isFastModeUnsupported(streamFailure) ) { logger.debug("anthropic: fast mode unsupported, retrying without speed", { model: model.id, @@ -2278,7 +2208,7 @@ const streamAnthropicOnce = ( continue; } const isTransientEnvelopeFailure = - isTransientStreamParseError(streamFailure) || isTransientStreamEnvelopeError(streamFailure); + AIError.isTransientStreamParseError(streamFailure) || AIError.isStreamEnvelopeError(streamFailure); const isLocalIdleTimeout = streamFailure === idleTimeoutAbortError || (streamFailure instanceof Error && streamFailure.message === idleTimeoutAbortError.message); @@ -2301,7 +2231,9 @@ const streamAnthropicOnce = ( // 429/529-style failures: retrying sooner than the server asked is a // guaranteed failure that just burns the retry budget. const headerDelayMs = - streamFailure instanceof AnthropicApiError ? retryDelayFromHeaders(streamFailure.headers) : undefined; + streamFailure instanceof Error && streamFailure instanceof AnthropicApiError + ? retryDelayFromHeaders(streamFailure.headers) + : undefined; const delayMs = headerDelayMs !== undefined ? Math.max(headerDelayMs, backoffDelayMs) : backoffDelayMs; if (options?.providerRetryWait) { await options.providerRetryWait(delayMs, options.signal); @@ -2331,18 +2263,16 @@ const streamAnthropicOnce = ( stripVariant<{ partialJson?: string }>(block, "partialJson"); stripVariant<{ lastParseLen?: number }>(block, "lastParseLen"); } - const firstEventTimeoutError = activeAbortTracker.getLocalAbortReason(); - output.stopReason = activeAbortTracker.wasCallerAbort() ? "aborted" : "error"; - output.errorStatus = extractHttpStatusFromError(error); - try { - output.errorMessage = - firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump)); - output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider); - } catch { - // finalizeErrorMessage must never take the stream down with it — a - // throw here would skip stream.end() and hang result() forever. - output.errorMessage = error instanceof Error ? error.message : String(error); - } + const result = await AIError.finalize(error, { + api: model.api, + provider: model.provider, + abortTracker: activeAbortTracker, + rawRequestDump, + }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); @@ -2633,7 +2563,7 @@ function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, maxAll const clampedBudget = raisedMaxTokens - OUTPUT_FALLBACK_BUFFER; if (clampedBudget <= 0) { - throw new Error( + throw new AIError.ConfigurationError( `Anthropic thinking budget requires max_tokens greater than ${OUTPUT_FALLBACK_BUFFER}; got ${raisedMaxTokens}`, ); } diff --git a/packages/ai/src/providers/aws-credentials.ts b/packages/ai/src/providers/aws-credentials.ts index 339e273e3..ecee8a71c 100644 --- a/packages/ai/src/providers/aws-credentials.ts +++ b/packages/ai/src/providers/aws-credentials.ts @@ -23,6 +23,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import type { FetchImpl } from "../types"; import { raceWithSignal } from "../utils/abort"; import type { AwsCredentials } from "./aws-sigv4"; @@ -111,9 +112,10 @@ async function resolveFresh( if (imdsCreds) return imdsCreds; } - throw new Error( + throw new AIError.AwsCredentialsError( `Unable to resolve AWS credentials. Set AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, ` + `or configure profile '${profile}' in ~/.aws/credentials (or ~/.aws/config for SSO).`, + "resolution", ); } @@ -245,11 +247,17 @@ async function readSsoCredentials( const token = await loadSsoCachedToken(startUrl, sessionName); if (!token?.accessToken) { - throw new Error(`AWS SSO token for ${startUrl} not found in ~/.aws/sso/cache. Run 'aws sso login' first.`); + throw new AIError.AwsCredentialsError( + `AWS SSO token for ${startUrl} not found in ~/.aws/sso/cache. Run 'aws sso login' first.`, + "sso-token-missing", + ); } const expiresAt = token.expiresAt ? Date.parse(token.expiresAt) : Number.POSITIVE_INFINITY; if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) { - throw new Error(`AWS SSO token for ${startUrl} has expired. Run 'aws sso login' to refresh.`); + throw new AIError.AwsCredentialsError( + `AWS SSO token for ${startUrl} has expired. Run 'aws sso login' to refresh.`, + "sso-token-expired", + ); } const url = @@ -263,13 +271,17 @@ async function readSsoCredentials( }); if (!response.ok) { const body = await response.text().catch(() => ""); - throw new Error(`AWS SSO GetRoleCredentials failed: ${response.status} ${body.slice(0, 200)}`); + throw new AIError.AwsCredentialsError( + `AWS SSO GetRoleCredentials failed: ${response.status} ${body.slice(0, 200)}`, + "sso-role", + ); } const json = (await response.json()) as { roleCredentials?: { accessKeyId: string; secretAccessKey: string; sessionToken: string; expiration: number }; }; const role = json.roleCredentials; - if (!role) throw new Error("AWS SSO GetRoleCredentials: missing roleCredentials in response"); + if (!role) + throw new AIError.AwsCredentialsError("AWS SSO GetRoleCredentials: missing roleCredentials in response", "sso-role"); // region is honored at the caller; we only consume defaultRegion to keep the // param wired for symmetry with other resolution paths. @@ -359,23 +371,32 @@ async function readCredentialProcess( ]); if (exitCode !== 0) { const tail = stderr.trim().slice(-512) || stdout.trim().slice(-512) || "(no output)"; - throw new Error(`AWS credential_process for profile '${profile}' exited ${exitCode}: ${tail}`); + throw new AIError.AwsCredentialsError( + `AWS credential_process for profile '${profile}' exited ${exitCode}: ${tail}`, + "credential-process", + ); } let parsed: CredentialProcessEnvelope; try { parsed = JSON.parse(stdout) as CredentialProcessEnvelope; } catch (err) { - throw new Error(`AWS credential_process for profile '${profile}' did not emit valid JSON: ${String(err)}`); + throw new AIError.AwsCredentialsError( + `AWS credential_process for profile '${profile}' did not emit valid JSON: ${String(err)}`, + "credential-process", + { cause: err }, + ); } if (parsed.Version !== 1) { - throw new Error( + throw new AIError.AwsCredentialsError( `AWS credential_process for profile '${profile}' returned unsupported Version ${parsed.Version ?? ""}; expected 1.`, + "credential-process", ); } if (!parsed.AccessKeyId || !parsed.SecretAccessKey) { - throw new Error( + throw new AIError.AwsCredentialsError( `AWS credential_process for profile '${profile}' returned envelope without AccessKeyId/SecretAccessKey.`, + "credential-process", ); } @@ -397,7 +418,7 @@ async function readCredentialProcess( function buildCredentialProcessArgv(profile: string, command: string): string[] { const tokens = tokenizeCredentialProcessCommand(command); if (tokens.length === 0) { - throw new Error(`AWS credential_process for profile '${profile}' is empty.`); + throw new AIError.AwsCredentialsError(`AWS credential_process for profile '${profile}' is empty.`, "credential-process"); } if (process.platform === "win32" && isBatchScript(tokens[0])) { return ["cmd.exe", "/d", "/s", "/c", command]; @@ -479,7 +500,7 @@ export function tokenizeCredentialProcessCommand(cmd: string): string[] { current += ch; } if (mode !== "normal") { - throw new Error("AWS credential_process command has an unterminated quote."); + throw new AIError.AwsCredentialsError("AWS credential_process command has an unterminated quote.", "credential-process"); } if (hasToken) tokens.push(current); return tokens; diff --git a/packages/ai/src/providers/aws-eventstream.ts b/packages/ai/src/providers/aws-eventstream.ts index b854b2844..d09bde011 100644 --- a/packages/ai/src/providers/aws-eventstream.ts +++ b/packages/ai/src/providers/aws-eventstream.ts @@ -17,6 +17,8 @@ * practice (`:event-type`, `:message-type`, `:content-type`, `:exception-type`). */ +import * as AIError from "../error"; + const PRELUDE_LEN = 8; const PRELUDE_CRC_LEN = 4; const MESSAGE_CRC_LEN = 4; @@ -41,17 +43,17 @@ export function crc32(bytes: Uint8Array): number { * frames. */ export function decodeMessage(frame: Uint8Array): EventStreamMessage { - if (frame.length < MIN_MESSAGE_LEN) throw new Error("eventstream: frame too short"); + if (frame.length < MIN_MESSAGE_LEN) throw new AIError.EventStreamFrameError("frame too short"); const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); const total = view.getUint32(0, false); - if (total !== frame.length) throw new Error(`eventstream: framed length ${total} != buffer ${frame.length}`); + if (total !== frame.length) throw new AIError.EventStreamFrameError(`framed length ${total} != buffer ${frame.length}`); const headersLen = view.getUint32(4, false); const preludeCrc = view.getUint32(8, false); const computedPreludeCrc = crc32(frame.subarray(0, PRELUDE_LEN)); - if (computedPreludeCrc !== preludeCrc) throw new Error("eventstream: prelude CRC mismatch"); + if (computedPreludeCrc !== preludeCrc) throw new AIError.EventStreamFrameError("prelude CRC mismatch"); const msgCrc = view.getUint32(total - MESSAGE_CRC_LEN, false); const computedMsgCrc = crc32(frame.subarray(0, total - MESSAGE_CRC_LEN)); - if (computedMsgCrc !== msgCrc) throw new Error("eventstream: message CRC mismatch"); + if (computedMsgCrc !== msgCrc) throw new AIError.EventStreamFrameError("message CRC mismatch"); const headersBytes = frame.subarray(HEADER_BLOCK_OFFSET, HEADER_BLOCK_OFFSET + headersLen); const payload = frame.subarray(HEADER_BLOCK_OFFSET + headersLen, total - MESSAGE_CRC_LEN); @@ -124,7 +126,7 @@ function parseHeaders(buf: Uint8Array): Record { break; } default: - throw new Error(`eventstream: unknown header value type ${type}`); + throw new AIError.EventStreamFrameError(`unknown header value type ${type}`); } } return out; @@ -158,7 +160,7 @@ export async function* decodeEventStream(source: ReadableStream): As while (buf.length - offset >= 4) { const dv = new DataView(buf.buffer, buf.byteOffset + offset, buf.length - offset); const total = dv.getUint32(0, false); - if (total < MIN_MESSAGE_LEN) throw new Error(`eventstream: total length ${total} below minimum`); + if (total < MIN_MESSAGE_LEN) throw new AIError.EventStreamFrameError(`total length ${total} below minimum`); if (buf.length - offset < total) break; const frame = buf.subarray(offset, offset + total); yield decodeMessage(frame); @@ -167,7 +169,7 @@ export async function* decodeEventStream(source: ReadableStream): As if (offset > 0) buf = buf.slice(offset); if (done) break; } - if (buf.length > 0) throw new Error("eventstream: truncated message at end of stream"); + if (buf.length > 0) throw new AIError.EventStreamFrameError("truncated message at end of stream"); completed = true; } finally { // On abnormal exit (consumer threw/broke, decode error) cancel the body so the diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index e3e85d7fd..fa7b4325a 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -1,4 +1,5 @@ -import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; +import { $env } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import { getEnvApiKey } from "../stream"; import type { AssistantMessage, @@ -12,7 +13,7 @@ import type { } from "../types"; import { createAbortSourceTracker } from "../utils/abort"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; +import type { RawHttpRequestDump } from "../utils/http-inspector"; import { getOpenAIStreamFirstEventTimeoutMs, getOpenAIStreamIdleTimeoutMs, @@ -97,7 +98,9 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" ); let rawRequestDump: RawHttpRequestDump | undefined; const abortTracker = createAbortSourceTracker(options?.signal); - const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE); + const firstEventTimeoutAbortError = new AIError.StreamTimeoutError( + AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE, + ); const { requestAbortController, requestSignal } = abortTracker; const onSseEvent = options?.onSseEvent; const rawSseObserver = onSseEvent @@ -217,15 +220,21 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" } if (abortTracker.wasCallerAbort()) { - throw new Error("Request was aborted"); + throw new AIError.AbortError(); } if (!sawTerminalResponseEvent) { - throw new Error("Azure OpenAI responses stream closed before a terminal response event was received"); + throw new AIError.ProviderResponseError( + "Azure OpenAI responses stream closed before a terminal response event was received", + { provider: model.provider, kind: "incomplete-stream" }, + ); } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); + throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", { + provider: model.provider, + kind: "output", + }); } output.duration = performance.now() - startTime; @@ -234,10 +243,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" stream.end(); } catch (error) { for (const block of output.content) stripVariant<{ index?: number }>(block, "index"); - const firstEventTimeoutError = abortTracker.getLocalAbortReason(); - output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error"; - output.errorStatus = extractHttpStatusFromError(error); - output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump)); + const result = await AIError.finalize(error, { api: model.api, abortTracker, rawRequestDump }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); @@ -268,7 +278,7 @@ function resolveAzureConfig( } if (!resolvedBaseUrl) { - throw new Error( + throw new AIError.ConfigurationError( "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.", ); } @@ -296,7 +306,8 @@ function buildAzureResponsesRequest( if (!apiKey) { const envKey = $env.AZURE_OPENAI_API_KEY; if (!envKey) { - throw new Error( + throw new AIError.MissingApiKeyError( + undefined, "Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.", ); } diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index f18d66db6..696b7e937 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -102,13 +102,8 @@ import { WriteSuccessSchema, } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; -import { - $env, - extractHttpStatusFromError, - parseJsonWithRepair, - parseStreamingJson, - sanitizeText, -} from "@oh-my-pi/pi-utils"; +import { $env, parseJsonWithRepair, parseStreamingJson, sanitizeText } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import type { Api, AssistantMessage, @@ -134,7 +129,6 @@ import { deterministicUuid } from "../utils/deterministic-id"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { connectProxiedSocket, getProxyForProvider, shouldBypassProxy } from "../utils/proxy"; import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug"; -import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { toolWireSchema } from "../utils/schema/wire"; import { stripVariant } from "../utils/strip"; @@ -195,11 +189,11 @@ function parseConnectEndStream(data: Uint8Array): Error | null { if (error) { const code = typeof error.code === "string" ? error.code : "unknown"; const message = typeof error.message === "string" ? error.message : "Unknown error"; - return new Error(`Connect error ${code}: ${message}`); + return new AIError.ProviderResponseError(`Connect error ${code}: ${message}`, { kind: "envelope" }); } return null; } catch { - return new Error("Failed to parse Connect end stream"); + return new AIError.ProviderResponseError("Failed to parse Connect end stream", { kind: "envelope" }); } } @@ -345,7 +339,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( try { const apiKey = options?.apiKey; if (!apiKey) { - throw new Error("Cursor API key (access token) is required"); + throw new AIError.MissingApiKeyError(undefined, "Cursor API key (access token) is required"); } const conversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID(); @@ -532,7 +526,12 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( const msg = trailers["grpc-message"]; if (status && status !== "0") { void closeDebugLog().finally(() => { - reject(new Error(`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`)); + reject( + new AIError.ProviderResponseError( + `gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`, + { kind: "envelope" }, + ), + ); }); } }); @@ -558,7 +557,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( options.signal.addEventListener("abort", () => { h2Request?.close(); void closeDebugLog().finally(() => { - reject(new Error("Request was aborted")); + reject(new AIError.AbortError()); }); }); } @@ -590,9 +589,11 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( }); stream.end(); } catch (error) { - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorStatus = extractHttpStatusFromError(error); - output.errorMessage = formatErrorMessageWithRetryAfter(error); + const result = await AIError.finalize(error, { api: model.api, signal: options?.signal }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); @@ -2168,7 +2169,7 @@ function storeCursorBlob(blobStore: Map, data: Uint8Array): function readCursorBlob(blobStore: Map, blobId: Uint8Array): Uint8Array { const data = blobStore.get(Buffer.from(blobId).toString("hex")); if (!data) { - throw new Error("Cursor blob not found"); + throw new AIError.ValidationError("Cursor blob not found"); } return data; } diff --git a/packages/ai/src/providers/devin.ts b/packages/ai/src/providers/devin.ts index 518c10e97..56559a2ac 100644 --- a/packages/ai/src/providers/devin.ts +++ b/packages/ai/src/providers/devin.ts @@ -28,7 +28,8 @@ import { StopReason, } from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/codeium_common_pb/codeium_common_pb"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; -import { extractHttpStatusFromError, logger, parseStreamingJson } from "@oh-my-pi/pi-utils"; +import { logger, parseStreamingJson } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import type { Api, AssistantMessage, @@ -44,7 +45,6 @@ import type { } from "../types"; import { deterministicUuid } from "../utils/deterministic-id"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { toolWireSchema } from "../utils/schema/wire"; /** Base host for Codeium/Windsurf's Cascade chat API (Connect protocol over HTTP/1.1). */ @@ -169,12 +169,16 @@ export const streamDevin: StreamFunction<"devin-agent"> = ( if (!response.ok) { const text = await response.text(); - throw Object.assign(new Error(`Devin API error ${response.status} ${response.statusText}: ${text}`), { - status: response.status, - }); + throw new AIError.DevinApiError( + `Devin API error ${response.status} ${response.statusText}: ${text}`, + response.status, + ); } if (!response.body) { - throw new Error("Devin API error: response body is empty"); + throw new AIError.ProviderResponseError("Devin API error: response body is empty", { + provider: model.provider, + kind: "empty-body", + }); } const body = response.body; @@ -199,7 +203,7 @@ export const streamDevin: StreamFunction<"devin-agent"> = ( if (flag & CONNECT_END_STREAM_FLAG) { const trailerBytes = flag & CONNECT_COMPRESSED_FLAG ? gunzipSync(payload) : payload; const trailerError = readConnectTrailerError(trailerBytes.toString("utf8").trim()); - if (trailerError) throw new Error(trailerError); + if (trailerError) throw new AIError.ValidationError(trailerError); continue; } @@ -325,13 +329,14 @@ export const streamDevin: StreamFunction<"devin-agent"> = ( stream.end(); } catch (error) { logger.error("devin: stream failed", { error: String(error) }); - const errorReason: "aborted" | "error" = options?.signal?.aborted ? "aborted" : "error"; - output.stopReason = errorReason; - output.errorStatus = extractHttpStatusFromError(error); - output.errorMessage = formatErrorMessageWithRetryAfter(error); + const result = await AIError.finalize(error, { api: model.api, signal: options?.signal }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "error", reason: errorReason, error: output }); + stream.push({ type: "error", reason: result.stopReason, error: output }); stream.end(); } })(); @@ -372,14 +377,17 @@ async function fetchDevinAuthMetadata( }); const payload = new Uint8Array(await response.arrayBuffer()); if (!response.ok) { - throw Object.assign( - new Error(`Devin auth error ${response.status} ${response.statusText}: ${new TextDecoder().decode(payload)}`), - { status: response.status }, + throw new AIError.DevinApiError( + `Devin auth error ${response.status} ${response.statusText}: ${new TextDecoder().decode(payload)}`, + response.status, ); } const decoded = decodeDevinUserJwtResponse(payload); if (!decoded.userJwt) { - throw new Error("Devin auth error: GetUserJwt returned an empty user JWT"); + throw new AIError.ProviderResponseError("Devin auth error: GetUserJwt returned an empty user JWT", { + provider: "devin", + kind: "runtime", + }); } const customBaseUrl = decoded.customApiServerUrl.trim(); return { userJwt: decoded.userJwt, ...(customBaseUrl ? { baseUrl: customBaseUrl.replace(/\/+$/, "") } : undefined) }; diff --git a/packages/ai/src/providers/gitlab-duo-workflow.ts b/packages/ai/src/providers/gitlab-duo-workflow.ts index 08b8d187a..5f177aac7 100644 --- a/packages/ai/src/providers/gitlab-duo-workflow.ts +++ b/packages/ai/src/providers/gitlab-duo-workflow.ts @@ -4,6 +4,7 @@ import { discoverGitLabDuoWorkflowRuntimeNamespace, type GitLabDuoWorkflowNamespaceSelection, } from "@oh-my-pi/pi-catalog/discovery/gitlab-duo-workflow"; +import * as AIError from "../error"; import type { Api, AssistantMessage, @@ -109,7 +110,7 @@ const GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES = 1_048_576; const GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES = 2_000_000; // An overflow-pattern message for an oversized goal. The "prompt is too long" prefix -// is one of the shared `OVERFLOW_PATTERNS` (packages/ai/src/utils/overflow.ts), so +// is one of the shared overflow classifier patterns, so // `isContextOverflow` recognizes it and the session triggers auto-compaction instead // of surfacing a hard failure. Byte counts (not tokens) are reported because the // budget is a byte budget. @@ -953,7 +954,7 @@ async function runGitLabDuoWorkflow( state: GitLabDuoWorkflowStreamState, ): Promise { const apiKey = options.apiKey; - if (!apiKey) throw new Error("No API key for provider: gitlab-duo-agent"); + if (!apiKey) throw new AIError.MissingApiKeyError("gitlab-duo-agent"); const baseUrl = normalizeGitLabBaseUrl(model.baseUrl || DEFAULT_GITLAB_BASE_URL); const fetchImpl = options.fetch ?? fetch; const providerSessionState = getGitLabDuoWorkflowProviderSessionState( @@ -1604,16 +1605,20 @@ async function requestGitLabDuoWorkflowDirectAccess( // when the assistant error exposes `errorStatus` or the message embeds an // `HTTP ` token. A 401 `{"message":"Unauthorized"}` or a 429 quota // body would otherwise surface as a hard failure with no recoverable status. - throw new Error( + throw new AIError.GitLabDuoWorkflowApiError( message ? `GitLab Duo Workflow direct_access failed with HTTP ${response.status}: ${message}` : `GitLab Duo Workflow direct_access failed with HTTP ${response.status}`, + response.status, ); } const payload = (await response.json()) as GitLabDirectAccessResponse; const token = extractGitLabWorkflowToken(payload); if (!token) { - throw new Error("GitLab Duo Workflow direct_access did not return credentials"); + throw new AIError.ProviderResponseError("GitLab Duo Workflow direct_access did not return credentials", { + provider: "gitlab-duo-agent", + kind: "empty-body", + }); } traceGitLabDuoWorkflow("direct_access.token", { hasToken: true }); const serviceEndpoint = !payload.gitlab_rails?.token && Boolean(payload.duo_workflow_service?.base_url); @@ -1658,12 +1663,18 @@ async function createGitLabDuoWorkflow( hasProjectId: Boolean(projectId), }); if (!response.ok) { - throw new Error(`GitLab Duo Workflow create failed with HTTP ${response.status}`); + throw new AIError.GitLabDuoWorkflowApiError( + `GitLab Duo Workflow create failed with HTTP ${response.status}`, + response.status, + ); } const payload = (await response.json()) as GitLabCreateWorkflowResponse; const workflowId = payload.id ?? payload.workflow_id ?? payload.workflowId; if (workflowId === undefined) { - throw new Error(`GitLab Duo Workflow create response missing workflow id (HTTP ${response.status})`); + throw new AIError.ProviderResponseError( + `GitLab Duo Workflow create response missing workflow id (HTTP ${response.status})`, + { provider: "gitlab-duo-agent", kind: "empty-body" }, + ); } traceGitLabDuoWorkflow("workflow.create.id", { workflowId }); return String(workflowId); @@ -1815,7 +1826,7 @@ export function runGitLabDuoWorkflowSocket( }; const abort = (): void => { close(); - settle("closed", new Error("GitLab Duo Workflow request aborted")); + settle("closed", new AIError.AbortError("GitLab Duo Workflow request aborted")); }; if (options.signal?.aborted) { abort(); @@ -1856,7 +1867,13 @@ export function runGitLabDuoWorkflowSocket( ws.onerror = event => { const detail = describeGitLabDuoWorkflowSocketEvent(event); traceGitLabDuoWorkflow("websocket.error", { event: detail }); - settle("closed", new Error(`GitLab Duo Workflow WebSocket error: ${detail}`)); + settle( + "closed", + new AIError.ProviderResponseError(`GitLab Duo Workflow WebSocket error: ${detail}`, { + provider: "gitlab-duo-agent", + kind: "runtime", + }), + ); }; ws.onclose = event => { traceGitLabDuoWorkflow("websocket.close", { code: event.code, reason: event.reason }); @@ -2775,7 +2792,10 @@ export async function resolveGitLabDuoWorkflowNamespaceSelection( cwd: options.cwd, }); } catch (error) { - throw new Error(`GitLab Duo Workflow runtime namespace resolution failed: ${gitLabDuoWorkflowErrorText(error)}`); + throw new AIError.ProviderResponseError( + `GitLab Duo Workflow runtime namespace resolution failed: ${gitLabDuoWorkflowErrorText(error)}`, + { provider: "gitlab-duo-agent", kind: "runtime" }, + ); } } @@ -3008,7 +3028,7 @@ function requireGitLabDuoWorkflowRequestID( source: Record, ): string { if (requestID) return requestID; - throw new Error( + throw new AIError.ValidationError( `GitLab Duo Workflow action "${actionName}" missing requestID (keys: ${Object.keys(source).slice(0, 20).join(", ")})`, ); } diff --git a/packages/ai/src/providers/gitlab-duo.ts b/packages/ai/src/providers/gitlab-duo.ts index 6c75cfd7a..d1ec9868f 100644 --- a/packages/ai/src/providers/gitlab-duo.ts +++ b/packages/ai/src/providers/gitlab-duo.ts @@ -1,4 +1,5 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import * as AIError from "../error"; import { ANTHROPIC_THINKING, mapAnthropicToolChoice } from "../stream"; import type { Api, Context, FetchImpl, Model, ModelSpec, SimpleStreamOptions } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; @@ -198,17 +199,29 @@ async function getDirectAccessToken( if (!response.ok) { const detail = await response.text(); if (response.status === 403) { - throw new Error(`GitLab Duo access denied. Ensure Duo is enabled for this account. ${detail}`); + throw new AIError.ProviderResponseError( + `GitLab Duo access denied. Ensure Duo is enabled for this account. ${detail}`, + { provider: "gitlab-duo", kind: "runtime" }, + ); } - throw new Error(`Failed to get GitLab Duo direct access token: ${response.status} ${detail}`); + throw new AIError.GitLabDuoApiError( + `Failed to get GitLab Duo direct access token: ${response.status} ${detail}`, + response.status, + ); } const payload = (await response.json()) as { token?: string; headers?: Record }; if (!payload.token || typeof payload.token !== "string") { - throw new Error("GitLab Duo direct access response missing token"); + throw new AIError.ProviderResponseError("GitLab Duo direct access response missing token", { + provider: "gitlab-duo", + kind: "envelope", + }); } if (!payload.headers || typeof payload.headers !== "object") { - throw new Error("GitLab Duo direct access response missing headers"); + throw new AIError.ProviderResponseError("GitLab Duo direct access response missing headers", { + provider: "gitlab-duo", + kind: "envelope", + }); } const token: DirectAccessToken = { @@ -239,12 +252,15 @@ export function streamGitLabDuo( try { const apiKey = typeof options?.apiKey === "string" ? options.apiKey : undefined; if (!apiKey || !options) { - throw new Error("Missing GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN."); + throw new AIError.MissingApiKeyError( + undefined, + "Missing GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN.", + ); } const mapping = getModelMapping(model.id); if (!mapping) { - throw new Error(`Unsupported GitLab Duo model: ${model.id}`); + throw new AIError.ConfigurationError(`Unsupported GitLab Duo model: ${model.id}`); } const directAccess = await getDirectAccessToken(apiKey, options.fetch); diff --git a/packages/ai/src/providers/google-auth.ts b/packages/ai/src/providers/google-auth.ts index 18c381e7d..11a2cd6c4 100644 --- a/packages/ai/src/providers/google-auth.ts +++ b/packages/ai/src/providers/google-auth.ts @@ -16,6 +16,7 @@ import { Buffer } from "node:buffer"; import * as os from "node:os"; import * as path from "node:path"; import { $envpos, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import type { FetchImpl } from "../types"; import { raceWithSignal } from "../utils/abort"; @@ -83,7 +84,7 @@ async function loadAdcCredentials(): Promise<{ source: string; creds: AdcFileCre if (gacPath) { const creds = await readJsonFile(gacPath); if (!creds) { - throw new Error(`GOOGLE_APPLICATION_CREDENTIALS points to a missing file: ${gacPath}`); + throw new AIError.ConfigurationError(`GOOGLE_APPLICATION_CREDENTIALS points to a missing file: ${gacPath}`); } return { source: `gac:${gacPath}`, creds }; } @@ -103,7 +104,7 @@ function pemToPkcs8(pem: string): Uint8Array { .replace(/-----BEGIN [^-]+-----/g, "") .replace(/-----END [^-]+-----/g, "") .replace(/\s+/g, ""); - if (!body) throw new Error("Invalid PEM: empty body"); + if (!body) throw new AIError.ConfigurationError("Invalid PEM: empty body"); return Uint8Array.fromBase64(body); } @@ -193,7 +194,11 @@ async function postForToken( }); if (!response.ok) { const detail = await response.text().catch(() => ""); - throw new Error(`Google OAuth token exchange failed (${response.status}): ${detail}`); + throw new AIError.OAuthError(`Google OAuth token exchange failed (${response.status}): ${detail}`, { + kind: "token-exchange", + provider: "google-vertex", + status: response.status, + }); } return (await response.json()) as TokenResponse; } @@ -239,7 +244,11 @@ async function resolveAccessTokenUncached( ); if (!response.ok) { const detail = await response.text().catch(() => ""); - throw new Error(`Google Impersonation token exchange failed (${response.status}): ${detail}`); + throw new AIError.OAuthError(`Google Impersonation token exchange failed (${response.status}): ${detail}`, { + kind: "token-exchange", + provider: "google-vertex", + status: response.status, + }); } const data = (await response.json()) as { accessToken: string; expireTime: string }; const expiresIn = Math.max(0, Math.floor((new Date(data.expireTime).getTime() - Date.now()) / 1000)); @@ -254,7 +263,8 @@ async function resolveAccessTokenUncached( } const metadata = await fetchMetadataToken(signal, fetchImpl); if (metadata) return { source: "metadata", token: metadata }; - throw new Error( + throw new AIError.MissingApiKeyError( + undefined, "Vertex AI requires Application Default Credentials. Set GOOGLE_APPLICATION_CREDENTIALS, run `gcloud auth application-default login`, or run on a GCE/Cloud Run instance with a service account.", ); } diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index f1456fc34..806556c00 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -14,7 +14,7 @@ import { } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; -import { ProviderHttpError } from "../errors"; +import * as AIError from "../error"; import type { Api, AssistantMessage, @@ -30,7 +30,7 @@ import type { import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { extractGoogleValidationUrl, formatGoogleValidationRequiredMessage } from "../utils/google-validation"; -import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump } from "../utils/http-inspector"; +import type { RawHttpRequestDump } from "../utils/http-inspector"; import { armPreResponseTimeout, getStreamFirstEventTimeoutMs } from "../utils/idle-iterator"; // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted); // the stream provider trusts the access token threaded through `options.apiKey`. @@ -60,11 +60,6 @@ import { */ export type { GoogleThinkingLevel }; -/** Non-2xx response (or in-stream error chunk) from the Cloud Code Assist API. */ -export class GeminiCliApiError extends ProviderHttpError { - override readonly name = "GeminiCliApiError"; -} - function isPlanningLeakPrefix(text: string): boolean { const trimmed = text.trimStart(); if (!trimmed.startsWith("{")) { @@ -346,22 +341,6 @@ function shouldInjectAntigravitySystemInstruction(modelId: string): boolean { return normalized.includes("claude") || normalized.includes("gemini-3"); } -/** - * Extract a clean, user-friendly error message from Google API error response. - * Parses JSON error responses and returns just the message field. - */ -function extractErrorMessage(errorText: string): string { - try { - const parsed = JSON.parse(errorText) as { error?: { message?: string } }; - if (parsed.error?.message) { - return parsed.error.message; - } - } catch { - // Not JSON, return as-is - } - return errorText; -} - const optionalCredentialString = type("unknown").pipe(raw => { const out = type("string")(raw); return out instanceof type.errors ? undefined : out; @@ -407,16 +386,16 @@ export function parseGeminiCliCredentials(apiKeyRaw: string): ParsedGeminiCliCre try { rawCredentials = JSON.parse(apiKeyRaw); } catch { - throw new Error(invalidCredentialsMessage); + throw new AIError.ValidationError(invalidCredentialsMessage); } const parsed = geminiCliCredentialsSchema(rawCredentials); if (parsed instanceof type.errors) { - throw new Error(invalidCredentialsMessage); + throw new AIError.ValidationError(invalidCredentialsMessage); } const projectId = parsed.projectId ?? parsed.project_id; if (parsed.token === undefined || projectId === undefined) { - throw new Error(missingCredentialsMessage); + throw new AIError.ValidationError(missingCredentialsMessage); } const refreshToken = parsed.refreshToken ?? parsed.refresh; @@ -543,7 +522,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( try { const apiKeyRaw = options?.apiKey; if (!apiKeyRaw) { - throw new Error("Google Cloud Code Assist requires OAuth authentication. Use /login to authenticate."); + throw new AIError.ConfigurationError( + "Google Cloud Code Assist requires OAuth authentication. Use /login to authenticate.", + ); } const isAntigravity = model.provider === "google-antigravity"; @@ -559,8 +540,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( parsedCredentials.expiresAt !== undefined && Date.now() >= parsedCredentials.expiresAt ) { - throw new Error( + throw new AIError.OAuthError( "OAuth token expired before request — please retry; AuthStorage will refresh on the next attempt.", + { kind: "token-refresh", provider: model.provider }, ); } const baseUrl = model.baseUrl?.trim(); @@ -671,7 +653,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const streamResponse = async (activeResponse: Response): Promise => { if (!activeResponse.body) { - throw new Error("No response body"); + throw new AIError.ProviderResponseError("No response body", { + provider: model.provider, + kind: "empty-body", + }); } // Scoped per attempt so a failed/empty retry cannot leak its @@ -707,16 +692,17 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const detail = chunk.error.message || chunk.error.status || "unknown error"; const message = `Cloud Code Assist stream error: ${detail}`; throw typeof chunk.error.code === "number" && chunk.error.code >= 400 - ? new GeminiCliApiError(message, chunk.error.code) - : new Error(message); + ? new AIError.GeminiCliApiError(message, chunk.error.code) + : new AIError.ProviderResponseError(message, { provider: model.provider, kind: "runtime" }); } const responseData = chunk.response; if (!responseData) continue; if (responseData.responseId) lastResponseId = responseData.responseId; if (!responseData.candidates?.length && responseData.promptFeedback?.blockReason) { const detail = responseData.promptFeedback.blockReasonMessage; - throw new Error( + throw new AIError.ProviderResponseError( `Request blocked by Google (${responseData.promptFeedback.blockReason})${detail ? `: ${detail}` : ""}`, + { provider: model.provider, kind: "content-blocked" }, ); } @@ -922,8 +908,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( "retry your request", parsedCredentials.email, ) - : extractErrorMessage(errorText); - throw new GeminiCliApiError( + : errorText; + throw new AIError.GeminiCliApiError( `Cloud Code Assist API error (${response.status}): ${errorMessage}`, response.status, { headers: response.headers }, @@ -935,7 +921,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( for (let emptyAttempt = 0; emptyAttempt <= MAX_EMPTY_STREAM_RETRIES; emptyAttempt++) { if (options?.signal?.aborted) { - throw new Error("Request was aborted"); + throw new AIError.AbortError("Request was aborted"); } if (emptyAttempt > 0) { @@ -943,11 +929,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( try { await scheduler.wait(backoffMs, { signal: options?.signal }); } catch { - throw new Error("Request was aborted"); + throw new AIError.AbortError("Request was aborted"); } if (!requestUrl) { - throw new Error("Missing request URL"); + throw new AIError.ConfigurationError("Missing request URL"); } currentResponse = await (options?.fetch ?? fetch)(requestUrl, { @@ -959,7 +945,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (!currentResponse.ok) { const retryErrorText = await currentResponse.text(); - throw new GeminiCliApiError( + throw new AIError.GeminiCliApiError( `Cloud Code Assist API error (${currentResponse.status}): ${retryErrorText}`, currentResponse.status, { headers: currentResponse.headers }, @@ -979,16 +965,20 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } if (!receivedContent) { - throw new Error("Cloud Code Assist API returned an empty response"); + throw new AIError.ProviderResponseError("Cloud Code Assist API returned an empty response", { + provider: model.provider, + kind: "empty-body", + }); } if (options?.signal?.aborted) { - throw new Error("Request was aborted"); + throw new AIError.AbortError("Request was aborted"); } if (!sawFinishReason) { - throw new Error( + throw new AIError.ProviderResponseError( "Cloud Code Assist stream ended without a finish reason (connection dropped or response truncated)", + { provider: model.provider, kind: "incomplete-stream" }, ); } @@ -1018,7 +1008,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); + throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", { + provider: model.provider, + kind: "output", + }); } output.duration = performance.now() - startTime; @@ -1031,13 +1024,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( stripVariant<{ index?: number }>(block, "index"); } } - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorStatus = extractHttpStatusFromError(error); - output.errorMessage = await appendRawHttpRequestDumpFor400( - error instanceof Error ? error.message : JSON.stringify(error), - error, - rawRequestDump, - ); + const result = await AIError.finalize(error, { api: model.api, signal: options?.signal, rawRequestDump }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 1a2a0d9e0..a597562f9 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -4,9 +4,9 @@ import { scheduler } from "node:timers/promises"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; -import { extractHttpStatusFromError, readSseJson } from "@oh-my-pi/pi-utils"; +import { readSseJson } from "@oh-my-pi/pi-utils"; import { renderDemotedThinking } from "../dialect/demotion"; -import { ProviderHttpError } from "../errors"; +import * as AIError from "../error"; import type { Api, AssistantMessage, @@ -23,7 +23,7 @@ import type { } from "../types"; import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; +import type { RawHttpRequestDump } from "../utils/http-inspector"; import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema"; import { stripVariant } from "../utils/strip"; import type { @@ -49,11 +49,6 @@ export type { } from "./google-types"; export { normalizeSchemaForGoogle }; -/** Non-2xx response (or in-stream error chunk) from the Google Generative Language / Vertex API. */ -export class GoogleApiError extends ProviderHttpError { - override readonly name = "GoogleApiError"; -} - type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex"; /** @@ -420,7 +415,7 @@ export function mapStopReason(reason: FinishReason): StopReason { case "NO_IMAGE": return "error"; default: { - throw new Error(`Unhandled stop reason: ${reason satisfies never}`); + throw new AIError.ConfigurationError(`Unhandled stop reason: ${reason satisfies never}`); } } } @@ -597,13 +592,14 @@ export async function consumeGoogleStream(args: { const detail = chunk.error.message || chunk.error.status || "unknown error"; const message = `Google API stream error: ${detail}`; throw typeof chunk.error.code === "number" && chunk.error.code >= 400 - ? new GoogleApiError(message, chunk.error.code) - : new Error(message); + ? new AIError.GoogleApiError(message, chunk.error.code) + : new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" }); } if (!chunk.candidates?.length && chunk.promptFeedback?.blockReason) { const detail = chunk.promptFeedback.blockReasonMessage; - throw new Error( + throw new AIError.ProviderResponseError( `Request blocked by Google (${chunk.promptFeedback.blockReason})${detail ? `: ${detail}` : ""}`, + { provider: model.provider, kind: "content-blocked" }, ); } const candidate = chunk.candidates?.[0]; @@ -734,15 +730,21 @@ export async function consumeGoogleStream(args: { flushCurrent(); if (options?.signal?.aborted) { - throw new Error("Request was aborted"); + throw new AIError.AbortError(); } if (!sawFinishReason) { - throw new Error("Google API stream ended without a finish reason (connection dropped or response truncated)"); + throw new AIError.ProviderResponseError( + "Google API stream ended without a finish reason (connection dropped or response truncated)", + { provider: model.provider, kind: "incomplete-stream" }, + ); } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); + throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", { + provider: model.provider, + kind: "output", + }); } } @@ -826,7 +828,7 @@ export function buildGoogleGenerateContentParams ""); - throw new GoogleApiError( + throw new AIError.GoogleApiError( `Google API error (${response.status}): ${extractGoogleErrorMessage(errorText)}`, response.status, { headers: response.headers }, ); } if (!response.body) { - throw new Error("Google API returned an empty response body"); + throw new AIError.ProviderResponseError("Google API returned an empty response body", { + provider: model.provider, + kind: "empty-body", + }); } return response.body as ReadableStream; }; @@ -950,14 +955,15 @@ export function streamGoogleGenAI= MAX_EMPTY_STREAM_RETRIES) { - throw new Error( + throw new AIError.ProviderResponseError( `Google API returned an empty response (finishReason STOP with no content) after ${MAX_EMPTY_STREAM_RETRIES + 1} attempts`, + { provider: model.provider, kind: "empty-body" }, ); } try { await scheduler.wait(EMPTY_STREAM_BASE_DELAY_MS * 2 ** emptyAttempt, { signal: options?.signal }); } catch { - throw new Error("Request was aborted"); + throw new AIError.AbortError(); } resetGoogleStreamOutputForRetry(output); body = await openStream(); @@ -973,9 +979,11 @@ export function streamGoogleGenAI(block, "index"); } } - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorStatus = extractHttpStatusFromError(error); - output.errorMessage = await finalizeErrorMessage(error, rawRequestDump); + const result = await AIError.finalize(error, { api: model.api, signal: options?.signal, rawRequestDump }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index a8c07f2c4..5400da7bd 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -1,4 +1,5 @@ import { $env } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import type { Context, Model, StreamFunction } from "../types"; import type { AssistantMessageEventStream } from "../utils/event-stream"; import { getVertexAccessToken } from "./google-auth"; @@ -69,7 +70,7 @@ function resolveApiKey(options?: GoogleVertexOptions): string | undefined { function resolveProject(options?: GoogleVertexOptions): string { const project = options?.project || $env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT; if (!project) { - throw new Error( + throw new AIError.ConfigurationError( "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT or pass project in options.", ); } @@ -83,7 +84,7 @@ function resolveLocation(options?: GoogleVertexOptions): string { const location = options?.location || $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION; if (!location) { - throw new Error( + throw new AIError.ConfigurationError( "Vertex AI requires a location. Set GOOGLE_VERTEX_LOCATION/GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION or pass location in options.", ); } diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 2d64c4199..24b5a1280 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import { getEnvApiKey } from "../stream"; import type { Context, Model, StreamFunction } from "../types"; import type { AssistantMessageEventStream } from "../utils/event-stream"; @@ -24,7 +25,10 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( prepare: (): GoogleGenAIRequestPlan => { const apiKey = options?.apiKey || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error("Google Generative AI requires an API key (GEMINI_API_KEY or options.apiKey)."); + throw new AIError.MissingApiKeyError( + undefined, + "Google Generative AI requires an API key (GEMINI_API_KEY or options.apiKey).", + ); } const params = buildGoogleGenerateContentParams(model, context, options ?? {}); // `model.baseUrl` already includes the API version segment when set (mirrors the diff --git a/packages/ai/src/providers/mock.ts b/packages/ai/src/providers/mock.ts index 1894be5d4..f79bb8d03 100644 --- a/packages/ai/src/providers/mock.ts +++ b/packages/ai/src/providers/mock.ts @@ -43,6 +43,7 @@ */ import { registerCustomApi } from "../api-registry"; +import * as AIError from "../error"; import type { Api, AssistantMessage, @@ -242,7 +243,7 @@ export function streamMock( if (!isMockModel(model)) { queueMicrotask(() => { stream.fail( - new Error( + new AIError.ValidationError( "streamMock called with a model not produced by createMockModel(). " + "Pass a MockModel instance.", ), ); @@ -300,7 +301,7 @@ async function runMock( if (handler === undefined) { stream.fail( - new Error( + new AIError.ValidationError( `Mock model "${model.id}" received call ${model.calls.length} but no response or handler is configured.`, ), ); @@ -490,7 +491,7 @@ function sleep(ms: number, signal?: AbortSignal): Promise { const onAbort = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); - reject(signal?.reason ?? new Error("aborted")); + reject(signal?.reason ?? new AIError.AbortError("aborted")); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); diff --git a/packages/ai/src/providers/ollama.ts b/packages/ai/src/providers/ollama.ts index 7839f0828..14eb259be 100644 --- a/packages/ai/src/providers/ollama.ts +++ b/packages/ai/src/providers/ollama.ts @@ -1,5 +1,5 @@ -import { extractHttpStatusFromError, fetchWithRetry, parseStreamingJson } from "@oh-my-pi/pi-utils"; -import { ProviderHttpError } from "../errors"; +import { fetchWithRetry, parseStreamingJson } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import { getEnvApiKey } from "../stream"; import type { Api, @@ -16,7 +16,7 @@ import type { } from "../types"; import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { type CapturedHttpErrorResponse, finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; +import type { CapturedHttpErrorResponse, RawHttpRequestDump } from "../utils/http-inspector"; import { armPreResponseTimeout, getOpenAIStreamFirstEventTimeoutMs, @@ -33,11 +33,6 @@ import { stripVariant } from "../utils/strip"; import { transformMessages } from "./transform-messages"; import { joinTextWithImagePlaceholder, partitionVisionContent } from "./vision-guard"; -/** Non-2xx response from the Ollama `/api/chat` endpoint. */ -export class OllamaApiError extends ProviderHttpError { - override readonly name = "OllamaApiError"; -} - export interface OllamaChatOptions extends StreamOptions { reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh"; disableReasoning?: boolean; @@ -559,7 +554,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = ( try { const apiKey = options.apiKey || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new AIError.MissingApiKeyError(model.provider); } const baseUrl = normalizeBaseUrl(model.baseUrl); let body = createChatBody(model, context, options); @@ -607,12 +602,14 @@ export const streamOllama: StreamFunction<"ollama-chat"> = ( } if (!response.ok) { capturedErrorResponse = await captureHttpErrorResponse(response); - throw new OllamaApiError(`HTTP ${response.status} from ${baseUrl}/api/chat`, response.status, { + throw new AIError.OllamaApiError(`HTTP ${response.status} from ${baseUrl}/api/chat`, response.status, { headers: response.headers, }); } if (!response.body) { - throw new Error("Ollama returned an empty response body"); + throw new AIError.OllamaApiError("Ollama returned an empty response body", response.status, { + headers: response.headers, + }); } stream.push({ type: "start", partial: output }); for await (const chunk of iterateNdjson(response.body)) { @@ -743,9 +740,16 @@ export const streamOllama: StreamFunction<"ollama-chat"> = ( stripVariant(block, "partialJson"); } } - output.stopReason = options.signal?.aborted ? "aborted" : "error"; - output.errorStatus = extractHttpStatusFromError(error); - output.errorMessage = await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse); + const result = await AIError.finalize(error, { + api: model.api, + signal: options.signal, + rawRequestDump, + capturedErrorResponse, + }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; output.duration = performance.now() - startTime; if (firstTokenTime) { output.ttft = firstTokenTime - startTime; diff --git a/packages/ai/src/providers/openai-chat-server.ts b/packages/ai/src/providers/openai-chat-server.ts index 69dab5005..ddc6c6a1c 100644 --- a/packages/ai/src/providers/openai-chat-server.ts +++ b/packages/ai/src/providers/openai-chat-server.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { type } from "arktype"; import { resolvePromptCacheKey } from "../auth-gateway/http"; +import * as AIError from "../error"; /** * Parsed inbound OpenAI chat-completions request, ready to feed into pi-ai * `stream(model, context, options)`. @@ -53,7 +54,7 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest { // vendor-neutral headers when the body doesn't carry one. const parsed = openaiChatRequestSchema(body); if (parsed instanceof type.errors) { - throw new Error(`openai-chat: ${parsed.summary}`); + throw new AIError.ValidationError(`openai-chat: ${parsed.summary}`); } const data = parsed; diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 7862c5206..c421a82ab 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -11,7 +11,6 @@ import { $env, $flag, asRecord, - extractHttpStatusFromError, fetchWithRetry, logger, parseStreamingJson, @@ -20,6 +19,7 @@ import { } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; import packageJson from "../../package.json" with { type: "json" }; +import * as AIError from "../error"; import { getEnvApiKey } from "../stream"; import type { Api, @@ -45,7 +45,7 @@ import { normalizeSystemPrompts, } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; +import type { RawHttpRequestDump } from "../utils/http-inspector"; import { armPreResponseTimeout, getOpenAIStreamFirstEventTimeoutMs, @@ -678,7 +678,7 @@ async function buildCodexRequestContext( ): Promise { const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new AIError.MissingApiKeyError(model.provider); } const accountId = getAccountId(apiKey); @@ -1958,7 +1958,7 @@ function finalizeCodexResponse( ): AssistantMessage { const { output } = context; if (context.options?.signal?.aborted) { - throw new Error("Request was aborted"); + throw new AIError.AbortError(); } if (!runtime.sawTerminalEvent) { if (context.requestContext.websocketState) { @@ -1974,10 +1974,10 @@ function finalizeCodexResponse( sentTurnStateHeader: Boolean(context.requestContext.websocketState?.turnState), sentModelsEtagHeader: Boolean(context.requestContext.websocketState?.modelsEtag), }); - throw new Error("Codex stream ended before terminal completion event"); + throw new CodexProviderStreamError("Codex stream ended before terminal completion event", false); } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("Codex response failed"); + throw new CodexProviderStreamError("Codex response failed", false); } output.providerPayload = createOpenAIResponsesHistoryPayload(context.model.provider, runtime.nativeOutputItems); @@ -2001,9 +2001,15 @@ async function handleCodexStreamFailure( context.requestContext.websocketState.turnState = undefined; context.requestContext.websocketState.modelsEtag = undefined; } - output.stopReason = context.options?.signal?.aborted ? "aborted" : "error"; - output.errorStatus = extractHttpStatusFromError(error); - output.errorMessage = await finalizeErrorMessage(error, context.requestContext.rawRequestDump); + const result = await AIError.finalize(error, { + api: context.model.api, + signal: context.options?.signal, + rawRequestDump: context.requestContext.rawRequestDump, + }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; output.duration = performance.now() - context.startTime; if (context.firstTokenTime) { output.ttft = context.firstTokenTime - context.startTime; @@ -3102,7 +3108,7 @@ async function openCodexSseEventStream( } updateCodexSessionMetadataFromHeaders(state, response.headers); if (!response.body) { - throw new Error("No response body"); + throw new CodexProviderStreamError("No response body", false); } return readSseJson>(response.body, signal, event => onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, undefined), @@ -3199,7 +3205,7 @@ function resolveCodexResponsesUrl(baseUrl: string | undefined): string { function getAccountId(accessToken: string): string { const accountId = getCodexAccountId(accessToken); if (!accountId) { - throw new Error("Failed to extract accountId from token"); + throw new AIError.OAuthError("Failed to extract accountId from token", { kind: "validation", provider: "openai" }); } return accountId; } diff --git a/packages/ai/src/providers/openai-codex/response-handler.ts b/packages/ai/src/providers/openai-codex/response-handler.ts index d391fe712..3e5fbb8be 100644 --- a/packages/ai/src/providers/openai-codex/response-handler.ts +++ b/packages/ai/src/providers/openai-codex/response-handler.ts @@ -1,5 +1,5 @@ import { toNumber } from "@oh-my-pi/pi-catalog/utils"; -import { ProviderHttpError } from "../../errors"; +import { ProviderHttpError } from "../../error"; export type CodexRateLimit = { used_percent?: number; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 457e2370b..d01a1b6e8 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -3,8 +3,9 @@ import { isKimiModelId } from "@oh-my-pi/pi-catalog/identity"; import { resolveWireModelId } from "@oh-my-pi/pi-catalog/model-thinking"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/types"; -import { $env, extractHttpStatusFromError, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils"; +import { $env, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils"; import { renderDemotedThinking } from "../dialect/demotion"; +import * as AIError from "../error"; import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import { getEnvApiKey } from "../stream"; import type { @@ -29,9 +30,8 @@ import type { import { normalizeSystemPrompts } from "../utils"; import { createAbortSourceTracker } from "../utils/abort"; import { hasVisibleAssistantContent, withEmptyCompletionRetry } from "../utils/empty-completion-retry"; -import { errorIdFromError } from "../utils/error-id"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector"; +import type { RawHttpRequestDump } from "../utils/http-inspector"; import { getOpenAIStreamFirstEventTimeoutMs, getOpenAIStreamIdleTimeoutMs, @@ -569,7 +569,7 @@ const streamOpenAICompletionsOnce = ( const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id); let rawRequestDump: RawHttpRequestDump | undefined; const abortTracker = createAbortSourceTracker(options?.signal); - const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE); + const firstEventTimeoutAbortError = new AIError.StreamTimeoutError(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE); const { requestAbortController, requestSignal } = abortTracker; const onSseEvent = options?.onSseEvent; const rawSseObserver = onSseEvent @@ -1256,19 +1256,22 @@ const streamOpenAICompletionsOnce = ( output.stopReason = "error"; output.errorMessage = EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE; } - const firstEventTimeoutError = abortTracker.getLocalAbortReason(); - if (firstEventTimeoutError) { - throw firstEventTimeoutError; + const localAbortReason = abortTracker.getLocalAbortReason(); + if (localAbortReason) { + throw localAbortReason; } if (abortTracker.wasCallerAbort()) { - throw new Error("Request was aborted"); + throw new AIError.AbortError(); } if (output.stopReason === "aborted") { - throw new Error("Request was aborted"); + throw new AIError.AbortError(); } if (output.stopReason === "error") { - throw new Error(output.errorMessage || "Provider returned an error stop reason"); + throw new AIError.ProviderResponseError(output.errorMessage || "Provider returned an error stop reason", { + provider: model.provider, + kind: "runtime", + }); } output.errorMessage = undefined; @@ -1284,18 +1287,21 @@ const streamOpenAICompletionsOnce = ( finishOpenBlocksOnError(); } catch {} for (const block of output.content) stripVariant(block, "index"); - const firstEventTimeoutError = abortTracker.getLocalAbortReason(); - output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error"; const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined; - output.errorStatus = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status; - output.errorId = errorIdFromError(error, model.api); - output.errorMessage = - firstEventTimeoutError?.message ?? - (await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse)); + const result = await AIError.finalize(error, { + api: model.api, + provider: model.provider, + abortTracker, + rawRequestDump, + capturedErrorResponse, + }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; // Some providers via OpenRouter include extra details here. const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw; if (rawMetadata) output.errorMessage += `\n${rawMetadata}`; - output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider); output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); @@ -1338,7 +1344,7 @@ function createRequestSetup( azureChatCompletions: { apiVersion, deploymentName }, }); if (!setup.baseUrl) { - throw new Error("OpenAI request setup did not resolve a base URL"); + throw new AIError.ConfigurationError("OpenAI request setup did not resolve a base URL"); } return setup as OpenAIRequestSetup & { baseUrl: string }; } diff --git a/packages/ai/src/providers/openai-responses-server.ts b/packages/ai/src/providers/openai-responses-server.ts index b125c15f1..3bcc22018 100644 --- a/packages/ai/src/providers/openai-responses-server.ts +++ b/packages/ai/src/providers/openai-responses-server.ts @@ -11,6 +11,7 @@ import { logger } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; +import * as AIError from "../error"; import { resolvePromptCacheKey } from "../auth-gateway/http"; import type { AuthGatewayStreamControl, AuthGatewayParsedRequest as ParsedRequest } from "../auth-gateway/types"; import type { @@ -266,7 +267,7 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest { const data = openaiResponsesRequestSchema(body); if (data instanceof type.errors) { - throw new Error(`openai-responses: ${data.summary}`); + throw new AIError.ValidationError(`openai-responses: ${data.summary}`); } const now = Date.now(); @@ -345,7 +346,7 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest { const parsedArgs: unknown = JSON.parse(argsRaw); args = isObj(parsedArgs) ? parsedArgs : {}; } catch { - throw new Error(`openai-responses: function_call ${call.call_id} has invalid JSON arguments`); + throw new AIError.ValidationError(`openai-responses: function_call ${call.call_id} has invalid JSON arguments`); } const toolCall: ToolCall = { type: "toolCall", diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 06c7e4432..397fddb98 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -1,5 +1,6 @@ import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts"; -import { $flag, extractHttpStatusFromError, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils"; +import { $flag, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import { getEnvApiKey } from "../stream"; import type { AssistantMessage, @@ -24,7 +25,7 @@ import { import { createAbortSourceTracker } from "../utils/abort"; import { withEmptyCompletionRetry } from "../utils/empty-completion-retry"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector"; +import type { RawHttpRequestDump } from "../utils/http-inspector"; import { getOpenAIStreamFirstEventTimeoutMs, getOpenAIStreamIdleTimeoutMs, @@ -371,7 +372,9 @@ const streamOpenAIResponsesOnce = ( let chainState: OpenAIResponsesChainState | undefined; let sentPreviousResponseId: string | undefined; const abortTracker = createAbortSourceTracker(options?.signal); - const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE); + const firstEventTimeoutAbortError = new AIError.StreamTimeoutError( + OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE, + ); const { requestAbortController, requestSignal } = abortTracker; const onSseEvent = options?.onSseEvent; const rawSseObserver = onSseEvent @@ -679,12 +682,12 @@ const streamOpenAIResponsesOnce = ( requestServiceTier: options?.serviceTier, }); - const firstEventTimeoutError = abortTracker.getLocalAbortReason(); - if (firstEventTimeoutError) { - throw firstEventTimeoutError; + const localAbortReason = abortTracker.getLocalAbortReason(); + if (localAbortReason) { + throw localAbortReason; } if (abortTracker.wasCallerAbort()) { - throw new Error("Request was aborted"); + throw new AIError.AbortError(); } // Detect premature stream closure: the HTTP stream ended without the @@ -693,11 +696,17 @@ const streamOpenAIResponsesOnce = ( // this guard the incomplete output is silently surfaced as a successful // "stop". if (!sawTerminalResponseEvent) { - throw new Error("OpenAI responses stream closed before a terminal response event was received"); + throw new AIError.ProviderResponseError( + "OpenAI responses stream closed before a terminal response event was received", + { provider: model.provider, kind: "incomplete-stream" }, + ); } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); + throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", { + provider: model.provider, + kind: "runtime", + }); } output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems); @@ -733,17 +742,21 @@ const streamOpenAIResponsesOnce = ( } catch (error) { for (const block of output.content) stripVariant<{ index?: number }>(block, "index"); if (chainState) resetOpenAIResponsesChainState(chainState); - const firstEventTimeoutError = abortTracker.getLocalAbortReason(); - output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error"; const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined; - output.errorStatus = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status; - output.errorMessage = - firstEventTimeoutError?.message ?? - (await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse)); + const result = await AIError.finalize(error, { + api: model.api, + provider: model.provider, + abortTracker, + rawRequestDump, + capturedErrorResponse, + }); + output.stopReason = result.stopReason; + output.errorStatus = result.status; + output.errorId = result.id; + output.errorMessage = result.message; // Some providers via OpenRouter include extra details here. const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw; if (rawMetadata) output.errorMessage += `\n${rawMetadata}`; - output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider); output.duration = performance.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 270a8e735..446a76b7e 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -85,6 +85,7 @@ import type { ResponseStatus, ResponseStreamEvent, } from "./openai-responses-wire"; +import * as AIError from "../error"; import { transformMessages } from "./transform-messages"; import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard"; @@ -170,7 +171,8 @@ export function resolveOpenAIRequestSetup( let apiKey = options.apiKey; if (!apiKey) { if (!$env.OPENAI_API_KEY) { - throw new Error( + throw new AIError.MissingApiKeyError( + undefined, "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.", ); } @@ -763,7 +765,7 @@ export function resolveOpenAICompatPolicy( ) { const minEffort = getSupportedEfforts(model)[0]; if (minEffort === undefined) { - throw new Error(`Model ${model.provider}/${model.id} has no supported reasoning efforts`); + throw new AIError.ConfigurationError(`Model ${model.provider}/${model.id} has no supported reasoning efforts`); } wireEffort = mapOpenAIReasoningEffort(model, compat, minEffort); } @@ -2182,13 +2184,16 @@ export async function processResponsesStream( : typeof statusDetailsReason === "string" && statusDetailsReason.length > 0 ? `status_details: ${statusDetailsReason}` : "Unknown error (no error details in response)"; - throw new Error(message); + throw new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" }); } if (response?.status === "incomplete" && response.incomplete_details?.reason === "content_filter") { // A content-filtered turn is a failure, not a token-cap truncation — // mapping it to "length" would route the agent loop into "shorten your // output" recovery against a filtered prompt. - throw new Error("incomplete: content_filter"); + throw new AIError.ProviderResponseError("incomplete: content_filter", { + provider: model.provider, + kind: "content-blocked", + }); } promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn); options?.onCompleted?.(); @@ -2204,7 +2209,10 @@ export async function processResponsesStream( const err = (event as any).error ?? event; const code = err.code ?? "unknown"; const message = err.message ?? "no message"; - throw new Error(`Error Code ${code}: ${message}`); + throw new AIError.ProviderResponseError(`Error Code ${code}: ${message}`, { + provider: model.provider, + kind: "output", + }); } else if (event.type === "response.failed") { populateResponsesUsageFromResponse(output, event.response?.usage); const error = event.response?.error ?? (event.response as any)?.status_details?.error; @@ -2214,7 +2222,7 @@ export async function processResponsesStream( : details?.reason ? `incomplete: ${details.reason}` : "Unknown error (no error details in response)"; - throw new Error(message); + throw new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" }); } } } diff --git a/packages/ai/src/providers/pi-native-client.ts b/packages/ai/src/providers/pi-native-client.ts index 2ee8e685b..e59ad3787 100644 --- a/packages/ai/src/providers/pi-native-client.ts +++ b/packages/ai/src/providers/pi-native-client.ts @@ -16,7 +16,7 @@ * itself stays credential-free. */ import { readSseJson } from "@oh-my-pi/pi-utils"; -import { ProviderHttpError } from "../errors"; +import * as AIError from "../error"; import type { Api, AssistantMessage, @@ -59,19 +59,7 @@ function buildWireOptions(options: SimpleStreamOptions | undefined): Record { +async function decodeGatewayError(response: Response): Promise { const status = response.status; let body: unknown; try { @@ -84,7 +72,7 @@ async function decodeGatewayError(response: Response): Promise if (typeof err === "object" && err !== null) { const message = (err as { message?: unknown }).message; const type = (err as { type?: unknown }).type; - return new AuthGatewayError( + return new AIError.AuthGatewayError( typeof message === "string" ? message : `auth-gateway ${status}`, status, response.headers, @@ -93,7 +81,11 @@ async function decodeGatewayError(response: Response): Promise } } const text = typeof body === "string" ? body : JSON.stringify(body); - return new AuthGatewayError(`auth-gateway ${status}: ${text || response.statusText}`, status, response.headers); + return new AIError.AuthGatewayError( + `auth-gateway ${status}: ${text || response.statusText}`, + status, + response.headers, + ); } /** @@ -104,7 +96,7 @@ async function decodeGatewayError(response: Response): Promise */ function resolveStreamUrl(model: Model): string { if (!model.baseUrl) { - throw new Error( + throw new AIError.ConfigurationError( `pi-native transport requires \`baseUrl\` on model ${model.id} (set it on the provider config in models.yml)`, ); } @@ -179,7 +171,9 @@ export function streamPiNative( return; } if (!response.body) { - stream.fail(new Error("auth-gateway returned empty body")); + stream.fail( + new AIError.AuthGatewayError("auth-gateway returned empty body", response.status, response.headers), + ); return; } diff --git a/packages/ai/src/providers/pi-native-server.ts b/packages/ai/src/providers/pi-native-server.ts index cf77b2921..64a6a7149 100644 --- a/packages/ai/src/providers/pi-native-server.ts +++ b/packages/ai/src/providers/pi-native-server.ts @@ -25,6 +25,7 @@ * 200 JSON (stream=false): { message: AssistantMessage } * 4xx/5xx: { error: { type, message } } */ +import * as AIError from "../error"; import type { AuthGatewayStreamControl } from "../auth-gateway/types"; import type { AssistantMessageEventStream, Context, SimpleStreamOptions } from "../types"; @@ -92,7 +93,7 @@ const ALLOWED_OPTION_KEYS: ReadonlySet = new Set([ */ export function parseRequest(body: unknown, _headers?: Headers): PiNativeParsedRequest { if (typeof body !== "object" || body === null || Array.isArray(body)) { - throw new Error("Request body must be a JSON object"); + throw new AIError.ValidationError("Request body must be a JSON object"); } const obj = body as Record; @@ -105,21 +106,21 @@ export function parseRequest(body: unknown, _headers?: Headers): PiNativeParsedR const m = obj.model as Record; if (typeof m.id === "string" && m.id.length > 0) modelId = m.id; } - if (!modelId) throw new Error("Missing `modelId` (or `model.id`) field"); + if (!modelId) throw new AIError.ValidationError("Missing `modelId` (or `model.id`) field"); const context = obj.context; if (typeof context !== "object" || context === null || Array.isArray(context)) { - throw new Error("Missing `context` object"); + throw new AIError.ValidationError("Missing `context` object"); } const ctxObj = context as Record; if (!Array.isArray(ctxObj.messages)) { - throw new Error("`context.messages` must be an array"); + throw new AIError.ValidationError("`context.messages` must be an array"); } if (ctxObj.systemPrompt !== undefined && !Array.isArray(ctxObj.systemPrompt)) { - throw new Error("`context.systemPrompt` must be an array of strings when present"); + throw new AIError.ValidationError("`context.systemPrompt` must be an array of strings when present"); } if (ctxObj.tools !== undefined && !Array.isArray(ctxObj.tools)) { - throw new Error("`context.tools` must be an array when present"); + throw new AIError.ValidationError("`context.tools` must be an array when present"); } const options: SimpleStreamOptions = {}; diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts index 1e9ed977a..6a85711b4 100644 --- a/packages/ai/src/providers/register-builtins.ts +++ b/packages/ai/src/providers/register-builtins.ts @@ -19,6 +19,7 @@ import type { Model, OptionsForApi, } from "../types"; +import * as AIError from "../error"; import { type AbortSourceTracker, createAbortSourceTracker } from "../utils/abort"; import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream"; import { @@ -248,8 +249,9 @@ function forwardStream( firstItemTimeoutMs, errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR, firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR, - onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)), - onFirstItemTimeout: () => abortTracker.abortLocally(new Error(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)), + onIdle: () => abortTracker.abortLocally(new AIError.StreamTimeoutError(LAZY_STREAM_IDLE_TIMEOUT_ERROR)), + onFirstItemTimeout: () => + abortTracker.abortLocally(new AIError.StreamTimeoutError(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)), abortSignal: options.signal, // The synthetic `start` event is yielded immediately by every provider before // the upstream model has emitted any tokens. Treating it as the first "real" diff --git a/packages/ai/src/registry/alibaba-coding-plan.ts b/packages/ai/src/registry/alibaba-coding-plan.ts index 6816de912..dee087c39 100644 --- a/packages/ai/src/registry/alibaba-coding-plan.ts +++ b/packages/ai/src/registry/alibaba-coding-plan.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import * as apiKeyValidation from "./api-key-validation"; import type { OAuthController, OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -10,7 +11,7 @@ const VALIDATION_MODEL = "qwen3.5-plus"; export async function loginAlibabaCodingPlan(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("Alibaba Coding Plan login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Alibaba Coding Plan"); } // Ask which endpoint to use @@ -21,7 +22,7 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise< // Check for abort after endpoint selection (Escape returns "") if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const choice = endpointChoice.trim(); @@ -39,7 +40,7 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise< }); const trimmedUrl = customUrl.trim().replace(/\/+$/, ""); if (!trimmedUrl) { - throw new Error("Custom URL is required for option 3"); + throw new AIError.ConfigurationError("Custom URL is required for option 3"); } baseUrl = trimmedUrl; authUrl = DEFAULT_AUTH_URL; @@ -61,12 +62,12 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise< }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } options.onProgress?.("Validating API key..."); diff --git a/packages/ai/src/registry/api-key-login.ts b/packages/ai/src/registry/api-key-login.ts index 35692ee3e..6cecd2849 100644 --- a/packages/ai/src/registry/api-key-login.ts +++ b/packages/ai/src/registry/api-key-login.ts @@ -12,6 +12,7 @@ import { validateOpenAICompatibleApiKey, } from "./api-key-validation"; import type { OAuthController } from "./oauth/types"; +import * as AIError from "../error"; type ChatCompletionsValidation = { kind: "chat-completions"; @@ -52,7 +53,7 @@ export type ApiKeyLoginConfig = { export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthController) => Promise { return async function login(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error(`${config.providerLabel} login requires onPrompt callback`); + throw new AIError.OnPromptRequiredError(config.providerLabel); } options.onAuth?.({ @@ -66,12 +67,12 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } if (config.validation) { diff --git a/packages/ai/src/registry/api-key-validation.ts b/packages/ai/src/registry/api-key-validation.ts index 12b2d2352..9be610405 100644 --- a/packages/ai/src/registry/api-key-validation.ts +++ b/packages/ai/src/registry/api-key-validation.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { FetchImpl } from "../types"; type OpenAICompatibleValidationOptions = { @@ -78,7 +79,7 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa const message = details ? `${options.provider} API key validation failed (${response.status}): ${details}` : `${options.provider} API key validation failed (${response.status})`; - throw new Error(message); + throw new AIError.ApiKeyRequiredError(message); } /** @@ -119,7 +120,7 @@ export async function validateAnthropicCompatibleApiKey(options: AnthropicCompat const message = details ? `${options.provider} API key validation failed (${response.status}): ${details}` : `${options.provider} API key validation failed (${response.status})`; - throw new Error(message); + throw new AIError.ApiKeyRequiredError(message); } /** @@ -156,5 +157,5 @@ export async function validateApiKeyAgainstModelsEndpoint(options: ModelListVali const message = details ? `${options.provider} API key validation failed (${response.status}): ${details}` : `${options.provider} API key validation failed (${response.status})`; - throw new Error(message); + throw new AIError.ApiKeyRequiredError(message); } diff --git a/packages/ai/src/registry/cloudflare-ai-gateway.ts b/packages/ai/src/registry/cloudflare-ai-gateway.ts index bbc424bd9..517592224 100644 --- a/packages/ai/src/registry/cloudflare-ai-gateway.ts +++ b/packages/ai/src/registry/cloudflare-ai-gateway.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -11,7 +12,7 @@ const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/aut */ export async function loginCloudflareAiGateway(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("Cloudflare AI Gateway login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Cloudflare AI Gateway"); } options.onAuth?.({ @@ -26,12 +27,12 @@ export async function loginCloudflareAiGateway(options: OAuthController): Promis }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } return trimmed; diff --git a/packages/ai/src/registry/coreweave.ts b/packages/ai/src/registry/coreweave.ts index b5c2a56ba..d07c2ff4c 100644 --- a/packages/ai/src/registry/coreweave.ts +++ b/packages/ai/src/registry/coreweave.ts @@ -1,5 +1,6 @@ import { coreWeaveProjectHeaders } from "@oh-my-pi/pi-catalog/wire/coreweave"; import { $env } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -10,7 +11,7 @@ const PROJECT_SETUP_INSTRUCTIONS = function requireCoreWeaveProjectHeaders(): Record { const headers = coreWeaveProjectHeaders($env); if (!headers) { - throw new Error( + throw new AIError.ConfigurationError( "CoreWeave Serverless Inference requires OpenAI-Project. Set COREWEAVE_PROJECT=/ before running /login coreweave.", ); } diff --git a/packages/ai/src/registry/deepseek.ts b/packages/ai/src/registry/deepseek.ts index d37ad6e8b..f14531f6c 100644 --- a/packages/ai/src/registry/deepseek.ts +++ b/packages/ai/src/registry/deepseek.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthController, OAuthLoginCallbacks, OAuthPrompt } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -22,7 +23,7 @@ export function normalizeDeepSeekApiKey(raw: string): string { } const stripped = trimmed.replace(/^bearer\b\s*/i, ""); if (!stripped) { - throw new Error("DeepSeek API key is empty after stripping Bearer prefix"); + throw new AIError.ApiKeyRequiredError("DeepSeek API key is empty after stripping Bearer prefix"); } return stripped; } diff --git a/packages/ai/src/registry/google-antigravity.ts b/packages/ai/src/registry/google-antigravity.ts index 78d87323a..3f1236911 100644 --- a/packages/ai/src/registry/google-antigravity.ts +++ b/packages/ai/src/registry/google-antigravity.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -11,7 +12,7 @@ export const googleAntigravityProvider = { }, refreshToken: async (credentials: OAuthCredentials) => { if (!credentials.projectId) { - throw new Error("Antigravity credentials missing projectId"); + throw new AIError.ConfigurationError("Antigravity credentials missing projectId"); } const { refreshAntigravityToken } = await import("./oauth/google-antigravity"); return refreshAntigravityToken(credentials.refresh, credentials.projectId); diff --git a/packages/ai/src/registry/google-gemini-cli.ts b/packages/ai/src/registry/google-gemini-cli.ts index 22b537c33..a3174da91 100644 --- a/packages/ai/src/registry/google-gemini-cli.ts +++ b/packages/ai/src/registry/google-gemini-cli.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -11,7 +12,7 @@ export const googleGeminiCliProvider = { }, refreshToken: async (credentials: OAuthCredentials) => { if (!credentials.projectId) { - throw new Error("Google Cloud credentials missing projectId"); + throw new AIError.ConfigurationError("Google Cloud credentials missing projectId"); } const { refreshGoogleCloudToken } = await import("./oauth/google-gemini-cli"); return refreshGoogleCloudToken(credentials.refresh, credentials.projectId); diff --git a/packages/ai/src/registry/kagi.ts b/packages/ai/src/registry/kagi.ts index e856a6b62..d94a4684c 100644 --- a/packages/ai/src/registry/kagi.ts +++ b/packages/ai/src/registry/kagi.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -11,7 +12,7 @@ const AUTH_URL = "https://kagi.com/settings/api"; */ export async function loginKagi(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("Kagi login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Kagi"); } options.onAuth?.({ @@ -26,12 +27,12 @@ export async function loginKagi(options: OAuthController): Promise { }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } return trimmed; diff --git a/packages/ai/src/registry/kilo.ts b/packages/ai/src/registry/kilo.ts index 450c56cdf..8c57f36b3 100644 --- a/packages/ai/src/registry/kilo.ts +++ b/packages/ai/src/registry/kilo.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthController, OAuthCredentials } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -25,9 +26,17 @@ export async function loginKilo(callbacks: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("LiteLLM login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("LiteLLM"); } options.onAuth?.({ @@ -26,12 +27,12 @@ export async function loginLiteLLM(options: OAuthController): Promise { }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } return trimmed; diff --git a/packages/ai/src/registry/llama-cpp.ts b/packages/ai/src/registry/llama-cpp.ts index 008bc156f..e21cbe9b6 100644 --- a/packages/ai/src/registry/llama-cpp.ts +++ b/packages/ai/src/registry/llama-cpp.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -8,7 +9,7 @@ const DEFAULT_LOCAL_TOKEN = "llama-cpp-local"; export async function loginLlamaCpp(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); + throw new AIError.OnPromptRequiredError(PROVIDER_ID); } options.onAuth?.({ url: AUTH_URL, @@ -20,7 +21,7 @@ export async function loginLlamaCpp(options: OAuthController): Promise { allowEmpty: true, }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); return trimmed || DEFAULT_LOCAL_TOKEN; diff --git a/packages/ai/src/registry/lm-studio.ts b/packages/ai/src/registry/lm-studio.ts index 6f8741b0c..30887c5d2 100644 --- a/packages/ai/src/registry/lm-studio.ts +++ b/packages/ai/src/registry/lm-studio.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -6,7 +7,7 @@ export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; export async function loginLmStudio(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); + throw new AIError.OnPromptRequiredError(PROVIDER_ID); } const apiKey = await options.onPrompt({ @@ -16,7 +17,7 @@ export async function loginLmStudio(options: OAuthController): Promise { }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); diff --git a/packages/ai/src/registry/nvidia.ts b/packages/ai/src/registry/nvidia.ts index 55f38cb7b..c4474d5de 100644 --- a/packages/ai/src/registry/nvidia.ts +++ b/packages/ai/src/registry/nvidia.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -9,7 +10,7 @@ const PROVIDER_ID = "nvidia"; export async function loginNvidia(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("NVIDIA login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("NVIDIA"); } options.onAuth?.({ @@ -23,12 +24,12 @@ export async function loginNvidia(options: OAuthController): Promise { }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } options.onProgress?.("Validating API key (optional)..."); diff --git a/packages/ai/src/registry/oauth/anthropic.ts b/packages/ai/src/registry/oauth/anthropic.ts index 6c9d0eca4..3eb60695e 100644 --- a/packages/ai/src/registry/oauth/anthropic.ts +++ b/packages/ai/src/registry/oauth/anthropic.ts @@ -2,6 +2,7 @@ * Anthropic OAuth flow (Claude Pro/Max) */ +import * as AIError from "../../error"; import { claudeCodeVersion } from "../../providers/anthropic"; import type { FetchImpl } from "../../types"; import { OAuthCallbackFlow } from "./callback-server"; @@ -59,7 +60,10 @@ async function postJson( const responseBody = await response.text(); if (!response.ok) { - throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`); + throw new AIError.ProviderHttpError( + `HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`, + response.status, + ); } return responseBody; } @@ -88,8 +92,9 @@ function parseOAuthTokenResponse(responseBody: string, operation: string): Anthr try { return JSON.parse(responseBody) as AnthropicTokenResponse; } catch (error) { - throw new Error( + throw new AIError.OAuthError( `Anthropic ${operation} returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`, + { kind: "validation", provider: "anthropic", cause: error }, ); } } @@ -131,14 +136,18 @@ async function fetchBootstrapIdentity( }); const responseBody = await response.text(); if (!response.ok) { - throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`); + throw new AIError.ProviderHttpError( + `HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`, + response.status, + ); } let data: AnthropicBootstrapResponse; try { data = JSON.parse(responseBody) as AnthropicBootstrapResponse; } catch (error) { - throw new Error( + throw new AIError.OAuthError( `Anthropic bootstrap returned invalid JSON. url=${url}; body=${responseBody}; details=${formatErrorDetails(error)}`, + { kind: "validation", provider: "anthropic", cause: error }, ); } const accountUuid = data.oauth_account?.account_uuid; @@ -227,8 +236,9 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow { this.#fetch, ); } catch (error) { - throw new Error( + throw new AIError.OAuthError( `Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`, + { kind: "token-exchange", provider: "anthropic", cause: error }, ); } @@ -278,7 +288,11 @@ export async function refreshAnthropicToken( }, ); } catch (error) { - throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`); + throw new AIError.OAuthError(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`, { + kind: "token-refresh", + provider: "anthropic", + cause: error, + }); } const data = parseOAuthTokenResponse(responseBody, "token refresh"); diff --git a/packages/ai/src/registry/oauth/callback-server.ts b/packages/ai/src/registry/oauth/callback-server.ts index acd9f888b..463407e45 100644 --- a/packages/ai/src/registry/oauth/callback-server.ts +++ b/packages/ai/src/registry/oauth/callback-server.ts @@ -10,6 +10,7 @@ * - generateAuthUrl(): Build provider-specific authorization URL * - exchangeToken(): Exchange authorization code for tokens */ +import * as AIError from "../../error"; import templateHtml from "./oauth.html" with { type: "text" }; import type { OAuthController, OAuthCredentials } from "./types"; @@ -127,7 +128,7 @@ export abstract class OAuthCallbackFlow { return { server, redirectUri }; } catch { if (this.redirectUri) { - throw new Error( + throw new AIError.ConfigurationError( `OAuth callback port ${this.preferredPort} unavailable; cannot fall back to a random port when oauth.redirectUri is set`, ); } @@ -214,7 +215,7 @@ export abstract class OAuthCallbackFlow { signal.addEventListener("abort", () => { this.#callbackResolve = undefined; this.#callbackReject = undefined; - reject(new Error(`OAuth callback cancelled: ${signal.reason}`)); + reject(new AIError.LoginCancelledError(`OAuth callback cancelled: ${signal.reason}`)); }); }); diff --git a/packages/ai/src/registry/oauth/cursor.ts b/packages/ai/src/registry/oauth/cursor.ts index c01112126..bd4fbe81e 100644 --- a/packages/ai/src/registry/oauth/cursor.ts +++ b/packages/ai/src/registry/oauth/cursor.ts @@ -1,3 +1,4 @@ +import * as AIError from "../../error"; import { generatePKCE } from "./pkce"; import type { OAuthCredentials } from "./types"; @@ -63,16 +64,26 @@ export async function pollCursorAuth( }; } - throw new Error(`Poll failed: ${response.status}`); + throw new AIError.OAuthError(`Poll failed: ${response.status}`, { + kind: "polling", + provider: "cursor", + status: response.status, + }); } catch { consecutiveErrors++; if (consecutiveErrors >= 3) { - throw new Error("Too many consecutive errors during Cursor auth polling"); + throw new AIError.OAuthError("Too many consecutive errors during Cursor auth polling", { + kind: "polling", + provider: "cursor", + }); } } } - throw new Error("Cursor authentication polling timeout"); + throw new AIError.OAuthError("Cursor authentication polling timeout", { + kind: "timeout", + provider: "cursor", + }); } export async function loginCursor( @@ -107,7 +118,10 @@ export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise< if (!response.ok) { const error = await response.text(); - throw new Error(`Cursor token refresh failed: ${error}`); + throw new AIError.OAuthError(`Cursor token refresh failed: ${error}`, { + kind: "token-refresh", + provider: "cursor", + }); } const data = (await response.json()) as { diff --git a/packages/ai/src/registry/oauth/devin.ts b/packages/ai/src/registry/oauth/devin.ts index 5e9e3eff2..b3b8029c5 100644 --- a/packages/ai/src/registry/oauth/devin.ts +++ b/packages/ai/src/registry/oauth/devin.ts @@ -1,3 +1,4 @@ +import * as AIError from "../../error"; import { OAuthCallbackFlow } from "./callback-server"; import { generatePKCE } from "./pkce"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -54,7 +55,10 @@ class DevinOAuthFlow extends OAuthCallbackFlow { async exchangeToken(code: string): Promise { if (!this.#pkce) { - throw new Error("Devin PKCE verifier was not initialized"); + throw new AIError.OAuthError("Devin PKCE verifier was not initialized", { + kind: "configuration", + provider: "devin", + }); } const token = await exchangeDevinCliToken(code, this.#pkce.verifier, this.ctrl.fetch); @@ -87,12 +91,19 @@ export async function exchangeDevinCliToken( if (!response.ok) { const error = await response.text(); - throw new Error(`Devin CLI token exchange failed: ${response.status} ${error}`.trim()); + throw new AIError.OAuthError(`Devin CLI token exchange failed: ${response.status} ${error}`.trim(), { + kind: "token-exchange", + provider: "devin", + status: response.status, + }); } const data = (await response.json()) as { token?: unknown }; if (typeof data.token !== "string" || data.token.length === 0) { - throw new Error("Devin CLI token exchange returned an empty token"); + throw new AIError.OAuthError("Devin CLI token exchange returned an empty token", { + kind: "validation", + provider: "devin", + }); } return data.token; } diff --git a/packages/ai/src/registry/oauth/github-copilot.ts b/packages/ai/src/registry/oauth/github-copilot.ts index c786c0fd7..07f98846a 100644 --- a/packages/ai/src/registry/oauth/github-copilot.ts +++ b/packages/ai/src/registry/oauth/github-copilot.ts @@ -12,6 +12,7 @@ import { normalizeGitHubCopilotEnterpriseDomain, OPENCODE_HEADERS, } from "@oh-my-pi/pi-catalog/wire/github-copilot"; +import * as AIError from "../../error"; import type { FetchImpl } from "../../types"; import type { OAuthCredentials } from "./types"; @@ -63,7 +64,7 @@ async function fetchJson(url: string, init: RequestInit, fetchImpl: FetchImpl): const response = await fetchImpl(url, init); if (!response.ok) { const text = await response.text(); - throw new Error(`${response.status} ${response.statusText}: ${text}`); + throw new AIError.ProviderHttpError(`${response.status} ${response.statusText}: ${text}`, response.status); } return response.json(); } @@ -88,7 +89,7 @@ async function startDeviceFlow(domain: string, fetchImpl: FetchImpl): Promise).device_code; @@ -104,7 +105,7 @@ async function startDeviceFlow(domain: string, fetchImpl: FetchImpl): Promise 0) { - throw new Error( + throw new AIError.OAuthError( "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.", + { kind: "timeout", provider: "github-copilot" }, ); } - throw new Error("Device flow timed out"); + throw new AIError.OAuthError("Device flow timed out", { kind: "timeout", provider: "github-copilot" }); } /** Far-future expiry (10 years). GitHub OAuth tokens are long-lived; no JWT exchange needed. */ @@ -314,13 +316,13 @@ export async function loginGitHubCopilot(options: GitHubCopilotLoginOptions): Pr }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = input.trim(); const normalizedDomain = normalizeDomain(input); if (trimmed && !normalizedDomain) { - throw new Error("Invalid GitHub Enterprise URL/domain"); + throw new AIError.OAuthError("Invalid GitHub Enterprise URL/domain", { kind: "validation", provider: "github-copilot" }); } const enterpriseDomain = normalizeGitHubCopilotEnterpriseDomain(normalizedDomain ?? undefined); const domain = diff --git a/packages/ai/src/registry/oauth/gitlab-duo-workflow.ts b/packages/ai/src/registry/oauth/gitlab-duo-workflow.ts index ca2fbd89d..afe01bfbe 100644 --- a/packages/ai/src/registry/oauth/gitlab-duo-workflow.ts +++ b/packages/ai/src/registry/oauth/gitlab-duo-workflow.ts @@ -1,3 +1,4 @@ +import * as AIError from "../../error"; import type { FetchImpl } from "../../types"; import { OAuthCallbackFlow } from "./callback-server"; import { generatePKCE } from "./pkce"; @@ -20,7 +21,10 @@ function mapTokenResponse(payload: { created_at?: number; }): OAuthCredentials { if (!payload.access_token || !payload.refresh_token || typeof payload.expires_in !== "number") { - throw new Error("GitLab Duo Workflow OAuth token response missing required fields"); + throw new AIError.OAuthError("GitLab Duo Workflow OAuth token response missing required fields", { + kind: "validation", + provider: "gitlab-duo-workflow", + }); } const createdAtMs = @@ -82,8 +86,9 @@ class GitLabDuoWorkflowOAuthFlow extends OAuthCallbackFlow { }); if (!response.ok) { - throw new Error( + throw new AIError.OAuthError( `GitLab Duo Workflow OAuth token exchange failed: ${response.status} ${await response.text()}`, + { kind: "token-exchange", provider: "gitlab-duo-workflow", status: response.status }, ); } @@ -120,7 +125,11 @@ export async function refreshGitLabDuoWorkflowToken( }); if (!response.ok) { - throw new Error(`GitLab Duo Workflow OAuth refresh failed: ${response.status} ${await response.text()}`); + throw new AIError.OAuthError(`GitLab Duo Workflow OAuth refresh failed: ${response.status} ${await response.text()}`, { + kind: "token-refresh", + provider: "gitlab-duo-workflow", + status: response.status, + }); } return mapTokenResponse( diff --git a/packages/ai/src/registry/oauth/gitlab-duo.ts b/packages/ai/src/registry/oauth/gitlab-duo.ts index 3b40fc809..e08af4c4a 100644 --- a/packages/ai/src/registry/oauth/gitlab-duo.ts +++ b/packages/ai/src/registry/oauth/gitlab-duo.ts @@ -1,3 +1,4 @@ +import * as AIError from "../../error"; import { clearGitLabDuoDirectAccessCache } from "../../providers/gitlab-duo"; import type { FetchImpl } from "../../types"; import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; @@ -59,15 +60,21 @@ function resolveCallbackOptions(): OAuthCallbackFlowOptions { try { parsed = new URL(raw); } catch { - throw new Error(`Invalid GITLAB_REDIRECT_URI: ${raw}`); + throw new AIError.OAuthError(`Invalid GITLAB_REDIRECT_URI: ${raw}`, { kind: "configuration", provider: "gitlab-duo" }); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`GITLAB_REDIRECT_URI must use http:// or https://, got: ${raw}`); + throw new AIError.OAuthError(`GITLAB_REDIRECT_URI must use http:// or https://, got: ${raw}`, { + kind: "configuration", + provider: "gitlab-duo", + }); } const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]"; if (isLoopback && parsed.protocol !== "http:") { - throw new Error(`GITLAB_REDIRECT_URI loopback callbacks must use http://, got: ${raw}`); + throw new AIError.OAuthError(`GITLAB_REDIRECT_URI loopback callbacks must use http://, got: ${raw}`, { + kind: "configuration", + provider: "gitlab-duo", + }); } const port = parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80; @@ -87,7 +94,10 @@ function mapTokenResponse(payload: { created_at?: number; }): OAuthCredentials { if (!payload.access_token || !payload.refresh_token || typeof payload.expires_in !== "number") { - throw new Error("GitLab OAuth token response missing required fields"); + throw new AIError.OAuthError("GitLab OAuth token response missing required fields", { + kind: "validation", + provider: "gitlab-duo", + }); } const createdAtMs = @@ -148,7 +158,11 @@ class GitLabDuoOAuthFlow extends OAuthCallbackFlow { }); if (!response.ok) { - throw new Error(`GitLab OAuth token exchange failed: ${response.status} ${await response.text()}`); + throw new AIError.OAuthError(`GitLab OAuth token exchange failed: ${response.status} ${await response.text()}`, { + kind: "token-exchange", + provider: "gitlab-duo", + status: response.status, + }); } clearGitLabDuoDirectAccessCache(); @@ -183,7 +197,11 @@ export async function refreshGitLabDuoToken(credentials: OAuthCredentials): Prom }); if (!response.ok) { - throw new Error(`GitLab OAuth refresh failed: ${response.status} ${await response.text()}`); + throw new AIError.OAuthError(`GitLab OAuth refresh failed: ${response.status} ${await response.text()}`, { + kind: "token-refresh", + provider: "gitlab-duo", + status: response.status, + }); } clearGitLabDuoDirectAccessCache(); diff --git a/packages/ai/src/registry/oauth/google-antigravity.ts b/packages/ai/src/registry/oauth/google-antigravity.ts index 123e19e89..6ef78c7f8 100644 --- a/packages/ai/src/registry/oauth/google-antigravity.ts +++ b/packages/ai/src/registry/oauth/google-antigravity.ts @@ -3,6 +3,7 @@ * Uses different OAuth credentials than google-gemini-cli for access to additional models. */ import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; +import * as AIError from "../../error"; import { runGoogleOAuthLogin } from "./google-oauth-shared"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -89,7 +90,10 @@ async function onboardProjectWithRetries( if (!onboardResponse.ok) { const errorText = await onboardResponse.text(); - throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`); + throw new AIError.OAuthError( + `onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`, + { kind: "provisioning", status: onboardResponse.status }, + ); } const operation = (await onboardResponse.json()) as LongRunningOperationResponse; @@ -103,8 +107,9 @@ async function onboardProjectWithRetries( } } - throw new Error( + throw new AIError.OAuthError( `onboardUser did not return a provisioned project id after ${PROJECT_ONBOARD_MAX_ATTEMPTS} attempts`, + { kind: "provisioning" }, ); } @@ -128,7 +133,10 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin if (!loadResponse.ok) { const errorText = await loadResponse.text(); - throw new Error(`loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`); + throw new AIError.OAuthError( + `loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`, + { kind: "discovery", status: loadResponse.status }, + ); } const loadPayload = (await loadResponse.json()) as LoadCodeAssistPayload; @@ -146,8 +154,9 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin const provisionedProject = await onboardProjectWithRetries(endpoint, headers, onboardBody, onProgress); return provisionedProject; } catch (error) { - throw new Error( + throw new AIError.OAuthError( `Could not discover or provision an Antigravity project. ${error instanceof Error ? error.message : String(error)}`, + { kind: "discovery", cause: error }, ); } } @@ -182,7 +191,7 @@ export async function refreshAntigravityToken(refreshToken: string, projectId: s if (!response.ok) { const error = await response.text(); - throw new Error(`Antigravity token refresh failed: ${error}`); + throw new AIError.OAuthError(`Antigravity token refresh failed: ${error}`, { kind: "token-refresh" }); } const data = (await response.json()) as { diff --git a/packages/ai/src/registry/oauth/google-gemini-cli.ts b/packages/ai/src/registry/oauth/google-gemini-cli.ts index d43f1669a..7833069a7 100644 --- a/packages/ai/src/registry/oauth/google-gemini-cli.ts +++ b/packages/ai/src/registry/oauth/google-gemini-cli.ts @@ -5,6 +5,7 @@ import { getGeminiCliHeaders } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { $env } from "@oh-my-pi/pi-utils"; +import * as AIError from "../../error"; import { runGoogleOAuthLogin } from "./google-oauth-shared"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -80,7 +81,11 @@ async function pollOperation( }); if (!response.ok) { - throw new Error(`Failed to poll operation: ${response.status} ${response.statusText}`); + throw new AIError.OAuthError(`Failed to poll operation: ${response.status} ${response.statusText}`, { + kind: "polling", + provider: "google-gemini-cli", + status: response.status, + }); } const data = (await response.json()) as LongRunningOperationResponse; @@ -130,7 +135,10 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin data = { currentTier: { id: TIER_STANDARD } }; } else { const errorText = await loadResponse.text(); - throw new Error(`loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`); + throw new AIError.OAuthError( + `loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`, + { kind: "discovery", provider: "google-gemini-cli", status: loadResponse.status }, + ); } } else { data = (await loadResponse.json()) as LoadCodeAssistPayload; @@ -143,9 +151,10 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin if (envProjectId) { return envProjectId; } - throw new Error( + throw new AIError.OAuthError( "This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", + { kind: "configuration", provider: "google-gemini-cli" }, ); } @@ -153,9 +162,10 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin const tierId = tier?.id ?? TIER_FREE; if (tierId !== TIER_FREE && !envProjectId) { - throw new Error( + throw new AIError.OAuthError( "This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", + { kind: "configuration", provider: "google-gemini-cli" }, ); } @@ -183,7 +193,10 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin if (!onboardResponse.ok) { const errorText = await onboardResponse.text(); - throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`); + throw new AIError.OAuthError( + `onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`, + { kind: "provisioning", provider: "google-gemini-cli", status: onboardResponse.status }, + ); } let lroData = (await onboardResponse.json()) as LongRunningOperationResponse; @@ -201,10 +214,11 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin return envProjectId; } - throw new Error( + throw new AIError.OAuthError( "Could not discover or provision a Google Cloud project. " + "Try setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", + { kind: "validation", provider: "google-gemini-cli" }, ); } @@ -238,7 +252,10 @@ export async function refreshGoogleCloudToken(refreshToken: string, projectId: s if (!response.ok) { const error = await response.text(); - throw new Error(`Google Cloud token refresh failed: ${error}`); + throw new AIError.OAuthError(`Google Cloud token refresh failed: ${error}`, { + kind: "token-refresh", + provider: "google-gemini-cli", + }); } const data = (await response.json()) as { diff --git a/packages/ai/src/registry/oauth/google-oauth-shared.ts b/packages/ai/src/registry/oauth/google-oauth-shared.ts index cf3c94d46..78c8359a3 100644 --- a/packages/ai/src/registry/oauth/google-oauth-shared.ts +++ b/packages/ai/src/registry/oauth/google-oauth-shared.ts @@ -4,6 +4,7 @@ * Both providers use the same authorization-code flow shape; only the client * credentials, scopes, endpoint constants, and project-discovery logic differ. */ +import * as AIError from "../../error"; import { extractGoogleValidationUrl, formatGoogleValidationRequiredMessage } from "../../utils/google-validation"; import { OAuthCallbackFlow } from "./callback-server"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -79,7 +80,7 @@ export class GoogleOAuthFlow extends OAuthCallbackFlow { if (!tokenResponse.ok) { const error = await tokenResponse.text(); - throw new Error(`Token exchange failed: ${error}`); + throw new AIError.OAuthError(`Token exchange failed: ${error}`, { kind: "token-exchange" }); } const tokenData = (await tokenResponse.json()) as { @@ -89,7 +90,7 @@ export class GoogleOAuthFlow extends OAuthCallbackFlow { }; if (!tokenData.refresh_token) { - throw new Error("No refresh token received. Please try again."); + throw new AIError.OAuthError("No refresh token received. Please try again.", { kind: "validation" }); } this.ctrl.onProgress?.("Getting user info..."); @@ -100,7 +101,9 @@ export class GoogleOAuthFlow extends OAuthCallbackFlow { } catch (err) { const validationUrl = extractGoogleValidationUrl(err instanceof Error ? err.message : String(err)); if (!validationUrl) throw err; - throw new Error(formatGoogleValidationRequiredMessage(validationUrl, "sign in again", email)); + throw new AIError.OAuthError(formatGoogleValidationRequiredMessage(validationUrl, "sign in again", email), { + kind: "validation", + }); } return { diff --git a/packages/ai/src/registry/oauth/index.ts b/packages/ai/src/registry/oauth/index.ts index e2ae2722a..4cca3a778 100644 --- a/packages/ai/src/registry/oauth/index.ts +++ b/packages/ai/src/registry/oauth/index.ts @@ -2,6 +2,7 @@ // High-level API // ============================================================================ +import * as AIError from "../../error"; import { getProviderDefinition, PROVIDER_REGISTRY } from "../registry"; import type { OAuthCredentials, @@ -46,14 +47,14 @@ async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefi return; } if (signal.aborted) { - throw new Error(DEVICE_FLOW_CANCEL_MESSAGE); + throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE); } const { promise, resolve, reject } = Promise.withResolvers(); let timer: Timer | undefined; const onAbort = () => { if (timer) clearTimeout(timer); - reject(new Error(DEVICE_FLOW_CANCEL_MESSAGE)); + reject(new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE)); }; timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); @@ -77,14 +78,14 @@ export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodeFlowOpt while (Date.now() < deadline) { if (options.signal?.aborted) { - throw new Error(DEVICE_FLOW_CANCEL_MESSAGE); + throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE); } const result = await options.poll(); if (result.status === "complete") { return result.value; } if (result.status === "failed") { - throw new Error(result.message); + throw new AIError.OAuthError(result.message, { kind: "polling" }); } if (result.status === "slow_down") { slowDownResponses += 1; @@ -98,7 +99,10 @@ export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodeFlowOpt await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal); } - throw new Error(slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE); + throw new AIError.OAuthError( + slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE, + { kind: "timeout" }, + ); } const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter( @@ -146,11 +150,17 @@ export async function refreshOAuthToken( credentials: OAuthCredentials, ): Promise { if (!credentials) { - throw new Error(`No OAuth credentials found for ${provider}`); + throw new AIError.OAuthError(`No OAuth credentials found for ${provider}`, { + kind: "validation", + provider, + }); } const def = getProviderDefinition(provider); if (!def?.login) { - throw new Error(`Unknown OAuth provider: ${provider}`); + throw new AIError.OAuthError(`Unknown OAuth provider: ${provider}`, { + kind: "validation", + provider, + }); } // Providers without a real refresher (static bearer tokens / API keys that // don't expire) return the credentials unchanged. @@ -219,8 +229,9 @@ export async function getOAuthApiKey( return { newCredentials: fallbackCredentials, apiKey: fallbackCredentials.access }; } } - throw new Error( + throw new AIError.OAuthError( `OAuth credential for ${provider} is expired and must be refreshed via AuthStorage before getOAuthApiKey is called`, + { kind: "validation", provider }, ); } // For providers that need request-time credential metadata, return JSON. diff --git a/packages/ai/src/registry/oauth/kimi.ts b/packages/ai/src/registry/oauth/kimi.ts index 7c98471f5..35423f4dc 100644 --- a/packages/ai/src/registry/oauth/kimi.ts +++ b/packages/ai/src/registry/oauth/kimi.ts @@ -9,6 +9,7 @@ import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { $env, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; import packageJson from "../../../package.json" with { type: "json" }; +import * as AIError from "../../error"; import type { OAuthController, OAuthCredentials } from "./types"; const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; @@ -113,7 +114,11 @@ async function requestDeviceAuthorization(): Promise<{ if (!response.ok) { const text = await response.text(); - throw new Error(`Kimi device authorization failed: ${response.status} ${text}`); + throw new AIError.OAuthError(`Kimi device authorization failed: ${response.status} ${text}`, { + kind: "device-auth", + provider: "kimi", + status: response.status, + }); } const payload = (await response.json()) as DeviceAuthorizationResponse; @@ -123,7 +128,10 @@ async function requestDeviceAuthorization(): Promise<{ const verificationUriComplete = payload.verification_uri_complete; if (!userCode || !deviceCode || !verificationUri) { - throw new Error("Kimi device authorization response missing required fields"); + throw new AIError.OAuthError("Kimi device authorization response missing required fields", { + kind: "validation", + provider: "kimi", + }); } const expiresInMs = typeof payload.expires_in === "number" ? payload.expires_in * 1000 : DEFAULT_DEVICE_FLOW_TTL_MS; @@ -142,12 +150,18 @@ async function requestDeviceAuthorization(): Promise<{ function parseTokenPayload(payload: TokenResponse, refreshTokenFallback?: string): OAuthCredentials { if (!payload.access_token || typeof payload.expires_in !== "number") { - throw new Error("Kimi token response missing required fields"); + throw new AIError.OAuthError("Kimi token response missing required fields", { + kind: "validation", + provider: "kimi", + }); } const refresh = payload.refresh_token ?? refreshTokenFallback; if (!refresh) { - throw new Error("Kimi token response missing refresh token"); + throw new AIError.OAuthError("Kimi token response missing refresh token", { + kind: "validation", + provider: "kimi", + }); } return { @@ -168,7 +182,7 @@ async function pollForToken( while (Date.now() < deadline) { if (signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, { @@ -204,18 +218,30 @@ async function pollForToken( } if (error === "expired_token") { - throw new Error("Kimi device authorization expired"); + throw new AIError.OAuthError("Kimi device authorization expired", { + kind: "validation", + provider: "kimi", + }); } if (error === "access_denied") { - throw new Error("Kimi device authorization denied"); + throw new AIError.OAuthError("Kimi device authorization denied", { + kind: "validation", + provider: "kimi", + }); } const description = payload.error_description ? `: ${payload.error_description}` : ""; - throw new Error(`Kimi device flow failed: ${error ?? response.status}${description}`); + throw new AIError.OAuthError(`Kimi device flow failed: ${error ?? response.status}${description}`, { + kind: "polling", + provider: "kimi", + }); } - throw new Error("Kimi device flow timed out"); + throw new AIError.OAuthError("Kimi device flow timed out", { + kind: "timeout", + provider: "kimi", + }); } /** @@ -251,7 +277,11 @@ export async function refreshKimiToken(refreshToken: string): Promise undefined)) as TokenResponse | undefined; const description = payload?.error_description ? `: ${payload.error_description}` : ""; - throw new Error(`Kimi token refresh failed: ${response.status}${description}`); + throw new AIError.OAuthError(`Kimi token refresh failed: ${response.status}${description}`, { + kind: "token-refresh", + provider: "kimi", + status: response.status, + }); } const payload = (await response.json()) as TokenResponse; diff --git a/packages/ai/src/registry/oauth/openai-codex.ts b/packages/ai/src/registry/oauth/openai-codex.ts index 6c9cfb7a4..725161aab 100644 --- a/packages/ai/src/registry/oauth/openai-codex.ts +++ b/packages/ai/src/registry/oauth/openai-codex.ts @@ -3,6 +3,7 @@ */ import { OPENAI_HEADER_VALUES } from "@oh-my-pi/pi-catalog/wire/codex"; +import * as AIError from "../../error"; import type { FetchImpl } from "../../types"; import { isRecord } from "../../utils"; import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; @@ -175,7 +176,10 @@ async function exchangeCodeForToken( if (!tokenResponse.ok) { const bodyText = await tokenResponse.text(); - throw new Error(`Token exchange failed: ${formatOpenAICodexTokenEndpointError(tokenResponse.status, bodyText)}`); + throw new AIError.OAuthError( + `Token exchange failed: ${formatOpenAICodexTokenEndpointError(tokenResponse.status, bodyText)}`, + { kind: "token-exchange", status: tokenResponse.status }, + ); } const tokenData = (await tokenResponse.json()) as { @@ -185,12 +189,12 @@ async function exchangeCodeForToken( }; if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") { - throw new Error("Token response missing required fields"); + throw new AIError.OAuthError("Token response missing required fields", { kind: "validation" }); } const { accountId, email } = getTokenProfile(tokenData.access_token); if (!accountId) { - throw new Error("Failed to extract accountId from token"); + throw new AIError.OAuthError("Failed to extract accountId from token", { kind: "validation" }); } return { @@ -235,7 +239,10 @@ export async function loginOpenAICodexDevice(ctrl: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("OpenCode Zen login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("OpenCode Zen"); } // Open browser to auth page @@ -37,12 +38,12 @@ export async function loginOpenCode(options: OAuthController): Promise { }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } return trimmed; diff --git a/packages/ai/src/registry/oauth/perplexity.ts b/packages/ai/src/registry/oauth/perplexity.ts index 968d14a97..34814ab00 100644 --- a/packages/ai/src/registry/oauth/perplexity.ts +++ b/packages/ai/src/registry/oauth/perplexity.ts @@ -14,6 +14,7 @@ import * as os from "node:os"; import { $env } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; +import * as AIError from "../../error"; import type { OAuthController, OAuthCredentials } from "./types"; const API_VERSION = "2.18"; @@ -87,15 +88,16 @@ async function extractFromNativeApp(): Promise { */ async function httpEmailLogin(ctrl: OAuthController): Promise { if (!ctrl.onPrompt) { - throw new Error("Perplexity login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Perplexity"); } const email = await ctrl.onPrompt({ message: "Enter your Perplexity email address", placeholder: "user@example.com", }); const trimmedEmail = email.trim(); - if (!trimmedEmail) throw new Error("Email is required for Perplexity login"); - if (ctrl.signal?.aborted) throw new Error("Login cancelled"); + if (!trimmedEmail) + throw new AIError.OAuthError("Email is required for Perplexity login", { kind: "validation", provider: "perplexity" }); + if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError(); ctrl.onProgress?.("Fetching Perplexity CSRF token..."); const csrfResponse = await fetch("https://www.perplexity.ai/api/auth/csrf", { @@ -107,12 +109,12 @@ async function httpEmailLogin(ctrl: OAuthController): Promise }); if (!csrfResponse.ok) { - throw new Error(`Perplexity CSRF request failed: ${csrfResponse.status}`); + throw new AIError.ProviderHttpError(`Perplexity CSRF request failed: ${csrfResponse.status}`, csrfResponse.status); } const csrfData = (await csrfResponse.json()) as { csrfToken?: string }; if (!csrfData.csrfToken) { - throw new Error("Perplexity CSRF response missing csrfToken"); + throw new AIError.OAuthError("Perplexity CSRF response missing csrfToken", { kind: "validation", provider: "perplexity" }); } ctrl.onProgress?.("Sending login code to your email..."); const sendResponse = await fetch("https://www.perplexity.ai/api/auth/signin-email", { @@ -131,15 +133,16 @@ async function httpEmailLogin(ctrl: OAuthController): Promise if (!sendResponse.ok) { const body = await sendResponse.text(); - throw new Error(`Perplexity send login code failed (${sendResponse.status}): ${body}`); + throw new AIError.ProviderHttpError(`Perplexity send login code failed (${sendResponse.status}): ${body}`, sendResponse.status); } const otp = await ctrl.onPrompt({ message: "Enter the code sent to your email", placeholder: "123456", }); const trimmedOtp = otp.trim(); - if (!trimmedOtp) throw new Error("OTP code is required"); - if (ctrl.signal?.aborted) throw new Error("Login cancelled"); + if (!trimmedOtp) + throw new AIError.OAuthError("OTP code is required", { kind: "validation", provider: "perplexity" }); + if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError(); ctrl.onProgress?.("Verifying login code..."); const verifyResponse = await fetch("https://www.perplexity.ai/api/auth/signin-otp", { method: "POST", @@ -165,11 +168,15 @@ async function httpEmailLogin(ctrl: OAuthController): Promise if (!verifyResponse.ok) { const reason = verifyData.text ?? verifyData.error_code ?? verifyData.status ?? "OTP verification failed"; - throw new Error(`Perplexity OTP verification failed: ${reason}`); + throw new AIError.OAuthError(`Perplexity OTP verification failed: ${reason}`, { + kind: "validation", + provider: "perplexity", + status: verifyResponse.status, + }); } if (!verifyData.token) { - throw new Error("Perplexity OTP verification response missing token"); + throw new AIError.OAuthError("Perplexity OTP verification response missing token", { kind: "validation", provider: "perplexity" }); } return jwtToCredentials(verifyData.token, trimmedEmail); @@ -188,7 +195,7 @@ async function httpEmailLogin(ctrl: OAuthController): Promise */ export async function loginPerplexity(ctrl: OAuthController): Promise { if (!ctrl.onPrompt) { - throw new Error("Perplexity login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Perplexity"); } // Path 1: Native macOS app JWT (skip if PI_AUTH_NO_BORROW=1) diff --git a/packages/ai/src/registry/oauth/xai-oauth.ts b/packages/ai/src/registry/oauth/xai-oauth.ts index 1806d05e6..bd2d47ef6 100644 --- a/packages/ai/src/registry/oauth/xai-oauth.ts +++ b/packages/ai/src/registry/oauth/xai-oauth.ts @@ -10,6 +10,7 @@ * rejected on every call site, not just the first. */ +import * as AIError from "../../error"; import type { FetchImpl } from "../../types"; import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; import { generatePKCE } from "./pkce"; @@ -54,14 +55,14 @@ export function validateXAIEndpoint(url: string, field: string): string { try { parsed = new URL(url); } catch { - throw new Error(`Invalid xAI ${field}: ${url}`); + throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" }); } if (parsed.protocol !== "https:") { - throw new Error(`Invalid xAI ${field}: ${url}`); + throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" }); } const host = parsed.hostname.toLowerCase(); if (!host || (host !== "x.ai" && !host.endsWith(".x.ai"))) { - throw new Error(`Invalid xAI ${field}: ${url}`); + throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" }); } return url; } @@ -84,28 +85,43 @@ async function xaiOAuthDiscovery( signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { - throw new Error(`xAI OIDC discovery failed: ${error instanceof Error ? error.message : String(error)}`); + throw new AIError.OAuthError(`xAI OIDC discovery failed: ${error instanceof Error ? error.message : String(error)}`, { + kind: "discovery", + provider: "xai", + cause: error, + }); } if (response.status !== 200) { - throw new Error(`xAI OIDC discovery returned status ${response.status}.`); + throw new AIError.OAuthError(`xAI OIDC discovery returned status ${response.status}.`, { + kind: "discovery", + provider: "xai", + status: response.status, + }); } let payload: unknown; try { payload = await response.json(); } catch (error) { - throw new Error( + throw new AIError.OAuthError( `xAI OIDC discovery returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + { kind: "validation", provider: "xai", cause: error }, ); } if (!payload || typeof payload !== "object") { - throw new Error("xAI OIDC discovery response was not a JSON object."); + throw new AIError.OAuthError("xAI OIDC discovery response was not a JSON object.", { + kind: "validation", + provider: "xai", + }); } const obj = payload as Record; const authorizationEndpoint = typeof obj.authorization_endpoint === "string" ? obj.authorization_endpoint.trim() : ""; const tokenEndpoint = typeof obj.token_endpoint === "string" ? obj.token_endpoint.trim() : ""; if (!authorizationEndpoint || !tokenEndpoint) { - throw new Error("xAI OIDC discovery response was missing required endpoints."); + throw new AIError.OAuthError("xAI OIDC discovery response was missing required endpoints.", { + kind: "validation", + provider: "xai", + }); } validateXAIEndpoint(authorizationEndpoint, "authorization_endpoint"); validateXAIEndpoint(tokenEndpoint, "token_endpoint"); @@ -245,26 +261,40 @@ export class XAIOAuthFlow extends OAuthCallbackFlow { } catch { // Ignore body-read failures; the status code is the diagnostic. } - throw new Error(`xAI token exchange failed: ${response.status}${detail ? ` ${detail}` : ""}`); + throw new AIError.OAuthError(`xAI token exchange failed: ${response.status}${detail ? ` ${detail}` : ""}`, { + kind: "token-exchange", + provider: "xai", + status: response.status, + }); } let tokenData: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; try { tokenData = (await response.json()) as typeof tokenData; } catch (error) { - throw new Error( + throw new AIError.OAuthError( `xAI token exchange returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + { kind: "validation", provider: "xai", cause: error }, ); } if (typeof tokenData.access_token !== "string" || !tokenData.access_token) { - throw new Error("xAI token exchange response missing access_token"); + throw new AIError.OAuthError("xAI token exchange response missing access_token", { + kind: "validation", + provider: "xai", + }); } if (typeof tokenData.refresh_token !== "string" || !tokenData.refresh_token) { - throw new Error("xAI token exchange response missing refresh_token"); + throw new AIError.OAuthError("xAI token exchange response missing refresh_token", { + kind: "validation", + provider: "xai", + }); } if (typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in)) { - throw new Error("xAI token exchange response missing expires_in"); + throw new AIError.OAuthError("xAI token exchange response missing expires_in", { + kind: "validation", + provider: "xai", + }); } return { @@ -292,7 +322,7 @@ export async function loginXAIOAuth(ctrl: OAuthController): Promise { const fetchImpl = fetchOverride ?? fetch; if (typeof refreshToken !== "string" || !refreshToken.trim()) { - throw new Error("missing refresh_token"); + throw new AIError.OAuthError("missing refresh_token", { kind: "validation", provider: "xai" }); } const discovery = await xaiOAuthDiscovery(DISCOVERY_TIMEOUT_MS, fetchImpl); @@ -321,23 +351,34 @@ export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?: } catch { // Ignore body-read failures; the status code is the diagnostic. } - throw new Error(`xAI token refresh failed: ${response.status}${detail ? ` ${detail}` : ""}`); + throw new AIError.OAuthError(`xAI token refresh failed: ${response.status}${detail ? ` ${detail}` : ""}`, { + kind: "token-refresh", + provider: "xai", + status: response.status, + }); } let data: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; try { data = (await response.json()) as typeof data; } catch (error) { - throw new Error( + throw new AIError.OAuthError( `xAI token refresh returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + { kind: "validation", provider: "xai", cause: error }, ); } if (typeof data.access_token !== "string" || !data.access_token) { - throw new Error("xAI token refresh response missing access_token"); + throw new AIError.OAuthError("xAI token refresh response missing access_token", { + kind: "validation", + provider: "xai", + }); } if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) { - throw new Error("xAI token refresh response missing expires_in"); + throw new AIError.OAuthError("xAI token refresh response missing expires_in", { + kind: "validation", + provider: "xai", + }); } const newRefresh = typeof data.refresh_token === "string" && data.refresh_token ? data.refresh_token : refreshToken; diff --git a/packages/ai/src/registry/oauth/xiaomi.ts b/packages/ai/src/registry/oauth/xiaomi.ts index b23948e1e..ca84c82d1 100644 --- a/packages/ai/src/registry/oauth/xiaomi.ts +++ b/packages/ai/src/registry/oauth/xiaomi.ts @@ -8,6 +8,7 @@ * login opens plan management so users copy the regional `tp-...` key. */ +import * as AIError from "../../error"; import type { FetchImpl } from "../../types"; import type { OAuthController } from "./types"; @@ -103,10 +104,11 @@ async function validateXiaomiApiKey( } catch { // ignore body parse errors, status is enough } - lastError = new Error( + lastError = new AIError.OAuthError( details ? `${PROVIDER_NAME} API key validation failed (${response.status}): ${details}` : `${PROVIDER_NAME} API key validation failed (${response.status})`, + { kind: "validation", provider: PROVIDER_ID, status: response.status }, ); continue; } @@ -121,7 +123,11 @@ async function validateXiaomiApiKey( const message = details ? `${PROVIDER_NAME} API key validation failed (${response.status}): ${details}` : `${PROVIDER_NAME} API key validation failed (${response.status})`; - throw new Error(message); + throw new AIError.OAuthError(message, { + kind: "validation", + provider: PROVIDER_ID, + status: response.status, + }); } catch (e) { // Only re-throw AbortError when the caller explicitly cancelled. // Timeout aborts (from AbortSignal.timeout) should fall through to @@ -132,7 +138,13 @@ async function validateXiaomiApiKey( lastError = e instanceof Error ? e : new Error(String(e)); } } - throw lastError ?? new Error(`${PROVIDER_NAME} API key validation failed`); + throw ( + lastError ?? + new AIError.OAuthError(`${PROVIDER_NAME} API key validation failed`, { + kind: "validation", + provider: PROVIDER_ID, + }) + ); } /** @@ -144,7 +156,7 @@ async function validateXiaomiApiKey( export async function loginXiaomi(options: OAuthController): Promise { const fetchImpl = options.fetch ?? fetch; if (!options.onPrompt) { - throw new Error(`${PROVIDER_NAME} login requires onPrompt callback`); + throw new AIError.OnPromptRequiredError(PROVIDER_NAME); } options.onAuth?.({ url: STANDARD_AUTH_URL, @@ -155,11 +167,11 @@ export async function loginXiaomi(options: OAuthController): Promise { placeholder: "sk-... or tp-...", }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } options.onProgress?.(`Validating ${PROVIDER_ID} API key...`); @@ -175,7 +187,7 @@ export async function loginXiaomi(options: OAuthController): Promise { export async function loginXiaomiTokenPlan(options: OAuthController, region: XiaomiTokenPlanRegion): Promise { const fetchImpl = options.fetch ?? fetch; if (!options.onPrompt) { - throw new Error(`Xiaomi Token Plan (${TOKEN_PLAN_REGION_NAMES[region]}) login requires onPrompt callback`); + throw new AIError.OnPromptRequiredError(`Xiaomi Token Plan (${TOKEN_PLAN_REGION_NAMES[region]})`); } options.onAuth?.({ url: TOKEN_PLAN_AUTH_URL, @@ -186,11 +198,11 @@ export async function loginXiaomiTokenPlan(options: OAuthController, region: Xia placeholder: "tp-...", }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } options.onProgress?.(`Validating Xiaomi Token Plan (${TOKEN_PLAN_REGION_NAMES[region]}) API key...`); diff --git a/packages/ai/src/registry/ollama-cloud.ts b/packages/ai/src/registry/ollama-cloud.ts index 4dd6d74c6..42873faef 100644 --- a/packages/ai/src/registry/ollama-cloud.ts +++ b/packages/ai/src/registry/ollama-cloud.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -5,10 +6,10 @@ const OLLAMA_CLOUD_KEYS_URL = "https://ollama.com/settings/keys"; export async function loginOllamaCloud(options: OAuthController): Promise { if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } if (!options.onPrompt) { - throw new Error("Interactive prompt is required for Ollama Cloud login"); + throw new AIError.ConfigurationError("Interactive prompt is required for Ollama Cloud login"); } options.onAuth?.({ url: OLLAMA_CLOUD_KEYS_URL, @@ -19,11 +20,11 @@ export async function loginOllamaCloud(options: OAuthController): Promise { if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } if (!options.onPrompt) { return ""; @@ -29,7 +30,7 @@ export async function loginOllama(options: OAuthController): Promise { }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } return apiKey.trim(); diff --git a/packages/ai/src/registry/parallel.ts b/packages/ai/src/registry/parallel.ts index ef3e98a9d..c69e12e35 100644 --- a/packages/ai/src/registry/parallel.ts +++ b/packages/ai/src/registry/parallel.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -11,7 +12,7 @@ const AUTH_URL = "https://platform.parallel.ai/settings?tab=api-keys"; */ export async function loginParallel(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("Parallel login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Parallel"); } options.onAuth?.({ @@ -25,12 +26,12 @@ export async function loginParallel(options: OAuthController): Promise { }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } return trimmed; diff --git a/packages/ai/src/registry/qwen-portal.ts b/packages/ai/src/registry/qwen-portal.ts index d8ab82254..2586add0d 100644 --- a/packages/ai/src/registry/qwen-portal.ts +++ b/packages/ai/src/registry/qwen-portal.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -8,7 +9,7 @@ const VALIDATION_MODEL = "coder-model"; export async function loginQwenPortal(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("Qwen Portal login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Qwen Portal"); } options.onAuth?.({ @@ -22,12 +23,12 @@ export async function loginQwenPortal(options: OAuthController): Promise }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = token.trim(); if (!trimmed) { - throw new Error("Qwen token/API key is required"); + throw new AIError.ApiKeyRequiredError("Qwen token/API key is required"); } options.onProgress?.("Validating credentials..."); diff --git a/packages/ai/src/registry/tavily.ts b/packages/ai/src/registry/tavily.ts index bc07ab743..c5dcdfa38 100644 --- a/packages/ai/src/registry/tavily.ts +++ b/packages/ai/src/registry/tavily.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -11,7 +12,7 @@ const AUTH_URL = "https://app.tavily.com/home"; */ export async function loginTavily(options: OAuthLoginCallbacks): Promise { if (!options.onPrompt) { - throw new Error("Tavily login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Tavily"); } options.onAuth?.({ @@ -25,12 +26,12 @@ export async function loginTavily(options: OAuthLoginCallbacks): Promise }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new AIError.ApiKeyRequiredError(); } return trimmed; diff --git a/packages/ai/src/registry/vercel-ai-gateway.ts b/packages/ai/src/registry/vercel-ai-gateway.ts index 9f555e312..77af5d58b 100644 --- a/packages/ai/src/registry/vercel-ai-gateway.ts +++ b/packages/ai/src/registry/vercel-ai-gateway.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; @@ -5,7 +6,7 @@ const AUTH_URL = "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi export async function loginVercelAiGateway(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error("Vercel AI Gateway login requires onPrompt callback"); + throw new AIError.OnPromptRequiredError("Vercel AI Gateway"); } options.onAuth?.({ @@ -19,12 +20,12 @@ export async function loginVercelAiGateway(options: OAuthController): Promise { if (!options.onPrompt) { - throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); + throw new AIError.OnPromptRequiredError(PROVIDER_ID); } options.onAuth?.({ url: AUTH_URL, @@ -20,7 +21,7 @@ export async function loginVllm(options: OAuthController): Promise { allowEmpty: true, }); if (options.signal?.aborted) { - throw new Error("Login cancelled"); + throw new AIError.LoginCancelledError(); } const trimmed = apiKey.trim(); return trimmed || DEFAULT_LOCAL_TOKEN; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index be73cef37..f6201df29 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -13,10 +13,11 @@ import { resolveWireModelId, } from "@oh-my-pi/pi-catalog/model-thinking"; import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models"; -import { $env, $pickenv, extractHttpStatusFromError, getConfigRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { $env, $pickenv, getConfigRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { getCustomApi } from "./api-registry"; import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry"; -import { ProviderHttpError } from "./errors"; +import * as AIError from "./error"; +import { ProviderHttpError } from "./error"; import type { BedrockOptions } from "./providers/amazon-bedrock"; import type { AnthropicOptions } from "./providers/anthropic"; import type { CursorOptions } from "./providers/cursor"; @@ -54,7 +55,7 @@ import { streamOpenAIResponses, } from "./providers/register-builtins"; import { isSyntheticModel, streamSynthetic } from "./providers/synthetic"; -import { isUsageLimitOutcome } from "./rate-limit-utils"; +import { isUsageLimitOutcome } from "./error/rate-limit"; import { PROVIDER_REGISTRY } from "./registry"; import type { Api, @@ -72,7 +73,7 @@ import type { import { AssistantMessageEventStream } from "./utils/event-stream"; import { wrapFetchForProxy } from "./utils/proxy"; import { withRequestDebugFetch } from "./utils/request-debug"; -import { isThinkingLoopStall, withGeminiThinkingLoopGuard } from "./utils/thinking-loop"; +import { withGeminiThinkingLoopGuard } from "./utils/thinking-loop"; function isGoogleVertexAuthenticatedModel(model: Model): boolean { return ( @@ -270,7 +271,7 @@ async function acquireProviderInFlightLock(provider: string, signal?: AbortSigna await fs.mkdir(path.dirname(lockDir), { recursive: true }); while (true) { - if (signal?.aborted) throw signal.reason ?? new Error("Provider request aborted before dispatch"); + if (signal?.aborted) throw signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch"); try { await fs.mkdir(lockDir); const lockIdentity = await readProviderInFlightLockIdentity(lockDir); @@ -375,7 +376,8 @@ async function signalProviderInFlightWaiters(provider: string): Promise { } function waitForProviderInFlightSignal(provider: string, signal?: AbortSignal): Promise { - if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Provider request aborted before dispatch")); + if (signal?.aborted) + return Promise.reject(signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch")); const signalPath = providerInFlightSignalPath(provider); const waitStarted = Date.now(); const { promise, resolve, reject } = Promise.withResolvers(); @@ -391,7 +393,7 @@ function waitForProviderInFlightSignal(provider: string, signal?: AbortSignal): settle(); }; const onAbort = () => { - finish(() => reject(signal?.reason ?? new Error("Provider request aborted before dispatch"))); + finish(() => reject(signal?.reason ?? new AIError.AbortError("Provider request aborted before dispatch"))); }; signal?.addEventListener("abort", onAbort, { once: true }); try { @@ -447,7 +449,7 @@ async function acquireProviderInFlightSlot( if (limit === undefined) return async () => {}; let loggedWait = false; while (true) { - if (signal?.aborted) throw signal.reason ?? new Error("Provider request aborted before dispatch"); + if (signal?.aborted) throw signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch"); const lease = await tryAcquireProviderInFlightLease(provider, limit, signal); if (lease) return () => releaseProviderInFlightLease(provider, lease); if (!loggedWait) { @@ -509,7 +511,7 @@ function withProviderInFlightLimit( if (isGitLabDuoModel(model)) { const apiKey = requestOptions.apiKey || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new AIError.MissingApiKeyError(model.provider); } return streamGitLabDuo(model, context, { ...(requestOptions as SimpleStreamOptions), @@ -722,7 +724,7 @@ function streamDispatch( if (model.api === "gitlab-duo-agent") { const apiKey = (requestOptions as StreamOptions | undefined)?.apiKey || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new AIError.MissingApiKeyError(model.provider); } return streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, { ...(requestOptions as StreamOptions | undefined), @@ -740,7 +742,7 @@ function streamDispatch( const apiKey = requestOptions.apiKey || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new AIError.MissingApiKeyError(model.provider); } const providerOptions = isGoogleVertexAuthenticatedModel(model) ? { @@ -824,7 +826,7 @@ function streamDispatch( return streamDevin(model as Model<"devin-agent">, context, providerOptions as DevinOptions); default: - throw new Error(`Unhandled API: ${api}`); + throw new AIError.ConfigurationError(`Unhandled API: ${api}`); } } @@ -849,7 +851,8 @@ async function resolveWithThinkingLoopCook( cook: () => AssistantMessageEventStream, ): Promise { let message = await dispatch().result(); - for (let attempt = 0; isThinkingLoopStall(message) && attempt < THINKING_LOOP_MAX_ABORTS - 1; attempt += 1) { + let thinkingLoopRetry = AIError.is(message.errorId, AIError.Flag.ThinkingLoop); + for (let attempt = 0; thinkingLoopRetry && attempt < THINKING_LOOP_MAX_ABORTS - 1; attempt += 1) { // A caller abort surfaces as a thrown abort (never the stall, which would // misclassify as a 502): throwIfAborted before backoff, and scheduler.wait // rejects if the abort lands mid-delay. @@ -857,8 +860,12 @@ async function resolveWithThinkingLoopCook( const delay = Math.min(THINKING_LOOP_RETRY_BASE_DELAY_MS * 2 ** attempt, THINKING_LOOP_RETRY_MAX_DELAY_MS); await scheduler.wait(delay, { signal }); message = await dispatch().result(); + thinkingLoopRetry = + message.stopReason === "error" && + message.content.length === 0 && + AIError.is(message.errorId, AIError.Flag.ThinkingLoop); } - if (!isThinkingLoopStall(message)) return message; + if (!thinkingLoopRetry) return message; signal?.throwIfAborted(); // Abort budget spent and still looping: let it cook with the guard disabled. return cook().result(); @@ -885,7 +892,7 @@ type AuthRetryFailure = { function extractStatusFromAssistantError(message: AssistantMessage): number | undefined { if (message.errorStatus !== undefined) return message.errorStatus; if (!message.errorMessage) return undefined; - return extractHttpStatusFromError({ message: message.errorMessage }); + return AIError.status({ message: message.errorMessage }); } function isRetryableUpstreamError(error: unknown, status: number | undefined, message: string | undefined): boolean { @@ -908,7 +915,9 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me function createAssistantAuthError(message: AssistantMessage): Error { const text = message.errorMessage ?? "Provider authentication failed"; const status = extractStatusFromAssistantError(message); - return status === undefined ? new Error(text) : new ProviderHttpError(text, status); + return status === undefined + ? new AIError.ProviderResponseError(text, { kind: "runtime" }) + : new ProviderHttpError(text, status); } function emitBufferedEvents(stream: AssistantMessageEventStream, events: AssistantMessageEvent[]): void { @@ -977,7 +986,7 @@ export function streamSimple( captureAuthFailure && isRetryableUpstreamError( error, - extractHttpStatusFromError(error), + AIError.status(error), error instanceof Error ? error.message : undefined, ) ) { @@ -1005,7 +1014,7 @@ export function streamSimple( // A thrown resolver is a broker/OAuth/network failure, not a missing // key — surface the cause instead of masking it as "No API key". outer.fail( - new Error( + new AIError.ConfigurationError( `Failed to resolve API key for provider ${model.provider}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ), @@ -1013,7 +1022,7 @@ export function streamSimple( return; } if (lastKey === undefined) { - outer.fail(new Error(`No API key for provider: ${model.provider}`)); + outer.fail(new AIError.MissingApiKeyError(model.provider)); return; } let failure = await runAttempt(lastKey, true); @@ -1074,7 +1083,7 @@ export function streamSimple( const apiKey = (typeof requestOptions?.apiKey === "string" ? requestOptions.apiKey : undefined) || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new AIError.MissingApiKeyError(model.provider); } // GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens @@ -1708,7 +1717,7 @@ function mapOptionsForApi( }); } default: - throw new Error(`Unhandled API in mapOptionsForApi: ${model.api}`); + throw new AIError.ConfigurationError(`Unhandled API in mapOptionsForApi: ${model.api}`); } } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 88712e531..59bce2a98 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -537,6 +537,8 @@ export interface AssistantMessage { errorMessage?: string; /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */ errorStatus?: number; + /** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */ + errorId?: number; /** * Stable identifiers for request features the provider silently dropped * during this turn (e.g. `"priority"`). Set when a server-side rejection diff --git a/packages/ai/src/usage/github-copilot.ts b/packages/ai/src/usage/github-copilot.ts index c15e432bc..bb41e41f4 100644 --- a/packages/ai/src/usage/github-copilot.ts +++ b/packages/ai/src/usage/github-copilot.ts @@ -16,6 +16,7 @@ import type { UsageStatus, UsageWindow, } from "../usage"; +import * as AIError from "../error"; import { isRecord } from "../utils"; type CopilotQuotaDetail = { @@ -142,7 +143,7 @@ async function fetchJson(ctx: UsageFetchContext, url: string, init: RequestInit) const response = await ctx.fetch(url, init); if (!response.ok) { const text = await response.text(); - throw new Error(`${response.status} ${response.statusText}: ${text}`); + throw new AIError.ProviderHttpError(`${response.status} ${response.statusText}: ${text}`, response.status); } return response.json(); } @@ -182,7 +183,7 @@ async function fetchInternalUsage( ...OPENCODE_HEADERS, }; const data = await fetchJson(ctx, `${githubApiBaseUrl}/copilot_internal/user`, { headers, signal }); - if (!isRecord(data)) throw new Error("Invalid Copilot usage response"); + if (!isRecord(data)) throw new AIError.ProviderHttpError("Invalid Copilot usage response", 200); return data as CopilotUsageResponse; } @@ -206,7 +207,7 @@ async function fetchBillingUsage( }, ); - if (!isRecord(data)) throw new Error("Invalid Copilot billing usage response"); + if (!isRecord(data)) throw new AIError.ProviderHttpError("Invalid Copilot billing usage response", 200); return data as BillingUsageResponse; } diff --git a/packages/ai/src/utils/abort.ts b/packages/ai/src/utils/abort.ts index 54d9f0da5..f37897baf 100644 --- a/packages/ai/src/utils/abort.ts +++ b/packages/ai/src/utils/abort.ts @@ -1,3 +1,5 @@ +import * as AIError from "../error"; + export interface AbortSourceTracker { requestAbortController: AbortController; requestSignal: AbortSignal; @@ -57,9 +59,9 @@ export function createAbortSourceTracker(callerSignal?: AbortSignal): AbortSourc */ export function raceWithSignal(promise: Promise, signal: AbortSignal | undefined): Promise { if (!signal) return promise; - if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Request was aborted")); + if (signal.aborted) return Promise.reject(signal.reason ?? new AIError.AbortError()); const { promise: aborted, reject } = Promise.withResolvers(); - const onAbort = () => reject(signal.reason ?? new Error("Request was aborted")); + const onAbort = () => reject(signal.reason ?? new AIError.AbortError()); signal.addEventListener("abort", onAbort, { once: true }); return Promise.race([promise, aborted]).finally(() => signal.removeEventListener("abort", onAbort)); } diff --git a/packages/ai/src/utils/event-stream.ts b/packages/ai/src/utils/event-stream.ts index f4819d98f..d7b75e863 100644 --- a/packages/ai/src/utils/event-stream.ts +++ b/packages/ai/src/utils/event-stream.ts @@ -1,3 +1,4 @@ +import * as AIError from "../error"; import type { AssistantMessage, AssistantMessageEvent } from "../types"; // Generic event stream class for async iteration @@ -63,7 +64,9 @@ export class EventStream implements AsyncIterable { // end() without a terminal value must still settle result() — // otherwise complete()/result() awaits hang forever. this.resultSettled = true; - this.rejectFinalResult(new Error("Stream ended without a final result")); + this.rejectFinalResult( + new AIError.ProviderResponseError("Stream ended without a final result", { kind: "envelope" }), + ); } // Notify all waiting consumers that we're done while (this.waiting.length > 0) { @@ -125,7 +128,7 @@ export class AssistantMessageEventStream extends EventStream { // Never persist dumps under the test runner: providers exercise the 400 path - // with mocked fetch responses, which would otherwise litter the real ~/.omp logs. - if (!dump || isBunTestRuntime() || extractHttpStatusFromError(error) !== 400) { + if (!dump || isBunTestRuntime() || AIError.status(error) !== 400) { return message; } @@ -77,7 +77,7 @@ export async function finalizeErrorMessage( */ export function rewriteCopilotError(errorMessage: string, error: unknown, provider: string): string { if (provider !== "github-copilot") return errorMessage; - const status = extractHttpStatusFromError(error); + const status = AIError.status(error); if (status === 401) { return `GitHub Copilot authentication failed (HTTP 401). Your token may have been revoked. Please re-login with /login github-copilot`; } diff --git a/packages/ai/src/utils/idle-iterator.ts b/packages/ai/src/utils/idle-iterator.ts index 39757f3dc..7cebaf64e 100644 --- a/packages/ai/src/utils/idle-iterator.ts +++ b/packages/ai/src/utils/idle-iterator.ts @@ -1,4 +1,5 @@ import { $env } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000; const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000; @@ -292,7 +293,7 @@ export async function* iterateWithIdleTimeout( if (activeTimeoutMs <= 0) { options.onFirstItemTimeout?.(); closeIterator(); - throw new Error(options.firstItemErrorMessage ?? options.errorMessage); + throw new AIError.StreamTimeoutError(options.firstItemErrorMessage ?? options.errorMessage); } } } else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) { @@ -300,7 +301,7 @@ export async function* iterateWithIdleTimeout( if (activeTimeoutMs <= 0) { options.onIdle?.(); closeIterator(); - throw new Error(options.errorMessage); + throw new AIError.StreamTimeoutError(options.errorMessage); } } @@ -343,7 +344,7 @@ export async function* iterateWithIdleTimeout( options.onFirstItemTimeout?.(); } closeIterator(); - throw new Error( + throw new AIError.StreamTimeoutError( !awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage), ); } @@ -467,6 +468,6 @@ export async function* iterateWithTerminalGrace( function abortReason(signal: AbortSignal): Error { const reason = signal.reason; if (reason instanceof Error) return reason; - if (typeof reason === "string") return new Error(reason); - return new Error("Request was aborted"); + if (typeof reason === "string") return new AIError.AbortError(reason); + return new AIError.AbortError(); } diff --git a/packages/ai/src/utils/openai-http.ts b/packages/ai/src/utils/openai-http.ts index 8b8670eb2..ef553fb3a 100644 --- a/packages/ai/src/utils/openai-http.ts +++ b/packages/ai/src/utils/openai-http.ts @@ -15,7 +15,11 @@ * chain-state detectors, which regex over `error.message`. */ import { fetchWithRetry, readSseJson, type SseEventObserver } from "@oh-my-pi/pi-utils"; -import { ProviderHttpError } from "../errors"; +import * as AIError from "../error"; +import { OpenAIHttpError } from "../error"; + +export { OpenAIHttpError }; + import type { FetchImpl } from "../types"; import type { CapturedHttpErrorResponse } from "./http-inspector"; @@ -28,17 +32,6 @@ const DEFAULT_MAX_ATTEMPTS = 6; /** Bound the `Error.message` allocation for proxy HTML error pages and the like. */ const MAX_DETAIL_CHARS = 4096; -/** Non-2xx response from an OpenAI-wire endpoint, with the decoded body attached. */ -export class OpenAIHttpError extends ProviderHttpError { - readonly captured: CapturedHttpErrorResponse; - - constructor(message: string, captured: CapturedHttpErrorResponse, code: string | undefined) { - super(message, captured.status, { headers: captured.headers, code }); - this.name = "OpenAIHttpError"; - this.captured = captured; - } -} - export interface OpenAIStreamRequestInit { url: string; headers: Record; @@ -88,7 +81,9 @@ export async function postOpenAIStream(init: OpenAIStreamRequestInit): P throw await captureOpenAIHttpError(response); } if (!response.body) { - throw new Error(`OpenAI stream response has no body (status ${response.status})`); + throw new AIError.ProviderResponseError(`OpenAI stream response has no body (status ${response.status})`, { + kind: "envelope", + }); } return { events: readSseJson(response.body, init.signal, init.onSseEvent), @@ -98,7 +93,7 @@ export async function postOpenAIStream(init: OpenAIStreamRequestInit): P } /** Decode a non-2xx response into an {@link OpenAIHttpError} without consuming it twice. */ -export async function captureOpenAIHttpError(response: Response): Promise { +export async function captureOpenAIHttpError(response: Response): Promise { let bodyText: string | undefined; let bodyJson: unknown; try { @@ -117,41 +112,11 @@ export async function captureOpenAIHttpError(response: Response): Promise MAX_DETAIL_CHARS ? detail.slice(0, MAX_DETAIL_CHARS) : detail}` : `${response.status} status code (no body)`; - return new OpenAIHttpError(message, captured, code); -} - -/** - * Pull a human-readable message and machine code out of an OpenAI-style error - * envelope (`{ error: { message, code, type } }`), tolerating the flat shapes - * compat hosts return (`{ error: "..." }`, `{ message: "..." }`) and falling - * back to the raw body text. - */ -function extractErrorDetail( - bodyJson: unknown, - bodyText: string | undefined, -): { detail: string | undefined; code: string | undefined } { - if (typeof bodyJson === "object" && bodyJson !== null) { - const envelope = bodyJson as { error?: unknown; message?: unknown }; - const error = envelope.error; - if (typeof error === "object" && error !== null) { - const { message, code, type } = error as { message?: unknown; code?: unknown; type?: unknown }; - return { - detail: typeof message === "string" && message.length > 0 ? message : bodyText, - code: typeof code === "string" ? code : typeof type === "string" ? type : undefined, - }; - } - if (typeof error === "string" && error.length > 0) { - return { detail: error, code: undefined }; - } - if (typeof envelope.message === "string" && envelope.message.length > 0) { - return { detail: envelope.message, code: undefined }; - } - } - return { detail: bodyText, code: undefined }; + return new AIError.OpenAIHttpError(message, captured, code); } diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts deleted file mode 100644 index ad9355c6e..000000000 --- a/packages/ai/src/utils/overflow.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { AssistantMessage } from "../types"; - -/** - * Regex patterns to detect context overflow errors from different providers. - * - * These patterns match error messages returned when the input exceeds - * the model's context window. - * - * Provider-specific patterns (with example error messages): - * - * - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum" - * - OpenAI: "Your input exceeds the context window of this model" - * - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)" - * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" - * - Groq: "Please reduce the length of the messages or completion" - * - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens" - * - llama.cpp: "the request exceeds the available context size, try increasing it" - * - LM Studio: "tokens to keep from the initial prompt is greater than the context length" - * - GitHub Copilot: "prompt token count of X exceeds the limit of Y" - * - MiniMax: "invalid params, context window exceeds limit" - * - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)" - * - Anthropic 413: "request_too_large" / "Request exceeds the maximum size" (payload too large) - * - HTTP 413 variants: "Payload Too Large" / "Request Entity Too Large" - * - z.ai / GLM: Returns finish_reason: "model_context_window_exceeded" mapped to error message - * - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow - * - Ollama OpenAI-compatible: "prompt filled the context window" after empty finish_reason:length - * - Ollama native: Silently truncates input - not detectable via error message - */ -const OVERFLOW_PATTERNS = [ - /prompt is too long/i, // Anthropic - /input is too long for requested model/i, // Amazon Bedrock - /exceeds the context window/i, // OpenAI (Completions & Responses API) - /input token count.*exceeds the maximum/i, // Google (Gemini) - /maximum prompt length is \d+/i, // xAI (Grok) - /reduce the length of the messages/i, // Groq - /maximum context length is \d+ tokens/i, // OpenRouter (all backends) - /exceeds the limit of \d+/i, // GitHub Copilot - /exceeds the available context size/i, // llama.cpp server - /requested tokens?.*exceed.*context (window|length|size)/i, // llama.cpp / OpenAI-compatible local servers - /context (window|length|size).*(exceeded|overflow|too small)/i, // Generic local server variants - /(prompt|input).*(too long|too large).*(context|n_ctx)/i, // llama.cpp phrasing variants - /requested tokens?.*(exceeds?|greater than).*(n_ctx|context)/i, // llama.cpp n_ctx variants - /greater than the context length/i, // LM Studio - /context window exceeds limit/i, // MiniMax - /exceeded model token limit/i, // Kimi For Coding - /context[_ ]length[_ ]exceeded/i, // Generic fallback - /too many tokens/i, // Generic fallback - /token limit exceeded/i, // Generic fallback - /request_too_large/i, // Anthropic 413 (request body too large) - /request exceeds the maximum size/i, // Anthropic 413 variant - /payload too large/i, // Generic HTTP 413 variant - /entity too large/i, // Generic HTTP 413 variant - /\b413\b.*\b(request|payload|entity)\b.*\btoo large\b/i, // "413 Request Entity Too Large" variants - /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text - /prompt filled the context window/i, // Ollama OpenAI-compatible empty length completion -]; -/** - * Check if an assistant message represents a context overflow error. - * - * This handles two cases: - * 1. Error-based overflow: Most providers return stopReason "error" with a - * specific error message pattern. - * 2. Silent overflow: Some providers accept overflow requests and return - * successfully. For these, we check if usage.input exceeds the context window. - * - * ## Reliability by Provider - * - * **Reliable detection (returns error with detectable message):** - * - Anthropic: "prompt is too long: X tokens > Y maximum" - * - OpenAI (Completions & Responses): "exceeds the context window" - * - Google Gemini: "input token count exceeds the maximum" - * - xAI (Grok): "maximum prompt length is X but request contains Y" - * - Groq: "reduce the length of the messages" - * - Cerebras: 400/413 status code (no body) - * - Mistral: 400/413 status code (no body) - * - HTTP 413 payload/entity-too-large variants - * - OpenRouter (all backends): "maximum context length is X tokens" - * - llama.cpp: "exceeds the available context size" - * - LM Studio: "greater than the context length" - * - Kimi For Coding: "exceeded model token limit: X (requested: Y)" - * - Anthropic 413: "request_too_large" (request body exceeds size limit) - * - HTTP 413: "Payload Too Large" / "Request Entity Too Large" - * - Ollama OpenAI-compatible: "prompt filled the context window" - * - * **Unreliable detection:** - * - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow), - * sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow. - * - Ollama native: Silently truncates input without error. Cannot be detected via this function. - * The response will have usage.input < expected, but we don't know the expected value. - * - * ## Custom Providers - * - * If you've added custom models via settings.json, this function may not detect - * overflow errors from those providers. To add support: - * - * 1. Send a request that exceeds the model's context window - * 2. Check the errorMessage in the response - * 3. Create a regex pattern that matches the error - * 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or - * check the errorMessage yourself before calling this function - * - * @param message - The assistant message to check - * @param contextWindow - Optional context window size for detecting silent overflow (z.ai) - * @returns true if the message indicates a context overflow - */ -export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { - // Case 1: Check error message patterns - if (message.stopReason === "error" && message.errorMessage) { - // Check known patterns - if (OVERFLOW_PATTERNS.some(p => p.test(message.errorMessage!))) { - return true; - } - - // Cerebras and Mistral return 400/413 with no body for context overflow. - // Proxy providers (e.g. api.synthetic.new) wrap upstream 400/413 no-body - // responses in a JSON envelope, so the status code phrase may appear - // anywhere in the message rather than at its start. - // Note: 429 is rate limiting (requests/tokens per time), NOT context overflow - if (/\b4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage)) { - return true; - } - } - - // Case 2: Usage-based overflow (silent or provider-specific) - if (contextWindow) { - const inputTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite; - if (inputTokens > contextWindow) { - return true; - } - } - - return false; -} - -/** - * Get the overflow patterns for testing purposes. - */ -export function getOverflowPatterns(): RegExp[] { - return [...OVERFLOW_PATTERNS]; -} diff --git a/packages/ai/src/utils/parse-bind.ts b/packages/ai/src/utils/parse-bind.ts index e55905e49..0885446dc 100644 --- a/packages/ai/src/utils/parse-bind.ts +++ b/packages/ai/src/utils/parse-bind.ts @@ -4,6 +4,8 @@ * gateway used to silently allow empty hostnames; this fixes it). */ +import * as AIError from "../error"; + export interface ParsedBind { hostname: string; port: number; @@ -11,11 +13,11 @@ export interface ParsedBind { function parsePort(raw: string, bind: string): number { if (!/^\d+$/.test(raw)) { - throw new Error(`Invalid bind '${bind}'; port must be an integer.`); + throw new AIError.ConfigurationError(`Invalid bind '${bind}'; port must be an integer.`); } const port = Number.parseInt(raw, 10); if (!Number.isFinite(port) || port < 0 || port > 65535) { - throw new Error(`Invalid bind '${bind}'; port out of range.`); + throw new AIError.ConfigurationError(`Invalid bind '${bind}'; port out of range.`); } return port; } @@ -36,19 +38,19 @@ function parsePort(raw: string, bind: string): number { export function parseBind(raw: string): ParsedBind { const trimmed = raw.trim(); if (trimmed.length === 0) { - throw new Error("Invalid bind; expected 'host:port' or 'port'."); + throw new AIError.ConfigurationError("Invalid bind; expected 'host:port' or 'port'."); } if (/^\d+$/.test(trimmed)) { return { hostname: "127.0.0.1", port: parsePort(trimmed, raw) }; } const lastColon = trimmed.lastIndexOf(":"); if (lastColon < 0) { - throw new Error(`Invalid bind '${raw}'; expected 'host:port' or 'port'.`); + throw new AIError.ConfigurationError(`Invalid bind '${raw}'; expected 'host:port' or 'port'.`); } const hostPart = trimmed.slice(0, lastColon); const portPart = trimmed.slice(lastColon + 1); if (hostPart.length === 0) { - throw new Error(`Invalid bind '${raw}'; host must not be empty.`); + throw new AIError.ConfigurationError(`Invalid bind '${raw}'; host must not be empty.`); } return { hostname: hostPart, port: parsePort(portPart, raw) }; } diff --git a/packages/ai/src/utils/proxy.ts b/packages/ai/src/utils/proxy.ts index 651c24154..f19ab4489 100644 --- a/packages/ai/src/utils/proxy.ts +++ b/packages/ai/src/utils/proxy.ts @@ -1,5 +1,6 @@ import * as net from "node:net"; import * as tls from "node:tls"; +import * as AIError from "../error"; import type { FetchImpl } from "../types"; /** @@ -228,7 +229,7 @@ export async function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: st tlsSocket.once("error", reject); } else { rawSocket.destroy(); - reject(new Error(`Proxy tunnel failed: ${firstLine}`)); + reject(new AIError.ValidationError(`Proxy tunnel failed: ${firstLine}`)); } } }; diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index ed56b519b..d89ee81b1 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -1,27 +1,11 @@ import { scheduler } from "node:timers/promises"; -import { extractHttpStatusFromError, isRetryableError } from "@oh-my-pi/pi-utils"; +import { isRetryableError } from "@oh-my-pi/pi-utils"; +import { isCopilotTransientModelError, status } from "../error/flags"; import { getHeadersFromError, getRetryAfterMsFromHeaders } from "./retry-after"; -/** - * GitHub Copilot intermittently rejects preview models (gpt-5.3-codex, - * gpt-5.4, gpt-5.4-mini, ...) with HTTP 400 `model_not_supported`, even - * though the model is listed as enabled on the user's account via `/models`. - * - * Root cause: Copilot's request-routing backend is rolled out per OAuth - * client. Our OAuth client id is shared with opencode; VS Code uses its own - * client and sees full availability, so the same account may succeed in VS - * Code and flap between 200/400 here. See opencode#13313 and copilot-cli#2597. - * - * Retrying the identical request 2-3 times almost always lands on a backend - * that has the model, so we wrap the initial request with a short retry loop. - */ -export function isCopilotTransientModelError(error: unknown): boolean { - if (extractHttpStatusFromError(error) !== 400) return false; - if (!error || typeof error !== "object") return false; - const info = error as { code?: unknown; error?: { code?: unknown } | null }; - const code = typeof info.code === "string" ? info.code : info.error?.code; - return code === "model_not_supported"; -} +// `isCopilotTransientModelError` now lives in the error module (its classifier +// home). Re-exported here so existing `../utils/retry` importers keep working. +export { isCopilotTransientModelError }; const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 3; const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400; @@ -57,8 +41,8 @@ export async function callWithCopilotModelRetry( if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break; let delayMs = retryBaseDelayMs * (attempt + 1); if (!transientModelError) { - const status = extractHttpStatusFromError(error); - if (status !== undefined) { + const errorStatus = status(error); + if (errorStatus !== undefined) { // Status-bearing retryable errors (429/5xx) are only re-sent when // the server told us when to come back — a blind fixed-delay retry // of a rate limit just burns the remaining attempts. Status-less diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index ffb7e1af6..493c06ecd 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -7,6 +7,7 @@ * for each target. */ import { logger } from "@oh-my-pi/pi-utils"; +import * as AIError from "../../error"; import { dereferenceJsonSchema } from "./dereference"; import { upgradeJsonSchemaTo202012 } from "./draft"; import { areJsonValuesEqual, mergeCompatibleEnumSchemas, mergePropertySchemas } from "./equality"; @@ -1711,7 +1712,7 @@ export function enforceStrictSchema( cache: WeakMap, Record> = new WeakMap(), ): Record { if (!enter(schema)) { - throw new Error("Schema contains a circular object graph — cannot enforce strict mode"); + throw new AIError.ValidationError("Schema contains a circular object graph — cannot enforce strict mode"); } try { const cached = cache.get(schema); @@ -1857,7 +1858,7 @@ function enforceStrictSchemaBody( !COMBINATOR_KEYS.some(key => Array.isArray(result[key])) && !isJsonObject(result.not) ) { - throw new Error("Schema node has no type, combinator, or $ref — cannot enforce strict mode"); + throw new AIError.ValidationError("Schema node has no type, combinator, or $ref — cannot enforce strict mode"); } return result; } diff --git a/packages/ai/src/utils/thinking-loop.ts b/packages/ai/src/utils/thinking-loop.ts index 6cfaeaec3..1810d0a1c 100644 --- a/packages/ai/src/utils/thinking-loop.ts +++ b/packages/ai/src/utils/thinking-loop.ts @@ -10,10 +10,9 @@ * * This guard watches the streamed `thinking` deltas and, on a match, terminates * the stream with a synthetic `error` {@link AssistantMessage} that carries - * **no observable content**. An empty-content `stopReason: "error"` whose message - * hits the transient-transport pattern is what {@link isThinkingLoopStall} and - * `AgentSession` classify as a *retryable* stop, so the turn is discarded and - * re-sampled instead of committing the garbage transcript. + * **no observable content**. An empty-content `stopReason: "error"` message tagged + * with `AIError.Flag.ThinkingLoop` lets result consumers and `AgentSession` discard + * the runaway and re-sample instead of committing garbage transcript. * * Three failure shapes are detected: * 1. **Verbatim tail repetition** — a short unit repeated back-to-back (e.g. @@ -38,6 +37,7 @@ * through one unguarded pass. Disable detection with `PI_NO_THINKING_LOOP_GUARD=1`. */ import { logger } from "@oh-my-pi/pi-utils"; +import * as AIError from "../error"; import type { Api, AssistantMessage, Model, StreamOptions } from "../types"; import { AssistantMessageEventStream } from "./event-stream"; @@ -46,21 +46,6 @@ import { AssistantMessageEventStream } from "./event-stream"; * classifiers treat it as a transient (retryable) stop without bespoke rules. */ export const THINKING_LOOP_ERROR_MARKER = "Thinking loop detected"; -/** - * True when a completed message is the guard's thinking-loop stall: an empty - * `stopReason: "error"` message carrying {@link THINKING_LOOP_ERROR_MARKER}. The - * empty content is load-bearing — it marks the discarded runaway as safe to - * re-sample. A *contentful* marker error already streamed visible output and is - * replay-unsafe, so it is deliberately excluded (re-sampling would duplicate it). - */ -export function isThinkingLoopStall(message: AssistantMessage): boolean { - return ( - message.stopReason === "error" && - message.content.length === 0 && - (message.errorMessage?.includes(THINKING_LOOP_ERROR_MARKER) ?? false) - ); -} - /** Rolling tail (chars) inspected for verbatim back-to-back repetition. */ const VERBATIM_TAIL_WINDOW = 250; /** Minimum total repeated chars before a verbatim run counts as a loop. */ @@ -417,7 +402,9 @@ export function guardThinkingLoopStream( provider: model.provider, detail, }); - controller.abort(new Error(THINKING_LOOP_ERROR_MARKER)); + controller.abort( + AIError.attach(new Error(THINKING_LOOP_ERROR_MARKER), AIError.create(AIError.Flag.ThinkingLoop)), + ); outer.push({ type: "error", reason: "error", @@ -449,7 +436,7 @@ export function guardThinkingLoopStream( * guard abort signal into the provider call so a detected loop tears down the * upstream, then wraps the returned stream. The guard only raises the retryable * stall; bounding the re-samples and the final cook pass lives in the - * result-awaiting callers (the {@link isThinkingLoopStall} consumers). + * result-awaiting caller. */ export function withGeminiThinkingLoopGuard< O extends { signal?: AbortSignal; loopGuard?: { enabled?: boolean; checkAssistantContent?: boolean } }, @@ -490,6 +477,7 @@ function buildThinkingLoopError(model: Model, detail: string): AssistantMes // "stream stall" makes the transport/session retry classifiers treat this // as a transient (retryable) failure with no bespoke rule. errorMessage: `${THINKING_LOOP_ERROR_MARKER}: the model repeated near-identical content (${detail}). Treating as a stream stall and retrying.`, + errorId: AIError.create(AIError.Flag.ThinkingLoop), timestamp: Date.now(), }; } diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 51c878dc1..748caba64 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -24,6 +24,7 @@ */ import { structuredCloneJSON } from "@oh-my-pi/pi-utils"; import { type Type, type } from "arktype"; +import * as AIError from "../error"; import type { ZodType } from "zod/v4"; import type { $ZodIssue as ZodIssue } from "zod/v4/core"; import type { Tool, ToolCall } from "../types"; @@ -1365,7 +1366,7 @@ const MAX_COERCION_PASSES = 5; export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["arguments"] { const tool = tools.find(t => t.name === toolCall.name); if (!tool) { - throw new Error(`Tool "${toolCall.name}" not found`); + throw new AIError.ToolNotFoundError(toolCall.name); } return validateToolArguments(tool, toolCall); } @@ -1405,7 +1406,7 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[ rawJson.length <= maxLen ? rawJson : `${rawJson.slice(0, maxLen)}… [truncated ${rawJson.length - maxLen} chars]`; - throw new Error( + throw new AIError.ValidationError( `Validation failed for tool "${toolCall.name}": Tool call arguments are not valid JSON.\nParse Error: ${parseError}\nRaw JSON:\n${truncatedRawJson}`, ); } @@ -1501,5 +1502,5 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[ toolCall.name }":\n${errors}\n\nReceived arguments:\n${JSON.stringify(receivedArgs, null, 2)}`; - throw new Error(errorMessage); + throw new AIError.ValidationError(errorMessage); } diff --git a/packages/ai/test/anthropic-client.test.ts b/packages/ai/test/anthropic-client.test.ts index 2fa82328a..e7b72e079 100644 --- a/packages/ai/test/anthropic-client.test.ts +++ b/packages/ai/test/anthropic-client.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { - AnthropicApiError, - AnthropicConnectionTimeoutError, - AnthropicMessagesClient, -} from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import * as AIError from "@oh-my-pi/pi-ai/error"; +import { AnthropicMessagesClient } from "@oh-my-pi/pi-ai/providers/anthropic-client"; import type { MessageCreateParamsStreaming } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; @@ -47,8 +44,8 @@ describe("AnthropicMessagesClient error mapping", () => { err => err, ); - expect(error).toBeInstanceOf(AnthropicApiError); - const apiError = error as AnthropicApiError; + expect(error).toBeInstanceOf(AIError.AnthropicApiError); + const apiError = error as AIError.AnthropicApiError; // Downstream classification reads `.status` (extractHttpStatusFromError) and // regex-matches the message body (isAnthropicStrictGrammarTooLargeError). expect(apiError.status).toBe(400); @@ -69,8 +66,8 @@ describe("AnthropicMessagesClient error mapping", () => { .asResponse() .catch(err => err); - expect(error).toBeInstanceOf(AnthropicApiError); - expect((error as AnthropicApiError).message).toBe("500 status code (no body)"); + expect(error).toBeInstanceOf(AIError.AnthropicApiError); + expect((error as AIError.AnthropicApiError).message).toBe("500 status code (no body)"); }); it("does not let fetchOptions override core request fields", async () => { @@ -118,8 +115,8 @@ describe("AnthropicMessagesClient retries", () => { .asResponse() .catch(err => err); - expect(error).toBeInstanceOf(AnthropicApiError); - expect((error as AnthropicApiError).status).toBe(503); + expect(error).toBeInstanceOf(AIError.AnthropicApiError); + expect((error as AIError.AnthropicApiError).status).toBe(503); expect(calls.length).toBe(1); }); @@ -134,7 +131,7 @@ describe("AnthropicMessagesClient retries", () => { .asResponse() .catch(err => err); - expect(error).toBeInstanceOf(AnthropicApiError); + expect(error).toBeInstanceOf(AIError.AnthropicApiError); expect(calls.length).toBe(3); // initial attempt + 2 retries }); }); @@ -153,7 +150,7 @@ describe("AnthropicMessagesClient timeout and abort", () => { .asResponse() .catch(err => err); - expect(error).toBeInstanceOf(AnthropicConnectionTimeoutError); + expect(error).toBeInstanceOf(AIError.AnthropicConnectionTimeoutError); // isRetryableError() keys off "timed out"/"timeout" phrasing. expect((error as Error).message).toMatch(/timed out/i); }); diff --git a/packages/ai/test/anthropic-fast-mode.test.ts b/packages/ai/test/anthropic-fast-mode.test.ts index e33f8dabe..b70601c8a 100644 --- a/packages/ai/test/anthropic-fast-mode.test.ts +++ b/packages/ai/test/anthropic-fast-mode.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { - clearAnthropicFastModeFallback, - isAnthropicFastModeUnsupportedError, - streamAnthropic, -} from "@oh-my-pi/pi-ai/providers/anthropic"; +import { isFastModeUnsupported } from "@oh-my-pi/pi-ai/error"; +import { clearAnthropicFastModeFallback, streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; import type { Context, Model, ProviderSessionState, ServiceTier } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; @@ -137,7 +134,7 @@ describe("clearAnthropicFastModeFallback", () => { }); }); -describe("isAnthropicFastModeUnsupportedError", () => { +describe("isFastModeUnsupported", () => { function makeStatusError(status: number, message: string): Error { const err = new Error(message) as Error & { status: number }; err.status = status; @@ -149,7 +146,7 @@ describe("isAnthropicFastModeUnsupportedError", () => { 400, '400 {"type":"error","error":{"type":"invalid_request_error","message":"\'claude-opus-4-5-20251101\' does not support the `speed` parameter."}}', ); - expect(isAnthropicFastModeUnsupportedError(err)).toBe(true); + expect(isFastModeUnsupported(err)).toBe(true); }); it("detects 429 rate_limit_error when fast mode requires extra usage", () => { @@ -159,7 +156,7 @@ describe("isAnthropicFastModeUnsupportedError", () => { 429, '429 {"type":"error","error":{"type":"rate_limit_error","message":"Extra usage is required for fast mode."}}', ); - expect(isAnthropicFastModeUnsupportedError(err)).toBe(true); + expect(isFastModeUnsupported(err)).toBe(true); }); it("ignores unrelated 429 rate limits", () => { @@ -167,7 +164,7 @@ describe("isAnthropicFastModeUnsupportedError", () => { 429, '429 {"type":"error","error":{"type":"rate_limit_error","message":"Number of requests has exceeded your account\'s rate limit."}}', ); - expect(isAnthropicFastModeUnsupportedError(err)).toBe(false); + expect(isFastModeUnsupported(err)).toBe(false); }); it("ignores unrelated 400 invalid_request errors", () => { @@ -175,6 +172,6 @@ describe("isAnthropicFastModeUnsupportedError", () => { 400, '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages: at least one message is required"}}', ); - expect(isAnthropicFastModeUnsupportedError(err)).toBe(false); + expect(isFastModeUnsupported(err)).toBe(false); }); }); diff --git a/packages/ai/test/anthropic-stream-timeout.test.ts b/packages/ai/test/anthropic-stream-timeout.test.ts index 6119beb46..3d89b9a4b 100644 --- a/packages/ai/test/anthropic-stream-timeout.test.ts +++ b/packages/ai/test/anthropic-stream-timeout.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; -import { AnthropicApiError, type AnthropicMessagesClientLike } from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { AnthropicMessagesClientLike } from "@oh-my-pi/pi-ai/providers/anthropic-client"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { waitForDelayOrAbort } from "./helpers"; @@ -436,7 +437,7 @@ describe("anthropic provider retry delays", () => { attempt += 1; if (attempt === 1) { return createRejectedAnthropicRequest( - new AnthropicApiError( + new AIError.AnthropicApiError( 529, '529 {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', new Headers({ "retry-after": "30" }), @@ -511,7 +512,7 @@ describe("anthropic provider retry delays", () => { attempt += 1; if (attempt <= 10) { return createRejectedAnthropicRequest( - new AnthropicApiError(502, "502 Bad Gateway", new Headers()), + new AIError.AnthropicApiError(502, "502 Bad Gateway", new Headers()), ) as never; } return createAnthropicMockStream({ diff --git a/packages/ai/test/auth-gateway-classify-error.test.ts b/packages/ai/test/auth-gateway-classify-error.test.ts index a499da6d8..e96766be6 100644 --- a/packages/ai/test/auth-gateway-classify-error.test.ts +++ b/packages/ai/test/auth-gateway-classify-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { classifyGatewayError } from "@oh-my-pi/pi-ai/auth-gateway/server"; +import { classifyGatewayError } from "@oh-my-pi/pi-ai/error"; describe("auth-gateway classifyGatewayError", () => { it("honours an explicit numeric `status` property on the error", () => { diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 2bd276fc5..bdbac2c1b 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -14,9 +14,9 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { ChildProcess } from "node:child_process"; import { execSync, spawn } from "node:child_process"; +import { isContextOverflow as originalIsContextOverflow } from "@oh-my-pi/pi-ai/error"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { AssistantMessage, Context, Model, Usage } from "@oh-my-pi/pi-ai/types"; -import { isContextOverflow as originalIsContextOverflow } from "@oh-my-pi/pi-ai/utils/overflow"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { $which } from "@oh-my-pi/pi-utils"; diff --git a/packages/ai/test/error-aierr.test.ts b/packages/ai/test/error-aierr.test.ts new file mode 100644 index 000000000..a6c1c6e80 --- /dev/null +++ b/packages/ai/test/error-aierr.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import * as AIError from "@oh-my-pi/pi-ai/error"; + +describe("AIError.classify — structural provider errors", () => { + it("classifies an Anthropic connection timeout as timeout + transient (no regex)", () => { + const id = AIError.classify(new AIError.AnthropicConnectionTimeoutError()); + expect(AIError.is(id, AIError.Flag.Timeout)).toBe(true); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + }); + + it("classifies an Anthropic connection error as transient", () => { + const id = AIError.classify(new AIError.AnthropicConnectionError(new Error("ECONNRESET"))); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + }); + + it("maps a 5xx ProviderHttpError to transient via status", () => { + const id = AIError.classify(new AIError.ProviderHttpError("Service Unavailable", 503)); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + }); + + it("maps the overloaded_error code to transient regardless of status", () => { + const id = AIError.classify(new AIError.ProviderHttpError("Overloaded", 529, { code: "overloaded_error" })); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + }); + + it("maps 401/403 to authFailed via status", () => { + expect( + AIError.is(AIError.classify(new AIError.ProviderHttpError("Unauthorized", 401)), AIError.Flag.AuthFailed), + ).toBe(true); + expect( + AIError.is(AIError.classify(new AIError.ProviderHttpError("Forbidden", 403)), AIError.Flag.AuthFailed), + ).toBe(true); + }); + + it("maps the usage_limit_reached code to usageLimit on a 429", () => { + const id = AIError.classify( + new AIError.ProviderHttpError("Payment Required", 429, { code: "usage_limit_reached" }), + ); + expect(AIError.is(id, AIError.Flag.UsageLimit)).toBe(true); + }); + + it("recognizes Codex transport errors by name without importing the provider", () => { + const transport = Object.assign(new Error("websocket closed"), { name: "CodexWebSocketTransportError" }); + expect(AIError.is(AIError.classify(transport), AIError.Flag.Transient)).toBe(true); + const retryableStream = Object.assign(new Error("server error"), { + name: "CodexProviderStreamError", + retryable: true, + }); + expect(AIError.is(AIError.classify(retryableStream), AIError.Flag.Transient)).toBe(true); + const fatalStream = Object.assign(new Error("bad request"), { + name: "CodexProviderStreamError", + retryable: false, + }); + expect(AIError.is(AIError.classify(fatalStream), AIError.Flag.Transient)).toBe(false); + }); +}); + +describe("AIError.finalize", () => { + it("bundles id, status, error stopReason, and message for a connection timeout", async () => { + const result = await AIError.finalize(new AIError.AnthropicConnectionTimeoutError(), {}); + expect(result.stopReason).toBe("error"); + expect(AIError.is(result.id, AIError.Flag.Timeout)).toBe(true); + expect(AIError.is(result.id, AIError.Flag.Transient)).toBe(true); + expect(result.message.length).toBeGreaterThan(0); + }); + + it("reports aborted when the caller signal is aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const result = await AIError.finalize(new Error("cancelled"), { signal: controller.signal }); + expect(result.stopReason).toBe("aborted"); + }); + + it("surfaces the HTTP status from a ProviderHttpError", async () => { + const result = await AIError.finalize(new AIError.ProviderHttpError("Bad Gateway", 502), {}); + expect(result.status).toBe(502); + expect(AIError.is(result.id, AIError.Flag.Transient)).toBe(true); + }); +}); + +describe("aierr flag helpers", () => { + it("compose then has round-trips multiple flags", () => { + const id = AIError.create(AIError.Flag.ThinkingLoop, AIError.Flag.Transient); + expect(AIError.is(id, AIError.Flag.ThinkingLoop)).toBe(true); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + expect(AIError.is(id, AIError.Flag.Timeout)).toBe(false); + }); + + it("treats transient and usageLimit ids as retryable", () => { + expect(AIError.retriable(AIError.create(AIError.Flag.Transient))).toBe(true); + expect(AIError.retriable(AIError.create(AIError.Flag.UsageLimit))).toBe(true); + }); +}); diff --git a/packages/ai/test/error-id.test.ts b/packages/ai/test/error-id.test.ts new file mode 100644 index 000000000..e6afc1181 --- /dev/null +++ b/packages/ai/test/error-id.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; + +function message(overrides: Partial = {}): AssistantMessage { + return { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + timestamp: Date.now(), + ...overrides, + }; +} + +describe("error-id classification", () => { + it("composes timeout with transient", () => { + const id = AIError.classify(new Error("provider stream stall timeout"), "anthropic-messages"); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + expect(AIError.is(id, AIError.Flag.Timeout)).toBe(true); + expect(AIError.is(id, AIError.Flag.Class)).toBe(true); + }); + + it("keeps raw status fallback unclassified", () => { + const id = 503; + expect(AIError.is(id, AIError.Flag.Class)).toBe(false); + expect(id).toBe(503); + }); + + it("gates stale Responses replay errors by API", () => { + const text = "Item with id 'resp_123' not found"; + const anthropicId = AIError.classify(new Error(text), "anthropic-messages"); + const responsesId = AIError.classify(new Error(text), "openai-responses"); + expect(AIError.is(anthropicId, AIError.Flag.StaleResponsesItem)).toBe(false); + expect(AIError.is(responsesId, AIError.Flag.StaleResponsesItem)).toBe(true); + }); + + it("walks causes and preserves carried ids", () => { + const inner = AIError.attach(new Error("inner"), AIError.create(AIError.Flag.ThinkingLoop)); + const outer = new Error("outer", { cause: inner }); + const id = AIError.classify(outer, "anthropic-messages"); + expect(AIError.is(id, AIError.Flag.ThinkingLoop)).toBe(true); + }); + + it("combines wrapper text classification with cause ids", () => { + const cause = AIError.attach(new Error("quota reached"), AIError.create(AIError.Flag.UsageLimit)); + const outer = new Error("network stream stall", { cause }); + const id = AIError.classify(outer, "anthropic-messages"); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + expect(AIError.is(id, AIError.Flag.Timeout)).toBe(true); + expect(AIError.is(id, AIError.Flag.UsageLimit)).toBe(true); + }); + + it("upgrades a stamped status fallback after final error text exists", () => { + const assistant = message({ + errorId: 503, + errorStatus: 503, + errorMessage: "usage limit reached", + }); + const id = AIError.classifyMessage(assistant); + expect(AIError.is(id, AIError.Flag.UsageLimit)).toBe(true); + expect(AIError.is(id, AIError.Flag.Class)).toBe(true); + expect(assistant.errorId).toBe(id); + }); + + it("merges existing cause-chain kinds with finalized error text kinds", () => { + const assistant = message({ + errorId: AIError.create(AIError.Flag.ThinkingLoop), + errorMessage: "usage limit reached", + }); + const id = AIError.classifyMessage(assistant); + expect(AIError.is(id, AIError.Flag.ThinkingLoop)).toBe(true); + expect(AIError.is(id, AIError.Flag.UsageLimit)).toBe(true); + expect(assistant.errorId).toBe(id); + }); +}); diff --git a/packages/ai/test/event-stream.test.ts b/packages/ai/test/event-stream.test.ts index c26db24a0..c652defcb 100644 --- a/packages/ai/test/event-stream.test.ts +++ b/packages/ai/test/event-stream.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import type { AssistantMessage } from "@oh-my-pi/pi-ai/types"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; @@ -47,4 +48,42 @@ describe("AssistantMessageEventStream", () => { stream.end(); await expect(stream.result()).resolves.toBe(message); }); + + it("stamps terminal error events with a classified errorId", async () => { + const stream = new AssistantMessageEventStream(); + const message = createPartial(); + message.stopReason = "error"; + message.errorMessage = "usage limit reached"; + + stream.push({ type: "error", reason: "error", error: message }); + + const result = await stream.result(); + expect(AIError.is(result.errorId, AIError.Flag.UsageLimit)).toBe(true); + }); + + it("leaves successful terminal messages without errorId", async () => { + const stream = new AssistantMessageEventStream(); + const message = createPartial("ok"); + + stream.push({ type: "done", reason: "stop", message }); + + const result = await stream.result(); + expect(result.errorId).toBeUndefined(); + }); + + it("upgrades raw status fallback ids after final terminal text is available", async () => { + const stream = new AssistantMessageEventStream(); + const message = createPartial(); + message.stopReason = "error"; + message.errorId = 503; + message.errorStatus = 503; + message.errorMessage = "stream stall"; + + stream.push({ type: "error", reason: "error", error: message }); + + const result = await stream.result(); + expect(AIError.is(result.errorId, AIError.Flag.Class)).toBe(true); + expect(AIError.is(result.errorId, AIError.Flag.Timeout)).toBe(true); + expect(AIError.is(result.errorId, AIError.Flag.Transient)).toBe(true); + }); }); diff --git a/packages/ai/test/gitlab-duo-workflow-provider.test.ts b/packages/ai/test/gitlab-duo-workflow-provider.test.ts index 46125ee89..6d81a04a5 100644 --- a/packages/ai/test/gitlab-duo-workflow-provider.test.ts +++ b/packages/ai/test/gitlab-duo-workflow-provider.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { isContextOverflow } from "@oh-my-pi/pi-ai/error"; import { buildGitLabDuoWorkflowApprovalStartRequest, buildGitLabDuoWorkflowCreateBody, @@ -37,7 +38,6 @@ import type { ToolResultMessage, } from "@oh-my-pi/pi-ai/types"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; -import { getOverflowPatterns } from "@oh-my-pi/pi-ai/utils/overflow"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; @@ -1366,7 +1366,9 @@ describe("GitLab Duo Workflow WebSocket state machine", () => { const result = await stream.result(); expect(result.stopReason).toBe("error"); - expect(getOverflowPatterns().some(p => p.test(result.errorMessage ?? ""))).toBe(true); + expect(isContextOverflow({ stopReason: "error", errorMessage: result.errorMessage, content: [] } as any)).toBe( + true, + ); expect(result.errorMessage).toContain("prompt is too long"); // The request was never spent and the created workflow was stopped. expect(socketOpened).toBe(false); @@ -1441,7 +1443,9 @@ describe("GitLab Duo Workflow WebSocket state machine", () => { expect(result.stopReason).toBe("error"); // The request WAS attempted (jitter zone can succeed), then relabeled on failure. expect(socketOpened).toBe(true); - expect(getOverflowPatterns().some(p => p.test(result.errorMessage ?? ""))).toBe(true); + expect(isContextOverflow({ stopReason: "error", errorMessage: result.errorMessage, content: [] } as any)).toBe( + true, + ); expect(result.errorMessage).toContain("prompt is too long"); expect(result.errorMessage).not.toContain("Internal server error"); }); diff --git a/packages/ai/test/inband-tools.test.ts b/packages/ai/test/inband-tools.test.ts index 7ac26d0df..ddb3d71c7 100644 --- a/packages/ai/test/inband-tools.test.ts +++ b/packages/ai/test/inband-tools.test.ts @@ -287,5 +287,4 @@ describe("in-band tool dialects", () => { .join(""); expect(deltas).toBe("line1\nconst x = `a`;"); }); - }); diff --git a/packages/ai/test/overflow-utils.test.ts b/packages/ai/test/overflow-utils.test.ts index 7dc3fe3f7..b62cf25e6 100644 --- a/packages/ai/test/overflow-utils.test.ts +++ b/packages/ai/test/overflow-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { isContextOverflow } from "@oh-my-pi/pi-ai/utils/overflow"; +import { isContextOverflow } from "@oh-my-pi/pi-ai/error"; function createErrorMessage(errorMessage: string): AssistantMessage { return { diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index 4b77a215e..4b9562ed7 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "bun:test"; +import { isUsageLimit } from "@oh-my-pi/pi-ai/error/flags"; import { calculateRateLimitBackoffMs, - isUsageLimitError, isUsageLimitOutcome, isUsageLimitStatus, parseRateLimitReason, -} from "@oh-my-pi/pi-ai/rate-limit-utils"; +} from "@oh-my-pi/pi-ai/error/rate-limit"; describe("parseRateLimitReason", () => { it("classifies Google Quota exceeded as QUOTA_EXHAUSTED", () => { @@ -80,10 +80,10 @@ describe("parseRateLimitReason", () => { }); }); -describe("isUsageLimitError", () => { +describe("isUsageLimit", () => { it("detects account rate limits as credential-rotatable usage limits", () => { expect( - isUsageLimitError( + isUsageLimit( '429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\'s rate limit. Please try again later."}}', ), ).toBe(true); @@ -91,7 +91,7 @@ describe("isUsageLimitError", () => { it("detects OpenCode Go insufficient balance as a credential-rotatable usage limit", () => { expect( - isUsageLimitError("401 Insufficient balance. Manage your billing here: https://opencode.ai/workspace/demo"), + isUsageLimit("401 Insufficient balance. Manage your billing here: https://opencode.ai/workspace/demo"), ).toBe(true); }); @@ -100,7 +100,7 @@ describe("isUsageLimitError", () => { // session sticks to the exhausted OAuth account instead of rotating — // see `agent-session.ts` line 8314 and `auth-storage.ts` line 3457. expect( - isUsageLimitError( + isUsageLimit( "Cloud Code Assist API error (429): You have exhausted your capacity on this model. Your quota will reset after 3h6m38s.", ), ).toBe(true); @@ -114,20 +114,20 @@ describe("isUsageLimitError", () => { // account (see issue #2198). it("detects Antigravity 'Individual quota reached' as a credential-rotatable usage limit", () => { expect( - isUsageLimitError( + isUsageLimit( "Cloud Code Assist API error (429): Individual quota reached. Contact your administrator to enable overages.", ), ).toBe(true); }); it("detects bare 'quota reached' phrasing", () => { - expect(isUsageLimitError("quota reached")).toBe(true); - expect(isUsageLimitError("quota_reached")).toBe(true); + expect(isUsageLimit("quota reached")).toBe(true); + expect(isUsageLimit("quota_reached")).toBe(true); }); it("detects OpenAI quota payload codes as credential-rotatable usage limits", () => { for (const message of ["insufficient_quota", "usage_limit_exceeded", "usage_limit_reached"]) { - expect(isUsageLimitError(message)).toBe(true); + expect(isUsageLimit(message)).toBe(true); } expect(isUsageLimitStatus(429)).toBe(true); expect(isUsageLimitStatus(400)).toBe(false); diff --git a/packages/ai/test/stream-markup-healing.test.ts b/packages/ai/test/stream-markup-healing.test.ts index 7cf36e40c..45f257f4c 100644 --- a/packages/ai/test/stream-markup-healing.test.ts +++ b/packages/ai/test/stream-markup-healing.test.ts @@ -1,4 +1,10 @@ import { describe, expect, it } from "bun:test"; +import { + type Dialect, + getDialectDefinition, + type InbandScanEvent, + ThinkingInbandScanner, +} from "@oh-my-pi/pi-ai/dialect"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { stream } from "@oh-my-pi/pi-ai/stream"; import type { Context, FetchImpl, Model, ThinkingContent, Tool, ToolCall } from "@oh-my-pi/pi-ai/types"; @@ -249,6 +255,80 @@ describe("StreamMarkupHealing thinking pattern", () => { expect(healing.feedEvents("king>hidden answer")).toEqual([{ type: "text", text: " answer" }]); }); + + // Heal input (one or more chunks) through the public entry point, returning the + // visible text and the recovered thinking. Spread a string to stream per char. + const heal = (...chunks: string[]): { text: string; thinking: string } => { + const healing = new StreamMarkupHealing({ pattern: "thinking" }); + const events = [...chunks.flatMap(chunk => healing.feedEvents(chunk)), ...healing.flushEvents()]; + let text = ""; + let thinking = ""; + for (const event of events) { + if (event.type === "text") text += event.text; + else if (event.type === "thinking") thinking += event.thinking; + } + return { text, thinking }; + }; + + // Exhaustive over the dialect union: a missing case is a compile error, so the + // healer is proven to recover every dialect's canonical `renderThinking` form. + const DIALECT_CASES: { [K in Dialect]: K } = { + anthropic: "anthropic", + deepseek: "deepseek", + gemini: "gemini", + gemma: "gemma", + glm: "glm", + harmony: "harmony", + hermes: "hermes", + kimi: "kimi", + minimax: "minimax", + qwen3: "qwen3", + xml: "xml", + }; + + for (const dialect of Object.values(DIALECT_CASES)) { + it(`heals leaked ${dialect} reasoning back into thinking`, () => { + const rendered = getDialectDefinition(dialect).renderThinking("REASONING_SENTINEL"); + const { text, thinking } = heal(`prefix ${rendered} suffix`); + expect(thinking).toContain("REASONING_SENTINEL"); + expect(text).toBe("prefix suffix"); + }); + } + + it("heals a gemini ```thinking fence streamed character by character", () => { + const { text, thinking } = heal(..."Sure.```thinking\nweigh options\n```Done."); + expect(thinking).toBe("weigh options\n"); + expect(text).toBe("Sure.Done."); + }); + + it("heals a bare harmony analysis channel leak", () => { + const { text, thinking } = heal("<|channel|>analysis<|message|>planning the edit<|end|>Final answer."); + expect(thinking).toBe("planning the edit"); + expect(text).toBe("Final answer."); + }); + + it("heals a leaked section", () => { + const { text, thinking } = heal("jotvisible"); + expect(thinking).toBe("jot"); + expect(text).toBe("visible"); + }); + + it("passes a bare '<' in idle prose through without holding it back", () => { + expect(heal("if a < b:\n return a")).toEqual({ text: "if a < b:\n return a", thinking: "" }); + }); + + it("leaves unrelated markup as visible text", () => { + expect(heal("see
content
end")).toEqual({ text: "see
content
end", thinking: "" }); + }); + + it("emits one balanced thinking boundary for a healed fence", () => { + const scanner = new ThinkingInbandScanner(); + const events: InbandScanEvent[] = [...scanner.feed("a```thinking\nx\n```b"), ...scanner.flush()]; + expect(events.filter(e => e.type === "thinkingStart")).toHaveLength(1); + expect(events.filter(e => e.type === "thinkingEnd")).toHaveLength(1); + const thinking = events.map(e => (e.type === "thinkingDelta" ? e.delta : "")).join(""); + expect(thinking).toBe("x\n"); + }); }); describe("Kimi K2 leaked markup healing", () => { const model = kimiModel(); diff --git a/packages/ai/test/thinking-loop.test.ts b/packages/ai/test/thinking-loop.test.ts index 87586ce8b..357da95e6 100644 --- a/packages/ai/test/thinking-loop.test.ts +++ b/packages/ai/test/thinking-loop.test.ts @@ -1,6 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { scheduler } from "node:timers/promises"; import { clearCustomApis } from "@oh-my-pi/pi-ai/api-registry"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { createMockModel, type MockContent, registerMockApi } from "@oh-my-pi/pi-ai/providers/mock"; import { complete, completeSimple, stream, streamSimple } from "@oh-my-pi/pi-ai/stream"; import type { Api, AssistantMessage, AssistantMessageEvent, Context, Model } from "@oh-my-pi/pi-ai/types"; @@ -359,6 +360,7 @@ describe("gemini thinking-loop guard (stream wrapper)", () => { expect(result.stopReason).toBe("error"); expect(result.content).toEqual([]); expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(AIError.is(result.errorId, AIError.Flag.ThinkingLoop)).toBe(true); // Empty content + transient phrasing is what makes the turn auto-retry. expect(result.errorMessage).toContain("stream stall"); expect(isRetryableError(new Error(result.errorMessage))).toBe(true); @@ -449,6 +451,7 @@ describe("gemini thinking-loop guard (stream wrapper)", () => { expect(result.stopReason).toBe("error"); expect(result.content).toEqual([]); expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(AIError.is(result.errorId, AIError.Flag.ThinkingLoop)).toBe(true); expect(isRetryableError(new Error(result.errorMessage))).toBe(true); } finally { clearCustomApis(); @@ -478,6 +481,7 @@ describe("withGeminiThinkingLoopGuard (Vertex transport)", () => { expect(result.stopReason).toBe("error"); expect(result.content.length).toBe(0); expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(AIError.is(result.errorId, AIError.Flag.ThinkingLoop)).toBe(true); expect(isRetryableError(new Error(result.errorMessage))).toBe(true); }); }); @@ -530,6 +534,7 @@ describe("loop guard assistant prose/text loops", () => { // drop it so AgentSession can retry with a clean assistant turn. expect(result.content).toEqual([]); expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(AIError.is(result.errorId, AIError.Flag.ThinkingLoop)).toBe(true); expect(result.errorMessage).toContain("stream stall"); expect(isRetryableError(new Error(result.errorMessage))).toBe(true); }); diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts index 160ce4431..e00f19a73 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -126,6 +126,7 @@ export type CustomToolSessionEvent = maxAttempts: number; delayMs: number; errorMessage: string; + errorId?: number; } | { reason: "auto_retry_end"; diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-bundled-keys.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-bundled-keys.ts index d07ae0d1e..f8452e377 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-bundled-keys.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-bundled-keys.ts @@ -23,6 +23,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-agent-core/compaction/tool-protection", "@oh-my-pi/pi-agent-core/compaction/utils", "@oh-my-pi/pi-ai", + "@oh-my-pi/pi-ai/error", "@oh-my-pi/pi-ai/auth-broker", "@oh-my-pi/pi-ai/auth-gateway", "@oh-my-pi/pi-ai/utils/harmony-leak", @@ -56,6 +57,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-ai/providers/devin", "@oh-my-pi/pi-ai/providers/error-message", "@oh-my-pi/pi-ai/providers/github-copilot-headers", + "@oh-my-pi/pi-ai/providers/gitlab-duo-workflow", "@oh-my-pi/pi-ai/providers/gitlab-duo", "@oh-my-pi/pi-ai/providers/google-auth", "@oh-my-pi/pi-ai/providers/google-gemini-cli", @@ -94,6 +96,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-ai/usage/kimi", "@oh-my-pi/pi-ai/usage/minimax-code", "@oh-my-pi/pi-ai/usage/ollama", + "@oh-my-pi/pi-ai/usage/openai-codex-base-url", "@oh-my-pi/pi-ai/usage/openai-codex-reset", "@oh-my-pi/pi-ai/usage/openai-codex", "@oh-my-pi/pi-ai/usage/opencode-go", @@ -110,7 +113,6 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-ai/utils/idle-iterator", "@oh-my-pi/pi-ai/utils/openai-http", "@oh-my-pi/pi-ai/utils/openrouter-headers", - "@oh-my-pi/pi-ai/utils/overflow", "@oh-my-pi/pi-ai/utils/parse-bind", "@oh-my-pi/pi-ai/utils/provider-response", "@oh-my-pi/pi-ai/utils/proxy", @@ -120,6 +122,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-ai/utils/sdk-stream-timeout", "@oh-my-pi/pi-ai/utils/sse-debug", "@oh-my-pi/pi-ai/utils/stream-markup-healing", + "@oh-my-pi/pi-ai/utils/strip", "@oh-my-pi/pi-ai/utils/thinking-loop", "@oh-my-pi/pi-ai/utils/tool-choice", "@oh-my-pi/pi-ai/utils/validation", @@ -128,6 +131,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-ai/oauth/cursor", "@oh-my-pi/pi-ai/oauth/devin", "@oh-my-pi/pi-ai/oauth/github-copilot", + "@oh-my-pi/pi-ai/oauth/gitlab-duo-workflow", "@oh-my-pi/pi-ai/oauth/gitlab-duo", "@oh-my-pi/pi-ai/oauth/google-antigravity", "@oh-my-pi/pi-ai/oauth/google-gemini-cli", @@ -187,6 +191,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/eval", "@oh-my-pi/pi-coding-agent/lsp", "@oh-my-pi/pi-coding-agent/lsp/clients", + "@oh-my-pi/pi-coding-agent/markit", "@oh-my-pi/pi-coding-agent/mcp", "@oh-my-pi/pi-coding-agent/mcp/transports", "@oh-my-pi/pi-coding-agent/memories", @@ -481,6 +486,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/internal-urls/router", "@oh-my-pi/pi-coding-agent/internal-urls/rule-protocol", "@oh-my-pi/pi-coding-agent/internal-urls/skill-protocol", + "@oh-my-pi/pi-coding-agent/internal-urls/ssh-protocol", "@oh-my-pi/pi-coding-agent/internal-urls/types", "@oh-my-pi/pi-coding-agent/internal-urls/vault-protocol", "@oh-my-pi/pi-coding-agent/eval/js/context-manager", @@ -508,6 +514,8 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/lsp/clients/biome-client", "@oh-my-pi/pi-coding-agent/lsp/clients/lsp-linter-client", "@oh-my-pi/pi-coding-agent/lsp/clients/swiftlint-client", + "@oh-my-pi/pi-coding-agent/markit/registry", + "@oh-my-pi/pi-coding-agent/markit/types", "@oh-my-pi/pi-coding-agent/mcp/client", "@oh-my-pi/pi-coding-agent/mcp/config-writer", "@oh-my-pi/pi-coding-agent/mcp/config", @@ -554,6 +562,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/modes/orchestrate", "@oh-my-pi/pi-coding-agent/modes/print-mode", "@oh-my-pi/pi-coding-agent/modes/prompt-action-autocomplete", + "@oh-my-pi/pi-coding-agent/modes/running-subagent-badge", "@oh-my-pi/pi-coding-agent/modes/runtime-init", "@oh-my-pi/pi-coding-agent/modes/session-observer-registry", "@oh-my-pi/pi-coding-agent/modes/setup-version", @@ -603,6 +612,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/modes/components/mcp-add-wizard", "@oh-my-pi/pi-coding-agent/modes/components/message-frame", "@oh-my-pi/pi-coding-agent/modes/components/model-selector", + "@oh-my-pi/pi-coding-agent/modes/components/move-overlay", "@oh-my-pi/pi-coding-agent/modes/components/oauth-selector", "@oh-my-pi/pi-coding-agent/modes/components/omfg-panel", "@oh-my-pi/pi-coding-agent/modes/components/overlay-box", @@ -614,6 +624,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/modes/components/read-tool-group", "@oh-my-pi/pi-coding-agent/modes/components/reset-usage-selector", "@oh-my-pi/pi-coding-agent/modes/components/segment-track", + "@oh-my-pi/pi-coding-agent/modes/components/select-list-mouse-routing", "@oh-my-pi/pi-coding-agent/modes/components/selector-helpers", "@oh-my-pi/pi-coding-agent/modes/components/session-selector", "@oh-my-pi/pi-coding-agent/modes/components/settings-defs", @@ -720,6 +731,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/session/sql-session-storage", "@oh-my-pi/pi-coding-agent/session/streaming-output", "@oh-my-pi/pi-coding-agent/session/tool-choice-queue", + "@oh-my-pi/pi-coding-agent/session/turn-persistence", "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier", "@oh-my-pi/pi-coding-agent/session/yield-queue", "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins", @@ -729,6 +741,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/slash-commands/types", "@oh-my-pi/pi-coding-agent/ssh/config-writer", "@oh-my-pi/pi-coding-agent/ssh/connection-manager", + "@oh-my-pi/pi-coding-agent/ssh/file-transfer", "@oh-my-pi/pi-coding-agent/ssh/ssh-executor", "@oh-my-pi/pi-coding-agent/ssh/sshfs-mount", "@oh-my-pi/pi-coding-agent/ssh/utils", @@ -838,6 +851,7 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/tui/types", "@oh-my-pi/pi-coding-agent/tui/utils", "@oh-my-pi/pi-coding-agent/tui/width-aware-text", + "@oh-my-pi/pi-coding-agent/utils/active-repo-context", "@oh-my-pi/pi-coding-agent/utils/block-context", "@oh-my-pi/pi-coding-agent/utils/changelog", "@oh-my-pi/pi-coding-agent/utils/clipboard", @@ -856,9 +870,11 @@ export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet = new Set([ "@oh-my-pi/pi-coding-agent/utils/ipc", "@oh-my-pi/pi-coding-agent/utils/jj", "@oh-my-pi/pi-coding-agent/utils/lang-from-path", + "@oh-my-pi/pi-coding-agent/utils/markit-cache", "@oh-my-pi/pi-coding-agent/utils/markit", "@oh-my-pi/pi-coding-agent/utils/mupdf-wasm-embed", "@oh-my-pi/pi-coding-agent/utils/open", + "@oh-my-pi/pi-coding-agent/utils/prompt-path", "@oh-my-pi/pi-coding-agent/utils/qrcode", "@oh-my-pi/pi-coding-agent/utils/session-color", "@oh-my-pi/pi-coding-agent/utils/shell-snapshot", diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-bundled-registry.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-bundled-registry.ts index b5c04ce3d..7613d5158 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-bundled-registry.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-bundled-registry.ts @@ -43,6 +43,7 @@ import * as bundledPiAiAuthGatewayHttp from "@oh-my-pi/pi-ai/auth-gateway/http"; import * as bundledPiAiAuthGatewayServer from "@oh-my-pi/pi-ai/auth-gateway/server"; import * as bundledPiAiAuthGatewayTypes from "@oh-my-pi/pi-ai/auth-gateway/types"; import * as bundledPiAiDialect from "@oh-my-pi/pi-ai/dialect"; +import * as bundledPiAiError from "@oh-my-pi/pi-ai/error"; import * as bundledPiAiOauth from "@oh-my-pi/pi-ai/oauth"; import * as bundledPiAiOauthAnthropic from "@oh-my-pi/pi-ai/oauth/anthropic"; import * as bundledPiAiOauthCallbackServer from "@oh-my-pi/pi-ai/oauth/callback-server"; @@ -50,6 +51,7 @@ import * as bundledPiAiOauthCursor from "@oh-my-pi/pi-ai/oauth/cursor"; import * as bundledPiAiOauthDevin from "@oh-my-pi/pi-ai/oauth/devin"; import * as bundledPiAiOauthGithubCopilot from "@oh-my-pi/pi-ai/oauth/github-copilot"; import * as bundledPiAiOauthGitlabDuo from "@oh-my-pi/pi-ai/oauth/gitlab-duo"; +import * as bundledPiAiOauthGitlabDuoWorkflow from "@oh-my-pi/pi-ai/oauth/gitlab-duo-workflow"; import * as bundledPiAiOauthGoogleAntigravity from "@oh-my-pi/pi-ai/oauth/google-antigravity"; import * as bundledPiAiOauthGoogleGeminiCli from "@oh-my-pi/pi-ai/oauth/google-gemini-cli"; import * as bundledPiAiOauthGoogleOauthShared from "@oh-my-pi/pi-ai/oauth/google-oauth-shared"; @@ -78,6 +80,7 @@ import * as bundledPiAiProvidersDevin from "@oh-my-pi/pi-ai/providers/devin"; import * as bundledPiAiProvidersErrorMessage from "@oh-my-pi/pi-ai/providers/error-message"; import * as bundledPiAiProvidersGithubCopilotHeaders from "@oh-my-pi/pi-ai/providers/github-copilot-headers"; import * as bundledPiAiProvidersGitlabDuo from "@oh-my-pi/pi-ai/providers/gitlab-duo"; +import * as bundledPiAiProvidersGitlabDuoWorkflow from "@oh-my-pi/pi-ai/providers/gitlab-duo-workflow"; import * as bundledPiAiProvidersGoogle from "@oh-my-pi/pi-ai/providers/google"; import * as bundledPiAiProvidersGoogleAuth from "@oh-my-pi/pi-ai/providers/google-auth"; import * as bundledPiAiProvidersGoogleGeminiCli from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; @@ -118,6 +121,7 @@ import * as bundledPiAiUsageKimi from "@oh-my-pi/pi-ai/usage/kimi"; import * as bundledPiAiUsageMinimaxCode from "@oh-my-pi/pi-ai/usage/minimax-code"; import * as bundledPiAiUsageOllama from "@oh-my-pi/pi-ai/usage/ollama"; import * as bundledPiAiUsageOpenaiCodex from "@oh-my-pi/pi-ai/usage/openai-codex"; +import * as bundledPiAiUsageOpenaiCodexBaseUrl from "@oh-my-pi/pi-ai/usage/openai-codex-base-url"; import * as bundledPiAiUsageOpenaiCodexReset from "@oh-my-pi/pi-ai/usage/openai-codex-reset"; import * as bundledPiAiUsageOpencodeGo from "@oh-my-pi/pi-ai/usage/opencode-go"; import * as bundledPiAiUsageShared from "@oh-my-pi/pi-ai/usage/shared"; @@ -134,7 +138,6 @@ import * as bundledPiAiUtilsHttpInspector from "@oh-my-pi/pi-ai/utils/http-inspe import * as bundledPiAiUtilsIdleIterator from "@oh-my-pi/pi-ai/utils/idle-iterator"; import * as bundledPiAiUtilsOpenaiHttp from "@oh-my-pi/pi-ai/utils/openai-http"; import * as bundledPiAiUtilsOpenrouterHeaders from "@oh-my-pi/pi-ai/utils/openrouter-headers"; -import * as bundledPiAiUtilsOverflow from "@oh-my-pi/pi-ai/utils/overflow"; import * as bundledPiAiUtilsParseBind from "@oh-my-pi/pi-ai/utils/parse-bind"; import * as bundledPiAiUtilsProviderResponse from "@oh-my-pi/pi-ai/utils/provider-response"; import * as bundledPiAiUtilsProxy from "@oh-my-pi/pi-ai/utils/proxy"; @@ -161,6 +164,7 @@ import * as bundledPiAiUtilsSchemaZodDecontaminate from "@oh-my-pi/pi-ai/utils/s import * as bundledPiAiUtilsSdkStreamTimeout from "@oh-my-pi/pi-ai/utils/sdk-stream-timeout"; import * as bundledPiAiUtilsSseDebug from "@oh-my-pi/pi-ai/utils/sse-debug"; import * as bundledPiAiUtilsStreamMarkupHealing from "@oh-my-pi/pi-ai/utils/stream-markup-healing"; +import * as bundledPiAiUtilsStrip from "@oh-my-pi/pi-ai/utils/strip"; import * as bundledPiAiUtilsThinkingLoop from "@oh-my-pi/pi-ai/utils/thinking-loop"; import * as bundledPiAiUtilsToolChoice from "@oh-my-pi/pi-ai/utils/tool-choice"; import * as bundledPiAiUtilsValidation from "@oh-my-pi/pi-ai/utils/validation"; @@ -486,6 +490,7 @@ import * as bundledPiCodingAgentInternalUrlsRegistryHelpers from "@oh-my-pi/pi-c import * as bundledPiCodingAgentInternalUrlsRouter from "@oh-my-pi/pi-coding-agent/internal-urls/router"; import * as bundledPiCodingAgentInternalUrlsRuleProtocol from "@oh-my-pi/pi-coding-agent/internal-urls/rule-protocol"; import * as bundledPiCodingAgentInternalUrlsSkillProtocol from "@oh-my-pi/pi-coding-agent/internal-urls/skill-protocol"; +import * as bundledPiCodingAgentInternalUrlsSshProtocol from "@oh-my-pi/pi-coding-agent/internal-urls/ssh-protocol"; import * as bundledPiCodingAgentInternalUrlsTypes from "@oh-my-pi/pi-coding-agent/internal-urls/types"; import * as bundledPiCodingAgentInternalUrlsVaultProtocol from "@oh-my-pi/pi-coding-agent/internal-urls/vault-protocol"; import * as bundledPiCodingAgentLsp from "@oh-my-pi/pi-coding-agent/lsp"; @@ -503,6 +508,9 @@ import * as bundledPiCodingAgentLspRender from "@oh-my-pi/pi-coding-agent/lsp/re import * as bundledPiCodingAgentLspStartupEvents from "@oh-my-pi/pi-coding-agent/lsp/startup-events"; import * as bundledPiCodingAgentLspTypes from "@oh-my-pi/pi-coding-agent/lsp/types"; import * as bundledPiCodingAgentLspUtils from "@oh-my-pi/pi-coding-agent/lsp/utils"; +import * as bundledPiCodingAgentMarkit from "@oh-my-pi/pi-coding-agent/markit"; +import * as bundledPiCodingAgentMarkitRegistry from "@oh-my-pi/pi-coding-agent/markit/registry"; +import * as bundledPiCodingAgentMarkitTypes from "@oh-my-pi/pi-coding-agent/markit/types"; import * as bundledPiCodingAgentMcp from "@oh-my-pi/pi-coding-agent/mcp"; import * as bundledPiCodingAgentMcpClient from "@oh-my-pi/pi-coding-agent/mcp/client"; import * as bundledPiCodingAgentMcpConfig from "@oh-my-pi/pi-coding-agent/mcp/config"; @@ -583,6 +591,7 @@ import * as bundledPiCodingAgentModesComponentsLogoutAccountSelector from "@oh-m import * as bundledPiCodingAgentModesComponentsMcpAddWizard from "@oh-my-pi/pi-coding-agent/modes/components/mcp-add-wizard"; import * as bundledPiCodingAgentModesComponentsMessageFrame from "@oh-my-pi/pi-coding-agent/modes/components/message-frame"; import * as bundledPiCodingAgentModesComponentsModelSelector from "@oh-my-pi/pi-coding-agent/modes/components/model-selector"; +import * as bundledPiCodingAgentModesComponentsMoveOverlay from "@oh-my-pi/pi-coding-agent/modes/components/move-overlay"; import * as bundledPiCodingAgentModesComponentsOauthSelector from "@oh-my-pi/pi-coding-agent/modes/components/oauth-selector"; import * as bundledPiCodingAgentModesComponentsOmfgPanel from "@oh-my-pi/pi-coding-agent/modes/components/omfg-panel"; import * as bundledPiCodingAgentModesComponentsOverlayBox from "@oh-my-pi/pi-coding-agent/modes/components/overlay-box"; @@ -594,6 +603,7 @@ import * as bundledPiCodingAgentModesComponentsQueueModeSelector from "@oh-my-pi import * as bundledPiCodingAgentModesComponentsReadToolGroup from "@oh-my-pi/pi-coding-agent/modes/components/read-tool-group"; import * as bundledPiCodingAgentModesComponentsResetUsageSelector from "@oh-my-pi/pi-coding-agent/modes/components/reset-usage-selector"; import * as bundledPiCodingAgentModesComponentsSegmentTrack from "@oh-my-pi/pi-coding-agent/modes/components/segment-track"; +import * as bundledPiCodingAgentModesComponentsSelectListMouseRouting from "@oh-my-pi/pi-coding-agent/modes/components/select-list-mouse-routing"; import * as bundledPiCodingAgentModesComponentsSelectorHelpers from "@oh-my-pi/pi-coding-agent/modes/components/selector-helpers"; import * as bundledPiCodingAgentModesComponentsSessionSelector from "@oh-my-pi/pi-coding-agent/modes/components/session-selector"; import * as bundledPiCodingAgentModesComponentsSettingsDefs from "@oh-my-pi/pi-coding-agent/modes/components/settings-defs"; @@ -657,6 +667,7 @@ import * as bundledPiCodingAgentModesRpcRpcClient from "@oh-my-pi/pi-coding-agen import * as bundledPiCodingAgentModesRpcRpcMode from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-mode"; import * as bundledPiCodingAgentModesRpcRpcSubagents from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-subagents"; import * as bundledPiCodingAgentModesRpcRpcTypes from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-types"; +import * as bundledPiCodingAgentModesRunningSubagentBadge from "@oh-my-pi/pi-coding-agent/modes/running-subagent-badge"; import * as bundledPiCodingAgentModesRuntimeInit from "@oh-my-pi/pi-coding-agent/modes/runtime-init"; import * as bundledPiCodingAgentModesSessionObserverRegistry from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; import * as bundledPiCodingAgentModesSetupVersion from "@oh-my-pi/pi-coding-agent/modes/setup-version"; @@ -719,6 +730,7 @@ import * as bundledPiCodingAgentSessionSnapcompactSavingsJournal from "@oh-my-pi import * as bundledPiCodingAgentSessionSqlSessionStorage from "@oh-my-pi/pi-coding-agent/session/sql-session-storage"; import * as bundledPiCodingAgentSessionStreamingOutput from "@oh-my-pi/pi-coding-agent/session/streaming-output"; import * as bundledPiCodingAgentSessionToolChoiceQueue from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue"; +import * as bundledPiCodingAgentSessionTurnPersistence from "@oh-my-pi/pi-coding-agent/session/turn-persistence"; import * as bundledPiCodingAgentSessionUnexpectedStopClassifier from "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier"; import * as bundledPiCodingAgentSessionYieldQueue from "@oh-my-pi/pi-coding-agent/session/yield-queue"; import * as bundledPiCodingAgentSlashCommandsAcpBuiltins from "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins"; @@ -728,6 +740,7 @@ import * as bundledPiCodingAgentSlashCommandsMarketplaceInstallParser from "@oh- import * as bundledPiCodingAgentSlashCommandsTypes from "@oh-my-pi/pi-coding-agent/slash-commands/types"; import * as bundledPiCodingAgentSshConfigWriter from "@oh-my-pi/pi-coding-agent/ssh/config-writer"; import * as bundledPiCodingAgentSshConnectionManager from "@oh-my-pi/pi-coding-agent/ssh/connection-manager"; +import * as bundledPiCodingAgentSshFileTransfer from "@oh-my-pi/pi-coding-agent/ssh/file-transfer"; import * as bundledPiCodingAgentSshSshExecutor from "@oh-my-pi/pi-coding-agent/ssh/ssh-executor"; import * as bundledPiCodingAgentSshSshfsMount from "@oh-my-pi/pi-coding-agent/ssh/sshfs-mount"; import * as bundledPiCodingAgentSshUtils from "@oh-my-pi/pi-coding-agent/ssh/utils"; @@ -841,6 +854,7 @@ import * as bundledPiCodingAgentTuiTreeList from "@oh-my-pi/pi-coding-agent/tui/ import * as bundledPiCodingAgentTuiTypes from "@oh-my-pi/pi-coding-agent/tui/types"; import * as bundledPiCodingAgentTuiUtils from "@oh-my-pi/pi-coding-agent/tui/utils"; import * as bundledPiCodingAgentTuiWidthAwareText from "@oh-my-pi/pi-coding-agent/tui/width-aware-text"; +import * as bundledPiCodingAgentUtilsActiveRepoContext from "@oh-my-pi/pi-coding-agent/utils/active-repo-context"; import * as bundledPiCodingAgentUtilsBlockContext from "@oh-my-pi/pi-coding-agent/utils/block-context"; import * as bundledPiCodingAgentUtilsChangelog from "@oh-my-pi/pi-coding-agent/utils/changelog"; import * as bundledPiCodingAgentUtilsClipboard from "@oh-my-pi/pi-coding-agent/utils/clipboard"; @@ -860,8 +874,10 @@ import * as bundledPiCodingAgentUtilsIpc from "@oh-my-pi/pi-coding-agent/utils/i import * as bundledPiCodingAgentUtilsJj from "@oh-my-pi/pi-coding-agent/utils/jj"; import * as bundledPiCodingAgentUtilsLangFromPath from "@oh-my-pi/pi-coding-agent/utils/lang-from-path"; import * as bundledPiCodingAgentUtilsMarkit from "@oh-my-pi/pi-coding-agent/utils/markit"; +import * as bundledPiCodingAgentUtilsMarkitCache from "@oh-my-pi/pi-coding-agent/utils/markit-cache"; import * as bundledPiCodingAgentUtilsMupdfWasmEmbed from "@oh-my-pi/pi-coding-agent/utils/mupdf-wasm-embed"; import * as bundledPiCodingAgentUtilsOpen from "@oh-my-pi/pi-coding-agent/utils/open"; +import * as bundledPiCodingAgentUtilsPromptPath from "@oh-my-pi/pi-coding-agent/utils/prompt-path"; import * as bundledPiCodingAgentUtilsQrcode from "@oh-my-pi/pi-coding-agent/utils/qrcode"; import * as bundledPiCodingAgentUtilsSessionColor from "@oh-my-pi/pi-coding-agent/utils/session-color"; import * as bundledPiCodingAgentUtilsShellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot"; @@ -1040,6 +1056,7 @@ export const BUNDLED_PI_REGISTRY: Readonly >, "@oh-my-pi/pi-ai": bundledPiAi as unknown as Readonly>, + "@oh-my-pi/pi-ai/error": bundledPiAiError as unknown as Readonly>, "@oh-my-pi/pi-ai/auth-broker": bundledPiAiAuthBroker as unknown as Readonly>, "@oh-my-pi/pi-ai/auth-gateway": bundledPiAiAuthGateway as unknown as Readonly>, "@oh-my-pi/pi-ai/utils/harmony-leak": bundledPiAiUtilsHarmonyLeak as unknown as Readonly>, @@ -1101,6 +1118,9 @@ export const BUNDLED_PI_REGISTRY: Readonly >, + "@oh-my-pi/pi-ai/providers/gitlab-duo-workflow": bundledPiAiProvidersGitlabDuoWorkflow as unknown as Readonly< + Record + >, "@oh-my-pi/pi-ai/providers/gitlab-duo": bundledPiAiProvidersGitlabDuo as unknown as Readonly< Record >, @@ -1187,6 +1207,9 @@ export const BUNDLED_PI_REGISTRY: Readonly>, "@oh-my-pi/pi-ai/usage/minimax-code": bundledPiAiUsageMinimaxCode as unknown as Readonly>, "@oh-my-pi/pi-ai/usage/ollama": bundledPiAiUsageOllama as unknown as Readonly>, + "@oh-my-pi/pi-ai/usage/openai-codex-base-url": bundledPiAiUsageOpenaiCodexBaseUrl as unknown as Readonly< + Record + >, "@oh-my-pi/pi-ai/usage/openai-codex-reset": bundledPiAiUsageOpenaiCodexReset as unknown as Readonly< Record >, @@ -1217,7 +1240,6 @@ export const BUNDLED_PI_REGISTRY: Readonly >, - "@oh-my-pi/pi-ai/utils/overflow": bundledPiAiUtilsOverflow as unknown as Readonly>, "@oh-my-pi/pi-ai/utils/parse-bind": bundledPiAiUtilsParseBind as unknown as Readonly>, "@oh-my-pi/pi-ai/utils/provider-response": bundledPiAiUtilsProviderResponse as unknown as Readonly< Record @@ -1233,6 +1255,7 @@ export const BUNDLED_PI_REGISTRY: Readonly >, + "@oh-my-pi/pi-ai/utils/strip": bundledPiAiUtilsStrip as unknown as Readonly>, "@oh-my-pi/pi-ai/utils/thinking-loop": bundledPiAiUtilsThinkingLoop as unknown as Readonly>, "@oh-my-pi/pi-ai/utils/tool-choice": bundledPiAiUtilsToolChoice as unknown as Readonly>, "@oh-my-pi/pi-ai/utils/validation": bundledPiAiUtilsValidation as unknown as Readonly>, @@ -1245,6 +1268,9 @@ export const BUNDLED_PI_REGISTRY: Readonly >, + "@oh-my-pi/pi-ai/oauth/gitlab-duo-workflow": bundledPiAiOauthGitlabDuoWorkflow as unknown as Readonly< + Record + >, "@oh-my-pi/pi-ai/oauth/gitlab-duo": bundledPiAiOauthGitlabDuo as unknown as Readonly>, "@oh-my-pi/pi-ai/oauth/google-antigravity": bundledPiAiOauthGoogleAntigravity as unknown as Readonly< Record @@ -1358,6 +1384,7 @@ export const BUNDLED_PI_REGISTRY: Readonly >, + "@oh-my-pi/pi-coding-agent/markit": bundledPiCodingAgentMarkit as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/mcp": bundledPiCodingAgentMcp as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/mcp/transports": bundledPiCodingAgentMcpTransports as unknown as Readonly< Record @@ -2099,6 +2126,8 @@ export const BUNDLED_PI_REGISTRY: Readonly>, "@oh-my-pi/pi-coding-agent/internal-urls/skill-protocol": bundledPiCodingAgentInternalUrlsSkillProtocol as unknown as Readonly>, + "@oh-my-pi/pi-coding-agent/internal-urls/ssh-protocol": + bundledPiCodingAgentInternalUrlsSshProtocol as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/internal-urls/types": bundledPiCodingAgentInternalUrlsTypes as unknown as Readonly< Record >, @@ -2170,6 +2199,12 @@ export const BUNDLED_PI_REGISTRY: Readonly>, "@oh-my-pi/pi-coding-agent/lsp/clients/swiftlint-client": bundledPiCodingAgentLspClientsSwiftlintClient as unknown as Readonly>, + "@oh-my-pi/pi-coding-agent/markit/registry": bundledPiCodingAgentMarkitRegistry as unknown as Readonly< + Record + >, + "@oh-my-pi/pi-coding-agent/markit/types": bundledPiCodingAgentMarkitTypes as unknown as Readonly< + Record + >, "@oh-my-pi/pi-coding-agent/mcp/client": bundledPiCodingAgentMcpClient as unknown as Readonly< Record >, @@ -2298,6 +2333,8 @@ export const BUNDLED_PI_REGISTRY: Readonly, "@oh-my-pi/pi-coding-agent/modes/prompt-action-autocomplete": bundledPiCodingAgentModesPromptActionAutocomplete as unknown as Readonly>, + "@oh-my-pi/pi-coding-agent/modes/running-subagent-badge": + bundledPiCodingAgentModesRunningSubagentBadge as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/modes/runtime-init": bundledPiCodingAgentModesRuntimeInit as unknown as Readonly< Record >, @@ -2407,6 +2444,8 @@ export const BUNDLED_PI_REGISTRY: Readonly>, "@oh-my-pi/pi-coding-agent/modes/components/model-selector": bundledPiCodingAgentModesComponentsModelSelector as unknown as Readonly>, + "@oh-my-pi/pi-coding-agent/modes/components/move-overlay": + bundledPiCodingAgentModesComponentsMoveOverlay as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/modes/components/oauth-selector": bundledPiCodingAgentModesComponentsOauthSelector as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/modes/components/omfg-panel": @@ -2429,6 +2468,8 @@ export const BUNDLED_PI_REGISTRY: Readonly>, "@oh-my-pi/pi-coding-agent/modes/components/segment-track": bundledPiCodingAgentModesComponentsSegmentTrack as unknown as Readonly>, + "@oh-my-pi/pi-coding-agent/modes/components/select-list-mouse-routing": + bundledPiCodingAgentModesComponentsSelectListMouseRouting as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/modes/components/selector-helpers": bundledPiCodingAgentModesComponentsSelectorHelpers as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/modes/components/session-selector": @@ -2667,6 +2708,8 @@ export const BUNDLED_PI_REGISTRY: Readonly>, "@oh-my-pi/pi-coding-agent/session/tool-choice-queue": bundledPiCodingAgentSessionToolChoiceQueue as unknown as Readonly>, + "@oh-my-pi/pi-coding-agent/session/turn-persistence": + bundledPiCodingAgentSessionTurnPersistence as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier": bundledPiCodingAgentSessionUnexpectedStopClassifier as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/session/yield-queue": bundledPiCodingAgentSessionYieldQueue as unknown as Readonly< @@ -2689,6 +2732,9 @@ export const BUNDLED_PI_REGISTRY: Readonly >, + "@oh-my-pi/pi-coding-agent/ssh/file-transfer": bundledPiCodingAgentSshFileTransfer as unknown as Readonly< + Record + >, "@oh-my-pi/pi-coding-agent/ssh/ssh-executor": bundledPiCodingAgentSshSshExecutor as unknown as Readonly< Record >, @@ -2985,6 +3031,8 @@ export const BUNDLED_PI_REGISTRY: Readonly >, + "@oh-my-pi/pi-coding-agent/utils/active-repo-context": + bundledPiCodingAgentUtilsActiveRepoContext as unknown as Readonly>, "@oh-my-pi/pi-coding-agent/utils/block-context": bundledPiCodingAgentUtilsBlockContext as unknown as Readonly< Record >, @@ -3031,6 +3079,9 @@ export const BUNDLED_PI_REGISTRY: Readonly >, + "@oh-my-pi/pi-coding-agent/utils/markit-cache": bundledPiCodingAgentUtilsMarkitCache as unknown as Readonly< + Record + >, "@oh-my-pi/pi-coding-agent/utils/markit": bundledPiCodingAgentUtilsMarkit as unknown as Readonly< Record >, @@ -3040,6 +3091,9 @@ export const BUNDLED_PI_REGISTRY: Readonly >, + "@oh-my-pi/pi-coding-agent/utils/prompt-path": bundledPiCodingAgentUtilsPromptPath as unknown as Readonly< + Record + >, "@oh-my-pi/pi-coding-agent/utils/qrcode": bundledPiCodingAgentUtilsQrcode as unknown as Readonly< Record >, diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts index 5835a43f9..7eb122e9e 100644 --- a/packages/coding-agent/src/extensibility/shared-events.ts +++ b/packages/coding-agent/src/extensibility/shared-events.ts @@ -238,6 +238,7 @@ export interface AutoRetryStartEvent { maxAttempts: number; delayMs: number; errorMessage: string; + errorId?: number; } /** Fired when auto-retry ends */ diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 11bfeb78c..19b499d05 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -2014,7 +2014,7 @@ export class AcpAgent implements Agent { } } } - if (notifications.length === 0 && message.errorMessage && !isSilentAbort(message.errorMessage)) { + if (notifications.length === 0 && message.errorMessage && !isSilentAbort(message)) { notifications.push({ sessionId, update: { diff --git a/packages/coding-agent/src/modes/components/assistant-message.ts b/packages/coding-agent/src/modes/components/assistant-message.ts index 571f5ae72..5db8282f6 100644 --- a/packages/coding-agent/src/modes/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/components/assistant-message.ts @@ -581,11 +581,11 @@ export class AssistantMessageComponent extends Container { if (content.type === "toolCall") return false; } if (this.#toolImagesByCallId.size > 0) return false; - if (message.stopReason === "aborted" && shouldRenderAbortReason(message.errorMessage)) return false; + if (message.stopReason === "aborted" && shouldRenderAbortReason(message)) return false; if (message.stopReason === "error" && !this.#errorPinned) return false; if ( message.errorMessage && - shouldRenderAbortReason(message.errorMessage) && + shouldRenderAbortReason(message) && message.stopReason !== "aborted" && message.stopReason !== "error" ) @@ -779,8 +779,8 @@ export class AssistantMessageComponent extends Container { // But only if there are no tool calls (tool execution components will show the error) const hasToolCalls = message.content.some(c => c.type === "toolCall"); if (!hasToolCalls) { - if (message.stopReason === "aborted" && shouldRenderAbortReason(message.errorMessage)) { - const abortMessage = resolveAbortLabel(message.errorMessage); + if (message.stopReason === "aborted" && shouldRenderAbortReason(message)) { + const abortMessage = resolveAbortLabel(message); if (hasVisibleContent) { this.#contentContainer.addChild(new Spacer(1)); } else { @@ -793,7 +793,7 @@ export class AssistantMessageComponent extends Container { } if ( message.errorMessage && - shouldRenderAbortReason(message.errorMessage) && + shouldRenderAbortReason(message) && message.stopReason !== "aborted" && message.stopReason !== "error" ) { diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 5b66804a7..d7d26fb03 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1,5 +1,5 @@ import type { ImageContent } from "@oh-my-pi/pi-ai"; -import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { extractTextContent } from "../../commit/utils"; @@ -697,7 +697,7 @@ export class EventController { this.#toolArgsReveal.flushAll(); let errorMessage: string | undefined; const aborted = this.ctx.streamingMessage.stopReason === "aborted"; - const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage); + const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage); const ttsrSilenced = aborted && this.ctx.viewSession.isTtsrAbortPending; if (aborted && !silentlyAborted && !ttsrSilenced) { // Resolve the operator-facing label: a user-interrupt (Esc) abort @@ -707,7 +707,7 @@ export class EventController { // AgentSession.#handleAgentEvent already stamped SILENT_ABORT_MARKER for // the plan-compact transition before this controller ran, so reaching // this branch implies the abort was NOT a silent internal transition. - errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.viewSession.retryAttempt); + errorMessage = resolveAbortLabel(this.ctx.streamingMessage, this.ctx.viewSession.retryAttempt); this.ctx.streamingMessage.errorMessage = errorMessage; } if (silentlyAborted || ttsrSilenced) { @@ -759,11 +759,7 @@ export class EventController { // above the editor so it survives transcript scroll. Cleared at the next // turn's agent_start. Suppress the transcript's inline `Error: …` line for // the same message while pinned so the error isn't rendered twice. - if ( - event.message.stopReason === "error" && - event.message.errorMessage && - !isSilentAbort(event.message.errorMessage) - ) { + if (event.message.stopReason === "error" && event.message.errorMessage && !isSilentAbort(event.message)) { this.#lastAssistantComponent?.setErrorPinned(true); this.#pinnedErrorComponent = this.#lastAssistantComponent; this.ctx.showPinnedError(event.message.errorMessage); @@ -1157,7 +1153,7 @@ export class EventController { async #handleAutoRetryStart(event: Extract): Promise { this.#stopWorkingLoader(); this.ctx.statusContainer.clear(); - if (event.errorMessage?.includes(THINKING_LOOP_ERROR_MARKER)) { + if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) { // The retry path drops the failed assistant from runtime context. Do not // restore its inline Error row; just unpin the fixed-region banner so the // retry UI is the visible state. diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 711a25c33..dec9d4050 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -83,7 +83,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // Check for error/aborted — skip silent-abort (plan-mode compaction transition) if ( (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") && - !isSilentAbort(assistantMsg.errorMessage) + !isSilentAbort(assistantMsg) ) { const errorLine = sanitizeText(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); // Flush before this hard exit — it bypasses the awaited postmortem.quit() diff --git a/packages/coding-agent/src/modes/utils/transcript-render-helpers.ts b/packages/coding-agent/src/modes/utils/transcript-render-helpers.ts index b313b86e8..d66a6a030 100644 --- a/packages/coding-agent/src/modes/utils/transcript-render-helpers.ts +++ b/packages/coding-agent/src/modes/utils/transcript-render-helpers.ts @@ -146,11 +146,11 @@ export function resolveAssistantErrorMessage( message: AssistantAgentMessage, retryAttempt = 0, ): { hasErrorStop: boolean; errorMessage: string | null } { - const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage); + const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message); const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error"); const errorMessage = hasErrorStop ? message.stopReason === "aborted" - ? resolveAbortLabel(message.errorMessage, retryAttempt) + ? resolveAbortLabel(message, retryAttempt) : message.errorMessage || "Error" : null; return { hasErrorStop, errorMessage }; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 4f4961ad4..5e9174bcd 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -975,6 +975,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory { maxAttempts: event.maxAttempts, delayMs: event.delayMs, errorMessage: event.errorMessage, + errorId: event.errorId, }, ctx, ), diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 470f9c0f7..daae5c493 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -102,18 +102,13 @@ import { clearAnthropicFastModeFallback, deriveClaudeDeviceId, Effort, - isContextOverflow, - isUsageLimitError, parseRateLimitReason, resolveServiceTier, streamSimple, } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; -import { - GeminiHeaderRunDetector, - isGeminiThinkingModel, - THINKING_LOOP_ERROR_MARKER, -} from "@oh-my-pi/pi-ai/utils/thinking-loop"; +import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop"; import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; @@ -125,7 +120,6 @@ import { getInstallId, isBunTestRuntime, isEnoent, - isUnexpectedSocketCloseMessage, logger, prompt, relativePathWithinRoot, @@ -307,7 +301,6 @@ import { type BashExecutionMessage, type CustomMessage, convertToLlm, - GENERIC_ABORT_SENTINEL, type PythonExecutionMessage, readQueueChipText, SILENT_ABORT_MARKER, @@ -370,7 +363,14 @@ export type AgentSessionEvent = /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */ skipped?: boolean; } - | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } + | { + type: "auto_retry_start"; + attempt: number; + maxAttempts: number; + delayMs: number; + errorMessage: string; + errorId?: number; + } | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string } | { type: "retry_fallback_applied"; from: string; to: string; role: string } | { type: "retry_fallback_succeeded"; model: string; role: string } @@ -1388,6 +1388,7 @@ export class AgentSession { * `message_end` + `stopReason: "aborted"`; callers clear it in `finally` so * it cannot leak into later unrelated aborts. */ #planInternalAbortPending = false; + #pendingAbortErrorId?: number; #postPromptTasks = new Set>(); #postPromptTasksPromise: Promise | undefined = undefined; @@ -2769,11 +2770,17 @@ export class AgentSession { if ( event.type === "message_end" && event.message.role === "assistant" && - event.message.stopReason === "aborted" && - this.#planInternalAbortPending + event.message.stopReason === "aborted" ) { - (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER; - this.#planInternalAbortPending = false; + const message = event.message as AssistantMessage; + if (this.#planInternalAbortPending) { + message.errorMessage = SILENT_ABORT_MARKER; + message.errorId = AIError.create(AIError.Flag.SilentAbort); + this.#planInternalAbortPending = false; + } else if (this.#pendingAbortErrorId) { + message.errorId = this.#pendingAbortErrorId; + this.#pendingAbortErrorId = undefined; + } } const messageEndPersistence = @@ -3086,7 +3093,7 @@ export class AgentSession { if ( msg.stopReason === "error" && msg.provider === "github-copilot" && - msg.errorMessage?.includes("GitHub Copilot authentication failed") + AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed) ) { await this.#modelRegistry.authStorage.remove("github-copilot"); } @@ -4477,6 +4484,7 @@ export class AgentSession { maxAttempts: event.maxAttempts, delayMs: event.delayMs, errorMessage: event.errorMessage, + errorId: event.errorId, }); } else if (event.type === "auto_retry_end") { await this.#extensionRunner.emit({ @@ -7251,6 +7259,7 @@ export class AgentSession { preserveCompaction?: boolean; }): Promise { const userInterrupt = options?.reason === USER_INTERRUPT_LABEL; + this.#pendingAbortErrorId = userInterrupt ? AIError.create(AIError.Flag.UserInterrupt) : undefined; if (userInterrupt) this.#advisorAutoResumeSuppressed = true; // Pull advisor concerns out of the steer/follow-up queues before any await so // the post-abort stranded-message drain can't auto-resume the run on them. @@ -8957,7 +8966,7 @@ export class AgentSession { const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); const errorIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime(); - if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) { + if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) { // Remove the error message from agent state (it IS saved to session for history, // but we don't want it in context for the retry) const messages = this.agent.state.messages; @@ -10759,11 +10768,12 @@ export class AgentSession { } const message = error instanceof Error ? error.message : String(error); + const id = AIError.classify(error, candidate.api); if (this.#isCompactionAuthFailure(error)) { lastError = this.#buildCompactionAuthError(); break; } - if (this.#isCompactionSummarizationTimeoutMessage(message)) { + if (AIError.is(id, AIError.Flag.Timeout)) { logger.warn( hasMoreCandidates ? "Auto-compaction summarization timed out, trying next model" @@ -10782,8 +10792,8 @@ export class AgentSession { retrySettings.enabled && attempt < retrySettings.maxRetries && (retryAfterMs !== undefined || - this.#isTransientErrorMessage(message) || - isUsageLimitError(message)); + AIError.is(id, AIError.Flag.Transient) || + AIError.is(id, AIError.Flag.UsageLimit)); if (!shouldRetry) { lastError = error; break; @@ -11197,7 +11207,7 @@ export class AgentSession { return ( message.stopReason === "aborted" && message.content.length === 0 && - message.errorMessage === GENERIC_ABORT_SENTINEL && + AIError.is(message.errorId, AIError.Flag.Abort) && !this.#abortInProgress && !this.#isDisposed && !this.#streamingEditAbortTriggered @@ -11210,21 +11220,15 @@ export class AgentSession { * Usage-limit errors are retryable because the retry handler performs credential switching. */ #isRetryableError(message: AssistantMessage): boolean { - if (message.stopReason !== "error" || !message.errorMessage) return false; + if (message.stopReason !== "error") return false; + const id = AIError.classifyMessage(message); // Context overflow is handled by compaction, not retry const contextWindow = this.model?.contextWindow ?? 0; - if (isContextOverflow(message, contextWindow)) return false; + if (AIError.isContextOverflow(message, contextWindow)) return false; if (this.#isClassifierRefusal(message)) return true; - if (this.#isProviderErrorFinishReasonBeforeToolUse(message)) return true; - if (this.#isMalformedFunctionCallError(message)) return true; - if (this.#hasReplayUnsafeToolOutput(message)) return false; - if (message.errorMessage.includes(THINKING_LOOP_ERROR_MARKER)) return true; - if (this.#isStaleOpenAIResponsesReplayError(message)) return true; - - const err = message.errorMessage; - return this.#isTransientErrorMessage(err) || isUsageLimitError(err); + return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) }); } /** * Retried turns remove the failed assistant message from active context. @@ -11236,73 +11240,12 @@ export class AgentSession { return message.content.some(block => block.type === "toolCall"); } - #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean { - const currentApi = this.model?.api; - if ( - message.api !== "openai-responses" && - message.api !== "openai-codex-responses" && - currentApi !== "openai-responses" && - currentApi !== "openai-codex-responses" - ) { - return false; - } - - const errorMessage = message.errorMessage; - if (!errorMessage) return false; - - return ( - /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) || - (/previous[ _]?response/i.test(errorMessage) && - /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage)) - ); - } - #isClassifierRefusal(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; const stopType = message.stopDetails?.type; return stopType === "refusal" || stopType === "sensitive"; } - #isProviderErrorFinishReasonBeforeToolUse(message: AssistantMessage): boolean { - if (!message.errorMessage) return false; - if (message.content.some(block => block.type === "toolCall")) return false; - return /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i.test(message.errorMessage); - } - - #isMalformedFunctionCallError(message: AssistantMessage): boolean { - if (!message.errorMessage) return false; - return /\bmalformed.?function.?call\b/i.test(message.errorMessage); - } - - #isTransientErrorMessage(errorMessage: string): boolean { - return ( - this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage) - ); - } - - #isTransientEnvelopeErrorMessage(errorMessage: string): boolean { - // Match Anthropic stream-envelope failures that indicate a broken stream before any content starts. - return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage); - } - - #isCompactionSummarizationTimeoutMessage(errorMessage: string): boolean { - return /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i.test(errorMessage); - } - - #isTransientTransportErrorMessage(errorMessage: string): boolean { - // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, - // service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed, - // gateway upstream failures, terminated, retry delay exceeded, Bun HTTP/2 stream resets - // (RST_STREAM / REFUSED_STREAM / ENHANCE_YOUR_CALM, surfaced verbatim from - // src/http/h2_client/dispatch.zig) - return ( - isUnexpectedSocketCloseMessage(errorMessage) || - /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i.test( - errorMessage, - ) - ); - } - #getRetryFallbackChains(): RetryFallbackChains { const configuredChains = this.settings.get("retry.fallbackChains"); if (!configuredChains || typeof configuredChains !== "object") return {}; @@ -11535,20 +11478,15 @@ export class AgentSession { #isFireworksFastFallbackEligible(message: AssistantMessage): boolean { const model = this.#activeFireworksFastModel(); if (!model) return false; - if (message.stopReason !== "error" || !message.errorMessage) return false; + if (message.stopReason !== "error") return false; if (message.content.some(block => block.type === "toolCall")) return false; // A content refusal/sensitivity stop is the model's decision, not a route // failure — switching to the base model would just re-trigger it. if (this.#isClassifierRefusal(message)) return false; - if (isContextOverflow(message, model.contextWindow ?? 0)) return false; - const err = message.errorMessage; - if (isUsageLimitError(err)) return false; - if ( - /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i.test( - err, - ) - ) - return false; + const id = AIError.classifyMessage(message); + if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false; + if (AIError.is(id, AIError.Flag.UsageLimit)) return false; + if (AIError.is(id, AIError.Flag.AuthFailed)) return false; return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined; } @@ -11714,7 +11652,8 @@ export class AgentSession { } const errorMessage = message.errorMessage || "Unknown error"; - const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message); + const id = AIError.classifyMessage(message); + const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem); const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage); let delayMs = staleOpenAIResponsesReplayError ? 0 @@ -11729,7 +11668,7 @@ export class AgentSession { this.#resetCurrentResponsesProviderSession("stale replay error"); } - if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) { + if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) { const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage)); const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached( this.model.provider, @@ -11836,6 +11775,7 @@ export class AgentSession { maxAttempts: retrySettings.maxRetries, delayMs, errorMessage, + errorId: message.errorId, }); // Remove the failed assistant message from active context before retrying. diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 867923562..ce4e23a0f 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -18,6 +18,7 @@ import type { TextContent, UserMessage, } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { prompt } from "@oh-my-pi/pi-utils"; import userInterjectionTemplate from "../prompts/steering/user-interjection.md" with { type: "text" }; @@ -70,11 +71,10 @@ export interface SkillPromptDetails { * (fallback error emission) read it via `isSilentAbort`. */ export const SILENT_ABORT_MARKER = "__omp.silent_abort__"; -/** Type-guard for `SILENT_ABORT_MARKER`. Renderers MUST branch on this rather - * than string-comparing inline so refactors to the marker constant (e.g., - * namespacing changes) propagate through every consumer in lockstep. */ -export function isSilentAbort(errorMessage: string | undefined): boolean { - return errorMessage === SILENT_ABORT_MARKER; +/** Type-guard for silent aborts. Renderers MUST call this helper so structured + * `errorId` and legacy persisted marker messages stay in lockstep. */ +export function isSilentAbort(message: Pick): boolean { + return AIError.is(message.errorId, AIError.Flag.SilentAbort) || message.errorMessage === SILENT_ABORT_MARKER; } /** Reason threaded through `AbortController.abort(reason)` when the user aborts @@ -84,12 +84,12 @@ export function isSilentAbort(errorMessage: string | undefined): boolean { * abort, but interactive renderers suppress this redundant transcript line. */ export const USER_INTERRUPT_LABEL = "Interrupted by user"; -export function isUserInterruptAbort(errorMessage: string | undefined): boolean { - return errorMessage === USER_INTERRUPT_LABEL; +export function isUserInterruptAbort(message: Pick): boolean { + return AIError.is(message.errorId, AIError.Flag.UserInterrupt) || message.errorMessage === USER_INTERRUPT_LABEL; } -export function shouldRenderAbortReason(errorMessage: string | undefined): boolean { - return !isSilentAbort(errorMessage) && !isUserInterruptAbort(errorMessage); +export function shouldRenderAbortReason(message: Pick): boolean { + return !isSilentAbort(message) && !isUserInterruptAbort(message); } /** Sentinel `errorMessage` the agent stamps on any abort that carried no custom @@ -101,9 +101,17 @@ export const GENERIC_ABORT_SENTINEL = "Request was aborted"; * no threaded reason fall back to the retry-aware generic label. Call * `shouldRenderAbortReason` before rendering when user interrupts should stay * visually quiet. */ -export function resolveAbortLabel(errorMessage: string | undefined, retryAttempt = 0): string { - if (errorMessage && errorMessage !== GENERIC_ABORT_SENTINEL && !isSilentAbort(errorMessage)) { - return errorMessage; +export function resolveAbortLabel( + message: Pick, + retryAttempt = 0, +): string { + const genericAbort = + AIError.is(message.errorId, AIError.Flag.Abort) || + !message.errorMessage || + message.errorMessage === GENERIC_ABORT_SENTINEL || + isSilentAbort(message); + if (!genericAbort) { + return message.errorMessage!; } if (retryAttempt > 0) { return `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`; diff --git a/packages/coding-agent/src/tools/image-gen.ts b/packages/coding-agent/src/tools/image-gen.ts index e0121e68b..8743e76b2 100644 --- a/packages/coding-agent/src/tools/image-gen.ts +++ b/packages/coding-agent/src/tools/image-gen.ts @@ -1,6 +1,7 @@ import * as os from "node:os"; import * as path from "node:path"; -import { type ApiKey, type FetchImpl, getEnvApiKey, type Model, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai"; +import { type ApiKey, type FetchImpl, getEnvApiKey, type Model, withAuth } from "@oh-my-pi/pi-ai"; +import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; import { CODEX_BASE_URL, getCodexAccountId, diff --git a/packages/coding-agent/src/tools/tts.ts b/packages/coding-agent/src/tools/tts.ts index 6162e9779..20956d086 100644 --- a/packages/coding-agent/src/tools/tts.ts +++ b/packages/coding-agent/src/tools/tts.ts @@ -4,7 +4,8 @@ // the `providers.tts` switch. import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { type ApiKey, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai"; +import { type ApiKey, withAuth } from "@oh-my-pi/pi-ai"; +import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; import { type } from "arktype"; import { settings } from "../config/settings"; import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types"; diff --git a/packages/coding-agent/test/agent-session-silent-abort.test.ts b/packages/coding-agent/test/agent-session-silent-abort.test.ts index 50319609f..243160f30 100644 --- a/packages/coding-agent/test/agent-session-silent-abort.test.ts +++ b/packages/coding-agent/test/agent-session-silent-abort.test.ts @@ -16,6 +16,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, TextContent } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -115,6 +116,7 @@ describe("AgentSession silent-abort marker stamping", () => { await Promise.resolve(); expect(message.errorMessage).toBe(SILENT_ABORT_MARKER); + expect(AIError.is(message.errorId, AIError.Flag.SilentAbort)).toBe(true); expect(session.isPlanInternalAbortPending).toBe(false); }); @@ -200,6 +202,7 @@ describe("AgentSession silent-abort marker stamping", () => { // `event.message` (the persistence-side reference) carries the marker via the // in-place stamp. expect(message.errorMessage).toBe(SILENT_ABORT_MARKER); + expect(AIError.is(message.errorId, AIError.Flag.SilentAbort)).toBe(true); // The emitted display event ALSO carries the marker because the spread copy // happened AFTER the stamp. @@ -218,6 +221,7 @@ describe("AgentSession silent-abort marker stamping", () => { throw new Error("expected emitted message_end to be an assistant message"); } expect(emittedMessage.errorMessage).toBe(SILENT_ABORT_MARKER); + expect(AIError.is(emittedMessage.errorId, AIError.Flag.SilentAbort)).toBe(true); // Prove the obfuscator branch actually ran by asserting the emitted message // is a distinct object (post-spread) AND its content was deobfuscated back to diff --git a/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts b/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts index a841d3966..692f6dfa2 100644 --- a/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts +++ b/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts @@ -11,9 +11,10 @@ import type { TextContent, ThinkingContent, } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; -import { THINKING_LOOP_ERROR_MARKER, withGeminiThinkingLoopGuard } from "@oh-my-pi/pi-ai/utils/thinking-loop"; +import { withGeminiThinkingLoopGuard } from "@oh-my-pi/pi-ai/utils/thinking-loop"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; @@ -89,25 +90,21 @@ function successStream(model: Model): AssistantMessageEventStream { return stream; } -function legacyContentfulLoopErrorStream(model: Model): AssistantMessageEventStream { +function errorIdOnlyThinkingLoopStream(model: Model): AssistantMessageEventStream { const stream = new AssistantMessageEventStream(); queueMicrotask(() => { - const text: TextContent = { type: "text", text: "Looping visible reasoning garbage." }; const partial: AssistantMessage = { role: "assistant", - content: [text], + content: [], api: model.api, provider: model.provider, model: model.id, usage: emptyUsage(), stopReason: "error", - errorMessage: `${THINKING_LOOP_ERROR_MARKER}: the model repeated near-identical content. Non-retryable because output was already streamed.`, + errorMessage: "loop guard stopped repeated reasoning", + errorId: AIError.create(AIError.Flag.ThinkingLoop), timestamp: Date.now(), }; - stream.push({ type: "start", partial }); - stream.push({ type: "text_start", contentIndex: 0, partial }); - stream.push({ type: "text_delta", contentIndex: 0, delta: text.text, partial }); - stream.push({ type: "text_end", contentIndex: 0, content: text.text, partial }); stream.push({ type: "error", reason: "error", error: partial }); }); return stream; @@ -182,7 +179,7 @@ describe("AgentSession thinking-loop retry", () => { expect(calls).toEqual(["openrouter/google/gemini-3.5-flash", "openrouter/google/gemini-3.5-flash"]); expect(retryStartEvents).toHaveLength(1); - expect(retryStartEvents[0].errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(AIError.is(retryStartEvents[0].errorId, AIError.Flag.ThinkingLoop)).toBe(true); expect(retryEndEvents).toEqual([{ type: "auto_retry_end", success: true, attempt: 1 }]); const assistants = session.agent.state.messages.filter( (message): message is AssistantMessage => message.role === "assistant", @@ -193,7 +190,7 @@ describe("AgentSession thinking-loop retry", () => { expect(assistants[0].errorMessage).toBeUndefined(); }); - it("starts retry for loop-marker errors even without transient wording", async () => { + it("starts retry for thinking-loop errorId even without transient wording", async () => { const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model; const modelRegistry = new ModelRegistry(authStorage); const calls: string[] = []; @@ -207,7 +204,7 @@ describe("AgentSession thinking-loop retry", () => { }, streamFn: requestedModel => { calls.push(`${requestedModel.provider}/${requestedModel.id}`); - return calls.length === 1 ? legacyContentfulLoopErrorStream(requestedModel) : successStream(requestedModel); + return calls.length === 1 ? errorIdOnlyThinkingLoopStream(requestedModel) : successStream(requestedModel); }, }); const settings = Settings.isolated({ @@ -232,12 +229,12 @@ describe("AgentSession thinking-loop retry", () => { if (event.type === "auto_retry_start") retryStartEvents.push(event); }); - await session.prompt("Trigger legacy loop marker once"); + await session.prompt("Trigger errorId-only loop once"); await session.waitForIdle(); expect(calls).toEqual(["openrouter/google/gemini-3.5-flash", "openrouter/google/gemini-3.5-flash"]); expect(retryStartEvents).toHaveLength(1); - expect(retryStartEvents[0].errorMessage).toContain("Non-retryable because output was already streamed"); + expect(AIError.is(retryStartEvents[0].errorId, AIError.Flag.ThinkingLoop)).toBe(true); const assistants = session.agent.state.messages.filter( (message): message is AssistantMessage => message.role === "assistant", ); diff --git a/packages/coding-agent/test/event-controller-abort-render.test.ts b/packages/coding-agent/test/event-controller-abort-render.test.ts index 9b0bf165a..861f5dd41 100644 --- a/packages/coding-agent/test/event-controller-abort-render.test.ts +++ b/packages/coding-agent/test/event-controller-abort-render.test.ts @@ -18,6 +18,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; @@ -118,6 +119,23 @@ describe("EventController #handleMessageEnd abort labeling", () => { expect(ctx.streamingMessage).toBeUndefined(); }); + it("C1b: silent-abort errorId without marker suppresses the abort line", async () => { + const message = makeAssistantMessage({ + stopReason: "aborted", + errorMessage: undefined, + errorId: AIError.create(AIError.Flag.SilentAbort), + }); + const { controller, streamingComponent } = createFixture({ streamingMessage: message }); + + await controller.handleEvent({ type: "message_end", message }); + + expect(message.errorMessage).toBeUndefined(); + expect(streamingComponent.updateContent).toHaveBeenCalledTimes(1); + const arg = streamingComponent.updateContent.mock.calls[0]![0] as AssistantMessage; + expect(arg.stopReason).toBe("stop"); + expect(arg.errorMessage).toBeUndefined(); + }); + it("C2: errorMessage undefined (no threaded reason) + aborted + no TTSR -> errorMessage='Operation aborted', updateContent receives original ref", async () => { const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: undefined }); const { controller, streamingComponent } = createFixture({ diff --git a/packages/coding-agent/test/event-controller-error-banner.test.ts b/packages/coding-agent/test/event-controller-error-banner.test.ts index b7fbb82e0..d32beb7de 100644 --- a/packages/coding-agent/test/event-controller-error-banner.test.ts +++ b/packages/coding-agent/test/event-controller-error-banner.test.ts @@ -9,7 +9,7 @@ */ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { ErrorBannerComponent } from "@oh-my-pi/pi-coding-agent/modes/components/error-banner"; @@ -137,8 +137,12 @@ describe("EventController error banner", () => { }); it("clears retryable thinking-loop banners without restoring the dropped inline error", async () => { - const errorMessage = `${THINKING_LOOP_ERROR_MARKER}: the model repeated near-identical content. Treating as a stream stall and retrying.`; - const message = makeAssistantMessage({ stopReason: "error", errorMessage }); + const errorMessage = "loop guard stopped repeated reasoning"; + const message = makeAssistantMessage({ + stopReason: "error", + errorMessage, + errorId: AIError.create(AIError.Flag.ThinkingLoop), + }); const { controller, clearPinnedError, streamingComponent } = createFixture(message); await controller.handleEvent({ type: "message_end", message } as Extract< @@ -154,6 +158,7 @@ describe("EventController error banner", () => { maxAttempts: 2, delayMs: 0, errorMessage, + errorId: AIError.create(AIError.Flag.ThinkingLoop), } as Extract); expect(clearPinnedError).toHaveBeenCalledTimes(1); diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index 428e596e3..c95071b11 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { Agent, AgentBusyError, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -1458,6 +1459,17 @@ describe("InteractiveMode plan review rendering", () => { expect(rendered).not.toContain(SILENT_ABORT_MARKER); }); + it("D1b: Replay of an assistant message with silent-abort errorId contains no abort line", () => { + const message = buildAbortedAssistantMessage({ + content: [], + errorId: AIError.create(AIError.Flag.SilentAbort), + errorMessage: undefined, + }); + const rendered = renderAssistant(message); + expect(rendered).not.toContain("Operation aborted"); + expect(rendered).not.toContain("Error:"); + }); + it("D2: Replay of an aborted message with no threaded reason + empty content: rendered component DOES contain the generic label", () => { // Over-suppression regression guard: silent path is opt-in via the // persisted marker. An abort with no marker and no threaded reason still diff --git a/packages/coding-agent/test/silent-abort-overlay-render.test.ts b/packages/coding-agent/test/silent-abort-overlay-render.test.ts index 9395a95f2..7ef4de7ee 100644 --- a/packages/coding-agent/test/silent-abort-overlay-render.test.ts +++ b/packages/coding-agent/test/silent-abort-overlay-render.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; import type { ObservableSession } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; @@ -139,6 +140,58 @@ describe("Agent hub silent-abort regression", () => { expect(renderedText).not.toContain("Error:"); }); + it("renders no error line for bit-classified silent aborts without marker text", () => { + const sessionFile = makeJsonlSessionFile(tmpDir, [ + { type: "session", version: 3, id: SESSION_ID, timestamp: new Date().toISOString() }, + { + type: "message", + id: "msg-user-bit", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "hello", timestamp: Date.now() }, + }, + { + type: "message", + id: "msg-assistant-bit", + parentId: "msg-user-bit", + timestamp: new Date().toISOString(), + message: { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "aborted", + errorId: AIError.create(AIError.Flag.SilentAbort), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }, + }, + ]); + + const viewer = makeViewer(sessionFile, [ + { + id: SESSION_ID, + kind: "subagent", + label: "Test Subagent", + status: "active", + sessionFile, + lastUpdate: Date.now(), + }, + ]); + + const rendered = viewer.render(120); + viewer.dispose(); + expect(rendered.join("\n")).not.toContain("Error:"); + }); + it("renders normal error messages with an Error: line", () => { const sessionFile = makeJsonlSessionFile(tmpDir, [ { type: "session", version: 3, id: SESSION_ID, timestamp: new Date().toISOString() }, diff --git a/packages/coding-agent/test/silent-abort-print-mode.test.ts b/packages/coding-agent/test/silent-abort-print-mode.test.ts index c88c68e28..82eeafbbb 100644 --- a/packages/coding-agent/test/silent-abort-print-mode.test.ts +++ b/packages/coding-agent/test/silent-abort-print-mode.test.ts @@ -7,6 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { runPrintMode } from "@oh-my-pi/pi-coding-agent/modes/print-mode"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; @@ -90,6 +91,21 @@ describe("Print-mode silent-abort regression", () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it("does not write bit-classified silent aborts to stderr or exit non-zero", async () => { + const silentAbortMsg = makeAssistantMessage({ + stopReason: "aborted", + errorId: AIError.create(AIError.Flag.SilentAbort), + errorMessage: undefined, + content: [], + }); + + const session = createMockSession([silentAbortMsg]); + await runPrintMode(session, { mode: "text" }); + + expect(stderrOutput.join("")).toBe(""); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it("writes real error messages to stderr and exits non-zero", async () => { const errorMsg = makeAssistantMessage({ stopReason: "error", diff --git a/packages/coding-agent/test/status-line-settings-cache.test.ts b/packages/coding-agent/test/status-line-settings-cache.test.ts index fc291455a..847e03ad1 100644 --- a/packages/coding-agent/test/status-line-settings-cache.test.ts +++ b/packages/coding-agent/test/status-line-settings-cache.test.ts @@ -160,12 +160,10 @@ describe("StatusLineComponent effective settings cache", () => { const component = makeComponent({ preset: "custom", leftSegments: [], rightSegments: [] }); component.setSubagentCount(2); - component.setSubagentHubHint("Alt+A"); const content = stripVTControlCharacters(component.getTopBorder(120).content); expect(content).toContain("2 agents"); expect(content).not.toContain("running"); - expect(content).not.toContain("Alt+A hub"); }); it("keeps plan and hook state dynamic without settings invalidation", () => { diff --git a/packages/mnemopi/src/core/embeddings.ts b/packages/mnemopi/src/core/embeddings.ts index d77448297..09db3e136 100644 --- a/packages/mnemopi/src/core/embeddings.ts +++ b/packages/mnemopi/src/core/embeddings.ts @@ -1,5 +1,6 @@ import { mkdirSync } from "node:fs"; -import { type ApiKey, getOpenRouterHeaders, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai"; +import { type ApiKey, getOpenRouterHeaders, withAuth } from "@oh-my-pi/pi-ai"; +import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts"; import { $env, diff --git a/packages/mnemopi/src/core/local-llm.ts b/packages/mnemopi/src/core/local-llm.ts index dc7b65584..a4ce01d16 100644 --- a/packages/mnemopi/src/core/local-llm.ts +++ b/packages/mnemopi/src/core/local-llm.ts @@ -5,9 +5,9 @@ import { completeSimple, type FetchImpl, type Model, - ProviderHttpError, withAuth, } from "@oh-my-pi/pi-ai"; +import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; import { type CompleteOptions, callHostLlm, getHostLlmBackend } from "./llm-backends"; import { getMnemopiRuntimeOptions,