1import { OpenRouterClient } from "@effect/ai-openrouter"
2import * as Errors from "@effect/ai-openrouter/internal/errors"
3import { assert, describe, it } from "@effect/vitest"
4import { Cause, Context, Effect, Exit, Layer, Option, Redacted, type Schema } from "effect"
5import { HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/http"
6
7describe("OpenRouterClient", () => {
8 it.effect("rejects unserializable bigint decision state with a typed error before making an HTTP call", () =>
9 Effect.gen(function*() {
10 const client = yield* OpenRouterClient.OpenRouterClient
11 const exit = yield* client.createDecisions({
12 model: "test-model",
13 state: { n: 1n },
14 questions: {
15 urgent: {
16 type: "noul",
17 instructions: "Is this urgent?",
18 criteria: { false: "Not urgent", true: "Urgent" }
19 }
20 }
21 }).pipe(Effect.exit)
22
23 assert.deepStrictEqual(yield* MockHttpClient.requests, [])
24 assert.isTrue(Exit.isFailure(exit))
25 if (!Exit.isFailure(exit)) {
26 return yield* Effect.die(new Error("Expected serialization to fail"))
27 }
28 assert.isFalse(Cause.hasDies(exit.cause))
29 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause))
30 assert.strictEqual(error._tag, "AiError")
31 assert.strictEqual(error.module, "OpenRouterClient")
32 assert.strictEqual(error.method, "createDecisions")
33 assert.strictEqual(error.reason._tag, "InvalidRequestError")
34 }).pipe(Effect.provide(makeTestLayer({
35 _tag: "Json",
36 body: {}
37 }))))
38
39 it.effect("redacts the API key in AI error context", () =>
40 Effect.gen(function*() {
41 const client = yield* OpenRouterClient.OpenRouterClient
42
43 const result = yield* client.createChatCompletion({
44 model: "openai/gpt-4o-mini",
45 messages: [{ role: "user", content: "hello" }]
46 }).pipe(Effect.flip)
47
48 assert.strictEqual(result.reason._tag, "InvalidRequestError")
49 if (result.reason._tag !== "InvalidRequestError" || result.reason.http === undefined) {
50 return yield* Effect.die(new Error("Expected InvalidRequestError with HTTP context"))
51 }
52 const requests = yield* MockHttpClient.requests
53 assert.include(requests[0]?.url, "/chat/completions")
54 assert.strictEqual(String(result.reason.http.request.headers["authorization"]), "<redacted>")
55 }).pipe(Effect.provide(makeTestLayer({
56 _tag: "Json",
57 status: 400,
58 body: {
59 error: {
60 code: 400,
61 message: "Bad request"
62 }
63 }
64 }))))
65
66 it.effect("surfaces the provider message on 401 AuthenticationError", () =>
67 Effect.gen(function*() {
68 const client = yield* OpenRouterClient.OpenRouterClient
69
70 const result = yield* client.createChatCompletion({
71 model: "openai/gpt-4o-mini",
72 messages: [{ role: "user", content: "hello" }]
73 }).pipe(Effect.flip)
74
75 assert.strictEqual(result.reason._tag, "AuthenticationError")
76 if (result.reason._tag !== "AuthenticationError") {
77 return yield* Effect.die(new Error("Expected AuthenticationError"))
78 }
79 assert.strictEqual(result.reason.kind, "InvalidKey")
80 assert.strictEqual(
81 result.reason.description,
82 "No auth credentials found (POST https://openrouter.ai/api/v1/chat/completions) [code: 401] [requestId: req_openrouter]"
83 )
84 assert.include(result.reason.message, "No auth credentials found")
85 }).pipe(Effect.provide(makeTestLayer({
86 _tag: "Json",
87 status: 401,
88 body: {
89 error: {
90 code: 401,
91 message: "No auth credentials found"
92 }
93 },
94 headers: { "x-request-id": "req_openrouter" }
95 }))))
96
97 it("preserves and truncates a fallback HTTP response", () => {
98 const body = `${"a".repeat(200)}b`
99 const reason = Errors.mapStatusCodeToReason({
100 status: 400,
101 headers: {},
102 message: undefined,
103 metadata: { errorCode: null, errorType: null, requestId: null },
104 http: makeHttpContext("https://openrouter.ai/api/v1/chat/completions", body)
105 })
106
107 assert.strictEqual(reason._tag, "InvalidRequestError")
108 if (reason._tag !== "InvalidRequestError") {
109 throw new Error("Expected InvalidRequestError")
110 }
111 assert.strictEqual(
112 reason.description,
113 `HTTP 400 (POST https://openrouter.ai/api/v1/chat/completions) Response: ${"a".repeat(200)}...`
114 )
115 })
116
117 it.effect("surfaces the provider message on 403 AuthenticationError", () =>
118 Effect.gen(function*() {
119 const client = yield* OpenRouterClient.OpenRouterClient
120
121 const result = yield* client.createChatCompletion({
122 model: "openai/gpt-4o-mini",
123 messages: [{ role: "user", content: "hello" }]
124 }).pipe(Effect.flip)
125
126 assert.strictEqual(result.reason._tag, "AuthenticationError")
127 if (result.reason._tag !== "AuthenticationError") {
128 return yield* Effect.die(new Error("Expected AuthenticationError"))
129 }
130 assert.strictEqual(result.reason.kind, "InsufficientPermissions")
131 assert.include(result.reason.description ?? "", "Key does not have permission")
132 assert.include(result.reason.message, "Key does not have permission")
133 }).pipe(Effect.provide(makeTestLayer({
134 _tag: "Json",
135 status: 403,
136 body: {
137 error: {
138 code: 403,
139 message: "Key does not have permission"
140 }
141 }
142 }))))
143})
144
145type MockResponse =
146 | {
147 readonly _tag: "Json"
148 readonly body: Schema.Json
149 readonly status?: number | undefined
150 readonly headers?: Record<string, string> | undefined
151 }
152 | {
153 readonly _tag: "Sse"
154 readonly events: ReadonlyArray<Schema.Json>
155 readonly status?: number | undefined
156 readonly headers?: Record<string, string> | undefined
157 }
158
159class MockOpenRouterResponse extends Context.Service<MockOpenRouterResponse, {
160 readonly response: MockResponse
161}>()("MockOpenRouterResponse") {}
162
163class MockHttpClient extends Context.Service<MockHttpClient, {
164 readonly requests: Effect.Effect<ReadonlyArray<HttpClientRequest.HttpClientRequest>>
165}>()("MockHttpClient") {
166 static requests = MockHttpClient.use((client) => client.requests)
167}
168
169const makeHttpClientContext = Effect.gen(function*() {
170 const capturedRequests: Array<HttpClientRequest.HttpClientRequest> = []
171 const mock = yield* MockOpenRouterResponse
172
173 const httpClient = HttpClient.makeWith(
174 Effect.fnUntraced(function*(requestEffect) {
175 const request = yield* requestEffect
176 capturedRequests.push(request)
177 return makeResponse(request, mock.response)
178 }),
179 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
180 )
181
182 const mockHttpClient: MockHttpClient["Service"] = {
183 requests: Effect.sync(() => capturedRequests)
184 }
185
186 return Context.make(HttpClient.HttpClient, httpClient).pipe(
187 Context.add(MockHttpClient, mockHttpClient)
188 )
189})
190
191const HttpClientLayer = Layer.effectContext(makeHttpClientContext)
192
193const makeTestLayer = (
194 response: MockResponse,
195 options: OpenRouterClient.Options = { apiKey: Redacted.make("sk-test-key") }
196) =>
197 OpenRouterClient.layer(options).pipe(
198 Layer.provideMerge(HttpClientLayer),
199 Layer.provide(Layer.succeed(MockOpenRouterResponse, { response }))
200 )
201
202const makeResponse = (
203 request: HttpClientRequest.HttpClientRequest,
204 response: MockResponse
205): HttpClientResponse.HttpClientResponse => {
206 const contentType = response._tag === "Json"
207 ? "application/json"
208 : "text/event-stream"
209 const body = response._tag === "Json"
210 ? JSON.stringify(response.body)
211 : response.events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")
212
213 return HttpClientResponse.fromWeb(
214 request,
215 new Response(body, {
216 status: response.status ?? 200,
217 headers: {
218 "content-type": contentType,
219 ...response.headers
220 }
221 })
222 )
223}
224
225const makeHttpContext = (url: string, body: string) => ({
226 request: {
227 method: "POST" as const,
228 url,
229 urlParams: [],
230 hash: undefined,
231 headers: {}
232 },
233 body
234})