diff --git a/packages/ai/src/route/executor.ts b/packages/ai/src/route/executor.ts index b962895486..635e55f712 100644 --- a/packages/ai/src/route/executor.ts +++ b/packages/ai/src/route/executor.ts @@ -34,44 +34,8 @@ export type HttpMiddleware = ( export class Service extends Context.Service()("@opencode/AI/RequestExecutor") {} -const BODY_LIMIT = 16_384 -const REDACTED = "" - -// One source of truth for what counts as a sensitive name across headers, -// URL query keys, and field names embedded inside request/response bodies. -// -// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header -// names like `Authorization` / `X-API-Key`) and as the body-field alternation -// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…` -// that are too generic to redact substring-style without false positives. -const SENSITIVE_NAME_SOURCE = - "authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature" -const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i") -const SHORT_QUERY_NAME = /^(key|sig)$/i -const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i") -const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi") -const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi") - -const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name) - -const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name) - -const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray) => - Object.fromEntries( - Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [ - name, - String(value), - ]), - ) - -const redactUrl = (value: string) => { - if (!URL.canParse(value)) return REDACTED - const url = new URL(value) - url.searchParams.forEach((_, key) => { - if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED) - }) - return url.toString() -} +const headerDetails = (headers: Headers.Headers) => + Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)])) const normalizedHeaders = (headers: Headers.Headers) => Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) @@ -144,58 +108,22 @@ const rateLimitDetails = (headers: Record, retryAfter: number | }) } -const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => +const requestDetails = (request: HttpClientRequest.HttpClientRequest) => new HttpRequestDetails({ method: request.method, - url: redactUrl(request.url), - headers: redactHeaders(request.headers, redactedNames), + url: request.url, + headers: headerDetails(request.headers), }) -const responseDetails = ( - response: HttpClientResponse.HttpClientResponse, - redactedNames: ReadonlyArray, -) => +const responseDetails = (response: HttpClientResponse.HttpClientResponse) => new HttpResponseDetails({ status: response.status, - headers: redactHeaders(response.headers, redactedNames), + headers: headerDetails(response.headers), }) -const secretValues = (request: HttpClientRequest.HttpClientRequest) => { - const values = new Set() - const add = (value: string) => { - if (value.length < 4) return - values.add(value) - values.add(encodeURIComponent(value)) - } - - Object.entries(request.headers).forEach(([name, value]) => { - if (!isSensitiveHeaderName(name)) return - add(value) - const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1] - if (bearer) add(bearer) - }) - - if (!URL.canParse(request.url)) return values - new URL(request.url).searchParams.forEach((value, key) => { - if (isSensitiveQueryName(key)) add(value) - }) - return values -} - -// 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, secrets: ReadonlySet) => - 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, secrets: ReadonlySet) => { +const responseBody = (body: string | void) => { if (body === undefined) return {} - const redacted = redactBody(body, secrets) - if (redacted.length <= BODY_LIMIT) return { body: redacted } - return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true } + return { body } } const decodeProviderBody = Schema.decodeUnknownOption( @@ -207,32 +135,31 @@ const decodeProviderBody = Schema.decodeUnknownOption( ), ) -const providerMessage = (status: number, body: { readonly body?: string }) => { - if (body.body && body.body.length <= 500) { - const decoded = Option.getOrUndefined(decodeProviderBody(body.body)) - return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}` - } - return `Provider request failed with HTTP ${status}` +const providerMessage = (status: number, body: string | void) => { + const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body)) + return ( + [decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ?? + `Provider request failed with HTTP ${status}` + ) } const responseHttp = (input: { readonly request: HttpClientRequest.HttpClientRequest readonly response: HttpClientResponse.HttpClientResponse - readonly redactedNames: ReadonlyArray readonly body: ReturnType readonly requestId?: string | undefined readonly rateLimit?: HttpRateLimitDetails | undefined }) => new HttpContext({ - request: requestDetails(input.request, input.redactedNames), - response: responseDetails(input.response, input.redactedNames), + request: requestDetails(input.request), + response: responseDetails(input.response), ...input.body, requestId: input.requestId, rateLimit: input.rateLimit, }) const statusError = - (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => + (request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) => Effect.gen(function* () { if (response.status < 400) return response @@ -240,19 +167,18 @@ const statusError = const headers = normalizedHeaders(response.headers) const retryAfter = retryAfterMs(headers) const rateLimit = rateLimitDetails(headers, retryAfter) - const details = responseBody(body, secretValues(request)) + const details = responseBody(body) return yield* new AIError({ module: "RequestExecutor", method: "execute", reason: classifyProviderFailure({ status: response.status, - message: providerMessage(response.status, details), + message: providerMessage(response.status, body), retryAfterMs: retryAfter, rateLimit, http: responseHttp({ request, response, - redactedNames, body: details, requestId: requestId(headers), rateLimit, @@ -262,10 +188,10 @@ 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 +// AI SDK's own fetch) onto the same reason types and 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. +// request headers are empty. export const classifyHttpFailure = (input: { readonly message: string readonly url: string @@ -277,7 +203,7 @@ export const classifyHttpFailure = (input: { const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders)) const retryAfter = retryAfterMs(headers) const rateLimit = rateLimitDetails(headers, retryAfter) - const details = responseBody(input.responseBody ?? undefined, new Set()) + const details = responseBody(input.responseBody) return classifyProviderFailure({ message: input.message, status: input.status, @@ -285,11 +211,11 @@ export const classifyHttpFailure = (input: { retryAfterMs: retryAfter, rateLimit, http: new HttpContext({ - request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }), + request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }), response: input.status === undefined ? undefined - : new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }), + : new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }), ...details, requestId: requestId(headers), rateLimit, @@ -319,7 +245,6 @@ const httpError = (input: { readonly error: unknown readonly request: HttpClientRequest.HttpClientRequest readonly operation: HttpOperation - readonly redactedNames: ReadonlyArray }) => { const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) => @@ -331,8 +256,8 @@ const httpError = (input: { transport: "http", operation: input.operation, code: failure.code, - url: redactUrl(request.url), - http: new HttpContext({ request: requestDetails(request, input.redactedNames) }), + url: request.url, + http: new HttpContext({ request: requestDetails(request) }), }), }) @@ -343,7 +268,7 @@ const httpError = (input: { const native = nativeTransportFailure(source) const code = native?.code const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined) - const detail = raw ? redactBody(raw, secretValues(request)) : undefined + const detail = raw const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source)) @@ -369,10 +294,9 @@ export const stream = ( ): Stream.Stream => Stream.unwrap( Effect.gen(function* () { - const redactedNames = yield* Headers.CurrentRedactedNames const response = yield* executor.execute(request, middleware) return response.stream.pipe( - Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })), + Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })), ) }), ) @@ -383,19 +307,18 @@ export const layer: Layer.Layer = Layer.e const http = yield* HttpClient.HttpClient const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) => Effect.gen(function* () { - const redactedNames = yield* Headers.CurrentRedactedNames if (!middleware) return yield* http.execute(request).pipe( - Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })), - Effect.flatMap(statusError(request, redactedNames)), + Effect.mapError((error) => httpError({ error, request, operation: "request" })), + Effect.flatMap(statusError(request)), ) const response = yield* middleware(request, (input) => http .execute(input) .pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), - ).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames }))) - return yield* statusError(response.request, redactedNames)(response) + ).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" }))) + return yield* statusError(response.request)(response) }) return Service.of({ execute: executeOnce, diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 63c29aafea..d2cf004466 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -66,6 +66,7 @@ const expectAIError = (error: unknown) => { } const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined) +const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}` describe("RequestExecutor", () => { it.effect("parses response body failures at the executor seam", () => @@ -76,11 +77,11 @@ describe("RequestExecutor", () => { expectAIError(error) expect(error.reason).toMatchObject({ _tag: "Transport", - message: "ECONNRESET: disconnected ", + message: "ECONNRESET: disconnected query-secret-123 header-secret-456", transport: "http", operation: "read", code: "ECONNRESET", - url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1", + url: "https://provider.test/v1/chat?api_key=query-secret-123&debug=1", }) }).pipe( Effect.provide( @@ -153,12 +154,12 @@ describe("RequestExecutor", () => { expectAIError(error) expect(error.reason).toMatchObject({ _tag: "Transport", - message: "ECONNRESET: proxy disconnected ", - url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E", + message: "ECONNRESET: proxy disconnected proxy-secret", + url: "https://proxy.test/v1/chat?api_key=proxy-secret", http: { request: { - url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E", - headers: { authorization: "" }, + url: "https://proxy.test/v1/chat?api_key=proxy-secret", + headers: { authorization: "Bearer proxy-secret" }, }, }, }) @@ -218,9 +219,49 @@ describe("RequestExecutor", () => { expectAIError(error) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() + expect(error.reason.message).toBe("Provider request failed with HTTP 400") }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), ) + it.effect("preserves structured provider messages from large error bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectAIError(error) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage }) + expect(errorHttp(error)?.body).toContain(largeProviderMessage) + expect(errorHttp(error)?.bodyTruncated).toBeUndefined() + }).pipe( + Effect.provide( + responsesLayer([ + new Response( + JSON.stringify({ + model: "gpt-5.6-sol", + error: { type: "invalid_request", message: largeProviderMessage }, + }), + { status: 400 }, + ), + ]), + ), + ), + ) + + it.effect("falls back when structured provider messages are empty", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectAIError(error) + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", + message: "Provider request failed with HTTP 400", + }) + }).pipe( + Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })])), + ), + ) + it.effect("classifies provider rate limits hidden behind HTTP 400", () => Effect.gen(function* () { const classify = (body: string) => @@ -254,7 +295,7 @@ describe("RequestExecutor", () => { }), ) - it.effect("returns redacted diagnostics for rate limits", () => + it.effect("returns complete diagnostics for rate limits", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service const error = yield* executor.execute(request).pipe(Effect.flip) @@ -269,15 +310,15 @@ describe("RequestExecutor", () => { requestId: "req_123", request: { method: "POST", - url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", - headers: { authorization: "", "x-safe": "visible" }, + url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1", + headers: { authorization: "Bearer secret", "x-safe": "visible" }, }, response: { status: 429, headers: { "retry-after-ms": "0", "x-request-id": "req_123", - "x-api-key": "", + "x-api-key": "secret", }, }, }, @@ -296,14 +337,14 @@ describe("RequestExecutor", () => { ), ) - it.effect("honors current redacted header names in diagnostics", () => + it.effect("preserves configured header names in diagnostics", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service const error = yield* executor.execute(request).pipe(Effect.flip) expectAIError(error) - expect(errorHttp(error)?.request.headers["x-safe"]).toBe("") - expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("") + expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible") + expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret") }).pipe( Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])), Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]), @@ -422,15 +463,15 @@ describe("RequestExecutor", () => { }), ) - it.effect("truncates large authentication error bodies", () => + it.effect("preserves large authentication error bodies", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service const error = yield* executor.execute(request).pipe(Effect.flip) expectAIError(error) expect(error.reason).toMatchObject({ _tag: "Authentication" }) - expect(errorHttp(error)?.bodyTruncated).toBe(true) - expect(errorHttp(error)?.body).toHaveLength(16_384) + expect(errorHttp(error)?.bodyTruncated).toBeUndefined() + expect(errorHttp(error)?.body).toHaveLength(20_000) }).pipe( Effect.provide( responsesLayer([ @@ -441,16 +482,15 @@ describe("RequestExecutor", () => { ), ) - it.effect("redacts common secret fields in response bodies", () => + it.effect("preserves response body fields", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service const error = yield* executor.execute(request).pipe(Effect.flip) expectAIError(error) - expect(errorHttp(error)?.body).toContain('"key":""') - expect(errorHttp(error)?.body).toContain("api_key=") - expect(errorHttp(error)?.body).not.toContain("body-secret") - expect(errorHttp(error)?.body).not.toContain("query-secret") + expect(errorHttp(error)?.body).toBe( + '{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', + ) }).pipe( Effect.provide( responsesLayer([ @@ -462,16 +502,15 @@ describe("RequestExecutor", () => { ), ) - it.effect("redacts echoed request secret values in response bodies", () => + it.effect("preserves echoed request values in response bodies", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service const error = yield* executor.execute(secretRequest).pipe(Effect.flip) expectAIError(error) - expect(errorHttp(error)?.body).toContain("provider echoed ") - expect(errorHttp(error)?.body).toContain("authorization ") - expect(errorHttp(error)?.body).not.toContain("query-secret-123") - expect(errorHttp(error)?.body).not.toContain("header-secret-456") + expect(errorHttp(error)?.body).toBe( + "provider echoed query-secret-123 and authorization header-secret-456", + ) }).pipe( Effect.provide( responsesLayer([ diff --git a/packages/ai/test/provider/anthropic-messages.recorded.test.ts b/packages/ai/test/provider/anthropic-messages.recorded.test.ts index 070e30f1f4..34d36ea108 100644 --- a/packages/ai/test/provider/anthropic-messages.recorded.test.ts +++ b/packages/ai/test/provider/anthropic-messages.recorded.test.ts @@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => { expect(error).toBeInstanceOf(AIError) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) - expect(error.message).toContain("HTTP 400") + expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks") }), ) }) diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index d8792c3d9d..838457f38d 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -1098,8 +1098,7 @@ describe("Anthropic Messages route", () => { ) expect(error).toBeInstanceOf(AIError) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) - expect(error.message).toContain("HTTP 400") + expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" }) }), ) diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index ea327aa123..ef1038a9a8 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -1275,8 +1275,7 @@ describe("OpenAI Chat route", () => { ) expect(error).toBeInstanceOf(AIError) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) - expect(error.message).toContain("HTTP 400") + expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" }) }), ) diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index bb0036db5b..7ea3a810f3 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -2610,8 +2610,7 @@ describe("OpenAI Responses route", () => { ) expect(error).toBeInstanceOf(AIError) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) - expect(error.message).toContain("HTTP 400") + expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" }) }), ) }) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index a762075944..bbb2f80ecc 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -468,7 +468,7 @@ it.effect("derives status and code when the AI SDK error message is empty", () = }), ) -it.effect("preserves redacted HTTP context on AI SDK call errors", () => +it.effect("preserves complete HTTP context on AI SDK call errors", () => Effect.gen(function* () { const error = yield* streamFailure( apiCallError({ @@ -480,7 +480,7 @@ it.effect("preserves redacted HTTP context on AI SDK call errors", () => 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("") + expect(http?.response?.headers["authorization"]).toBe("Bearer secret-token") expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}') }), )