339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import { Cause, Context, Effect, Layer } from "effect"
|
|
import {
|
|
FetchHttpClient,
|
|
Headers,
|
|
HttpClient,
|
|
HttpClientError,
|
|
HttpClientRequest,
|
|
HttpClientResponse,
|
|
} from "effect/unstable/http"
|
|
import {
|
|
HttpContext,
|
|
HttpRateLimitDetails,
|
|
HttpRequestDetails,
|
|
HttpResponseDetails,
|
|
LLMError,
|
|
TransportReason,
|
|
} from "../schema"
|
|
import { classifyProviderFailure } from "../provider-error"
|
|
|
|
export interface Interface {
|
|
readonly execute: (
|
|
request: HttpClientRequest.HttpClientRequest,
|
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
|
|
|
|
const BODY_LIMIT = 16_384
|
|
const REDACTED = "<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)
|
|
|
|
export const redactHeaders = (headers: Headers.Input, redactedNames: ReadonlyArray<string | RegExp> = []) =>
|
|
Object.fromEntries(
|
|
Object.entries(Headers.redact(Headers.fromInput(headers), [...redactedNames, SENSITIVE_NAME])).map(
|
|
([name, value]) => [name, String(value)],
|
|
),
|
|
)
|
|
|
|
export 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 normalizedHeaders = (headers: Headers.Headers) =>
|
|
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
|
|
|
const requestId = (headers: Record<string, string>) => {
|
|
return (
|
|
headers["x-request-id"] ??
|
|
headers["request-id"] ??
|
|
headers["x-amzn-requestid"] ??
|
|
headers["x-amz-request-id"] ??
|
|
headers["x-goog-request-id"] ??
|
|
headers["cf-ray"]
|
|
)
|
|
}
|
|
|
|
const retryAfterMs = (headers: Record<string, string>) => {
|
|
const millis = Number(headers["retry-after-ms"])
|
|
if (Number.isFinite(millis)) return Math.max(0, millis)
|
|
|
|
const value = headers["retry-after"]
|
|
if (!value) return undefined
|
|
|
|
const seconds = Number(value)
|
|
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
|
|
|
const date = Date.parse(value)
|
|
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
|
return undefined
|
|
}
|
|
|
|
const addRateLimitValue = (target: Record<string, string>, key: string, value: string) => {
|
|
if (key.length > 0) target[key] = value
|
|
}
|
|
|
|
const rateLimitDetails = (headers: Record<string, string>, retryAfter: number | undefined) => {
|
|
const limit: Record<string, string> = {}
|
|
const remaining: Record<string, string> = {}
|
|
const reset: Record<string, string> = {}
|
|
|
|
Object.entries(headers).forEach(([name, value]) => {
|
|
const openaiLimit = /^x-ratelimit-limit-(.+)$/.exec(name)?.[1]
|
|
if (openaiLimit) return addRateLimitValue(limit, openaiLimit, value)
|
|
|
|
const openaiRemaining = /^x-ratelimit-remaining-(.+)$/.exec(name)?.[1]
|
|
if (openaiRemaining) return addRateLimitValue(remaining, openaiRemaining, value)
|
|
|
|
const openaiReset = /^x-ratelimit-reset-(.+)$/.exec(name)?.[1]
|
|
if (openaiReset) return addRateLimitValue(reset, openaiReset, value)
|
|
|
|
const anthropic = /^anthropic-ratelimit-(.+)-(limit|remaining|reset)$/.exec(name)
|
|
if (!anthropic) return
|
|
if (anthropic[2] === "limit") return addRateLimitValue(limit, anthropic[1], value)
|
|
if (anthropic[2] === "remaining") return addRateLimitValue(remaining, anthropic[1], value)
|
|
return addRateLimitValue(reset, anthropic[1], value)
|
|
})
|
|
|
|
if (
|
|
retryAfter === undefined &&
|
|
Object.keys(limit).length === 0 &&
|
|
Object.keys(remaining).length === 0 &&
|
|
Object.keys(reset).length === 0
|
|
)
|
|
return undefined
|
|
|
|
return new HttpRateLimitDetails({
|
|
retryAfterMs: retryAfter,
|
|
limit: Object.keys(limit).length === 0 ? undefined : limit,
|
|
remaining: Object.keys(remaining).length === 0 ? undefined : remaining,
|
|
reset: Object.keys(reset).length === 0 ? undefined : reset,
|
|
})
|
|
}
|
|
|
|
const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
|
|
new HttpRequestDetails({
|
|
method: request.method,
|
|
url: redactUrl(request.url),
|
|
headers: redactHeaders(request.headers, redactedNames),
|
|
})
|
|
|
|
const responseDetails = (
|
|
response: HttpClientResponse.HttpClientResponse,
|
|
redactedNames: ReadonlyArray<string | RegExp>,
|
|
) =>
|
|
new HttpResponseDetails({
|
|
status: response.status,
|
|
headers: redactHeaders(response.headers, redactedNames),
|
|
})
|
|
|
|
interface RedactionRequest {
|
|
readonly url: string
|
|
readonly headers: Headers.Input
|
|
}
|
|
|
|
const secretValues = (request: RedactionRequest) => {
|
|
const values = new Set<string>()
|
|
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, request: RedactionRequest) =>
|
|
Array.from(secretValues(request)).reduce(
|
|
(text, secret) => text.split(secret).join(REDACTED),
|
|
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
|
|
)
|
|
|
|
export const redactResponseBody = (body: string | void, request: RedactionRequest) => {
|
|
if (body === undefined) return {}
|
|
const redacted = redactBody(body, request)
|
|
if (redacted.length <= BODY_LIMIT) return { body: redacted }
|
|
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
|
}
|
|
|
|
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
|
if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
|
|
return `Provider request failed with HTTP ${status}`
|
|
}
|
|
|
|
const responseHttp = (input: {
|
|
readonly request: HttpClientRequest.HttpClientRequest
|
|
readonly response: HttpClientResponse.HttpClientResponse
|
|
readonly redactedNames: ReadonlyArray<string | RegExp>
|
|
readonly body: ReturnType<typeof redactResponseBody>
|
|
readonly requestId?: string | undefined
|
|
readonly rateLimit?: HttpRateLimitDetails | undefined
|
|
}) =>
|
|
new HttpContext({
|
|
request: requestDetails(input.request, input.redactedNames),
|
|
response: responseDetails(input.response, input.redactedNames),
|
|
...input.body,
|
|
requestId: input.requestId,
|
|
rateLimit: input.rateLimit,
|
|
})
|
|
|
|
const statusError =
|
|
(request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
|
|
(response: HttpClientResponse.HttpClientResponse) =>
|
|
Effect.gen(function* () {
|
|
if (response.status < 400) return response
|
|
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
|
|
const headers = normalizedHeaders(response.headers)
|
|
const retryAfter = retryAfterMs(headers)
|
|
const rateLimit = rateLimitDetails(headers, retryAfter)
|
|
const details = redactResponseBody(body, request)
|
|
return yield* new LLMError({
|
|
module: "RequestExecutor",
|
|
method: "execute",
|
|
reason: classifyProviderFailure({
|
|
status: response.status,
|
|
message: providerMessage(response.status, details),
|
|
retryAfterMs: retryAfter,
|
|
rateLimit,
|
|
http: responseHttp({
|
|
request,
|
|
response,
|
|
redactedNames,
|
|
body: details,
|
|
requestId: requestId(headers),
|
|
rateLimit,
|
|
}),
|
|
}),
|
|
})
|
|
})
|
|
|
|
const TIMEOUT_CODES = new Set([
|
|
"ETIMEDOUT",
|
|
"UND_ERR_BODY_TIMEOUT",
|
|
"UND_ERR_CONNECT_TIMEOUT",
|
|
"UND_ERR_HEADERS_TIMEOUT",
|
|
])
|
|
|
|
const errorCause = (error: unknown) => {
|
|
if (HttpClientError.isHttpClientError(error) && "cause" in error.reason) return error.reason.cause
|
|
return error instanceof Error ? error.cause : undefined
|
|
}
|
|
|
|
const errorCode = (error: unknown) => {
|
|
if (typeof error !== "object" || error === null) return undefined
|
|
const code = Reflect.get(error, "code")
|
|
return typeof code === "string" ? code.toUpperCase() : undefined
|
|
}
|
|
|
|
const errorMessage = (error: unknown, fallback: string) => {
|
|
const cause = errorCause(error)
|
|
if (cause instanceof Error) return cause.message
|
|
if (error instanceof Error) return error.message
|
|
return fallback
|
|
}
|
|
|
|
export const mapHttpClientError = (error: unknown, redactedNames: ReadonlyArray<string | RegExp>) => {
|
|
const transportError = (input: {
|
|
readonly message: string
|
|
readonly kind?: string | undefined
|
|
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
|
}) =>
|
|
new LLMError({
|
|
module: "RequestExecutor",
|
|
method: "execute",
|
|
reason: new TransportReason({
|
|
message: input.message,
|
|
kind: input.kind,
|
|
url: input.request ? redactUrl(input.request.url) : undefined,
|
|
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
|
}),
|
|
})
|
|
|
|
const cause = errorCause(error)
|
|
const code = errorCode(cause) ?? errorCode(error)
|
|
const request = HttpClientError.isHttpClientError(error) ? error.request : undefined
|
|
if (
|
|
Cause.isTimeoutError(error) ||
|
|
Cause.isTimeoutError(cause) ||
|
|
(error instanceof Error && error.name === "TimeoutError") ||
|
|
(cause instanceof Error && cause.name === "TimeoutError") ||
|
|
(code !== undefined && TIMEOUT_CODES.has(code))
|
|
)
|
|
return transportError({ message: errorMessage(error, "HTTP transport timed out"), kind: code ?? "Timeout", request })
|
|
if (!HttpClientError.isHttpClientError(error)) {
|
|
return transportError({ message: errorMessage(error, "HTTP transport failed"), kind: code })
|
|
}
|
|
if (error.reason._tag === "TransportError") {
|
|
return transportError({
|
|
message: error.reason.description ?? errorMessage(error, "HTTP transport failed"),
|
|
kind: code ?? error.reason._tag,
|
|
request,
|
|
})
|
|
}
|
|
return transportError({
|
|
message: errorMessage(error, `HTTP transport failed: ${error.reason._tag}`),
|
|
kind: code ?? error.reason._tag,
|
|
request,
|
|
})
|
|
}
|
|
|
|
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const http = yield* HttpClient.HttpClient
|
|
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
|
|
Effect.gen(function* () {
|
|
const redactedNames = yield* Headers.CurrentRedactedNames
|
|
return yield* http
|
|
.execute(request)
|
|
.pipe(
|
|
Effect.mapError((error) => mapHttpClientError(error, redactedNames)),
|
|
Effect.flatMap(statusError(request, redactedNames)),
|
|
)
|
|
})
|
|
return Service.of({
|
|
execute: executeOnce,
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer))
|
|
|
|
export * as RequestExecutor from "./executor"
|