| 1 | /** |
| 2 | * HTTP client for Cloudflare Clef decisions through Workers AI REST. |
| 3 | * |
| 4 | * @stability unstable |
| 5 | * @since 4.0.0 |
| 6 | */ |
| 7 | import * as AiError from "effect/ai/AiError" |
| 8 | import * as Config from "effect/Config" |
| 9 | import * as Context from "effect/Context" |
| 10 | import * as Effect from "effect/Effect" |
| 11 | import { flow, identity } from "effect/Function" |
| 12 | import * as HttpClient from "effect/http/HttpClient" |
| 13 | import * as HttpClientRequest from "effect/http/HttpClientRequest" |
| 14 | import * as HttpClientResponse from "effect/http/HttpClientResponse" |
| 15 | import * as Layer from "effect/Layer" |
| 16 | import * as Redacted from "effect/Redacted" |
| 17 | import * as Schema from "effect/Schema" |
| 18 | import * as CloudflareSchema from "./CloudflareSchema.ts" |
| 19 | import * as Errors from "./internal/errors.ts" |
| 20 | |
| 21 | /** |
| 22 | * Low-level Cloudflare decision operations. |
| 23 | * |
| 24 | * @stability unstable |
| 25 | * @category services |
| 26 | * @since 4.0.0 |
| 27 | */ |
| 28 | export interface Service { |
| 29 | readonly client: HttpClient.HttpClient |
| 30 | readonly createDecisions: ( |
| 31 | request: typeof CloudflareSchema.DecisionsRequest.Encoded |
| 32 | ) => Effect.Effect<typeof CloudflareSchema.DecisionsResponse.Type, AiError.AiError> |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Cloudflare Workers AI client service. |
| 37 | * |
| 38 | * @stability unstable |
| 39 | * @category services |
| 40 | * @since 4.0.0 |
| 41 | */ |
| 42 | export class CloudflareClient |
| 43 | extends Context.Service<CloudflareClient, Service>()("@effect/ai-cloudflare/CloudflareClient") |
| 44 | {} |
| 45 | |
| 46 | /** |
| 47 | * Account, authentication, API base URL, and HTTP customization options. |
| 48 | * |
| 49 | * @stability unstable |
| 50 | * @category options |
| 51 | * @since 4.0.0 |
| 52 | */ |
| 53 | export interface Options { |
| 54 | readonly accountId: string |
| 55 | readonly apiKey: Redacted.Redacted<string> |
| 56 | readonly apiUrl?: string | undefined |
| 57 | readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined |
| 58 | } |
| 59 | |
| 60 | const decodeResponse = HttpClientResponse.schemaBodyJson(Schema.Union([ |
| 61 | Schema.Struct({ success: Schema.Literal(true), result: CloudflareSchema.DecisionsResponse }), |
| 62 | Schema.Struct({ success: Schema.Literal(false) }) |
| 63 | ])) |
| 64 | |
| 65 | /** |
| 66 | * Builds a Cloudflare client without automatic retries. |
| 67 | * |
| 68 | * @stability unstable |
| 69 | * @category constructors |
| 70 | * @since 4.0.0 |
| 71 | */ |
| 72 | export const make = Effect.fnUntraced( |
| 73 | function*(options: Options): Effect.fn.Return<Service, never, HttpClient.HttpClient> { |
| 74 | const base = yield* HttpClient.HttpClient |
| 75 | const client = base.pipe( |
| 76 | HttpClient.mapRequest(flow( |
| 77 | HttpClientRequest.prependUrl(options.apiUrl ?? "https://api.cloudflare.com/client/v4"), |
| 78 | HttpClientRequest.bearerToken(Redacted.value(options.apiKey)), |
| 79 | HttpClientRequest.acceptJson |
| 80 | )), |
| 81 | HttpClient.filterStatusOk, |
| 82 | options.transformClient ?? identity |
| 83 | ) |
| 84 | const path = `/accounts/${encodeURIComponent(options.accountId)}/ai/run/@cf/cloudflare/` |
| 85 | return CloudflareClient.of({ |
| 86 | client, |
| 87 | createDecisions: (payload) => |
| 88 | HttpClientRequest.bodyJson(HttpClientRequest.post(path + payload.model), payload).pipe( |
| 89 | Effect.mapError((error) => |
| 90 | AiError.make({ |
| 91 | module: "CloudflareClient", |
| 92 | method: "createDecisions", |
| 93 | reason: new AiError.InvalidRequestError({ description: String(error) }) |
| 94 | }) |
| 95 | ), |
| 96 | Effect.flatMap(client.execute), |
| 97 | Effect.flatMap((response) => |
| 98 | decodeResponse(response).pipe( |
| 99 | Effect.flatMap((body) => body.success ? Effect.succeed(body.result) : Errors.mapResponseError(response)) |
| 100 | ) |
| 101 | ), |
| 102 | Effect.catchTags({ |
| 103 | HttpClientError: Errors.mapHttpClientError, |
| 104 | SchemaError: (error) => |
| 105 | Effect.fail(AiError.make({ |
| 106 | module: "CloudflareClient", |
| 107 | method: "createDecisions", |
| 108 | reason: AiError.InvalidOutputError.fromSchemaError(error) |
| 109 | })) |
| 110 | }) |
| 111 | ) |
| 112 | }) |
| 113 | } |
| 114 | ) |
| 115 | |
| 116 | /** |
| 117 | * Provides a Cloudflare client from explicit options. |
| 118 | * |
| 119 | * @stability unstable |
| 120 | * @category layers |
| 121 | * @since 4.0.0 |
| 122 | */ |
| 123 | export const layer = (options: Options): Layer.Layer<CloudflareClient, never, HttpClient.HttpClient> => |
| 124 | Layer.effect(CloudflareClient, make(options)) |
| 125 | |
| 126 | /** |
| 127 | * Provides a client from configuration, defaulting to CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. |
| 128 | * |
| 129 | * @stability unstable |
| 130 | * @category layers |
| 131 | * @since 4.0.0 |
| 132 | */ |
| 133 | export const layerConfig = (options?: { |
| 134 | readonly accountId?: Config.Config<string> | undefined |
| 135 | readonly apiKey?: Config.Config<Redacted.Redacted<string>> | undefined |
| 136 | readonly apiUrl?: Config.Config<string> | undefined |
| 137 | readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined |
| 138 | }): Layer.Layer<CloudflareClient, Config.ConfigError, HttpClient.HttpClient> => |
| 139 | Layer.effect( |
| 140 | CloudflareClient, |
| 141 | Effect.gen(function*() { |
| 142 | return yield* make({ |
| 143 | accountId: yield* (options?.accountId ?? Config.String("CLOUDFLARE_ACCOUNT_ID")), |
| 144 | apiKey: yield* (options?.apiKey ?? Config.Redacted("CLOUDFLARE_API_TOKEN")), |
| 145 | apiUrl: options?.apiUrl ? yield* options.apiUrl : undefined, |
| 146 | transformClient: options?.transformClient |
| 147 | }) |
| 148 | }) |
| 149 | ) |