Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 37 additions & 15 deletions packages/opencode/src/altimate/free/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 4 additions & 1 deletion packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
Expand All @@ -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.",
)
Expand All @@ -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.",
)
Expand All @@ -236,27 +236,28 @@ 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", () => {
const body = JSON.stringify({
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.",
)
})
Expand All @@ -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.",
)
})
Expand All @@ -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 =
"<html>\n<head><title>413 Request Entity Too Large</title></head>\n<body>\n<center>413 Request Entity Too Large</center>\n<hr><center>nginx</center>\n</body>\n</html>"
expect(FreeTier.describeRequestTooLarge({ status: 413, body: html })).toBe(
"This request is too large for Altimate Base. Start a new session, or switch to another model for this task.",
)
})

test("a 413 with an empty body returns the same friendly fallback message", () => {
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.",
)
expect(FreeTier.describeRequestTooLarge({ status: 413 })).toBe(
"This request is too large for Altimate Base. Start a new session, or switch to another model for this task.",
)
})

test("a 413 with the known JSON shape still returns the specific byte-count message, not the generic fallback", () => {
const body = JSON.stringify({
error: { code: "request_too_large", message: "Request is 179608 bytes; the free tier limit is 128000 bytes." },
})
expect(FreeTier.describeRequestTooLarge({ status: 413, body })).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.",
)
})

test("an HTML body on a non-413 status still returns undefined (status is what gates the fallback)", () => {
const html = "<html><body>502 Bad Gateway</body></html>"
expect(FreeTier.describeRequestTooLarge({ status: 502, body: html })).toBeUndefined()
})
})
20 changes: 20 additions & 0 deletions packages/opencode/test/provider/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,4 +466,24 @@ describe("ProviderError.parseAPICallError: Altimate Base isolation", () => {
})
expect(result.type).toBe("context_overflow")
})

// altimate_change start — BUG FIX: production rejects oversized requests with nginx's raw HTML
// edge page, not LiteLLM's JSON body. That body never parses, so this must still resolve to the
// friendly Altimate Base message instead of falling through to the generic context_overflow path.
test("a 413 with an unparseable HTML body (nginx edge rejection) still gets the friendly Altimate Base message", () => {
const htmlBody =
"<html>\n<head><title>413 Request Entity Too Large</title></head>\n<body>\n<center>413 Request Entity Too Large</center>\n<hr><center>nginx</center>\n</body>\n</html>"
const result = ProviderError.parseAPICallError({
providerID: "altimate-free" as any,
error: makeAPICallError({ message: "Payload Too Large", statusCode: 413, responseBody: htmlBody }),
})
expect(result.type).toBe("api_error")
if (result.type === "api_error") {
expect(result.message).toBe(
"This request is too large for Altimate Base. Start a new session, or switch to another model for this task.",
)
expect(result.isRetryable).toBe(false)
}
})
// altimate_change end
})
3 changes: 3 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ test("Altimate Base is pinned to the hosted model contract without affecting oth
expect(base.env).toEqual([])
expect(base.options.baseURL).toBe(`${ALTIMATE_BASE_GATEWAY_URL}/v1`)
expect(base.options.apiKey).toBe(FreeTier.MANAGED_API_KEY_PLACEHOLDER)
// BUG FIX: without a client-side header timeout, a hung gateway response never resolves
// or rejects — the CLI just hangs. This mirrors the openai loader's default.
expect(base.options.headerTimeout).toBe(10_000)
expect(JSON.stringify(base)).not.toContain("sk-altimate-base")

const model = base.models[FreeTier.MODEL_ID]
Expand Down
Loading