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