| 1 | import { OpenRouterClient } from "@effect/ai-openrouter" |
| 2 | import * as Errors from "@effect/ai-openrouter/internal/errors" |
| 3 | import { assert, describe, it } from "@effect/vitest" |
| 4 | import { Cause, Context, Effect, Exit, Layer, Option, Redacted, type Schema } from "effect" |
| 5 | import { HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/http" |
| 6 | |
| 7 | describe("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 | |
| 145 | type 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 | |
| 159 | class MockOpenRouterResponse extends Context.Service<MockOpenRouterResponse, { |
| 160 | readonly response: MockResponse |
| 161 | }>()("MockOpenRouterResponse") {} |
| 162 | |
| 163 | class 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 | |
| 169 | const 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 | |
| 191 | const HttpClientLayer = Layer.effectContext(makeHttpClientContext) |
| 192 | |
| 193 | const 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 | |
| 202 | const 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 | |
| 225 | const 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 | }) |