1/**
2 * The `AnthropicClient` module defines the low-level Effect service for
3 * Anthropic's Messages API. It builds a generated Anthropic HTTP client with
4 * authentication headers, API version headers, response decoding, and error
5 * mapping, then exposes helpers for regular and streaming message requests.
6 *
7 * @stability unstable
8 * @since 4.0.0
9 */
10import type * as AiError from "effect/ai/AiError"
11import * as Array from "effect/Array"
12import type * as Config from "effect/Config"
13import * as Context from "effect/Context"
14import * as Effect from "effect/Effect"
15import * as Sse from "effect/encoding/Sse"
16import { identity } from "effect/Function"
17import * as Headers from "effect/http/Headers"
18import * as HttpBody from "effect/http/HttpBody"
19import * as HttpClient from "effect/http/HttpClient"
20import * as HttpClientError from "effect/http/HttpClientError"
21import * as HttpClientRequest from "effect/http/HttpClientRequest"
22import type * as HttpClientResponse from "effect/http/HttpClientResponse"
23import * as Layer from "effect/Layer"
24import * as Predicate from "effect/Predicate"
25import * as Redacted from "effect/Redacted"
26import * as Schema from "effect/Schema"
27import * as Stream from "effect/Stream"
28import { AnthropicConfig } from "./AnthropicConfig.ts"
29import * as Generated from "./Generated.ts"
30import * as Errors from "./internal/errors.ts"
31
32// =============================================================================
33// Service Interface
34// =============================================================================
35
36/**
37 * Represents the Anthropic client service with methods for the Messages API, including regular and streaming message
38 * creation.
39 *
40 * @stability unstable
41 * @category services
42 * @since 4.0.0
43 */
44export interface Service {
45 /**
46 * The underlying generated Anthropic client that exposes all API endpoints.
47 */
48 readonly client: Generated.AnthropicClient
49
50 /**
51 * Executes a low-level streaming HTTP request and decodes the Server-Sent Events response using the provided schema.
52 */
53 readonly streamRequest: <S extends Sse.EventCodec>(
54 schema: S
55 ) => (request: HttpClientRequest.HttpClientRequest) => Stream.Stream<
56 S["Type"],
57 HttpClientError.HttpClientError | Schema.SchemaError | Sse.Retry | Sse.SseError,
58 S["DecodingServices"]
59 >
60
61 /**
62 * Creates a message using the Anthropic Messages API and maps all errors to the unified `AiError` type.
63 */
64 readonly createMessage: (options: {
65 readonly payload: typeof Generated.BetaCreateMessageParams.Encoded
66 readonly params?: typeof Generated.BetaMessagesPostParams.Encoded | undefined
67 }) => Effect.Effect<
68 [body: typeof Generated.BetaMessage.Type, response: HttpClientResponse.HttpClientResponse],
69 AiError.AiError
70 >
71
72 /**
73 * Creates a streaming message using the Anthropic Messages API and maps all errors to the unified `AiError` type.
74 *
75 * **Details**
76 *
77 * The returned Effect yields the HTTP response and a stream of events as the model generates its response. The stream
78 * automatically terminates when a `message_stop` event is received.
79 */
80 readonly createMessageStream: (options: {
81 readonly payload: Omit<typeof Generated.BetaCreateMessageParams.Encoded, "stream">
82 readonly params?: typeof Generated.BetaMessagesPostParams.Encoded | undefined
83 }) => Effect.Effect<
84 [response: HttpClientResponse.HttpClientResponse, stream: Stream.Stream<MessageStreamEvent, AiError.AiError>],
85 AiError.AiError
86 >
87}
88
89/**
90 * Represents an event received from the Anthropic Messages API during a streaming request.
91 *
92 * **Details**
93 *
94 * Events include:
95 * - `message_start`: Initial event containing message metadata
96 * - `message_delta`: Incremental updates to the message (e.g., stop reason)
97 * - `message_stop`: Final event indicating the message is complete
98 * - `content_block_start`: Start of a content block
99 * - `content_block_delta`: Incremental content updates (text, tool use, etc.)
100 * - `content_block_stop`: End of a content block
101 * - `error`: Error events with type and message
102 *
103 * @stability unstable
104 * @category models
105 * @since 4.0.0
106 */
107export type MessageStreamEvent =
108 | typeof Generated.BetaMessageStartEvent.Type
109 | typeof Generated.BetaMessageDeltaEvent.Type
110 | typeof Generated.BetaMessageStopEvent.Type
111 | typeof Generated.BetaContentBlockStartEvent.Type
112 | typeof Generated.BetaContentBlockDeltaEvent.Type
113 | typeof Generated.BetaContentBlockStopEvent.Type
114 | typeof Generated.BetaErrorResponse.Type
115
116// =============================================================================
117// Service Identifier
118// =============================================================================
119
120/**
121 * Service tag for the Anthropic client.
122 *
123 * **When to use**
124 *
125 * Use when accessing or providing the Anthropic client service through Effect's
126 * context.
127 *
128 * @see {@link make} for constructing an Anthropic client effectfully
129 * @see {@link layer} for providing a client from explicit options
130 * @see {@link layerConfig} for providing a client from `Config`
131 *
132 * @stability unstable
133 * @category services
134 * @since 4.0.0
135 */
136export class AnthropicClient extends Context.Service<AnthropicClient, Service>()(
137 "@effect/ai-anthropic/AnthropicClient"
138) {}
139
140// =============================================================================
141// Options
142// =============================================================================
143
144/**
145 * Configuration for creating an Anthropic client.
146 *
147 * **When to use**
148 *
149 * Use when the Anthropic client settings are already available as values and
150 * should be passed directly to `make` or `layer`.
151 *
152 * **Details**
153 *
154 * These options configure the base Anthropic URL, the `x-api-key`
155 * authentication header, the `anthropic-version` header, and an optional
156 * transformation of the underlying `HttpClient`.
157 *
158 * @see {@link make} for constructing an Anthropic client from explicit options
159 * @see {@link layer} for providing an Anthropic client from explicit options
160 * @see {@link layerConfig} for loading Anthropic client settings from `Config`
161 *
162 * @stability unstable
163 * @category options
164 * @since 4.0.0
165 */
166export type Options = {
167 /**
168 * The Anthropic API key for authentication. Requests are made without authentication when this is omitted, which is
169 * useful for proxied setups or testing.
170 */
171 readonly apiKey?: Redacted.Redacted<string> | undefined
172
173 /**
174 * The base URL for the Anthropic API. Override this to use a proxy or a different API-compatible endpoint.
175 *
176 * @default "https://api.anthropic.com"
177 */
178 readonly apiUrl?: string | undefined
179
180 /**
181 * The Anthropic API version header value. This controls which version of the API to use.
182 *
183 * @default "2023-06-01"
184 */
185 readonly apiVersion?: string | undefined
186
187 /**
188 * Optional transformer for the underlying HTTP client, such as middleware, logging, or custom request/response
189 * handling.
190 */
191 readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
192}
193
194// =============================================================================
195// Constructor
196// =============================================================================
197
198const RedactedAnthropicHeaders = {
199 AnthropicApiKey: "x-api-key"
200}
201
202const withRedactedHeaders = Effect.updateService(
203 Headers.CurrentRedactedNames,
204 Array.appendAll(Object.values(RedactedAnthropicHeaders))
205)
206
207/**
208 * Creates an Anthropic client service with the given options.
209 *
210 * **When to use**
211 *
212 * Use when you have explicit configuration values and need an `Effect` that
213 * constructs the Anthropic client service, rather than providing it as a `Layer`.
214 *
215 * **Details**
216 *
217 * The client handles API key authentication via the `x-api-key` header, API versioning via the `anthropic-version`
218 * header, error mapping to the unified `AiError` type, and request/response transformations via `AnthropicConfig`. It
219 * requires an `HttpClient` in the context.
220 *
221 * @see {@link layer} for providing the client as a `Layer` from explicit options
222 * @see {@link layerConfig} for providing the client as a `Layer` with `Config`-based settings
223 *
224 * @stability unstable
225 * @category constructors
226 * @since 4.0.0
227 */
228export const make = Effect.fnUntraced(
229 function*(options: Options): Effect.fn.Return<Service, never, HttpClient.HttpClient> {
230 const baseClient = yield* HttpClient.HttpClient
231 const apiVersion = options.apiVersion ?? "2023-06-01"
232
233 const httpClient = baseClient.pipe(
234 HttpClient.mapRequest((request) =>
235 request.pipe(
236 HttpClientRequest.prependUrl(options.apiUrl ?? "https://api.anthropic.com"),
237 Predicate.isNotUndefined(options.apiKey)
238 ? HttpClientRequest.setHeader(
239 RedactedAnthropicHeaders.AnthropicApiKey,
240 Redacted.value(options.apiKey)
241 )
242 : identity,
243 HttpClientRequest.setHeader("anthropic-version", apiVersion),
244 HttpClientRequest.acceptJson
245 )
246 ),
247 Predicate.isNotUndefined(options.transformClient)
248 ? options.transformClient
249 : identity
250 )
251
252 // Let status mapping handle gateway errors without an Anthropic envelope.
253 const generatedHttpClient = HttpClient.transformResponse(
254 httpClient,
255 Effect.flatMap((response) => {
256 if (
257 response.status < 400 || response.status >= 500 ||
258 !response.request.url.endsWith("/v1/messages?beta=true")
259 ) {
260 return Effect.succeed(response)
261 }
262 return Effect.flatMap(Effect.option(response.json), (body) => {
263 if (
264 body._tag === "Some" &&
265 Schema.decodeUnknownOption(Generated.BetaMessagesPost4XX)(body.value)._tag === "Some"
266 ) {
267 return Effect.succeed(response)
268 }
269 return Effect.flatMap(
270 Effect.orElseSucceed(response.text, () => "Unexpected status code"),
271 (description) =>
272 Effect.fail(
273 new HttpClientError.HttpClientError({
274 reason: new HttpClientError.StatusCodeError({
275 request: response.request,
276 response,
277 description
278 })
279 })
280 )
281 )
282 })
283 })
284 )
285
286 const client = Generated.make(generatedHttpClient, {
287 transformClient: Effect.fnUntraced(function*(client) {
288 const config = yield* AnthropicConfig.getOrUndefined
289 if (Predicate.isNotUndefined(config?.transformClient)) {
290 return config.transformClient(client)
291 }
292 return client
293 })
294 })
295
296 const httpClientOk = HttpClient.filterStatusOk(httpClient)
297
298 const streamRequest =
299 <S extends Sse.EventCodec>(schema: S) =>
300 (request: HttpClientRequest.HttpClientRequest): Stream.Stream<
301 S["Type"],
302 HttpClientError.HttpClientError | Schema.SchemaError | Sse.Retry | Sse.SseError,
303 S["DecodingServices"]
304 > =>
305 httpClientOk.execute(request).pipe(
306 Effect.map((response) => response.stream),
307 Stream.unwrap,
308 Stream.decodeText,
309 Stream.pipeThroughChannel(Sse.decodeSchema(schema))
310 )
311
312 const createMessage = (options: {
313 readonly payload: typeof Generated.BetaCreateMessageParams.Encoded
314 readonly params?: typeof Generated.BetaMessagesPostParams.Encoded | undefined
315 }): Effect.Effect<
316 [body: typeof Generated.BetaMessage.Type, response: HttpClientResponse.HttpClientResponse],
317 AiError.AiError
318 > =>
319 client.betaMessagesPost({ ...options, config: { includeResponse: true } }).pipe(
320 Effect.catchTags({
321 BetaMessagesPost4XX: (error) => Effect.fail(Errors.mapClientError(error, "createMessage")),
322 HttpClientError: (error) => Errors.mapHttpClientError(error, "createMessage"),
323 SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createMessage"))
324 }),
325 withRedactedHeaders
326 )
327
328 const PingEvent = Schema.Struct({
329 type: Schema.Literal("ping")
330 })
331
332 const MessageEvent = Schema.Union([
333 PingEvent,
334 Generated.BetaMessageStartEvent,
335 Generated.BetaMessageDeltaEvent,
336 Generated.BetaMessageStopEvent,
337 Generated.BetaContentBlockStartEvent,
338 Generated.BetaContentBlockDeltaEvent,
339 Generated.BetaContentBlockStopEvent,
340 Generated.BetaErrorResponse
341 ])
342
343 const buildMessageStream = (
344 response: HttpClientResponse.HttpClientResponse
345 ): [HttpClientResponse.HttpClientResponse, Stream.Stream<MessageStreamEvent, AiError.AiError>] => {
346 const stream = response.stream.pipe(
347 Stream.decodeText,
348 Stream.pipeThroughChannel(Sse.decodeDataSchema(MessageEvent)),
349 Stream.takeUntil((event) => event.data.type === "message_stop"),
350 Stream.map((event) => event.data),
351 Stream.filter((event): event is MessageStreamEvent => event.type !== "ping"),
352 Stream.catchTags({
353 // TODO: handle SSE retries
354 Retry: (error) => Stream.die(error),
355 SseError: (error) => Stream.fail(Errors.mapSseError(error, "createMessageStream")),
356 HttpClientError: (error) => Stream.fromEffect(Errors.mapHttpClientError(error, "createMessageStream")),
357 SchemaError: (error) => Stream.fail(Errors.mapSchemaError(error, "createMessageStream"))
358 })
359 ) as any
360 return [response, stream]
361 }
362
363 const createMessageStream: Service["createMessageStream"] = (options) => {
364 const request = HttpClientRequest.post("/v1/messages", {
365 headers: Headers.fromInput({
366 "anthropic-beta": options.params?.["anthropic-beta"] ?? undefined,
367 "anthropic-version": options.params?.["anthropic-version"] ?? apiVersion
368 }),
369 body: HttpBody.jsonUnsafe({
370 ...options.payload,
371 stream: true
372 })
373 })
374 return httpClientOk.execute(request).pipe(
375 Effect.map(buildMessageStream),
376 Effect.catchTag(
377 "HttpClientError",
378 (error) => Errors.mapHttpClientError(error, "createMessageStream")
379 ),
380 withRedactedHeaders
381 )
382 }
383
384 return AnthropicClient.of({
385 client,
386 streamRequest,
387 createMessage,
388 createMessageStream
389 })
390 },
391 withRedactedHeaders
392)
393
394// =============================================================================
395// Layers
396// =============================================================================
397
398/**
399 * Creates a layer for the Anthropic client with the given options.
400 *
401 * **When to use**
402 *
403 * Use when you already have explicit `Options` values, such as an API key or
404 * custom API URL, and want to provide `AnthropicClient` as a `Layer`.
405 *
406 * @see {@link make} for constructing the client service effectfully
407 * @see {@link layerConfig} for loading client settings from `Config`
408 *
409 * @stability unstable
410 * @category layers
411 * @since 4.0.0
412 */
413export const layer = (options: Options): Layer.Layer<AnthropicClient, never, HttpClient.HttpClient> =>
414 Layer.effect(AnthropicClient, make(options))
415
416/**
417 * Creates a layer for the Anthropic client, loading the requisite configuration
418 * via Effect's `Config` module.
419 *
420 * **When to use**
421 *
422 * Use when you want to provide the Anthropic client as a `Layer` with
423 * configuration loaded from Effect's `Config` module, such as from environment
424 * variables or a secrets provider.
425 *
426 * @see {@link layer} for providing the client from explicit options instead of `Config`
427 * @see {@link make} for constructing the client service effectfully
428 *
429 * @stability unstable
430 * @category layers
431 * @since 4.0.0
432 */
433export const layerConfig = (options?: {
434 /**
435 * The Anthropic API key for authentication. Requests are made without authentication when this is omitted, which is
436 * useful for proxied setups or testing.
437 */
438 readonly apiKey?: Config.Config<Redacted.Redacted<string> | undefined> | undefined
439
440 /**
441 * The base URL for the Anthropic API. Override this to use a proxy or a different API-compatible endpoint.
442 *
443 * @default "https://api.anthropic.com"
444 */
445 readonly apiUrl?: Config.Config<string> | undefined
446
447 /**
448 * The Anthropic API version header value. This controls which version of the API to use.
449 *
450 * @default "2023-06-01"
451 */
452 readonly apiVersion?: Config.Config<string> | undefined
453
454 /**
455 * Optional transformer for the underlying HTTP client, such as middleware, logging, or custom request/response
456 * handling.
457 */
458 readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
459}): Layer.Layer<AnthropicClient, Config.ConfigError, HttpClient.HttpClient> =>
460 Layer.effect(
461 AnthropicClient,
462 Effect.gen(function*() {
463 const apiKey = Predicate.isNotUndefined(options?.apiKey)
464 ? yield* options.apiKey :
465 undefined
466 const apiUrl = Predicate.isNotUndefined(options?.apiUrl)
467 ? yield* options.apiUrl :
468 undefined
469 const apiVersion = Predicate.isNotUndefined(options?.apiVersion)
470 ? yield* options.apiVersion :
471 undefined
472 return yield* make({
473 apiKey,
474 apiUrl,
475 apiVersion,
476 transformClient: options?.transformClient
477 })
478 })
479 )