| 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 | */ |
| 10 | import type * as AiError from "effect/ai/AiError" |
| 11 | import * as Array from "effect/Array" |
| 12 | import type * as Config from "effect/Config" |
| 13 | import * as Context from "effect/Context" |
| 14 | import * as Effect from "effect/Effect" |
| 15 | import * as Sse from "effect/encoding/Sse" |
| 16 | import { identity } from "effect/Function" |
| 17 | import * as Headers from "effect/http/Headers" |
| 18 | import * as HttpBody from "effect/http/HttpBody" |
| 19 | import * as HttpClient from "effect/http/HttpClient" |
| 20 | import * as HttpClientError from "effect/http/HttpClientError" |
| 21 | import * as HttpClientRequest from "effect/http/HttpClientRequest" |
| 22 | import type * as HttpClientResponse from "effect/http/HttpClientResponse" |
| 23 | import * as Layer from "effect/Layer" |
| 24 | import * as Predicate from "effect/Predicate" |
| 25 | import * as Redacted from "effect/Redacted" |
| 26 | import * as Schema from "effect/Schema" |
| 27 | import * as Stream from "effect/Stream" |
| 28 | import { AnthropicConfig } from "./AnthropicConfig.ts" |
| 29 | import * as Generated from "./Generated.ts" |
| 30 | import * 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 | */ |
| 44 | export 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 | */ |
| 107 | export 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 | */ |
| 136 | export 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 | */ |
| 166 | export 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 | |
| 198 | const RedactedAnthropicHeaders = { |
| 199 | AnthropicApiKey: "x-api-key" |
| 200 | } |
| 201 | |
| 202 | const 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 | */ |
| 228 | export 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 | */ |
| 413 | export 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 | */ |
| 433 | export 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 | ) |