executor.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { Cause, Context, Effect, Layer } from "effect"
  2. import {
  3. FetchHttpClient,
  4. Headers,
  5. HttpClient,
  6. HttpClientError,
  7. HttpClientRequest,
  8. HttpClientResponse,
  9. } from "effect/unstable/http"
  10. import {
  11. HttpContext,
  12. HttpRateLimitDetails,
  13. HttpRequestDetails,
  14. HttpResponseDetails,
  15. LLMError,
  16. TransportReason,
  17. } from "../schema"
  18. import { classifyProviderFailure } from "../provider-error"
  19. export interface Interface {
  20. readonly execute: (
  21. request: HttpClientRequest.HttpClientRequest,
  22. ) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
  23. }
  24. export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
  25. const BODY_LIMIT = 16_384
  26. const REDACTED = "<redacted>"
  27. // One source of truth for what counts as a sensitive name across headers,
  28. // URL query keys, and field names embedded inside request/response bodies.
  29. //
  30. // `SENSITIVE_NAME` is used as both a substring matcher (for free-form header
  31. // names like `Authorization` / `X-API-Key`) and as the body-field alternation
  32. // list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…`
  33. // that are too generic to redact substring-style without false positives.
  34. const SENSITIVE_NAME_SOURCE =
  35. "authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature"
  36. const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i")
  37. const SHORT_QUERY_NAME = /^(key|sig)$/i
  38. const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i")
  39. const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi")
  40. const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi")
  41. const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
  42. const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
  43. const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
  44. Object.fromEntries(
  45. Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
  46. name,
  47. String(value),
  48. ]),
  49. )
  50. const redactUrl = (value: string) => {
  51. if (!URL.canParse(value)) return REDACTED
  52. const url = new URL(value)
  53. url.searchParams.forEach((_, key) => {
  54. if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED)
  55. })
  56. return url.toString()
  57. }
  58. const normalizedHeaders = (headers: Headers.Headers) =>
  59. Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
  60. const requestId = (headers: Record<string, string>) => {
  61. return (
  62. headers["x-request-id"] ??
  63. headers["request-id"] ??
  64. headers["x-amzn-requestid"] ??
  65. headers["x-amz-request-id"] ??
  66. headers["x-goog-request-id"] ??
  67. headers["cf-ray"]
  68. )
  69. }
  70. const retryAfterMs = (headers: Record<string, string>) => {
  71. const millis = Number(headers["retry-after-ms"])
  72. if (Number.isFinite(millis)) return Math.max(0, millis)
  73. const value = headers["retry-after"]
  74. if (!value) return undefined
  75. const seconds = Number(value)
  76. if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
  77. const date = Date.parse(value)
  78. if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
  79. return undefined
  80. }
  81. const addRateLimitValue = (target: Record<string, string>, key: string, value: string) => {
  82. if (key.length > 0) target[key] = value
  83. }
  84. const rateLimitDetails = (headers: Record<string, string>, retryAfter: number | undefined) => {
  85. const limit: Record<string, string> = {}
  86. const remaining: Record<string, string> = {}
  87. const reset: Record<string, string> = {}
  88. Object.entries(headers).forEach(([name, value]) => {
  89. const openaiLimit = /^x-ratelimit-limit-(.+)$/.exec(name)?.[1]
  90. if (openaiLimit) return addRateLimitValue(limit, openaiLimit, value)
  91. const openaiRemaining = /^x-ratelimit-remaining-(.+)$/.exec(name)?.[1]
  92. if (openaiRemaining) return addRateLimitValue(remaining, openaiRemaining, value)
  93. const openaiReset = /^x-ratelimit-reset-(.+)$/.exec(name)?.[1]
  94. if (openaiReset) return addRateLimitValue(reset, openaiReset, value)
  95. const anthropic = /^anthropic-ratelimit-(.+)-(limit|remaining|reset)$/.exec(name)
  96. if (!anthropic) return
  97. if (anthropic[2] === "limit") return addRateLimitValue(limit, anthropic[1], value)
  98. if (anthropic[2] === "remaining") return addRateLimitValue(remaining, anthropic[1], value)
  99. return addRateLimitValue(reset, anthropic[1], value)
  100. })
  101. if (
  102. retryAfter === undefined &&
  103. Object.keys(limit).length === 0 &&
  104. Object.keys(remaining).length === 0 &&
  105. Object.keys(reset).length === 0
  106. )
  107. return undefined
  108. return new HttpRateLimitDetails({
  109. retryAfterMs: retryAfter,
  110. limit: Object.keys(limit).length === 0 ? undefined : limit,
  111. remaining: Object.keys(remaining).length === 0 ? undefined : remaining,
  112. reset: Object.keys(reset).length === 0 ? undefined : reset,
  113. })
  114. }
  115. const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
  116. new HttpRequestDetails({
  117. method: request.method,
  118. url: redactUrl(request.url),
  119. headers: redactHeaders(request.headers, redactedNames),
  120. })
  121. const responseDetails = (
  122. response: HttpClientResponse.HttpClientResponse,
  123. redactedNames: ReadonlyArray<string | RegExp>,
  124. ) =>
  125. new HttpResponseDetails({
  126. status: response.status,
  127. headers: redactHeaders(response.headers, redactedNames),
  128. })
  129. const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
  130. const values = new Set<string>()
  131. const add = (value: string) => {
  132. if (value.length < 4) return
  133. values.add(value)
  134. values.add(encodeURIComponent(value))
  135. }
  136. Object.entries(request.headers).forEach(([name, value]) => {
  137. if (!isSensitiveHeaderName(name)) return
  138. add(value)
  139. const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1]
  140. if (bearer) add(bearer)
  141. })
  142. if (!URL.canParse(request.url)) return values
  143. new URL(request.url).searchParams.forEach((value, key) => {
  144. if (isSensitiveQueryName(key)) add(value)
  145. })
  146. return values
  147. }
  148. // Two passes: structural (redact `"name": "value"` and `name=value` patterns
  149. // for any field name that looks sensitive) plus literal (replace any actual
  150. // secret values we sent in the request, in case the response echoes one back).
  151. const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
  152. Array.from(secretValues(request)).reduce(
  153. (text, secret) => text.split(secret).join(REDACTED),
  154. body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
  155. )
  156. const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
  157. if (body === undefined) return {}
  158. const redacted = redactBody(body, request)
  159. if (redacted.length <= BODY_LIMIT) return { body: redacted }
  160. return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
  161. }
  162. const providerMessage = (status: number, body: { readonly body?: string }) => {
  163. if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
  164. return `Provider request failed with HTTP ${status}`
  165. }
  166. const responseHttp = (input: {
  167. readonly request: HttpClientRequest.HttpClientRequest
  168. readonly response: HttpClientResponse.HttpClientResponse
  169. readonly redactedNames: ReadonlyArray<string | RegExp>
  170. readonly body: ReturnType<typeof responseBody>
  171. readonly requestId?: string | undefined
  172. readonly rateLimit?: HttpRateLimitDetails | undefined
  173. }) =>
  174. new HttpContext({
  175. request: requestDetails(input.request, input.redactedNames),
  176. response: responseDetails(input.response, input.redactedNames),
  177. ...input.body,
  178. requestId: input.requestId,
  179. rateLimit: input.rateLimit,
  180. })
  181. const statusError =
  182. (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
  183. (response: HttpClientResponse.HttpClientResponse) =>
  184. Effect.gen(function* () {
  185. if (response.status < 400) return response
  186. const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
  187. const headers = normalizedHeaders(response.headers)
  188. const retryAfter = retryAfterMs(headers)
  189. const rateLimit = rateLimitDetails(headers, retryAfter)
  190. const details = responseBody(body, request)
  191. return yield* new LLMError({
  192. module: "RequestExecutor",
  193. method: "execute",
  194. reason: classifyProviderFailure({
  195. status: response.status,
  196. message: providerMessage(response.status, details),
  197. retryAfterMs: retryAfter,
  198. rateLimit,
  199. http: responseHttp({
  200. request,
  201. response,
  202. redactedNames,
  203. body: details,
  204. requestId: requestId(headers),
  205. rateLimit,
  206. }),
  207. }),
  208. })
  209. })
  210. const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
  211. const transportError = (input: {
  212. readonly message: string
  213. readonly kind?: string | undefined
  214. readonly request?: HttpClientRequest.HttpClientRequest | undefined
  215. }) =>
  216. new LLMError({
  217. module: "RequestExecutor",
  218. method: "execute",
  219. reason: new TransportReason({
  220. message: input.message,
  221. kind: input.kind,
  222. url: input.request ? redactUrl(input.request.url) : undefined,
  223. http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
  224. }),
  225. })
  226. if (Cause.isTimeoutError(error)) {
  227. return transportError({ message: error.message, kind: "Timeout" })
  228. }
  229. if (!HttpClientError.isHttpClientError(error)) {
  230. return transportError({ message: "HTTP transport failed" })
  231. }
  232. const request = "request" in error ? error.request : undefined
  233. if (error.reason._tag === "TransportError") {
  234. return transportError({
  235. message: error.reason.description ?? "HTTP transport failed",
  236. kind: error.reason._tag,
  237. request,
  238. })
  239. }
  240. return transportError({
  241. message: `HTTP transport failed: ${error.reason._tag}`,
  242. kind: error.reason._tag,
  243. request,
  244. })
  245. }
  246. export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
  247. Service,
  248. Effect.gen(function* () {
  249. const http = yield* HttpClient.HttpClient
  250. const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
  251. Effect.gen(function* () {
  252. const redactedNames = yield* Headers.CurrentRedactedNames
  253. return yield* http
  254. .execute(request)
  255. .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
  256. })
  257. return Service.of({
  258. execute: executeOnce,
  259. })
  260. }),
  261. )
  262. export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer))
  263. export * as RequestExecutor from "./executor"