diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 2c7dbff8a..6ac672d7d 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -536,27 +536,49 @@ export function describeRateLimit( return undefined } -export function describeRequestTooLarge(body?: string): string | undefined { +// altimate_change start — the generic (no byte-count) request-too-large message is shared by two +// branches below: the parsed-JSON shape whose message didn't match the byte-count pattern, and +// the unparseable/empty-body 413 fallback (nginx's raw HTML edge rejection). +const REQUEST_TOO_LARGE_MESSAGE = + "This request is too large for Altimate Base. Start a new session, or switch to another model for this task." +// altimate_change end + +export function describeRequestTooLarge(input: { status?: number; body?: string }): string | undefined { + const { status, body } = input type Inner = { code?: unknown; message?: unknown; provider_specific_fields?: { error?: Inner } } let parsed: { error?: Inner } | undefined + let validJson = true try { parsed = body ? JSON.parse(body) : undefined } catch { - return undefined + validJson = false + } + if (validJson) { + const inner = parsed?.error?.provider_specific_fields?.error + const isRequestTooLarge = parsed?.error?.code === "request_too_large" || inner?.code === "request_too_large" + if (isRequestTooLarge) { + const detail = + typeof parsed?.error?.message === "string" + ? parsed.error.message + : typeof inner?.message === "string" + ? inner.message + : "" + const sizes = detail.match(/Request is (\d+) bytes; the free tier limit is (\d+) bytes/) + if (!sizes) return REQUEST_TOO_LARGE_MESSAGE + const numbers = ` (${Math.round(Number(sizes[1]) / 1024)}KB against a ${Math.round(Number(sizes[2]) / 1024)}KB limit)` + return `This request is too large for Altimate Base${numbers}. Start a new session, or switch to another model for this task.` + } + // Valid JSON but a shape unrelated to the free-tier byte cap (e.g. another provider's 413, + // or a different gateway error entirely) — never rewrite it, regardless of status. + if (body) return undefined } - const inner = parsed?.error?.provider_specific_fields?.error - if (parsed?.error?.code !== "request_too_large" && inner?.code !== "request_too_large") return undefined - const detail = - typeof parsed?.error?.message === "string" - ? parsed.error.message - : typeof inner?.message === "string" - ? inner.message - : "" - const sizes = detail.match(/Request is (\d+) bytes; the free tier limit is (\d+) bytes/) - const numbers = sizes - ? ` (${Math.round(Number(sizes[1]) / 1024)}KB against a ${Math.round(Number(sizes[2]) / 1024)}KB limit)` - : "" - return `This request is too large for Altimate Base${numbers}. Start a new session, or switch to another model for this task.` + // altimate_change start — in production, an oversized request is rejected by nginx at the edge + // with a raw HTML error page, not LiteLLM's JSON `request_too_large` body. That body will never + // parse, so the friendly message must key off the actual HTTP status instead of a JSON shape + // that this failure mode can never produce. + if (status === 413) return REQUEST_TOO_LARGE_MESSAGE + // altimate_change end + return undefined } export * as FreeTier from "./client" diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 1de0f4ceb..aeea86f1f 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -333,7 +333,10 @@ export namespace ProviderError { // The gateway's fixed request-byte cap is not a context overflow. Retrying compaction can // never help when system instructions and tool schemas alone exceed it. if (String(input.providerID) === FreeTier.PROVIDER_ID && input.error.statusCode === 413) { - const described = FreeTier.describeRequestTooLarge(input.error.responseBody) + const described = FreeTier.describeRequestTooLarge({ + status: input.error.statusCode, + body: input.error.responseBody, + }) if (described) { return { type: "api_error", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ea35e36f3..e74e5500c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -393,6 +393,9 @@ export namespace Provider { // authorizedFetch. Provider options are serialized by public provider APIs. apiKey: FreeTier.MANAGED_API_KEY_PLACEHOLDER, fetch: FreeTier.authorizedFetch, + // BUG FIX: without this, a hung gateway response never times out client-side, unlike + // the openai loader below which already sets this. + headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT, }, } }, diff --git a/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts b/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts index e35b041a0..8d0f97c37 100644 --- a/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts +++ b/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts @@ -150,7 +150,7 @@ describe("malformed JSON response body during inference", () => { // The Altimate-Base-specific error mappers must also degrade gracefully on this same malformed // body — both already catch a `JSON.parse` failure and return `undefined` rather than crashing. expect(FreeTier.describeRateLimit({ body: raw })).toBeUndefined() - expect(FreeTier.describeRequestTooLarge(raw)).toBeUndefined() + expect(FreeTier.describeRequestTooLarge({ status: response.status, body: raw })).toBeUndefined() }) }) diff --git a/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts b/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts index e7d5de5af..18702eb07 100644 --- a/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts +++ b/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts @@ -196,7 +196,7 @@ describe("describeRequestTooLarge — via the fake gateway (413 request_too_larg const response = await chat() expect(response.status).toBe(413) - const described = FreeTier.describeRequestTooLarge(await response.text()) + const described = FreeTier.describeRequestTooLarge({ status: response.status, body: await response.text() }) expect(described).toBe( "This request is too large for Altimate Base (175KB against a 125KB limit). Start a new session, or switch to another model for this task.", ) @@ -208,7 +208,7 @@ describe("describeRequestTooLarge — via the fake gateway (413 request_too_larg const response = await chat() expect(response.status).toBe(413) - const described = FreeTier.describeRequestTooLarge(await response.text()) + const described = FreeTier.describeRequestTooLarge({ status: response.status, body: await response.text() }) expect(described).toBe( "This request is too large for Altimate Base (977KB against a 488KB limit). Start a new session, or switch to another model for this task.", ) @@ -227,7 +227,7 @@ describe("describeRequestTooLarge — via the fake gateway (413 request_too_larg expect(body.error.provider_specific_fields.error.code).toBe("request_too_large") // 50000/1024 = 48.828125 -> round 49. 40000/1024 = 39.0625 -> round 39. - const described = FreeTier.describeRequestTooLarge(JSON.stringify(body)) + const described = FreeTier.describeRequestTooLarge({ status: response.status, body: JSON.stringify(body) }) expect(described).toBe( "This request is too large for Altimate Base (49KB against a 39KB limit). Start a new session, or switch to another model for this task.", ) @@ -236,19 +236,20 @@ describe("describeRequestTooLarge — via the fake gateway (413 request_too_larg describe("describeRequestTooLarge — pure-function edge cases FakeGateway's ChatMode cannot express", () => { test("absent body returns undefined", () => { - expect(FreeTier.describeRequestTooLarge(undefined)).toBeUndefined() + expect(FreeTier.describeRequestTooLarge({})).toBeUndefined() }) - test("unparseable JSON body returns undefined", () => { - expect(FreeTier.describeRequestTooLarge("{not json")).toBeUndefined() + test("unparseable JSON body returns undefined when the status isn't 413", () => { + expect(FreeTier.describeRequestTooLarge({ body: "{not json" })).toBeUndefined() }) test("a 413 without the request_too_large code (an unrelated provider 413) returns undefined", () => { // Falls through to error.ts's generic context_overflow handling instead of the // Altimate-Base-specific rewrite — request_too_large and context overflow are distinct - // gateway error codes. + // gateway error codes. Valid, parseable JSON with a different shape must never be rewritten, + // even though the status is 413 — that's what separates this from the HTML-body fallback below. const body = JSON.stringify({ error: { code: "some_other_413", message: "payload too large" } }) - expect(FreeTier.describeRequestTooLarge(body)).toBeUndefined() + expect(FreeTier.describeRequestTooLarge({ status: 413, body })).toBeUndefined() }) test("the outer error.code (not the nested provider_specific_fields shape) also matches", () => { @@ -256,7 +257,7 @@ describe("describeRequestTooLarge — pure-function edge cases FakeGateway's Cha error: { code: "request_too_large", message: "Request is 300000 bytes; the free tier limit is 100000 bytes." }, }) // 300000/1024 = 292.96875 -> round 293. 100000/1024 = 97.65625 -> round 98. - expect(FreeTier.describeRequestTooLarge(body)).toBe( + expect(FreeTier.describeRequestTooLarge({ status: 413, body })).toBe( "This request is too large for Altimate Base (293KB against a 98KB limit). Start a new session, or switch to another model for this task.", ) }) @@ -265,14 +266,14 @@ describe("describeRequestTooLarge — pure-function edge cases FakeGateway's Cha const body = JSON.stringify({ error: { code: "request_too_large", message: "Payload rejected: too large for this tier." }, }) - expect(FreeTier.describeRequestTooLarge(body)).toBe( + expect(FreeTier.describeRequestTooLarge({ status: 413, body })).toBe( "This request is too large for Altimate Base. Start a new session, or switch to another model for this task.", ) }) test("request_too_large with no message at all on either the outer or inner error omits the KB parenthetical", () => { const body = JSON.stringify({ error: { code: "request_too_large" } }) - expect(FreeTier.describeRequestTooLarge(body)).toBe( + expect(FreeTier.describeRequestTooLarge({ status: 413, body })).toBe( "This request is too large for Altimate Base. Start a new session, or switch to another model for this task.", ) }) @@ -292,8 +293,44 @@ describe("describeRequestTooLarge — pure-function edge cases FakeGateway's Cha }, }) // 250000/1024 = 244.140625 -> round 244. 128000/1024 = 125 exactly. - expect(FreeTier.describeRequestTooLarge(body)).toBe( + expect(FreeTier.describeRequestTooLarge({ status: 413, body })).toBe( "This request is too large for Altimate Base (244KB against a 125KB limit). Start a new session, or switch to another model for this task.", ) }) }) + +// BUG FIX: in production, nginx rejects oversized requests at the edge with a raw HTML error +// page — not LiteLLM's JSON `request_too_large` shape. The unparseable/empty body previously fell +// through to `undefined` (a generic fallback), even though the status line already says 413. +describe("describeRequestTooLarge — 413 with a body that never parses (nginx edge rejection)", () => { + test("a 413 with a raw HTML body returns the friendly fallback message, not undefined", () => { + const html = + "\n