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