1import { AnthropicClient } from "@effect/ai-anthropic"
2import * as Errors from "@effect/ai-anthropic/internal/errors"
3import { assert, describe, it } from "@effect/vitest"
4import { Context, Duration, Effect, Layer, Redacted, type Schema } from "effect"
5import { Headers, HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/http"
6
7describe("AnthropicClient", () => {
8 it.effect("maps a gateway 400 without an Anthropic error envelope", () =>
9 Effect.gen(function*() {
10 const client = yield* AnthropicClient.AnthropicClient
11 const error = yield* client.createMessage({
12 payload: {
13 model: "claude-sonnet-4-20250514",
14 max_tokens: 1,
15 messages: [{ role: "user", content: "hello" }]
16 }
17 }).pipe(Effect.flip)
18
19 assert.strictEqual(error.reason._tag, "InvalidRequestError")
20 if (error.reason._tag !== "InvalidRequestError") return
21 assert.include(error.reason.description ?? "", "HTTP 400")
22 assert.include(error.reason.description ?? "", "gateway rejected request")
23 assert.strictEqual(error.reason.http?.response?.status, 400)
24 }).pipe(Effect.provide(makeTestLayer({
25 _tag: "Json",
26 status: 400,
27 body: { error: { message: "gateway rejected request" } }
28 }))))
29
30 it.effect("maps a gateway 429 without an Anthropic error envelope and preserves retry-after", () =>
31 Effect.gen(function*() {
32 const client = yield* AnthropicClient.AnthropicClient
33 const error = yield* client.createMessage({
34 payload: {
35 model: "claude-sonnet-4-20250514",
36 max_tokens: 1,
37 messages: [{ role: "user", content: "hello" }]
38 }
39 }).pipe(Effect.flip)
40
41 assert.strictEqual(error.reason._tag, "RateLimitError")
42 if (error.reason._tag !== "RateLimitError") return
43 assert.strictEqual(Duration.toMillis(error.reason.retryAfter!), 3000)
44 assert.strictEqual(error.reason.http?.response?.status, 429)
45 }).pipe(Effect.provide(makeTestLayer({
46 _tag: "Json",
47 status: 429,
48 headers: { "retry-after": "3" },
49 body: { error: { message: "gateway rate limit" } }
50 }))))
51
52 it.effect("redacts the API key in AI error context", () =>
53 Effect.gen(function*() {
54 const client = yield* AnthropicClient.AnthropicClient
55
56 const result = yield* client.createMessage({
57 payload: {
58 model: "claude-sonnet-4-20250514",
59 max_tokens: 1,
60 messages: [{ role: "user", content: "hello" }]
61 }
62 }).pipe(
63 Effect.flip,
64 Effect.updateService(Headers.CurrentRedactedNames, () => [])
65 )
66
67 assert.strictEqual(result.reason._tag, "InvalidRequestError")
68 if (result.reason._tag !== "InvalidRequestError" || result.reason.http === undefined) {
69 return yield* Effect.die(new Error("Expected InvalidRequestError with HTTP context"))
70 }
71 const requests = yield* MockHttpClient.requests
72 assert.include(requests[0]?.url, "/v1/messages")
73 assert.strictEqual(String(result.reason.http.request.headers["x-api-key"]), "<redacted>")
74 }).pipe(Effect.provide(makeTestLayer({
75 _tag: "Json",
76 status: 400,
77 body: {
78 type: "error",
79 error: {
80 type: "invalid_request_error",
81 message: "Bad request"
82 },
83 request_id: null
84 }
85 }))))
86
87 it.effect("surfaces the provider message on 401 AuthenticationError", () =>
88 Effect.gen(function*() {
89 const client = yield* AnthropicClient.AnthropicClient
90
91 const result = yield* client.createMessage({
92 payload: {
93 model: "claude-sonnet-4-20250514",
94 max_tokens: 1,
95 messages: [{ role: "user", content: "hello" }]
96 }
97 }).pipe(Effect.flip)
98
99 assert.strictEqual(result.reason._tag, "AuthenticationError")
100 if (result.reason._tag !== "AuthenticationError") {
101 return yield* Effect.die(new Error("Expected AuthenticationError"))
102 }
103 assert.strictEqual(result.reason.kind, "InvalidKey")
104 assert.deepStrictEqual(result.reason.metadata?.anthropic, {
105 errorType: "authentication_error",
106 requestId: "req_anthropic"
107 })
108 assert.strictEqual(
109 result.reason.description,
110 "invalid x-api-key (POST https://api.anthropic.com/v1/messages?beta=true) [type: authentication_error] [requestId: req_anthropic]"
111 )
112 assert.include(result.reason.message, "invalid x-api-key")
113 assert.strictEqual(
114 result.reason.message,
115 "InvalidKey: Verify your API key is correct. invalid x-api-key (POST https://api.anthropic.com/v1/messages?beta=true) [type: authentication_error] [requestId: req_anthropic]"
116 )
117 }).pipe(Effect.provide(makeTestLayer({
118 _tag: "Json",
119 status: 401,
120 body: {
121 type: "error",
122 error: { type: "authentication_error", message: "invalid x-api-key" },
123 request_id: "req_anthropic"
124 }
125 }))))
126
127 it("preserves and truncates a fallback HTTP response", () => {
128 const body = `${"a".repeat(200)}b`
129 const reason = Errors.mapStatusCodeToReason({
130 status: 400,
131 headers: {},
132 message: undefined,
133 metadata: { errorType: null, requestId: null },
134 http: makeHttpContext("https://api.anthropic.com/v1/messages?beta=true", body)
135 })
136
137 assert.strictEqual(reason._tag, "InvalidRequestError")
138 if (reason._tag !== "InvalidRequestError") {
139 throw new Error("Expected InvalidRequestError")
140 }
141 assert.strictEqual(
142 reason.description,
143 `HTTP 400 (POST https://api.anthropic.com/v1/messages?beta=true) Response: ${"a".repeat(200)}...`
144 )
145 })
146
147 it.effect("surfaces the provider message on 403 AuthenticationError", () =>
148 Effect.gen(function*() {
149 const client = yield* AnthropicClient.AnthropicClient
150
151 const result = yield* client.createMessage({
152 payload: {
153 model: "claude-sonnet-4-20250514",
154 max_tokens: 1,
155 messages: [{ role: "user", content: "hello" }]
156 }
157 }).pipe(Effect.flip)
158
159 assert.strictEqual(result.reason._tag, "AuthenticationError")
160 if (result.reason._tag !== "AuthenticationError") {
161 return yield* Effect.die(new Error("Expected AuthenticationError"))
162 }
163 assert.strictEqual(result.reason.kind, "InsufficientPermissions")
164 assert.include(result.reason.description ?? "", "not available for this account")
165 assert.include(result.reason.message, "not available for this account")
166 }).pipe(Effect.provide(makeTestLayer({
167 _tag: "Json",
168 status: 403,
169 body: {
170 type: "error",
171 error: {
172 type: "permission_error",
173 message: "claude-sonnet-4-20250514 is not available for this account"
174 },
175 request_id: null
176 }
177 }))))
178})
179
180type MockResponse =
181 | {
182 readonly _tag: "Json"
183 readonly body: Schema.Json
184 readonly status?: number | undefined
185 readonly headers?: Record<string, string> | undefined
186 }
187 | {
188 readonly _tag: "Sse"
189 readonly events: ReadonlyArray<Schema.Json>
190 readonly status?: number | undefined
191 readonly headers?: Record<string, string> | undefined
192 }
193
194class MockAnthropicResponse extends Context.Service<MockAnthropicResponse, {
195 readonly response: MockResponse
196}>()("MockAnthropicResponse") {}
197
198class MockHttpClient extends Context.Service<MockHttpClient, {
199 readonly requests: Effect.Effect<ReadonlyArray<HttpClientRequest.HttpClientRequest>>
200}>()("MockHttpClient") {
201 static requests = MockHttpClient.use((client) => client.requests)
202}
203
204const makeHttpClientContext = Effect.gen(function*() {
205 const capturedRequests: Array<HttpClientRequest.HttpClientRequest> = []
206 const mock = yield* MockAnthropicResponse
207
208 const httpClient = HttpClient.makeWith(
209 Effect.fnUntraced(function*(requestEffect) {
210 const request = yield* requestEffect
211 capturedRequests.push(request)
212 return makeResponse(request, mock.response)
213 }),
214 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
215 )
216
217 const mockHttpClient: MockHttpClient["Service"] = {
218 requests: Effect.sync(() => capturedRequests)
219 }
220
221 return Context.make(HttpClient.HttpClient, httpClient).pipe(
222 Context.add(MockHttpClient, mockHttpClient)
223 )
224})
225
226const HttpClientLayer = Layer.effectContext(makeHttpClientContext)
227
228const makeTestLayer = (
229 response: MockResponse,
230 options: AnthropicClient.Options = { apiKey: Redacted.make("sk-test-key") }
231) =>
232 AnthropicClient.layer(options).pipe(
233 Layer.provideMerge(HttpClientLayer),
234 Layer.provide(Layer.succeed(MockAnthropicResponse, { response }))
235 )
236
237const makeResponse = (
238 request: HttpClientRequest.HttpClientRequest,
239 response: MockResponse
240): HttpClientResponse.HttpClientResponse => {
241 const contentType = response._tag === "Json"
242 ? "application/json"
243 : "text/event-stream"
244 const body = response._tag === "Json"
245 ? JSON.stringify(response.body)
246 : response.events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")
247
248 return HttpClientResponse.fromWeb(
249 request,
250 new Response(body, {
251 status: response.status ?? 200,
252 headers: {
253 "content-type": contentType,
254 ...response.headers
255 }
256 })
257 )
258}
259
260const makeHttpContext = (url: string, body: string) => ({
261 request: {
262 method: "POST" as const,
263 url,
264 urlParams: [],
265 hash: undefined,
266 headers: {}
267 },
268 body
269})