mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddf2619760 | |||
| 825f13417b | |||
| 96a5677903 | |||
| da7c2ecd47 | |||
| e9beaeb02c | |||
| 84fd347afa | |||
| e8f215bfbc | |||
| 445af9ce70 |
@@ -86,7 +86,7 @@ export interface ProviderFailure {
|
||||
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
export function classifyProviderFailure(input: ProviderFailure) {
|
||||
const body = input.http?.body ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
|
||||
@@ -185,15 +185,15 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
|
||||
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
|
||||
// for any field name that looks sensitive) plus literal (replace any actual
|
||||
// secret values we sent in the request, in case the response echoes one back).
|
||||
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
Array.from(secretValues(request)).reduce(
|
||||
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
|
||||
Array.from(secrets).reduce(
|
||||
(text, secret) => text.split(secret).join(REDACTED),
|
||||
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
|
||||
)
|
||||
|
||||
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
|
||||
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
|
||||
if (body === undefined) return {}
|
||||
const redacted = redactBody(body, request)
|
||||
const redacted = redactBody(body, secrets)
|
||||
if (redacted.length <= BODY_LIMIT) return { body: redacted }
|
||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
||||
}
|
||||
@@ -240,7 +240,7 @@ const statusError =
|
||||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body, request)
|
||||
const details = responseBody(body, secretValues(request))
|
||||
return yield* new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
@@ -261,6 +261,40 @@ const statusError =
|
||||
})
|
||||
})
|
||||
|
||||
// Classifies an HTTP failure captured outside the executor (for example by the
|
||||
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
|
||||
// executor-driven requests produce. The originating request is not available on
|
||||
// that path, so the method is assumed (language model calls are always POST),
|
||||
// request headers are empty, and only structural body redaction applies.
|
||||
export const classifyHttpFailure = (input: {
|
||||
readonly message: string
|
||||
readonly url: string
|
||||
readonly status?: number | undefined
|
||||
readonly responseHeaders?: Record<string, string> | undefined
|
||||
readonly responseBody?: string | undefined
|
||||
}) => {
|
||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
|
||||
response:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const transportError = (input: {
|
||||
readonly message: string
|
||||
|
||||
@@ -474,7 +474,34 @@ export type Endpoint5_31Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly http?:
|
||||
| {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?:
|
||||
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
| undefined
|
||||
readonly body?: string | undefined
|
||||
readonly bodyTruncated?: boolean | undefined
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?:
|
||||
| {
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly limit?: { readonly [x: string]: string } | undefined
|
||||
readonly remaining?: { readonly [x: string]: string } | undefined
|
||||
readonly reset?: { readonly [x: string]: string } | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -601,7 +628,34 @@ export type Endpoint5_31Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly http?:
|
||||
| {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?:
|
||||
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
| undefined
|
||||
readonly body?: string | undefined
|
||||
readonly bodyTruncated?: boolean | undefined
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?:
|
||||
| {
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly limit?: { readonly [x: string]: string } | undefined
|
||||
readonly remaining?: { readonly [x: string]: string } | undefined
|
||||
readonly reset?: { readonly [x: string]: string } | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
@@ -763,7 +817,34 @@ export type Endpoint5_31Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly http?:
|
||||
| {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?:
|
||||
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
| undefined
|
||||
readonly body?: string | undefined
|
||||
readonly bodyTruncated?: boolean | undefined
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?:
|
||||
| {
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly limit?: { readonly [x: string]: string } | undefined
|
||||
readonly remaining?: { readonly [x: string]: string } | undefined
|
||||
readonly reset?: { readonly [x: string]: string } | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
readonly content?:
|
||||
| readonly [
|
||||
(
|
||||
@@ -803,7 +884,34 @@ export type Endpoint5_31Output =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly http?:
|
||||
| {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?:
|
||||
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
| undefined
|
||||
readonly body?: string | undefined
|
||||
readonly bodyTruncated?: boolean | undefined
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?:
|
||||
| {
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly limit?: { readonly [x: string]: string } | undefined
|
||||
readonly remaining?: { readonly [x: string]: string } | undefined
|
||||
readonly reset?: { readonly [x: string]: string } | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -853,7 +961,34 @@ export type Endpoint5_31Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly http?:
|
||||
| {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?:
|
||||
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
| undefined
|
||||
readonly body?: string | undefined
|
||||
readonly bodyTruncated?: boolean | undefined
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?:
|
||||
| {
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly limit?: { readonly [x: string]: string } | undefined
|
||||
readonly remaining?: { readonly [x: string]: string } | undefined
|
||||
readonly reset?: { readonly [x: string]: string } | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,24 @@ export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||
export type SessionStructuredError = {
|
||||
type: string
|
||||
message: string
|
||||
status?: number
|
||||
http?: {
|
||||
request: { method: string; url: string; headers: { [x: string]: string } }
|
||||
response?: { status: number; headers: { [x: string]: string } }
|
||||
body?: string
|
||||
bodyTruncated?: boolean
|
||||
requestId?: string
|
||||
rateLimit?: {
|
||||
retryAfterMs?: number
|
||||
limit?: { [x: string]: string }
|
||||
remaining?: { [x: string]: string }
|
||||
reset?: { [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionRunning = {
|
||||
type: "compaction"
|
||||
@@ -2689,7 +2706,31 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: {
|
||||
readonly status: number
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -2724,11 +2765,53 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -2759,7 +2842,28 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
>
|
||||
@@ -2940,7 +3044,31 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: {
|
||||
readonly status: number
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -2975,11 +3103,53 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -3010,7 +3180,28 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
>
|
||||
@@ -3191,7 +3382,31 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: {
|
||||
readonly status: number
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -3226,11 +3441,53 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -3261,7 +3518,28 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly http?: {
|
||||
readonly request: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
}
|
||||
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
|
||||
readonly body?: string
|
||||
readonly bodyTruncated?: boolean
|
||||
readonly requestId?: string
|
||||
readonly rateLimit?: {
|
||||
readonly retryAfterMs?: number
|
||||
readonly limit?: { readonly [x: string]: string }
|
||||
readonly remaining?: { readonly [x: string]: string }
|
||||
readonly reset?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
JSONValue,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
LanguageModel,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
TransportReason,
|
||||
ToolResultValue,
|
||||
UnknownProviderReason,
|
||||
type ContentPart,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import type { ID, Info } from "./model"
|
||||
@@ -723,7 +725,9 @@ function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof AIError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
||||
: APICallError.isInstance(error)
|
||||
? apiCallErrorReason(error)
|
||||
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
|
||||
return new AIError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
@@ -731,4 +735,50 @@ function llmError(method: string, error: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function apiCallErrorReason(error: APICallError) {
|
||||
const reason = RequestExecutor.classifyHttpFailure({
|
||||
message: unknownErrorMessage(error),
|
||||
url: error.url,
|
||||
status: error.statusCode,
|
||||
responseHeaders: error.responseHeaders,
|
||||
responseBody: error.responseBody,
|
||||
})
|
||||
if (error.statusCode !== undefined || !error.isRetryable) return reason
|
||||
return new TransportReason({
|
||||
message: reason.message,
|
||||
kind: error.name,
|
||||
url: error.url,
|
||||
http: reason.http,
|
||||
})
|
||||
}
|
||||
|
||||
const ProviderErrorBody = Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(
|
||||
Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const decodeErrorData = Schema.decodeUnknownOption(ProviderErrorBody)
|
||||
const decodeErrorBody = Schema.decodeUnknownOption(Schema.fromJsonString(ProviderErrorBody))
|
||||
|
||||
// AI SDK errors such as AI_APICallError can carry an empty message while still
|
||||
// holding structured provider details. Derive a safe non-empty fallback from
|
||||
// recognized error fields only; never surface the raw response payload.
|
||||
function unknownErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.trim() !== "") return message
|
||||
if (!APICallError.isInstance(error)) return "Provider request failed"
|
||||
const body =
|
||||
Option.getOrUndefined(decodeErrorData(error.data)) ?? Option.getOrUndefined(decodeErrorBody(error.responseBody))
|
||||
const detail = body?.error ?? body
|
||||
if (detail?.message) return detail.message
|
||||
const prefix =
|
||||
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
|
||||
return detail?.code ? `${prefix}: ${detail.code}` : prefix
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -60,7 +60,12 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
}
|
||||
|
||||
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
|
||||
const status =
|
||||
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
const http = "http" in reason ? reason.http : undefined
|
||||
const status = http?.response?.status ?? ("status" in reason ? reason.status : undefined)
|
||||
return {
|
||||
type,
|
||||
message: reason.message,
|
||||
...(status === undefined ? {} : { status }),
|
||||
...(http === undefined ? {} : { http }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { LLM, AIError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
@@ -337,3 +340,131 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
||||
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
|
||||
}),
|
||||
)
|
||||
|
||||
const failingModel = (failure: unknown): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
provider: "test",
|
||||
modelId: "test",
|
||||
supportedUrls: {},
|
||||
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
||||
doStream: () => Promise.reject(failure),
|
||||
})
|
||||
|
||||
const streamFailure = (failure: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => failingModel(failure) }
|
||||
})
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
||||
Effect.provide(client),
|
||||
Effect.flip,
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("preserves non-empty AI SDK error messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(new Error("Bad Request"))
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
|
||||
}),
|
||||
)
|
||||
|
||||
const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
|
||||
new APICallError({
|
||||
message: "",
|
||||
url: "https://api.example.com/chat",
|
||||
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
|
||||
responseHeaders: { authorization: "Bearer secret-token" },
|
||||
...input,
|
||||
})
|
||||
|
||||
it.effect("derives status and code when the AI SDK error message is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
||||
data: { error: { message: "", code: "not_found" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
|
||||
expect(error.reason.message).not.toContain("secret-token")
|
||||
expect(error.reason.message).not.toContain("private prompt")
|
||||
const projected = toSessionError(error)
|
||||
expect(projected.type).toBe("provider.invalid-request")
|
||||
expect(projected.status).toBe(404)
|
||||
expect(projected.message).not.toBe("")
|
||||
expect(projected.http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists redacted HTTP context from AI SDK call errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
const http = toSessionError(error).http
|
||||
expect(http?.request.url).toBe("https://api.example.com/chat")
|
||||
expect(http?.response?.status).toBe(404)
|
||||
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
|
||||
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies retryable AI SDK failures with retry-after details", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 429,
|
||||
responseHeaders: { "retry-after": "7" },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries status-less AI SDK transport failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
message: "Cannot connect to API: connection refused",
|
||||
isRetryable: true,
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
expect(toSessionError(error).http?.request.url).toBe("https://api.example.com/chat")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers a structured provider message over the code fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
data: { error: { message: "The requested model does not exist", code: "not_found" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason.message).toBe("The requested model does not exist")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the status alone for malformed response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 502,
|
||||
isRetryable: false,
|
||||
responseBody: "<html>Bad Gateway</html>",
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 502")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider HTTP status", () => {
|
||||
test("preserves provider HTTP context", () => {
|
||||
const http = new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
|
||||
response: new HttpResponseDetails({ status: 413, headers: {} }),
|
||||
@@ -83,6 +83,7 @@ describe("toSessionError", () => {
|
||||
type: "provider.invalid-request",
|
||||
message: "too large",
|
||||
status: 413,
|
||||
http,
|
||||
})
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
|
||||
type: "provider.internal",
|
||||
|
||||
@@ -1316,7 +1316,15 @@ export function write(
|
||||
}).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))),
|
||||
{ concurrency: 8, discard: true },
|
||||
)
|
||||
yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n")
|
||||
// Format the manifest with the same prettier settings as the repo-wide
|
||||
// format pass, so `check:generated` stays clean after the generate bot
|
||||
// reformats the tree.
|
||||
const manifestJson = JSON.stringify(output.files.map((file) => file.path).sort())
|
||||
const manifestContent = yield* Effect.tryPromise({
|
||||
try: () => format(manifestJson, { filepath: manifest, parser: "json", printWidth: 120 }),
|
||||
catch: (error) => new GenerationError({ reason: `Failed to format ${manifest}: ${String(error)}` }),
|
||||
})
|
||||
yield* fs.writeFileString(manifest, manifestContent)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("HttpApiCodegen.write", () => {
|
||||
|
||||
expect(writes).toEqual([
|
||||
{ path: "/generated/session.ts", content: "export const session = {}\n" },
|
||||
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
|
||||
{ path: "/generated/.httpapi-codegen.json", content: '["session.ts"]\n' },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
|
||||
@@ -3,9 +3,33 @@ export * as SessionError from "./session-error.js"
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
const HttpRateLimitDetails = Schema.Struct({
|
||||
retryAfterMs: Schema.Finite.pipe(optional),
|
||||
limit: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
remaining: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
reset: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
})
|
||||
|
||||
const HttpContext = Schema.Struct({
|
||||
request: Schema.Struct({
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
}),
|
||||
response: Schema.Struct({
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })),
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
}).pipe(optional),
|
||||
body: Schema.String.pipe(optional),
|
||||
bodyTruncated: Schema.Boolean.pipe(optional),
|
||||
requestId: Schema.String.pipe(optional),
|
||||
rateLimit: HttpRateLimitDetails.pipe(optional),
|
||||
})
|
||||
|
||||
export interface Error extends Schema.Schema.Type<typeof Error> {}
|
||||
export const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
|
||||
http: HttpContext.pipe(optional),
|
||||
}).annotate({ identifier: "Session.StructuredError" })
|
||||
|
||||
@@ -12,6 +12,18 @@ describe("SessionError", () => {
|
||||
const values: SessionError.Error[] = [
|
||||
{ type: "provider.rate-limit", message: "Slow down" },
|
||||
{ type: "provider.auth", message: "Authentication failed" },
|
||||
{
|
||||
type: "provider.internal",
|
||||
message: "Unavailable",
|
||||
status: 503,
|
||||
http: {
|
||||
request: { method: "POST", url: "https://api.example.com/chat", headers: {} },
|
||||
response: { status: 503, headers: { "retry-after": "5" } },
|
||||
body: '{"error":{"code":"unavailable"}}',
|
||||
requestId: "request-1",
|
||||
rateLimit: { retryAfterMs: 5_000 },
|
||||
},
|
||||
},
|
||||
{ type: "provider.future-condition", message: "A future provider failure" },
|
||||
{ type: "unknown", message: "Unexpected" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user