| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- 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)
- const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
- 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 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),
- })
- const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
- 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: HttpClientRequest.HttpClientRequest) =>
- 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}`),
- )
- const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
- 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 responseBody>
- 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 = responseBody(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 toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
- 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,
- }),
- })
- if (Cause.isTimeoutError(error)) {
- return transportError({ message: error.message, kind: "Timeout" })
- }
- if (!HttpClientError.isHttpClientError(error)) {
- return transportError({ message: "HTTP transport failed" })
- }
- const request = "request" in error ? error.request : undefined
- if (error.reason._tag === "TransportError") {
- return transportError({
- message: error.reason.description ?? "HTTP transport failed",
- kind: error.reason._tag,
- request,
- })
- }
- return transportError({
- message: `HTTP transport failed: ${error.reason._tag}`,
- kind: 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(toHttpError(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"
|