t3-code-android-nightly/.repos/effect-smol/packages/ai/anthropic/test/AnthropicClient.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

269 lines
9.3 KiB
TypeScript

import { AnthropicClient } from "@effect/ai-anthropic"
import * as Errors from "@effect/ai-anthropic/internal/errors"
import { assert, describe, it } from "@effect/vitest"
import { Context, Duration, Effect, Layer, Redacted, type Schema } from "effect"
import { Headers, HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/http"
describe("AnthropicClient", () => {
it.effect("maps a gateway 400 without an Anthropic error envelope", () =>
Effect.gen(function*() {
const client = yield* AnthropicClient.AnthropicClient
const error = yield* client.createMessage({
payload: {
model: "claude-sonnet-4-20250514",
max_tokens: 1,
messages: [{ role: "user", content: "hello" }]
}
}).pipe(Effect.flip)
assert.strictEqual(error.reason._tag, "InvalidRequestError")
if (error.reason._tag !== "InvalidRequestError") return
assert.include(error.reason.description ?? "", "HTTP 400")
assert.include(error.reason.description ?? "", "gateway rejected request")
assert.strictEqual(error.reason.http?.response?.status, 400)
}).pipe(Effect.provide(makeTestLayer({
_tag: "Json",
status: 400,
body: { error: { message: "gateway rejected request" } }
}))))
it.effect("maps a gateway 429 without an Anthropic error envelope and preserves retry-after", () =>
Effect.gen(function*() {
const client = yield* AnthropicClient.AnthropicClient
const error = yield* client.createMessage({
payload: {
model: "claude-sonnet-4-20250514",
max_tokens: 1,
messages: [{ role: "user", content: "hello" }]
}
}).pipe(Effect.flip)
assert.strictEqual(error.reason._tag, "RateLimitError")
if (error.reason._tag !== "RateLimitError") return
assert.strictEqual(Duration.toMillis(error.reason.retryAfter!), 3000)
assert.strictEqual(error.reason.http?.response?.status, 429)
}).pipe(Effect.provide(makeTestLayer({
_tag: "Json",
status: 429,
headers: { "retry-after": "3" },
body: { error: { message: "gateway rate limit" } }
}))))
it.effect("redacts the API key in AI error context", () =>
Effect.gen(function*() {
const client = yield* AnthropicClient.AnthropicClient
const result = yield* client.createMessage({
payload: {
model: "claude-sonnet-4-20250514",
max_tokens: 1,
messages: [{ role: "user", content: "hello" }]
}
}).pipe(
Effect.flip,
Effect.updateService(Headers.CurrentRedactedNames, () => [])
)
assert.strictEqual(result.reason._tag, "InvalidRequestError")
if (result.reason._tag !== "InvalidRequestError" || result.reason.http === undefined) {
return yield* Effect.die(new Error("Expected InvalidRequestError with HTTP context"))
}
const requests = yield* MockHttpClient.requests
assert.include(requests[0]?.url, "/v1/messages")
assert.strictEqual(String(result.reason.http.request.headers["x-api-key"]), "<redacted>")
}).pipe(Effect.provide(makeTestLayer({
_tag: "Json",
status: 400,
body: {
type: "error",
error: {
type: "invalid_request_error",
message: "Bad request"
},
request_id: null
}
}))))
it.effect("surfaces the provider message on 401 AuthenticationError", () =>
Effect.gen(function*() {
const client = yield* AnthropicClient.AnthropicClient
const result = yield* client.createMessage({
payload: {
model: "claude-sonnet-4-20250514",
max_tokens: 1,
messages: [{ role: "user", content: "hello" }]
}
}).pipe(Effect.flip)
assert.strictEqual(result.reason._tag, "AuthenticationError")
if (result.reason._tag !== "AuthenticationError") {
return yield* Effect.die(new Error("Expected AuthenticationError"))
}
assert.strictEqual(result.reason.kind, "InvalidKey")
assert.deepStrictEqual(result.reason.metadata?.anthropic, {
errorType: "authentication_error",
requestId: "req_anthropic"
})
assert.strictEqual(
result.reason.description,
"invalid x-api-key (POST https://api.anthropic.com/v1/messages?beta=true) [type: authentication_error] [requestId: req_anthropic]"
)
assert.include(result.reason.message, "invalid x-api-key")
assert.strictEqual(
result.reason.message,
"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]"
)
}).pipe(Effect.provide(makeTestLayer({
_tag: "Json",
status: 401,
body: {
type: "error",
error: { type: "authentication_error", message: "invalid x-api-key" },
request_id: "req_anthropic"
}
}))))
it("preserves and truncates a fallback HTTP response", () => {
const body = `${"a".repeat(200)}b`
const reason = Errors.mapStatusCodeToReason({
status: 400,
headers: {},
message: undefined,
metadata: { errorType: null, requestId: null },
http: makeHttpContext("https://api.anthropic.com/v1/messages?beta=true", body)
})
assert.strictEqual(reason._tag, "InvalidRequestError")
if (reason._tag !== "InvalidRequestError") {
throw new Error("Expected InvalidRequestError")
}
assert.strictEqual(
reason.description,
`HTTP 400 (POST https://api.anthropic.com/v1/messages?beta=true) Response: ${"a".repeat(200)}...`
)
})
it.effect("surfaces the provider message on 403 AuthenticationError", () =>
Effect.gen(function*() {
const client = yield* AnthropicClient.AnthropicClient
const result = yield* client.createMessage({
payload: {
model: "claude-sonnet-4-20250514",
max_tokens: 1,
messages: [{ role: "user", content: "hello" }]
}
}).pipe(Effect.flip)
assert.strictEqual(result.reason._tag, "AuthenticationError")
if (result.reason._tag !== "AuthenticationError") {
return yield* Effect.die(new Error("Expected AuthenticationError"))
}
assert.strictEqual(result.reason.kind, "InsufficientPermissions")
assert.include(result.reason.description ?? "", "not available for this account")
assert.include(result.reason.message, "not available for this account")
}).pipe(Effect.provide(makeTestLayer({
_tag: "Json",
status: 403,
body: {
type: "error",
error: {
type: "permission_error",
message: "claude-sonnet-4-20250514 is not available for this account"
},
request_id: null
}
}))))
})
type MockResponse =
| {
readonly _tag: "Json"
readonly body: Schema.Json
readonly status?: number | undefined
readonly headers?: Record<string, string> | undefined
}
| {
readonly _tag: "Sse"
readonly events: ReadonlyArray<Schema.Json>
readonly status?: number | undefined
readonly headers?: Record<string, string> | undefined
}
class MockAnthropicResponse extends Context.Service<MockAnthropicResponse, {
readonly response: MockResponse
}>()("MockAnthropicResponse") {}
class MockHttpClient extends Context.Service<MockHttpClient, {
readonly requests: Effect.Effect<ReadonlyArray<HttpClientRequest.HttpClientRequest>>
}>()("MockHttpClient") {
static requests = MockHttpClient.use((client) => client.requests)
}
const makeHttpClientContext = Effect.gen(function*() {
const capturedRequests: Array<HttpClientRequest.HttpClientRequest> = []
const mock = yield* MockAnthropicResponse
const httpClient = HttpClient.makeWith(
Effect.fnUntraced(function*(requestEffect) {
const request = yield* requestEffect
capturedRequests.push(request)
return makeResponse(request, mock.response)
}),
Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
)
const mockHttpClient: MockHttpClient["Service"] = {
requests: Effect.sync(() => capturedRequests)
}
return Context.make(HttpClient.HttpClient, httpClient).pipe(
Context.add(MockHttpClient, mockHttpClient)
)
})
const HttpClientLayer = Layer.effectContext(makeHttpClientContext)
const makeTestLayer = (
response: MockResponse,
options: AnthropicClient.Options = { apiKey: Redacted.make("sk-test-key") }
) =>
AnthropicClient.layer(options).pipe(
Layer.provideMerge(HttpClientLayer),
Layer.provide(Layer.succeed(MockAnthropicResponse, { response }))
)
const makeResponse = (
request: HttpClientRequest.HttpClientRequest,
response: MockResponse
): HttpClientResponse.HttpClientResponse => {
const contentType = response._tag === "Json"
? "application/json"
: "text/event-stream"
const body = response._tag === "Json"
? JSON.stringify(response.body)
: response.events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")
return HttpClientResponse.fromWeb(
request,
new Response(body, {
status: response.status ?? 200,
headers: {
"content-type": contentType,
...response.headers
}
})
)
}
const makeHttpContext = (url: string, body: string) => ({
request: {
method: "POST" as const,
url,
urlParams: [],
hash: undefined,
headers: {}
},
body
})