-
Notifications
You must be signed in to change notification settings - Fork 56
release: v0.7.1 — provider error handling pass #794
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
0617139
eb3c5d9
6b13e56
fd28dd3
724b114
ea7548f
c977c23
ec8bdfc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,17 @@ export namespace ProviderError { | |
| function isOpenAiErrorRetryable(e: APICallError) { | ||
| const status = e.statusCode | ||
| if (!status) return e.isRetryable | ||
| // altimate_change start — upstream_fix: don't retry-storm on model_not_found. | ||
| // OpenAI 404s are forced retryable below because some legitimate models 404 | ||
| // transiently, but `model_not_found` will never recover; retrying 5x just | ||
| // delays the user seeing the (now-readable) error message. | ||
| if (status === 404) { | ||
| try { | ||
| const body = e.responseBody ? JSON.parse(e.responseBody) : null | ||
| if (body?.error?.code === "model_not_found") return false | ||
| } catch {} | ||
| } | ||
| // altimate_change end | ||
| // openai sometimes returns 404 for models that are actually available | ||
| return status === 404 || e.isRetryable | ||
| } | ||
|
|
@@ -61,19 +72,27 @@ export namespace ProviderError { | |
|
|
||
| try { | ||
| const body = JSON.parse(e.responseBody) | ||
| // altimate_change start — upstream_fix: OpenAI errors use {error: {message}} shape; | ||
| // the original `body.message || body.error || body.error?.message` short-circuits on | ||
| // the parent object, fails the typeof string guard, and dumps the raw body. Use an | ||
| // explicit-typeof ternary so a truthy non-string at any level can't block a valid | ||
| // string further down the chain (matches parseStreamError's pattern below). | ||
| // altimate_change start — upstream_fix: extract provider error messages | ||
| // across the four shapes in the wild: | ||
| // 1. {error: {message: "..."}} — OpenAI / Azure OpenAI / OpenRouter | ||
| // 2. {message: "..."} — Anthropic-style top-level | ||
| // 3. {errorMessage: "..."} — Bedrock / AWS Lambda | ||
| // 4. {error: "..."} — legacy plain-string shape | ||
| // The original `body.message || body.error || body.error?.message` short- | ||
| // circuited on a truthy parent object, failed the `typeof === "string"` | ||
| // guard, and dumped the raw body. Use an explicit-typeof ternary so a | ||
| // truthy non-string at any tier can't block a valid string further down | ||
| // the chain (matches parseStreamError's pattern below). | ||
| const errMsg = | ||
| typeof body.error?.message === "string" | ||
| ? body.error.message | ||
| : typeof body.message === "string" | ||
| ? body.message | ||
| : typeof body.error === "string" | ||
| ? body.error | ||
| : undefined | ||
| : typeof body.errorMessage === "string" | ||
| ? body.errorMessage | ||
| : typeof body.error === "string" | ||
| ? body.error | ||
| : undefined | ||
| if (errMsg) return `${msg}: ${errMsg}` | ||
| // altimate_change end | ||
| } catch {} | ||
|
|
@@ -161,6 +180,32 @@ export namespace ProviderError { | |
| responseBody, | ||
| } | ||
| } | ||
|
|
||
| // altimate_change start — upstream_fix: extend extraction to non-OpenAI error | ||
| // codes. The switch above only handles 4 OpenAI shapes; everything else fell | ||
| // through to `JSON.stringify(e)` in the caller (session/message-v2.ts), which | ||
| // showed users `Unknown: {"type":"error",...}`. Apply the same string-typeof | ||
| // chain we use in parseAPICallError so any extractable provider message lands | ||
| // as a clean api_error. | ||
| const fallbackMsg = | ||
| typeof body?.error?.message === "string" | ||
| ? body.error.message | ||
| : typeof body?.message === "string" | ||
| ? body.message | ||
| : typeof body?.errorMessage === "string" | ||
| ? body.errorMessage | ||
| : typeof body?.error === "string" | ||
| ? body.error | ||
| : undefined | ||
| if (fallbackMsg) { | ||
| return { | ||
| type: "api_error", | ||
| message: fallbackMsg, | ||
| isRetryable: false, | ||
| responseBody, | ||
| } | ||
| } | ||
| // altimate_change end | ||
| } | ||
|
|
||
| export type ParsedAPICallError = | ||
|
|
@@ -179,6 +224,66 @@ export namespace ProviderError { | |
| metadata?: Record<string, string> | ||
| } | ||
|
|
||
| // altimate_change start — cap responseBody at 4KB before it lands on a | ||
| // MessageV2.APIError. Without this cap, a hostile gateway returning a 100KB | ||
| // body (or just verbose providers like LiteLLM) would inflate local storage, | ||
| // share-backend uploads, and diagnostic dumps. | ||
| const RESPONSE_BODY_CAP = 4096 | ||
| function capResponseBody(body: string | undefined): string | undefined { | ||
| if (!body) return body | ||
| if (body.length <= RESPONSE_BODY_CAP) return body | ||
| return body.slice(0, RESPONSE_BODY_CAP) + `…[truncated ${body.length - RESPONSE_BODY_CAP} chars]` | ||
| } | ||
| // altimate_change end | ||
|
|
||
| // altimate_change start — sanitize metadata.url before it lands on the | ||
| // parsed error. Two transforms are applied: | ||
| // (1) basic-auth userinfo (`user:pass@…`) is stripped on every URL, | ||
| // internal or public — a credential in a misconfigured proxy URL | ||
| // must not flow into telemetry / local storage / share regardless | ||
| // of where the URL points. | ||
| // (2) the hostname is rewritten to `internal-host.redacted` if it | ||
| // matches an internal endpoint (RFC1918, *.local, *.internal, | ||
| // localhost, *.localhost, IPv6 loopback / ULA / link-local, or | ||
| // the AWS IMDS address 169.254.169.254). Public provider URLs | ||
| // are otherwise preserved for debugging. | ||
| function maskInternalHost(url: string): string { | ||
| try { | ||
| const u = new URL(url) | ||
| // u.hostname keeps IPv6 brackets (e.g. "[::1]"); strip for regex match. | ||
| const host = u.hostname.replace(/^\[|\]$/g, "") | ||
| const hadCredentials = u.username !== "" || u.password !== "" | ||
| // Always clear userinfo — the credential is the riskier part of the URL. | ||
| u.username = "" | ||
| u.password = "" | ||
| const isInternal = | ||
| host === "localhost" || | ||
| host.endsWith(".local") || | ||
| host.endsWith(".internal") || | ||
| host.endsWith(".localhost") || | ||
| /^127\./.test(host) || | ||
| /^10\./.test(host) || | ||
| /^192\.168\./.test(host) || | ||
| /^172\.(1[6-9]|2\d|3[01])\./.test(host) || | ||
| /^169\.254\./.test(host) || // AWS IMDS / link-local IPv4 | ||
| host === "::1" || // IPv6 loopback | ||
| /^fc[0-9a-f]{2}:/i.test(host) || // IPv6 ULA (RFC4193 fc00::/8) | ||
| /^fd[0-9a-f]{2}:/i.test(host) || // IPv6 ULA (RFC4193 fd00::/8) | ||
| /^fe80:/i.test(host) // IPv6 link-local | ||
| if (isInternal) { | ||
| u.hostname = "internal-host.redacted" | ||
| return u.toString() | ||
| } | ||
| // No host change but we may have removed credentials — re-serialize | ||
| // only if userinfo was present, otherwise return the original string | ||
| // so URLs round-trip untouched (preserves trailing slashes, casing). | ||
| return hadCredentials ? u.toString() : url | ||
| } catch { | ||
| return url | ||
| } | ||
| } | ||
| // altimate_change end | ||
|
|
||
| export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError { | ||
| const m = message(input.providerID, input.error) | ||
| // Check responseBody for context_length_exceeded code (e.g., OpenAI-style errors) | ||
|
|
@@ -188,20 +293,38 @@ export namespace ProviderError { | |
| return { | ||
| type: "context_overflow", | ||
| message: m, | ||
| responseBody: input.error.responseBody, | ||
| // altimate_change start — cap responseBody on context_overflow path | ||
| responseBody: capResponseBody(input.error.responseBody), | ||
| // altimate_change end | ||
| } | ||
| } | ||
|
|
||
| const metadata = input.error.url ? { url: input.error.url } : undefined | ||
| // altimate_change start — append a `models` discoverability hint when the | ||
| // error code is model_not_found. Pairs with the retry-storm carve-out in | ||
| // isOpenAiErrorRetryable so the user sees the hint on the first attempt | ||
| // instead of after 5 silent retries. | ||
| let finalMessage = m | ||
| if (codeFromBody === "model_not_found") { | ||
| finalMessage = `${m} Run \`altimate models\` to see available models.` | ||
| } | ||
|
Comment on lines
+308
to
+314
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hint text doesn't match the docs or the changelog. Three different spellings ship in this PR for the same string:
Per 🤖 Prompt for AI Agents |
||
| // altimate_change end | ||
|
|
||
| // altimate_change start — mask internal hostnames in metadata.url | ||
| const metadata = input.error.url ? { url: maskInternalHost(input.error.url) } : undefined | ||
| // altimate_change end | ||
| return { | ||
| type: "api_error", | ||
| message: m, | ||
| // altimate_change start — finalMessage carries the optional /models hint | ||
| message: finalMessage, | ||
| // altimate_change end | ||
| statusCode: input.error.statusCode, | ||
| isRetryable: input.providerID.startsWith("openai") | ||
| ? isOpenAiErrorRetryable(input.error) | ||
| : input.error.isRetryable, | ||
| responseHeaders: input.error.responseHeaders, | ||
| responseBody: input.error.responseBody, | ||
| // altimate_change start — cap responseBody on api_error path | ||
| responseBody: capResponseBody(input.error.responseBody), | ||
| // altimate_change end | ||
| metadata, | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.