From ea2ae116410d130ff0999aecf89760f6adcb18e8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Jul 2026 16:38:07 +0000 Subject: [PATCH 1/7] fix(coderd/x/chatd/chaterror): extract plain-text provider error bodies --- coderd/x/chatd/chaterror/classify_test.go | 108 ++++++++++++++++++++- coderd/x/chatd/chaterror/provider_error.go | 25 ++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 2db9ab5a1a49e..fb7bc1111d4e6 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -1431,12 +1431,118 @@ func TestClassify_FallsBackToProviderMessageForDetail(t *testing.T) { " image exceeds 5 MB maximum ", 400, nil, - testProviderResponseDump("not-json"), )) require.Equal(t, "image exceeds 5 MB maximum", classified.Detail) } +func TestClassify_AnthropicPlainTextBudgetBody(t *testing.T) { + t.Parallel() + + // aibridge returns its budget error as a plain-text 403. The Anthropic + // adapter's Message is the SDK transport string without the body, so + // the budget text is only present in the dumped ResponseBody. It must + // still classify as a usage limit, not auth. + classified := chaterror.Classify(testProviderError( + `POST "https://api.example.com/v1/messages": 403 Forbidden`, + 403, + nil, + []byte("HTTP/1.1 403 Forbidden\r\n"+ + "Content-Type: text/plain; charset=utf-8\r\n"+ + "X-Content-Type-Options: nosniff\r\n"+ + "\r\n"+ + "AI budget of US$10.00 exceeded. Please contact an administrator for more details.\n"), + )) + + require.Equal(t, codersdk.ChatErrorKindUsageLimit, classified.Kind) + require.False(t, classified.Retryable) + require.Equal(t, + "AI budget of US$10.00 exceeded. Please contact an administrator for more details.", + classified.Detail) +} + +func TestClassify_UsesPlainTextBodyForDetail(t *testing.T) { + t.Parallel() + + // A non-JSON body is still the provider's actual response, so prefer + // it over the SDK-constructed Message. + classified := chaterror.Classify(testProviderError( + `POST "https://example.com/api": 400 Bad Request`, + 400, + nil, + testProviderResponseDump("upstream rejected the request\n"), + )) + + require.Equal(t, "upstream rejected the request", classified.Detail) +} + +func TestClassify_SkipsHTMLBodyForDetail(t *testing.T) { + t.Parallel() + + // Proxies and load balancers return HTML error pages; those are not + // user-facing details, so fall back to the provider message. + classified := chaterror.Classify(testProviderError( + "upstream failed", + 502, + nil, + testProviderResponseDump("502 Bad Gateway"), + )) + + require.Equal(t, "upstream failed", classified.Detail) +} + +func TestClassify_SkipsWhitespaceOnlyBodyForDetail(t *testing.T) { + t.Parallel() + + classified := chaterror.Classify(testProviderError( + "upstream failed", + 400, + nil, + testProviderResponseDump(" \n\t\n"), + )) + + require.Equal(t, "upstream failed", classified.Detail) +} + +func TestClassify_PlainTextBodyUsesFirstLineOnly(t *testing.T) { + t.Parallel() + + classified := chaterror.Classify(testProviderError( + "", + 400, + nil, + testProviderResponseDump("first line of the error\nsecond line\nthird line\n"), + )) + + require.Equal(t, "first line of the error", classified.Detail) +} + +func TestClassify_PrefersJSONEnvelopeOverPlainTextFallback(t *testing.T) { + t.Parallel() + + classified := chaterror.Classify(testProviderError( + "", + 400, + nil, + testProviderResponseDump(`{"error":{"message":"nested wins"}}`), + )) + + require.Equal(t, "nested wins", classified.Detail) +} + +func TestClassify_CapsPlainTextBodyFallback(t *testing.T) { + t.Parallel() + + classified := chaterror.Classify(testProviderError( + "", + 400, + nil, + testProviderResponseDump(strings.Repeat("y", 2048)), + )) + + require.LessOrEqual(t, len(classified.Detail), 512) +} + func TestClassify_UnwrapsTransportWrapperInMessageFallback(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chaterror/provider_error.go b/coderd/x/chatd/chaterror/provider_error.go index 0b3b440639446..493b1c1874557 100644 --- a/coderd/x/chatd/chaterror/provider_error.go +++ b/coderd/x/chatd/chaterror/provider_error.go @@ -53,13 +53,34 @@ func providerErrorDetail(providerErr *fantasy.ProviderError) string { // and headers. It understands both the top-level `{"message":...}` shape // used by many providers and the nested `{"error":{"message":...}}` // envelope. When the extracted message is itself an SDK-formatted transport -// error wrapper, the clean inner provider message is returned. +// error wrapper, the clean inner provider message is returned. Non-JSON +// bodies (e.g. aibridge's plain-text budget errors) fall back to the first +// line of the body unless it looks like markup. func providerErrorResponseMessage(responseDump []byte) string { if len(responseDump) == 0 || len(responseDump) > 64*1024 { return "" } body := providerErrorResponseBody(responseDump) - return unwrapTransportErrorMessage(jsonErrorMessage(body)) + if msg := unwrapTransportErrorMessage(jsonErrorMessage(body)); msg != "" { + return msg + } + return plainTextErrorMessage(body) +} + +// plainTextErrorMessage returns the first line of a plain-text error body, +// trimmed and capped. Markup bodies (HTML error pages from proxies and load +// balancers) are skipped because the result is user-facing. +func plainTextErrorMessage(body []byte) string { + const maxLen = 512 + line, _, _ := strings.Cut(strings.TrimSpace(string(body)), "\n") + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "<") { + return "" + } + if len(line) > maxLen { + line = line[:maxLen] + } + return line } // unwrapTransportErrorMessage extracts the clean provider message from an From 2c78e5da71f37fd00254a2bec37a6fc4519c7e05 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Jul 2026 17:41:40 +0000 Subject: [PATCH 2/7] fix(coderd/x/chatd/chaterror): gate plain-text fallback on text/plain dumps --- coderd/x/chatd/chaterror/classify_test.go | 72 +++++++++++++--------- coderd/x/chatd/chaterror/provider_error.go | 61 ++++++++++++------ 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index fb7bc1111d4e6..24097df023ab6 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -1427,6 +1427,7 @@ func TestClassify_AuthKeepsStructuredProviderDetail(t *testing.T) { func TestClassify_FallsBackToProviderMessageForDetail(t *testing.T) { t.Parallel() + // The Message fallback applies only when the body yields nothing. classified := chaterror.Classify(testProviderError( " image exceeds 5 MB maximum ", 400, @@ -1461,31 +1462,17 @@ func TestClassify_AnthropicPlainTextBudgetBody(t *testing.T) { classified.Detail) } -func TestClassify_UsesPlainTextBodyForDetail(t *testing.T) { - t.Parallel() - - // A non-JSON body is still the provider's actual response, so prefer - // it over the SDK-constructed Message. - classified := chaterror.Classify(testProviderError( - `POST "https://example.com/api": 400 Bad Request`, - 400, - nil, - testProviderResponseDump("upstream rejected the request\n"), - )) - - require.Equal(t, "upstream rejected the request", classified.Detail) -} - func TestClassify_SkipsHTMLBodyForDetail(t *testing.T) { t.Parallel() - // Proxies and load balancers return HTML error pages; those are not - // user-facing details, so fall back to the provider message. + // Proxies and load balancers return HTML error pages with a text/html + // Content-Type; the text/plain gate keeps them out of the user-facing + // detail, so fall back to the provider message. classified := chaterror.Classify(testProviderError( "upstream failed", 502, nil, - testProviderResponseDump("502 Bad Gateway"), + testPlainDump("text/html", "502 Bad Gateway"), )) require.Equal(t, "upstream failed", classified.Detail) @@ -1498,7 +1485,7 @@ func TestClassify_SkipsWhitespaceOnlyBodyForDetail(t *testing.T) { "upstream failed", 400, nil, - testProviderResponseDump(" \n\t\n"), + testPlainDump("text/plain", " \n\t\n"), )) require.Equal(t, "upstream failed", classified.Detail) @@ -1511,36 +1498,59 @@ func TestClassify_PlainTextBodyUsesFirstLineOnly(t *testing.T) { "", 400, nil, - testProviderResponseDump("first line of the error\nsecond line\nthird line\n"), + testPlainDump("text/plain", "first line of the error\nsecond line\nthird line\n"), )) require.Equal(t, "first line of the error", classified.Detail) } -func TestClassify_PrefersJSONEnvelopeOverPlainTextFallback(t *testing.T) { +func TestClassify_JSONBodyWithoutMessageFallsBackToMessage(t *testing.T) { t.Parallel() + // Valid JSON without an extractable message must not leak raw JSON + // into the user-facing detail, even when served as text/plain. classified := chaterror.Classify(testProviderError( - "", + "upstream failed", 400, nil, - testProviderResponseDump(`{"error":{"message":"nested wins"}}`), + testPlainDump("text/plain", `{"type":"error"}`), )) - require.Equal(t, "nested wins", classified.Detail) + require.Equal(t, "upstream failed", classified.Detail) } -func TestClassify_CapsPlainTextBodyFallback(t *testing.T) { +func TestClassify_PlainTextQuotaBodyOn503(t *testing.T) { t.Parallel() + // A plain-text body feeds the same pattern matching as a JSON message, + // so "quota" beats the 503 timeout signal. This is intended: it + // mirrors the behavior for JSON bodies carrying the same text. classified := chaterror.Classify(testProviderError( "", - 400, + 503, nil, - testProviderResponseDump(strings.Repeat("y", 2048)), + testPlainDump("text/plain", "quota exceeded for this key\n"), )) - require.LessOrEqual(t, len(classified.Detail), 512) + require.Equal(t, codersdk.ChatErrorKindUsageLimit, classified.Kind) + require.False(t, classified.Retryable) +} + +func TestClassify_GoogleRawMessageWithBlankLine(t *testing.T) { + t.Parallel() + + // Fantasy's Google adapter stores a raw message (not an HTTP dump) in + // ResponseBody. A blank line inside it must not be mistaken for a + // header/body separator; detail falls back to the full trimmed + // Message, not a mangled second paragraph. + classified := chaterror.Classify(testProviderError( + "google: model overloaded", + 500, + nil, + []byte("model overloaded\n\nplease try again later"), + )) + + require.Equal(t, "google: model overloaded", classified.Detail) } func TestClassify_UnwrapsTransportWrapperInMessageFallback(t *testing.T) { @@ -1613,6 +1623,12 @@ func testProviderError( } } +func testPlainDump(contentType, body string) []byte { + return []byte("HTTP/1.1 400 Bad Request\r\n" + + "Content-Type: " + contentType + "\r\n" + + "\r\n" + body) +} + func testProviderResponseDump(body string) []byte { return []byte(`HTTP/1.1 400 Bad Request Content-Type: application/json diff --git a/coderd/x/chatd/chaterror/provider_error.go b/coderd/x/chatd/chaterror/provider_error.go index 493b1c1874557..221853128e6d9 100644 --- a/coderd/x/chatd/chaterror/provider_error.go +++ b/coderd/x/chatd/chaterror/provider_error.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "mime" "net/http" "regexp" "strconv" @@ -54,33 +55,45 @@ func providerErrorDetail(providerErr *fantasy.ProviderError) string { // used by many providers and the nested `{"error":{"message":...}}` // envelope. When the extracted message is itself an SDK-formatted transport // error wrapper, the clean inner provider message is returned. Non-JSON -// bodies (e.g. aibridge's plain-text budget errors) fall back to the first -// line of the body unless it looks like markup. +// text/plain bodies (e.g. aibridge's budget errors) fall back to the first +// line of the body. func providerErrorResponseMessage(responseDump []byte) string { if len(responseDump) == 0 || len(responseDump) > 64*1024 { return "" } - body := providerErrorResponseBody(responseDump) + headers, body := splitResponseDump(responseDump) if msg := unwrapTransportErrorMessage(jsonErrorMessage(body)); msg != "" { return msg } - return plainTextErrorMessage(body) + return unwrapTransportErrorMessage(plainTextErrorMessage(headers, body)) } // plainTextErrorMessage returns the first line of a plain-text error body, -// trimmed and capped. Markup bodies (HTML error pages from proxies and load -// balancers) are skipped because the result is user-facing. -func plainTextErrorMessage(body []byte) string { - const maxLen = 512 - line, _, _ := strings.Cut(strings.TrimSpace(string(body)), "\n") - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "<") { +// trimmed. It applies only when the dumped response declares a text/plain +// Content-Type (keeping proxy/LB HTML and other opaque bodies out of the +// user-facing detail) and the body is not valid JSON (valid JSON without an +// extractable message must not leak raw JSON). +func plainTextErrorMessage(headers, body []byte) string { + if !headersDeclareTextPlain(headers) || json.Valid(body) { return "" } - if len(line) > maxLen { - line = line[:maxLen] + line, _, _ := strings.Cut(strings.TrimSpace(string(body)), "\n") + return strings.TrimSpace(line) +} + +// headersDeclareTextPlain reports whether a dumped HTTP header block +// declares Content-Type text/plain, tolerating media-type parameters such +// as charset. +func headersDeclareTextPlain(headers []byte) bool { + for line := range strings.Lines(string(headers)) { + name, value, ok := strings.Cut(line, ":") + if !ok || !strings.EqualFold(strings.TrimSpace(name), "Content-Type") { + continue + } + mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(value)) + return err == nil && mediaType == "text/plain" } - return line + return false } // unwrapTransportErrorMessage extracts the clean provider message from an @@ -136,14 +149,22 @@ func jsonErrorMessage(body []byte) string { return strings.TrimSpace(env.Message) } -func providerErrorResponseBody(responseDump []byte) []byte { - if _, body, ok := bytes.Cut(responseDump, []byte("\r\n\r\n")); ok { - return body +// splitResponseDump separates a dumped HTTP response into its header block +// and body. Non-dump payloads (e.g. fantasy's Google adapter stores a raw +// message in ResponseBody) are returned whole as the body with no headers, +// so a blank line inside a raw message is never mistaken for the +// header/body separator. +func splitResponseDump(responseDump []byte) (headers, body []byte) { + if !bytes.HasPrefix(responseDump, []byte("HTTP/")) { + return nil, responseDump + } + if h, b, ok := bytes.Cut(responseDump, []byte("\r\n\r\n")); ok { + return h, b } - if _, body, ok := bytes.Cut(responseDump, []byte("\n\n")); ok { - return body + if h, b, ok := bytes.Cut(responseDump, []byte("\n\n")); ok { + return h, b } - return responseDump + return nil, responseDump } func retryAfterFromHeaders(headers map[string]string) time.Duration { From 60afe709bf026f0a6bdc96a83ea7c0515b11c946 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Jul 2026 17:46:04 +0000 Subject: [PATCH 3/7] docs(coderd/x/chatd/chaterror): tighten comments --- .pr-body.md | 133 +++++++++++++++++++++ coderd/x/chatd/chaterror/classify_test.go | 19 ++- coderd/x/chatd/chaterror/provider_error.go | 25 ++-- 3 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 .pr-body.md diff --git a/.pr-body.md b/.pr-body.md new file mode 100644 index 0000000000000..c899ece1c96f7 --- /dev/null +++ b/.pr-body.md @@ -0,0 +1,133 @@ +Follows up #27538. + +## Problem + +#27538 added the `"ai budget of"` pattern to `usageLimitAnyStatusPatterns`, but it only works for OpenAI. aibridge returns its budget error as a **plain-text** 403 body. Fantasy's Anthropic adapter sets `ProviderError.Message` to the SDK transport string (`POST "…": 403 Forbidden`), which drops the body, and `providerErrorResponseMessage` only extracted JSON messages — so the budget text never reached the pattern check and the weak-auth 403 rule fired, misclassifying the error as `auth` instead of `usage_limit`. + +## Fix + +When JSON extraction of the dumped response body yields nothing, fall back to the trimmed first line of the plain-text body. The fallback is tightly gated because the result surfaces in the user-facing `Detail` field: + +- only when the dump declares `Content-Type: text/plain` (what aibridge's `http.Error` sends; keeps proxy/LB HTML and other opaque bodies out), +- only when the body is not valid JSON (valid JSON without an extractable message must not leak raw JSON), +- piped through `unwrapTransportErrorMessage`, symmetric with the JSON path. + +Additionally, the dump header/body split now only applies when the payload actually starts with `HTTP/` — fantasy's Google adapter stores a raw message (not an HTTP dump) in `ResponseBody`, and a blank line inside it was previously mistaken for the header/body separator. + +## Tests + +- End-to-end `Classify` test with an Anthropic-shaped 403 budget error (`Content-Type: text/plain; charset=utf-8`) → `usage_limit`, not retryable (failed before the fix with `auth`). +- HTML (`text/html`) skipped, whitespace-only skipped, multi-line → first line, JSON-without-message falls back to Message, plain-text "quota" on 503 → usage limit (mirrors JSON behavior), Google raw message with a blank line not mangled. +- `go test ./coderd/x/chatd/chaterror/...` (incl. `-race`) and `golangci-lint run coderd/x/chatd/chaterror/...` pass. + +
+Implementation plan + +# Plan: Classify Anthropic aibridge budget 403s as UsageLimit + +## Context + +PR #27538 added the `"ai budget of"` pattern to `usageLimitAnyStatusPatterns`, +but it only works for OpenAI. aibridge returns its budget error as a +**plain-text** 403 (`http.Error`, `coderd/aibridged/http.go:156`): + +``` +AI budget of US$10.00 exceeded. Please contact an administrator for more details. +``` + +- **OpenAI path**: fantasy's OpenAI adapter falls back to the raw response + body for `ProviderError.Message`, so `err.Error()` contains the budget text + and the pattern matches. +- **Anthropic path**: fantasy's Anthropic adapter sets + `Message: apiErr.Error()`, which anthropic-sdk-go formats as + `POST "url": 403 Forbidden` — body dropped. `ResponseBody` *does* contain + the full dumped response (`apiErr.DumpResponse(true)`), but + `providerErrorResponseMessage` (`chaterror/provider_error.go:57`) only + extracts JSON messages and returns `""` for plain text. With no match, + the weak-auth rule (`statusCode == 403`) fires → `ChatErrorKindAuth`. + +## Fix (central, minimal) + +In `providerErrorResponseMessage`, when `jsonErrorMessage` returns empty, +fall back to the trimmed plain-text body, guarded: + +- Skip if body is empty after trimming. +- Skip if body looks like markup (starts with `<`) — proxies/LBs return HTML + error pages and `detail` is user-facing. +- Cap fallback at a sane length (e.g. 512 bytes, first line only) since + `normalizeClassificationDetail` already truncates but we shouldn't feed it + megabyte-ish garbage semantics. + +No changes to fantasy, signals.go, or classify.go rule ordering. + +**Files changed:** +- `coderd/x/chatd/chaterror/provider_error.go` (~10 lines) +- `coderd/x/chatd/chaterror/provider_error_test.go` or the existing test file + covering `providerErrorResponseMessage` / `extractProviderErrorDetails` + +## Decisions (confirmed by user) + +1. Central fix in `chaterror` only; no fantasy changes. +2. Plain-text fallback may surface in the user-facing `Detail` field + (HTML/length guards in place). +3. First line of the body only. + +## Red phase — failing tests first + +In `classify_test.go` (table-driven, matching existing conventions) and/or +the provider_error tests: + +1. **End-to-end Anthropic-shaped error** (the actual bug): + `fantasy.ProviderError{StatusCode: 403, Message: `POST "https://…": 403 Forbidden`, + ResponseBody: dump("HTTP/1.1 403 Forbidden\r\n…\r\n\r\nAI budget of US$10.00 exceeded. …\n")}` + → want `ChatErrorKindUsageLimit`, no retry. **Fails today** (gets Auth). +2. **Plain-text body extraction**: `providerErrorResponseMessage` with a + dumped plain-text response → returns trimmed message. +3. **JSON body still preferred**: existing JSON envelope cases unchanged + (regression guard — run existing suite). +4. **HTML body skipped**: body `502` → `""`, + classification falls through to status-code rules as before. +5. **Whitespace-only body** → `""`. +6. **Multi-line plain text** → first line only. + +## Green phase + +Implement the fallback in `providerErrorResponseMessage`: + +```go +if msg := unwrapTransportErrorMessage(jsonErrorMessage(body)); msg != "" { + return msg +} +return plainTextErrorMessage(body) // trim, reject '<' prefix, first line, cap +``` + +Run: `go test ./coderd/x/chatd/chaterror/` + +## Refactor phase + +- Re-check `providerErrorDetail`'s Message fallback — with body fallback in + place, is any of that path now dead or duplicative? Delete if so. +- Ensure comments on `providerErrorResponseMessage` reflect new behavior. +- Re-run full package tests + `make lint` (or repo-standard golangci run). + +## Verification + +- `go test ./coderd/x/chatd/chaterror/ -run TestClassify -v` — new cases pass, + no existing case changed. +- Manual (optional, as in #27538): set a group AI budget, hit an Anthropic + model through aibridge, confirm the UI shows the usage-limit error state. + +## Delivery + +- Feature branch off `origin/main` (current checkout is + `cj/rm-chatd-usage-limit-enforcement` with local changes — do not touch). +- Conventional commit: `fix(coderd/x/chatd/chaterror): extract plain-text provider error bodies` +- Draft PR only, with agent disclosure. + +
+ +--- + +> Generated by Coder Agents on behalf of @johnstcn. + + diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 24097df023ab6..42ac830d0397b 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -1442,8 +1442,8 @@ func TestClassify_AnthropicPlainTextBudgetBody(t *testing.T) { // aibridge returns its budget error as a plain-text 403. The Anthropic // adapter's Message is the SDK transport string without the body, so - // the budget text is only present in the dumped ResponseBody. It must - // still classify as a usage limit, not auth. + // the budget text appears only in the dumped ResponseBody. Classify + // must still report a usage limit, not auth. classified := chaterror.Classify(testProviderError( `POST "https://api.example.com/v1/messages": 403 Forbidden`, 403, @@ -1466,8 +1466,8 @@ func TestClassify_SkipsHTMLBodyForDetail(t *testing.T) { t.Parallel() // Proxies and load balancers return HTML error pages with a text/html - // Content-Type; the text/plain gate keeps them out of the user-facing - // detail, so fall back to the provider message. + // Content-Type. The text/plain gate keeps them out of the user-facing + // detail, so detail falls back to the provider message. classified := chaterror.Classify(testProviderError( "upstream failed", 502, @@ -1522,9 +1522,8 @@ func TestClassify_JSONBodyWithoutMessageFallsBackToMessage(t *testing.T) { func TestClassify_PlainTextQuotaBodyOn503(t *testing.T) { t.Parallel() - // A plain-text body feeds the same pattern matching as a JSON message, - // so "quota" beats the 503 timeout signal. This is intended: it - // mirrors the behavior for JSON bodies carrying the same text. + // Intended: a plain-text body feeds the same pattern matching as a + // JSON message, so "quota" beats the 503 timeout signal. classified := chaterror.Classify(testProviderError( "", 503, @@ -1540,9 +1539,9 @@ func TestClassify_GoogleRawMessageWithBlankLine(t *testing.T) { t.Parallel() // Fantasy's Google adapter stores a raw message (not an HTTP dump) in - // ResponseBody. A blank line inside it must not be mistaken for a - // header/body separator; detail falls back to the full trimmed - // Message, not a mangled second paragraph. + // ResponseBody. A blank line inside it is not a header/body separator: + // detail must fall back to the full trimmed Message, not the second + // paragraph. classified := chaterror.Classify(testProviderError( "google: model overloaded", 500, diff --git a/coderd/x/chatd/chaterror/provider_error.go b/coderd/x/chatd/chaterror/provider_error.go index 221853128e6d9..23aaf7b6914f1 100644 --- a/coderd/x/chatd/chaterror/provider_error.go +++ b/coderd/x/chatd/chaterror/provider_error.go @@ -54,9 +54,9 @@ func providerErrorDetail(providerErr *fantasy.ProviderError) string { // and headers. It understands both the top-level `{"message":...}` shape // used by many providers and the nested `{"error":{"message":...}}` // envelope. When the extracted message is itself an SDK-formatted transport -// error wrapper, the clean inner provider message is returned. Non-JSON -// text/plain bodies (e.g. aibridge's budget errors) fall back to the first -// line of the body. +// error wrapper, the clean inner provider message is returned. For +// non-JSON text/plain bodies (e.g. aibridge's budget errors) it returns +// the first line of the body. func providerErrorResponseMessage(responseDump []byte) string { if len(responseDump) == 0 || len(responseDump) > 64*1024 { return "" @@ -68,11 +68,11 @@ func providerErrorResponseMessage(responseDump []byte) string { return unwrapTransportErrorMessage(plainTextErrorMessage(headers, body)) } -// plainTextErrorMessage returns the first line of a plain-text error body, -// trimmed. It applies only when the dumped response declares a text/plain -// Content-Type (keeping proxy/LB HTML and other opaque bodies out of the -// user-facing detail) and the body is not valid JSON (valid JSON without an -// extractable message must not leak raw JSON). +// plainTextErrorMessage returns the trimmed first line of a plain-text +// error body. The result is user-facing, so it requires a text/plain +// Content-Type (excluding proxy HTML and other opaque bodies) and rejects +// valid JSON (which jsonErrorMessage already handled; passing it through +// would leak raw JSON). func plainTextErrorMessage(headers, body []byte) string { if !headersDeclareTextPlain(headers) || json.Valid(body) { return "" @@ -82,7 +82,7 @@ func plainTextErrorMessage(headers, body []byte) string { } // headersDeclareTextPlain reports whether a dumped HTTP header block -// declares Content-Type text/plain, tolerating media-type parameters such +// declares Content-Type text/plain, ignoring media-type parameters such // as charset. func headersDeclareTextPlain(headers []byte) bool { for line := range strings.Lines(string(headers)) { @@ -150,10 +150,9 @@ func jsonErrorMessage(body []byte) string { } // splitResponseDump separates a dumped HTTP response into its header block -// and body. Non-dump payloads (e.g. fantasy's Google adapter stores a raw -// message in ResponseBody) are returned whole as the body with no headers, -// so a blank line inside a raw message is never mistaken for the -// header/body separator. +// and body. It returns non-dump payloads whole as the body: fantasy's +// Google adapter stores a raw message in ResponseBody, and a blank line +// inside that message is not a header/body separator. func splitResponseDump(responseDump []byte) (headers, body []byte) { if !bytes.HasPrefix(responseDump, []byte("HTTP/")) { return nil, responseDump From 4d5f976a5d39f03f63872fb990ac4de666cbbe91 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Jul 2026 17:46:51 +0000 Subject: [PATCH 4/7] chore: remove stray PR body file --- .pr-body.md | 133 ---------------------------------------------------- 1 file changed, 133 deletions(-) delete mode 100644 .pr-body.md diff --git a/.pr-body.md b/.pr-body.md deleted file mode 100644 index c899ece1c96f7..0000000000000 --- a/.pr-body.md +++ /dev/null @@ -1,133 +0,0 @@ -Follows up #27538. - -## Problem - -#27538 added the `"ai budget of"` pattern to `usageLimitAnyStatusPatterns`, but it only works for OpenAI. aibridge returns its budget error as a **plain-text** 403 body. Fantasy's Anthropic adapter sets `ProviderError.Message` to the SDK transport string (`POST "…": 403 Forbidden`), which drops the body, and `providerErrorResponseMessage` only extracted JSON messages — so the budget text never reached the pattern check and the weak-auth 403 rule fired, misclassifying the error as `auth` instead of `usage_limit`. - -## Fix - -When JSON extraction of the dumped response body yields nothing, fall back to the trimmed first line of the plain-text body. The fallback is tightly gated because the result surfaces in the user-facing `Detail` field: - -- only when the dump declares `Content-Type: text/plain` (what aibridge's `http.Error` sends; keeps proxy/LB HTML and other opaque bodies out), -- only when the body is not valid JSON (valid JSON without an extractable message must not leak raw JSON), -- piped through `unwrapTransportErrorMessage`, symmetric with the JSON path. - -Additionally, the dump header/body split now only applies when the payload actually starts with `HTTP/` — fantasy's Google adapter stores a raw message (not an HTTP dump) in `ResponseBody`, and a blank line inside it was previously mistaken for the header/body separator. - -## Tests - -- End-to-end `Classify` test with an Anthropic-shaped 403 budget error (`Content-Type: text/plain; charset=utf-8`) → `usage_limit`, not retryable (failed before the fix with `auth`). -- HTML (`text/html`) skipped, whitespace-only skipped, multi-line → first line, JSON-without-message falls back to Message, plain-text "quota" on 503 → usage limit (mirrors JSON behavior), Google raw message with a blank line not mangled. -- `go test ./coderd/x/chatd/chaterror/...` (incl. `-race`) and `golangci-lint run coderd/x/chatd/chaterror/...` pass. - -
-Implementation plan - -# Plan: Classify Anthropic aibridge budget 403s as UsageLimit - -## Context - -PR #27538 added the `"ai budget of"` pattern to `usageLimitAnyStatusPatterns`, -but it only works for OpenAI. aibridge returns its budget error as a -**plain-text** 403 (`http.Error`, `coderd/aibridged/http.go:156`): - -``` -AI budget of US$10.00 exceeded. Please contact an administrator for more details. -``` - -- **OpenAI path**: fantasy's OpenAI adapter falls back to the raw response - body for `ProviderError.Message`, so `err.Error()` contains the budget text - and the pattern matches. -- **Anthropic path**: fantasy's Anthropic adapter sets - `Message: apiErr.Error()`, which anthropic-sdk-go formats as - `POST "url": 403 Forbidden` — body dropped. `ResponseBody` *does* contain - the full dumped response (`apiErr.DumpResponse(true)`), but - `providerErrorResponseMessage` (`chaterror/provider_error.go:57`) only - extracts JSON messages and returns `""` for plain text. With no match, - the weak-auth rule (`statusCode == 403`) fires → `ChatErrorKindAuth`. - -## Fix (central, minimal) - -In `providerErrorResponseMessage`, when `jsonErrorMessage` returns empty, -fall back to the trimmed plain-text body, guarded: - -- Skip if body is empty after trimming. -- Skip if body looks like markup (starts with `<`) — proxies/LBs return HTML - error pages and `detail` is user-facing. -- Cap fallback at a sane length (e.g. 512 bytes, first line only) since - `normalizeClassificationDetail` already truncates but we shouldn't feed it - megabyte-ish garbage semantics. - -No changes to fantasy, signals.go, or classify.go rule ordering. - -**Files changed:** -- `coderd/x/chatd/chaterror/provider_error.go` (~10 lines) -- `coderd/x/chatd/chaterror/provider_error_test.go` or the existing test file - covering `providerErrorResponseMessage` / `extractProviderErrorDetails` - -## Decisions (confirmed by user) - -1. Central fix in `chaterror` only; no fantasy changes. -2. Plain-text fallback may surface in the user-facing `Detail` field - (HTML/length guards in place). -3. First line of the body only. - -## Red phase — failing tests first - -In `classify_test.go` (table-driven, matching existing conventions) and/or -the provider_error tests: - -1. **End-to-end Anthropic-shaped error** (the actual bug): - `fantasy.ProviderError{StatusCode: 403, Message: `POST "https://…": 403 Forbidden`, - ResponseBody: dump("HTTP/1.1 403 Forbidden\r\n…\r\n\r\nAI budget of US$10.00 exceeded. …\n")}` - → want `ChatErrorKindUsageLimit`, no retry. **Fails today** (gets Auth). -2. **Plain-text body extraction**: `providerErrorResponseMessage` with a - dumped plain-text response → returns trimmed message. -3. **JSON body still preferred**: existing JSON envelope cases unchanged - (regression guard — run existing suite). -4. **HTML body skipped**: body `502` → `""`, - classification falls through to status-code rules as before. -5. **Whitespace-only body** → `""`. -6. **Multi-line plain text** → first line only. - -## Green phase - -Implement the fallback in `providerErrorResponseMessage`: - -```go -if msg := unwrapTransportErrorMessage(jsonErrorMessage(body)); msg != "" { - return msg -} -return plainTextErrorMessage(body) // trim, reject '<' prefix, first line, cap -``` - -Run: `go test ./coderd/x/chatd/chaterror/` - -## Refactor phase - -- Re-check `providerErrorDetail`'s Message fallback — with body fallback in - place, is any of that path now dead or duplicative? Delete if so. -- Ensure comments on `providerErrorResponseMessage` reflect new behavior. -- Re-run full package tests + `make lint` (or repo-standard golangci run). - -## Verification - -- `go test ./coderd/x/chatd/chaterror/ -run TestClassify -v` — new cases pass, - no existing case changed. -- Manual (optional, as in #27538): set a group AI budget, hit an Anthropic - model through aibridge, confirm the UI shows the usage-limit error state. - -## Delivery - -- Feature branch off `origin/main` (current checkout is - `cj/rm-chatd-usage-limit-enforcement` with local changes — do not touch). -- Conventional commit: `fix(coderd/x/chatd/chaterror): extract plain-text provider error bodies` -- Draft PR only, with agent disclosure. - -
- ---- - -> Generated by Coder Agents on behalf of @johnstcn. - - From ef803145f8cc949f70ff7ccde51ea70832d4841f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Jul 2026 20:19:44 +0000 Subject: [PATCH 5/7] refactor(coderd/x/chatd/chaterror): parse response dumps with http.ReadResponse --- coderd/x/chatd/chaterror/classify_test.go | 20 ++++++ coderd/x/chatd/chaterror/provider_error.go | 72 ++++++++++------------ 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 42ac830d0397b..58314e891280a 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -1535,6 +1535,26 @@ func TestClassify_PlainTextQuotaBodyOn503(t *testing.T) { require.False(t, classified.Retryable) } +func TestClassify_DechunksPlainTextDump(t *testing.T) { + t.Parallel() + + // A dump of a chunked response keeps the chunk framing. Parsing the + // dump as an HTTP response removes it, so the detail is the joined + // body, not a hex chunk-size line. + part1, part2 := "AI budget of US$10.00 ", "exceeded." + dump := "HTTP/1.1 403 Forbidden\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + fmt.Sprintf("%x\r\n%s\r\n", len(part1), part1) + + fmt.Sprintf("%x\r\n%s\r\n", len(part2), part2) + + "0\r\n\r\n" + classified := chaterror.Classify(testProviderError("", 403, nil, []byte(dump))) + + require.Equal(t, "AI budget of US$10.00 exceeded.", classified.Detail) + require.Equal(t, codersdk.ChatErrorKindUsageLimit, classified.Kind) +} + func TestClassify_GoogleRawMessageWithBlankLine(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chaterror/provider_error.go b/coderd/x/chatd/chaterror/provider_error.go index 23aaf7b6914f1..42d0105bafcef 100644 --- a/coderd/x/chatd/chaterror/provider_error.go +++ b/coderd/x/chatd/chaterror/provider_error.go @@ -1,9 +1,11 @@ package chaterror import ( + "bufio" "bytes" "encoding/json" "errors" + "io" "mime" "net/http" "regexp" @@ -61,41 +63,31 @@ func providerErrorResponseMessage(responseDump []byte) string { if len(responseDump) == 0 || len(responseDump) > 64*1024 { return "" } - headers, body := splitResponseDump(responseDump) + body, textPlain := readResponseDump(responseDump) if msg := unwrapTransportErrorMessage(jsonErrorMessage(body)); msg != "" { return msg } - return unwrapTransportErrorMessage(plainTextErrorMessage(headers, body)) + if !textPlain { + // The plain-text fallback surfaces in the user-facing detail, so + // only text/plain bodies qualify; proxy HTML and other opaque + // bodies stay out. + return "" + } + return unwrapTransportErrorMessage(plainTextErrorMessage(body)) } // plainTextErrorMessage returns the trimmed first line of a plain-text -// error body. The result is user-facing, so it requires a text/plain -// Content-Type (excluding proxy HTML and other opaque bodies) and rejects -// valid JSON (which jsonErrorMessage already handled; passing it through -// would leak raw JSON). -func plainTextErrorMessage(headers, body []byte) string { - if !headersDeclareTextPlain(headers) || json.Valid(body) { +// error body. It rejects valid JSON, which jsonErrorMessage already +// handled; passing it through would leak raw JSON into the user-facing +// detail. +func plainTextErrorMessage(body []byte) string { + if json.Valid(body) { return "" } line, _, _ := strings.Cut(strings.TrimSpace(string(body)), "\n") return strings.TrimSpace(line) } -// headersDeclareTextPlain reports whether a dumped HTTP header block -// declares Content-Type text/plain, ignoring media-type parameters such -// as charset. -func headersDeclareTextPlain(headers []byte) bool { - for line := range strings.Lines(string(headers)) { - name, value, ok := strings.Cut(line, ":") - if !ok || !strings.EqualFold(strings.TrimSpace(name), "Content-Type") { - continue - } - mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(value)) - return err == nil && mediaType == "text/plain" - } - return false -} - // unwrapTransportErrorMessage extracts the clean provider message from an // SDK-formatted wrapper such as: // @@ -149,21 +141,25 @@ func jsonErrorMessage(body []byte) string { return strings.TrimSpace(env.Message) } -// splitResponseDump separates a dumped HTTP response into its header block -// and body. It returns non-dump payloads whole as the body: fantasy's -// Google adapter stores a raw message in ResponseBody, and a blank line -// inside that message is not a header/body separator. -func splitResponseDump(responseDump []byte) (headers, body []byte) { - if !bytes.HasPrefix(responseDump, []byte("HTTP/")) { - return nil, responseDump - } - if h, b, ok := bytes.Cut(responseDump, []byte("\r\n\r\n")); ok { - return h, b - } - if h, b, ok := bytes.Cut(responseDump, []byte("\n\n")); ok { - return h, b - } - return nil, responseDump +// readResponseDump parses a dumped HTTP response into its body, removing +// the status line, headers, and any chunk framing, and reports whether the +// response declares Content-Type text/plain (ignoring media-type +// parameters such as charset). Payloads that do not parse as an HTTP +// response, such as the raw message fantasy's Google adapter stores in +// ResponseBody, are returned whole. +func readResponseDump(responseDump []byte) (body []byte, textPlain bool) { + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(responseDump)), nil) + if err != nil { + return responseDump, false + } + defer resp.Body.Close() + // The dump is already bounded to 64KB; the limit is defense in depth. + body, err = io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return responseDump, false + } + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + return body, err == nil && mediaType == "text/plain" } func retryAfterFromHeaders(headers map[string]string) time.Duration { From c2a0c1c0f8a89f62e5f2318ce103943b421cb8c7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Jul 2026 21:05:30 +0000 Subject: [PATCH 6/7] refactor(coderd/x/chatd/chaterror): inline plain-text fallback --- coderd/x/chatd/chaterror/classify_test.go | 5 ++--- coderd/x/chatd/chaterror/provider_error.go | 24 ++++++---------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 58314e891280a..9a32fe4e7d19d 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -1427,7 +1427,6 @@ func TestClassify_AuthKeepsStructuredProviderDetail(t *testing.T) { func TestClassify_FallsBackToProviderMessageForDetail(t *testing.T) { t.Parallel() - // The Message fallback applies only when the body yields nothing. classified := chaterror.Classify(testProviderError( " image exceeds 5 MB maximum ", 400, @@ -1522,8 +1521,8 @@ func TestClassify_JSONBodyWithoutMessageFallsBackToMessage(t *testing.T) { func TestClassify_PlainTextQuotaBodyOn503(t *testing.T) { t.Parallel() - // Intended: a plain-text body feeds the same pattern matching as a - // JSON message, so "quota" beats the 503 timeout signal. + // A plain-text body feeds the same pattern matching as a JSON + // message, so "quota" beats the 503 timeout signal. classified := chaterror.Classify(testProviderError( "", 503, diff --git a/coderd/x/chatd/chaterror/provider_error.go b/coderd/x/chatd/chaterror/provider_error.go index 42d0105bafcef..e443519b6467a 100644 --- a/coderd/x/chatd/chaterror/provider_error.go +++ b/coderd/x/chatd/chaterror/provider_error.go @@ -58,7 +58,9 @@ func providerErrorDetail(providerErr *fantasy.ProviderError) string { // envelope. When the extracted message is itself an SDK-formatted transport // error wrapper, the clean inner provider message is returned. For // non-JSON text/plain bodies (e.g. aibridge's budget errors) it returns -// the first line of the body. +// the trimmed first line of the body; the result surfaces in the +// user-facing detail, so other content types (proxy HTML, opaque bodies) +// and valid JSON without an extractable message yield nothing. func providerErrorResponseMessage(responseDump []byte) string { if len(responseDump) == 0 || len(responseDump) > 64*1024 { return "" @@ -67,21 +69,7 @@ func providerErrorResponseMessage(responseDump []byte) string { if msg := unwrapTransportErrorMessage(jsonErrorMessage(body)); msg != "" { return msg } - if !textPlain { - // The plain-text fallback surfaces in the user-facing detail, so - // only text/plain bodies qualify; proxy HTML and other opaque - // bodies stay out. - return "" - } - return unwrapTransportErrorMessage(plainTextErrorMessage(body)) -} - -// plainTextErrorMessage returns the trimmed first line of a plain-text -// error body. It rejects valid JSON, which jsonErrorMessage already -// handled; passing it through would leak raw JSON into the user-facing -// detail. -func plainTextErrorMessage(body []byte) string { - if json.Valid(body) { + if !textPlain || json.Valid(body) { return "" } line, _, _ := strings.Cut(strings.TrimSpace(string(body)), "\n") @@ -153,8 +141,8 @@ func readResponseDump(responseDump []byte) (body []byte, textPlain bool) { return responseDump, false } defer resp.Body.Close() - // The dump is already bounded to 64KB; the limit is defense in depth. - body, err = io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + // The caller already bounds dumps at 64KB. + body, err = io.ReadAll(resp.Body) if err != nil { return responseDump, false } From c7a22087a27546916e0339a5b56f5768c10f9adb Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Jul 2026 22:09:02 +0000 Subject: [PATCH 7/7] test(coderd/x/chatd/chaterror): table-drive response dump cases --- coderd/x/chatd/chaterror/classify_test.go | 206 +++++++++++----------- 1 file changed, 102 insertions(+), 104 deletions(-) diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 9a32fe4e7d19d..f4788b488e9de 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -1461,114 +1461,112 @@ func TestClassify_AnthropicPlainTextBudgetBody(t *testing.T) { classified.Detail) } -func TestClassify_SkipsHTMLBodyForDetail(t *testing.T) { +func TestClassify_ProviderResponseDumps(t *testing.T) { t.Parallel() - // Proxies and load balancers return HTML error pages with a text/html - // Content-Type. The text/plain gate keeps them out of the user-facing - // detail, so detail falls back to the provider message. - classified := chaterror.Classify(testProviderError( - "upstream failed", - 502, - nil, - testPlainDump("text/html", "502 Bad Gateway"), - )) - - require.Equal(t, "upstream failed", classified.Detail) -} - -func TestClassify_SkipsWhitespaceOnlyBodyForDetail(t *testing.T) { - t.Parallel() - - classified := chaterror.Classify(testProviderError( - "upstream failed", - 400, - nil, - testPlainDump("text/plain", " \n\t\n"), - )) - - require.Equal(t, "upstream failed", classified.Detail) -} - -func TestClassify_PlainTextBodyUsesFirstLineOnly(t *testing.T) { - t.Parallel() - - classified := chaterror.Classify(testProviderError( - "", - 400, - nil, - testPlainDump("text/plain", "first line of the error\nsecond line\nthird line\n"), - )) - - require.Equal(t, "first line of the error", classified.Detail) -} - -func TestClassify_JSONBodyWithoutMessageFallsBackToMessage(t *testing.T) { - t.Parallel() - - // Valid JSON without an extractable message must not leak raw JSON - // into the user-facing detail, even when served as text/plain. - classified := chaterror.Classify(testProviderError( - "upstream failed", - 400, - nil, - testPlainDump("text/plain", `{"type":"error"}`), - )) - - require.Equal(t, "upstream failed", classified.Detail) -} - -func TestClassify_PlainTextQuotaBodyOn503(t *testing.T) { - t.Parallel() - - // A plain-text body feeds the same pattern matching as a JSON - // message, so "quota" beats the 503 timeout signal. - classified := chaterror.Classify(testProviderError( - "", - 503, - nil, - testPlainDump("text/plain", "quota exceeded for this key\n"), - )) - - require.Equal(t, codersdk.ChatErrorKindUsageLimit, classified.Kind) - require.False(t, classified.Retryable) -} - -func TestClassify_DechunksPlainTextDump(t *testing.T) { - t.Parallel() - - // A dump of a chunked response keeps the chunk framing. Parsing the - // dump as an HTTP response removes it, so the detail is the joined - // body, not a hex chunk-size line. - part1, part2 := "AI budget of US$10.00 ", "exceeded." - dump := "HTTP/1.1 403 Forbidden\r\n" + - "Content-Type: text/plain; charset=utf-8\r\n" + - "Transfer-Encoding: chunked\r\n" + - "\r\n" + - fmt.Sprintf("%x\r\n%s\r\n", len(part1), part1) + - fmt.Sprintf("%x\r\n%s\r\n", len(part2), part2) + - "0\r\n\r\n" - classified := chaterror.Classify(testProviderError("", 403, nil, []byte(dump))) - - require.Equal(t, "AI budget of US$10.00 exceeded.", classified.Detail) - require.Equal(t, codersdk.ChatErrorKindUsageLimit, classified.Kind) -} - -func TestClassify_GoogleRawMessageWithBlankLine(t *testing.T) { - t.Parallel() + tests := []struct { + name string + message string + status int + dump []byte + wantDetail string + wantKind codersdk.ChatErrorKind + wantRetryable bool + }{ + { + // Proxies and load balancers return HTML error pages with a + // text/html Content-Type. The text/plain gate keeps them out + // of the user-facing detail, so detail falls back to the + // provider message. + name: "SkipsHTMLBody", + message: "upstream failed", + status: 502, + dump: testPlainDump("text/html", "502 Bad Gateway"), + wantDetail: "upstream failed", + wantKind: codersdk.ChatErrorKindTimeout, + wantRetryable: true, + }, + { + name: "SkipsWhitespaceOnlyBody", + message: "upstream failed", + status: 400, + dump: testPlainDump("text/plain", " \n\t\n"), + wantDetail: "upstream failed", + wantKind: codersdk.ChatErrorKindGeneric, + wantRetryable: false, + }, + { + name: "PlainTextBodyFirstLineOnly", + status: 400, + dump: testPlainDump("text/plain", "first line of the error\nsecond line\nthird line\n"), + wantDetail: "first line of the error", + wantKind: codersdk.ChatErrorKindGeneric, + wantRetryable: false, + }, + { + // Valid JSON without an extractable message must not leak raw + // JSON into the user-facing detail, even when served as + // text/plain. + name: "JSONBodyWithoutMessage", + message: "upstream failed", + status: 400, + dump: testPlainDump("text/plain", `{"type":"error"}`), + wantDetail: "upstream failed", + wantKind: codersdk.ChatErrorKindGeneric, + wantRetryable: false, + }, + { + // A plain-text body feeds the same pattern matching as a JSON + // message, so "quota" beats the 503 timeout signal. + name: "PlainTextQuotaBodyOn503", + status: 503, + dump: testPlainDump("text/plain", "quota exceeded for this key\n"), + wantDetail: "quota exceeded for this key", + wantKind: codersdk.ChatErrorKindUsageLimit, + wantRetryable: false, + }, + { + // A dump of a chunked response keeps the chunk framing. + // Parsing the dump as an HTTP response removes it, so the + // detail is the joined body, not a hex chunk-size line. + name: "DechunksPlainTextBody", + status: 403, + dump: []byte("HTTP/1.1 403 Forbidden\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "16\r\nAI budget of US$10.00 \r\n" + + "9\r\nexceeded.\r\n" + + "0\r\n\r\n"), + wantDetail: "AI budget of US$10.00 exceeded.", + wantKind: codersdk.ChatErrorKindUsageLimit, + wantRetryable: false, + }, + { + // Fantasy's Google adapter stores a raw message (not an HTTP + // dump) in ResponseBody. A blank line inside it is not a + // header/body separator: detail must fall back to the full + // trimmed Message, not the second paragraph. + name: "GoogleRawMessageWithBlankLine", + message: "google: model overloaded", + status: 500, + dump: []byte("model overloaded\n\nplease try again later"), + wantDetail: "google: model overloaded", + wantKind: codersdk.ChatErrorKindOverloaded, + wantRetryable: true, + }, + } - // Fantasy's Google adapter stores a raw message (not an HTTP dump) in - // ResponseBody. A blank line inside it is not a header/body separator: - // detail must fall back to the full trimmed Message, not the second - // paragraph. - classified := chaterror.Classify(testProviderError( - "google: model overloaded", - 500, - nil, - []byte("model overloaded\n\nplease try again later"), - )) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - require.Equal(t, "google: model overloaded", classified.Detail) + classified := chaterror.Classify(testProviderError(tt.message, tt.status, nil, tt.dump)) + require.Equal(t, tt.wantDetail, classified.Detail) + require.Equal(t, tt.wantKind, classified.Kind) + require.Equal(t, tt.wantRetryable, classified.Retryable) + }) + } } func TestClassify_UnwrapsTransportWrapperInMessageFallback(t *testing.T) {