mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(ai): read provider error messages from common body layouts (#52014)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import {
|
||||
AuthenticationError,
|
||||
ContentPolicyError,
|
||||
@@ -155,6 +155,41 @@ const CONTENT_POLICY_TEXT =
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
const Message = Schema.String.check(Schema.isPattern(/\S/))
|
||||
|
||||
const messageAt = <Fields extends Schema.Struct.Fields>(
|
||||
fields: Fields,
|
||||
message: (body: Schema.Struct<Fields>["Type"]) => string,
|
||||
) =>
|
||||
Schema.Struct(fields).pipe(
|
||||
Schema.decodeTo(Schema.String, {
|
||||
decode: SchemaGetter.transform(message),
|
||||
encode: SchemaGetter.forbidden(() => "Provider error messages are decode-only"),
|
||||
}),
|
||||
)
|
||||
|
||||
// Common error body layouts that carry a human-readable message, in priority order.
|
||||
// Provider-specific layouts belong in their protocol.
|
||||
const decodeMessage = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Union([
|
||||
messageAt({ error: Schema.Struct({ message: Message }) }, (body) => body.error.message),
|
||||
messageAt({ error: Message }, (body) => body.error),
|
||||
messageAt({ message: Message }, (body) => body.message),
|
||||
// AWS services
|
||||
messageAt({ Message: Message }, (body) => body.Message),
|
||||
// RFC 9457 problem details
|
||||
messageAt({ detail: Message }, (body) => body.detail),
|
||||
messageAt(
|
||||
{ errors: Schema.NonEmptyArray(Schema.Struct({ message: Message })) },
|
||||
(body) => body.errors[0].message,
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
export const providerErrorMessage = (body: string) => Option.getOrUndefined(decodeMessage(body))
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { classifyProviderFailure, providerErrorMessage } from "../provider-error.js"
|
||||
import { Service, type HttpMiddleware, type Interface } from "./executor-service.js"
|
||||
|
||||
export { Service } from "./executor-service.js"
|
||||
@@ -84,23 +84,13 @@ export const responseHttp = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
headers: headerDetails(response.headers),
|
||||
})
|
||||
|
||||
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const MAX_BODY_CHARS = 2000
|
||||
|
||||
// Without a recognized message, show the raw body so the provider's explanation is never dropped.
|
||||
const providerMessage = (status: number, body: string | void) => {
|
||||
const fallback = `Provider request failed with HTTP ${status}`
|
||||
const text = body?.trim() ?? ""
|
||||
const decoded = Option.getOrUndefined(decodeProviderBody(text))
|
||||
const message = [decoded?.error?.message, decoded?.message].find((message) => message?.trim())
|
||||
const message = providerErrorMessage(text)
|
||||
if (message) return message
|
||||
// Gateway and proxy HTML error pages are markup, not an explanation.
|
||||
if (!text || /^<(?:!doctype|html)/i.test(text)) return fallback
|
||||
|
||||
@@ -282,7 +282,24 @@ describe("RequestExecutor", () => {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toBe('Provider request failed with HTTP 401: {"detail":"Invalid API Key"}')
|
||||
expect(error.message).toBe(
|
||||
'Provider request failed with HTTP 422: {"object":"error","message":{"detail":[{"msg":"Input should be less than or equal to 1.5"}]}}',
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse('{"object":"error","message":{"detail":[{"msg":"Input should be less than or equal to 1.5"}]}}', {
|
||||
status: 422,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("shows messages from common provider error layouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Invalid API Key")
|
||||
}).pipe(Effect.provide(fixedResponse('{"detail":"Invalid API Key"}', { status: 401 }))),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isContextOverflow } from "../src/index.js"
|
||||
import { classifyProviderFailure } from "../src/provider-error.js"
|
||||
import { classifyProviderFailure, providerErrorMessage } from "../src/provider-error.js"
|
||||
|
||||
describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
@@ -396,3 +396,42 @@ describe("provider error rawBody classification", () => {
|
||||
).toBe("QuotaExceeded")
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider error messages", () => {
|
||||
test("reads messages from common error body layouts", () => {
|
||||
expect(
|
||||
[
|
||||
'{"error":{"message":"Invalid API Key","type":"invalid_request_error"}}',
|
||||
'{"code":"invalid-argument","error":"Incorrect API key provided."}',
|
||||
'{"message":"1 validation error detected"}',
|
||||
'{"Message":"Invalid API Key format: Must start with pre-defined prefix"}',
|
||||
'{"type":"about:blank","title":"Gone","status":410,"detail":"The model has reached its end of life"}',
|
||||
'{"result":null,"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}',
|
||||
].map(providerErrorMessage),
|
||||
).toEqual([
|
||||
"Invalid API Key",
|
||||
"Incorrect API key provided.",
|
||||
"1 validation error detected",
|
||||
"Invalid API Key format: Must start with pre-defined prefix",
|
||||
"The model has reached its end of life",
|
||||
"Authentication error",
|
||||
])
|
||||
})
|
||||
|
||||
test("prefers the nested error message over a top-level message", () => {
|
||||
expect(providerErrorMessage('{"message":"Bad Request","error":{"message":"model not found"}}')).toBe(
|
||||
"model not found",
|
||||
)
|
||||
})
|
||||
|
||||
test("ignores blank, non-string, and non-JSON messages", () => {
|
||||
expect(
|
||||
[
|
||||
'{"error":{"message":" "}}',
|
||||
'{"message":{"detail":[{"msg":"too high"}]}}',
|
||||
'{"errors":[]}',
|
||||
"invalid parameter",
|
||||
].map(providerErrorMessage),
|
||||
).toEqual([undefined, undefined, undefined, undefined])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user