1import * as AiError from "effect/ai/AiError"
2import type * as Response from "effect/ai/Response"
3import * as Duration from "effect/Duration"
4import * as Effect from "effect/Effect"
5import type * as Sse from "effect/encoding/Sse"
6import { dual } from "effect/Function"
7import type * as HttpClientError from "effect/http/HttpClientError"
8import type * as HttpClientRequest from "effect/http/HttpClientRequest"
9import type * as HttpClientResponse from "effect/http/HttpClientResponse"
10import * as Number from "effect/Number"
11import * as Option from "effect/Option"
12import * as Predicate from "effect/Predicate"
13import * as Redactable from "effect/Redactable"
14import * as Schema from "effect/Schema"
15import type { OpenAiErrorMetadata } from "../OpenAiError.ts"
16
17// =============================================================================
18// OpenAI Error Body Schema
19// =============================================================================
20
21/** @internal */
22export const OpenAiErrorBody = Schema.Struct({
23 error: Schema.Struct({
24 message: Schema.String,
25 type: Schema.optional(Schema.NullOr(Schema.String)),
26 param: Schema.optional(Schema.NullOr(Schema.String)),
27 code: Schema.optional(Schema.NullOr(Schema.String))
28 })
29})
30
31const OpenAiCompatibleErrorBody = Schema.Struct({
32 error: Schema.String,
33 code: Schema.optional(Schema.String)
34})
35
36// =============================================================================
37// Error Mappers
38// =============================================================================
39
40/** @internal */
41export const mapSchemaError = dual<
42 (method: string) => (error: Schema.SchemaError) => AiError.AiError,
43 (error: Schema.SchemaError, method: string) => AiError.AiError
44>(2, (error, method) =>
45 AiError.make({
46 module: "OpenAiClient",
47 method,
48 reason: AiError.InvalidOutputError.fromSchemaError(error)
49 }))
50
51/** @internal */
52export const mapSseError = dual<
53 (method: string) => (error: Sse.SseError) => AiError.AiError,
54 (error: Sse.SseError, method: string) => AiError.AiError
55>(2, (error, method) =>
56 AiError.make({
57 module: "OpenAiClient",
58 method,
59 reason: new AiError.InvalidOutputError({ description: error.message })
60 }))
61
62/** @internal */
63export const mapHttpClientError = dual<
64 (method: string) => (error: HttpClientError.HttpClientError) => Effect.Effect<never, AiError.AiError>,
65 (error: HttpClientError.HttpClientError, method: string) => Effect.Effect<never, AiError.AiError>
66>(2, (error, method) => {
67 const reason = error.reason
68 switch (reason._tag) {
69 case "TransportError": {
70 return Effect.fail(AiError.make({
71 module: "OpenAiClient",
72 method,
73 reason: new AiError.NetworkError({
74 reason: "TransportError",
75 description: reason.description,
76 request: buildHttpRequestDetails(reason.request)
77 })
78 }))
79 }
80 case "EncodeError": {
81 return Effect.fail(AiError.make({
82 module: "OpenAiClient",
83 method,
84 reason: new AiError.NetworkError({
85 reason: "EncodeError",
86 description: reason.description,
87 request: buildHttpRequestDetails(reason.request)
88 })
89 }))
90 }
91 case "InvalidUrlError": {
92 return Effect.fail(AiError.make({
93 module: "OpenAiClient",
94 method,
95 reason: new AiError.NetworkError({
96 reason: "InvalidUrlError",
97 description: reason.description,
98 request: buildHttpRequestDetails(reason.request)
99 })
100 }))
101 }
102 case "StatusCodeError": {
103 return mapStatusCodeError(reason, method)
104 }
105 case "DecodeError": {
106 return Effect.fail(AiError.make({
107 module: "OpenAiClient",
108 method,
109 reason: new AiError.InvalidOutputError({
110 description: reason.description ?? "Failed to decode response"
111 })
112 }))
113 }
114 case "EmptyBodyError": {
115 return Effect.fail(AiError.make({
116 module: "OpenAiClient",
117 method,
118 reason: new AiError.InvalidOutputError({
119 description: reason.description ?? "Response body was empty"
120 })
121 }))
122 }
123 }
124})
125
126/** @internal */
127const mapStatusCodeError = Effect.fnUntraced(function*(
128 error: HttpClientError.StatusCodeError,
129 method: string
130) {
131 const { request, response, description } = error
132 const status = response.status
133 const headers = response.headers as Record<string, string>
134 const requestId = headers["x-request-id"]
135
136 // Try to get the actual response body. The description from filterStatusOk
137 // is often just "non 2xx status code", so try reading from response.text
138 let body: string | undefined = description
139 if (!description || !description.startsWith("{")) {
140 const responseBody = yield* Effect.option(response.text)
141 if (Option.isSome(responseBody) && responseBody.value) {
142 body = responseBody.value
143 }
144 }
145
146 // Try to parse the body as JSON to extract error details
147 let json: unknown = undefined
148 // @effect-diagnostics effect/tryCatchInEffectGen:off
149 try {
150 json = Predicate.isNotUndefined(body) ? JSON.parse(body) : undefined
151 } catch {
152 json = undefined
153 }
154 const decoded = Schema.decodeUnknownOption(OpenAiErrorBody)(json)
155 const compatibleDecoded = Schema.decodeUnknownOption(OpenAiCompatibleErrorBody)(json)
156 const message = Option.isSome(decoded)
157 ? decoded.value.error.message
158 : Option.isSome(compatibleDecoded)
159 ? compatibleDecoded.value.error
160 : undefined
161 const errorCode = Option.isSome(decoded)
162 ? decoded.value.error.code ?? null
163 : Option.isSome(compatibleDecoded)
164 ? compatibleDecoded.value.code ?? null
165 : null
166
167 const reason = mapStatusCodeToReason({
168 status,
169 headers,
170 message,
171 http: buildHttpContext({ request, response, body }),
172 metadata: {
173 errorCode,
174 errorType: Option.isSome(decoded) ? decoded.value.error.type ?? null : null,
175 requestId: requestId ?? null
176 }
177 })
178
179 return yield* AiError.make({ module: "OpenAiClient", method, reason })
180})
181
182// =============================================================================
183// Rate Limits
184// =============================================================================
185
186/** @internal */
187export const parseRateLimitHeaders = (headers: Record<string, string>) => {
188 const retryAfterRaw = headers["retry-after"]
189 let retryAfter: Duration.Duration | undefined
190 if (Predicate.isNotUndefined(retryAfterRaw)) {
191 const parsed = Number.parse(retryAfterRaw)
192 if (Option.isSome(parsed)) {
193 retryAfter = Duration.seconds(parsed.value)
194 }
195 }
196 const remainingRaw = headers["x-ratelimit-remaining-requests"]
197 const remaining = Predicate.isNotUndefined(remainingRaw)
198 ? Option.getOrNull(Number.parse(remainingRaw))
199 : null
200 return {
201 retryAfter,
202 limit: headers["x-ratelimit-limit-requests"] ?? null,
203 remaining,
204 resetRequests: headers["x-ratelimit-reset-requests"] ?? null,
205 resetTokens: headers["x-ratelimit-reset-tokens"] ?? null
206 }
207}
208
209// =============================================================================
210// HTTP Context
211// =============================================================================
212
213/** @internal */
214export const buildHttpRequestDetails = (
215 request: HttpClientRequest.HttpClientRequest
216): typeof Response.HttpRequestDetails.Type => ({
217 method: request.method,
218 url: request.url,
219 urlParams: Array.from(request.urlParams),
220 hash: Option.getOrUndefined(request.hash),
221 headers: Redactable.redact(request.headers) as Record<string, string>
222})
223
224/** @internal */
225export const buildHttpContext = (params: {
226 readonly request: HttpClientRequest.HttpClientRequest
227 readonly response?: HttpClientResponse.HttpClientResponse
228 readonly body?: string | undefined
229}): typeof AiError.HttpContext.Type => ({
230 request: buildHttpRequestDetails(params.request),
231 response: Predicate.isNotUndefined(params.response)
232 ? {
233 status: params.response.status,
234 headers: Redactable.redact(params.response.headers) as Record<string, string>
235 }
236 : undefined,
237 body: params.body
238})
239
240// =============================================================================
241// HTTP Status Code
242// =============================================================================
243
244/** @internal */
245export const mapStatusCodeToReason = ({ status, headers, message, metadata, http }: {
246 readonly status: number
247 readonly headers: Record<string, string>
248 readonly message: string | undefined
249 readonly metadata: OpenAiErrorMetadata
250 readonly http: typeof AiError.HttpContext.Type
251}): AiError.AiErrorReason => {
252 const errorDescription = AiError.buildErrorDescription({
253 status,
254 message,
255 method: http.request.method,
256 url: http.request.url,
257 errorCode: metadata.errorCode,
258 errorType: metadata.errorType,
259 requestId: metadata.requestId,
260 body: http.body
261 })
262
263 switch (status) {
264 case 400:
265 return new AiError.InvalidRequestError({
266 description: errorDescription,
267 metadata: { openai: metadata },
268 http
269 })
270 case 401:
271 return new AiError.AuthenticationError({
272 kind: "InvalidKey",
273 description: errorDescription,
274 metadata,
275 http
276 })
277 case 403:
278 return new AiError.AuthenticationError({
279 kind: "InsufficientPermissions",
280 description: errorDescription,
281 metadata,
282 http
283 })
284 case 404:
285 return new AiError.InvalidRequestError({
286 description: errorDescription,
287 metadata: { openai: metadata },
288 http
289 })
290 case 409:
291 case 422:
292 return new AiError.InvalidRequestError({
293 description: errorDescription,
294 metadata: { openai: metadata },
295 http
296 })
297 case 402:
298 return new AiError.QuotaExhaustedError({
299 metadata: { openai: metadata },
300 http
301 })
302 case 429: {
303 // Best-effort detection: OpenAI returns insufficient_quota for billing/quota issues
304 if (
305 metadata.errorCode === "insufficient_quota" ||
306 metadata.errorType === "insufficient_quota" ||
307 metadata.errorCode === "billing_insufficient_balance" ||
308 metadata.errorType === "billing_insufficient_balance"
309 ) {
310 return new AiError.QuotaExhaustedError({
311 metadata: { openai: metadata },
312 http
313 })
314 }
315 const { retryAfter, ...rateLimitMetadata } = parseRateLimitHeaders(headers)
316 return new AiError.RateLimitError({
317 retryAfter,
318 metadata: {
319 openai: {
320 ...metadata,
321 ...rateLimitMetadata
322 }
323 },
324 http
325 })
326 }
327 default:
328 if (status >= 500) {
329 return new AiError.InternalProviderError({
330 description: message ?? "Server error",
331 metadata,
332 http
333 })
334 }
335 return new AiError.UnknownError({
336 description: message,
337 metadata,
338 http
339 })
340 }
341}