mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4a1bbe50a | |||
| c916d8a0e8 | |||
| 5411a685d5 | |||
| ddf2619760 | |||
| 825f13417b | |||
| 96a5677903 | |||
| da7c2ecd47 | |||
| e9beaeb02c |
@@ -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,42 @@ 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 code?: string | 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,
|
||||
code: input.code,
|
||||
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
|
||||
|
||||
@@ -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,62 @@ function llmError(method: string, error: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function apiCallErrorReason(error: APICallError) {
|
||||
const details = providerErrorDetails(error)
|
||||
const reason = RequestExecutor.classifyHttpFailure({
|
||||
message: apiCallErrorMessage(error, details),
|
||||
url: error.url,
|
||||
status: error.statusCode,
|
||||
code: details.code,
|
||||
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: "http" in reason ? reason.http : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const ProviderErrorCode = Schema.Union([Schema.String, Schema.Finite])
|
||||
const ProviderErrorBody = Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(ProviderErrorCode),
|
||||
error: Schema.optionalKey(
|
||||
Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(ProviderErrorCode),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const decodeErrorData = Schema.decodeUnknownOption(ProviderErrorBody)
|
||||
const decodeErrorBody = Schema.decodeUnknownOption(Schema.fromJsonString(ProviderErrorBody))
|
||||
|
||||
function unknownErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.trim() === "" ? "Provider request failed" : message
|
||||
}
|
||||
|
||||
function providerErrorDetails(error: APICallError) {
|
||||
const data = Option.getOrUndefined(decodeErrorData(error.data))
|
||||
const body = Option.getOrUndefined(decodeErrorBody(error.responseBody))
|
||||
const message = [data?.error?.message, data?.message, body?.error?.message, body?.message].find(
|
||||
(value) => value !== undefined && value.trim() !== "",
|
||||
)
|
||||
const code = [data?.error?.code, data?.code, body?.error?.code, body?.code].find((value) => value !== undefined)
|
||||
return { message, code: code === undefined ? undefined : String(code) }
|
||||
}
|
||||
|
||||
// AI SDK errors can carry an empty message while still holding structured
|
||||
// provider details. Only recognized message and code fields are displayed.
|
||||
function apiCallErrorMessage(error: APICallError, details: ReturnType<typeof providerErrorDetails>) {
|
||||
if (error.message.trim() !== "") return error.message
|
||||
if (details.message !== undefined) return details.message
|
||||
const prefix =
|
||||
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
|
||||
return details.code === undefined ? prefix : `${prefix}: ${details.code}`
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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"
|
||||
import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { expect } from "bun:test"
|
||||
@@ -337,3 +340,170 @@ 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("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves redacted HTTP context on 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 = "http" in error.reason ? error.reason.http : undefined
|
||||
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("classifies data-only AI SDK provider codes", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "api_error" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 400 })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies data-only AI SDK authentication errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "authentication_error" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("detects context overflow from data-only AI SDK errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "context_length_exceeded" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
expect(isContextOverflowFailure(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
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("http" in error.reason ? error.reason.http?.request.url : undefined).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: { code: "not_found" } },
|
||||
responseBody: '{"message":"The requested model does not exist"}',
|
||||
}),
|
||||
)
|
||||
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")
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user