| 1 | import * as CloudflareClient from "@effect/ai-cloudflare/CloudflareClient" |
| 2 | import { assert, describe, it } from "@effect/vitest" |
| 3 | import { Config, ConfigProvider, Duration, Effect, Layer, Redacted } from "effect" |
| 4 | import * as HttpClient from "effect/http/HttpClient" |
| 5 | import * as HttpClientError from "effect/http/HttpClientError" |
| 6 | import * as HttpClientRequest from "effect/http/HttpClientRequest" |
| 7 | import * as HttpClientResponse from "effect/http/HttpClientResponse" |
| 8 | |
| 9 | const payload = { |
| 10 | model: "clef-flash", |
| 11 | state: "Checkout is down", |
| 12 | questions: { urgent: { type: "noul", instructions: "Does this need immediate attention?" } } |
| 13 | } as const |
| 14 | |
| 15 | const result = { |
| 16 | model: "clef-flash", |
| 17 | answers: { urgent: { type: "noul", noul: 0.99 } }, |
| 18 | usage: { input_tokens: 120, output_tokens: 0 } |
| 19 | } as const |
| 20 | |
| 21 | const httpLayer = ( |
| 22 | handler: ( |
| 23 | request: HttpClientRequest.HttpClientRequest |
| 24 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> |
| 25 | ) => |
| 26 | Layer.succeed( |
| 27 | HttpClient.HttpClient, |
| 28 | HttpClient.makeWith( |
| 29 | Effect.flatMap(handler), |
| 30 | Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never> |
| 31 | ) |
| 32 | ) |
| 33 | |
| 34 | const clientLayer = (response: (request: HttpClientRequest.HttpClientRequest) => Response) => |
| 35 | CloudflareClient.layer({ accountId: "test-account", apiKey: Redacted.make("test-token") }).pipe( |
| 36 | Layer.provide(httpLayer((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response(request))))) |
| 37 | ) |
| 38 | |
| 39 | const createDecisions = Effect.gen(function*() { |
| 40 | const client = yield* CloudflareClient.CloudflareClient |
| 41 | return yield* client.createDecisions(payload) |
| 42 | }) |
| 43 | |
| 44 | describe("CloudflareClient", () => { |
| 45 | it.effect("posts decisions to the account model endpoint and unwraps the result envelope", () => |
| 46 | Effect.gen(function*() { |
| 47 | const response = yield* createDecisions |
| 48 | assert.deepStrictEqual(response, result) |
| 49 | }).pipe(Effect.provide(clientLayer((request) => { |
| 50 | assert.strictEqual(request.method, "POST") |
| 51 | assert.strictEqual( |
| 52 | request.url, |
| 53 | "https://api.cloudflare.com/client/v4/accounts/test-account/ai/run/@cf/cloudflare/clef-flash" |
| 54 | ) |
| 55 | assert.strictEqual(request.headers.authorization, "Bearer test-token") |
| 56 | assert.strictEqual(request.body._tag, "Uint8Array") |
| 57 | if (request.body._tag === "Uint8Array") { |
| 58 | assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), payload) |
| 59 | } |
| 60 | return Response.json({ success: true, result, errors: [], messages: [] }) |
| 61 | })))) |
| 62 | |
| 63 | it.effect("layerConfig reads Cloudflare credentials from the environment", () => |
| 64 | createDecisions.pipe( |
| 65 | Effect.provide(CloudflareClient.layerConfig({ |
| 66 | apiUrl: Config.String("TEST_API_URL"), |
| 67 | transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader("x-test", "configured")) |
| 68 | })), |
| 69 | Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ |
| 70 | env: { |
| 71 | CLOUDFLARE_ACCOUNT_ID: "configured-account", |
| 72 | CLOUDFLARE_API_TOKEN: "configured-token", |
| 73 | TEST_API_URL: "https://example.com/api" |
| 74 | } |
| 75 | }))), |
| 76 | Effect.provide(httpLayer((request) => { |
| 77 | assert.strictEqual( |
| 78 | request.url, |
| 79 | "https://example.com/api/accounts/configured-account/ai/run/@cf/cloudflare/clef-flash" |
| 80 | ) |
| 81 | assert.strictEqual(request.headers.authorization, "Bearer configured-token") |
| 82 | assert.strictEqual(request.headers["x-test"], "configured") |
| 83 | return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ success: true, result }))) |
| 84 | })) |
| 85 | )) |
| 86 | |
| 87 | describe("error mapping", () => { |
| 88 | for ( |
| 89 | const [status, tag] of [ |
| 90 | [401, "AuthenticationError"], |
| 91 | [404, "InvalidRequestError"], |
| 92 | [422, "InvalidRequestError"], |
| 93 | [200, "InternalProviderError"] |
| 94 | ] as const |
| 95 | ) { |
| 96 | it.effect(`maps a failure envelope with HTTP ${status} to ${tag}`, () => |
| 97 | Effect.gen(function*() { |
| 98 | const error = yield* Effect.flip(createDecisions) |
| 99 | assert.strictEqual(error.module, "CloudflareClient") |
| 100 | assert.strictEqual(error.method, "createDecisions") |
| 101 | assert.strictEqual(error.reason._tag, tag) |
| 102 | if ("http" in error.reason) { |
| 103 | assert.strictEqual(error.reason.http?.response?.status, status) |
| 104 | assert.notInclude(JSON.stringify(error.reason.http?.request), "test-token") |
| 105 | } |
| 106 | if ("description" in error.reason) { |
| 107 | assert.include(error.reason.description, "Request failed") |
| 108 | assert.include(error.reason.description, "3040") |
| 109 | assert.include(error.reason.description, "test-ray") |
| 110 | } |
| 111 | }).pipe(Effect.provide(clientLayer(() => |
| 112 | Response.json( |
| 113 | { success: false, result: null, errors: [{ code: 3040, message: "Request failed" }] }, |
| 114 | { status, headers: { "cf-ray": "test-ray" } } |
| 115 | ) |
| 116 | )))) |
| 117 | } |
| 118 | |
| 119 | it.effect("maps a failure envelope without errors with HTTP 200 to InternalProviderError", () => |
| 120 | Effect.gen(function*() { |
| 121 | const error = yield* Effect.flip(createDecisions) |
| 122 | assert.strictEqual(error.reason._tag, "InternalProviderError") |
| 123 | }).pipe(Effect.provide(clientLayer(() => Response.json({ success: false, result: null }))))) |
| 124 | |
| 125 | it.effect("maps 429 to RateLimitError with Retry-After and Cloudflare metadata", () => |
| 126 | Effect.gen(function*() { |
| 127 | const error = yield* Effect.flip(createDecisions) |
| 128 | assert.strictEqual(error.reason._tag, "RateLimitError") |
| 129 | if (error.reason._tag === "RateLimitError") { |
| 130 | assert.strictEqual(Duration.toMillis(error.reason.retryAfter!), 2000) |
| 131 | assert.deepStrictEqual(error.reason.metadata, { |
| 132 | cloudflare: { rayId: "test-ray", errors: [{ code: 3040, message: "Request failed" }] } |
| 133 | }) |
| 134 | } |
| 135 | }).pipe(Effect.provide(clientLayer(() => |
| 136 | Response.json( |
| 137 | { success: false, result: null, errors: [{ code: 3040, message: "Request failed" }] }, |
| 138 | { status: 429, headers: { "cf-ray": "test-ray", "retry-after": "2" } } |
| 139 | ) |
| 140 | )))) |
| 141 | |
| 142 | it.effect("maps a non-JSON failure body by HTTP status", () => |
| 143 | Effect.gen(function*() { |
| 144 | const error = yield* Effect.flip(createDecisions) |
| 145 | assert.strictEqual(error.reason._tag, "InternalProviderError") |
| 146 | if (error.reason._tag === "InternalProviderError") { |
| 147 | assert.include(error.reason.description, "Bad gateway") |
| 148 | } |
| 149 | }).pipe(Effect.provide(clientLayer(() => new Response("Bad gateway", { status: 502 }))))) |
| 150 | |
| 151 | it.effect("maps a success body without a result envelope to InvalidOutputError", () => |
| 152 | Effect.gen(function*() { |
| 153 | const error = yield* Effect.flip(createDecisions) |
| 154 | assert.strictEqual(error.reason._tag, "InvalidOutputError") |
| 155 | }).pipe(Effect.provide(clientLayer(() => Response.json(result))))) |
| 156 | |
| 157 | it.effect("maps TransportError to NetworkError", () => |
| 158 | Effect.gen(function*() { |
| 159 | const error = yield* Effect.flip(createDecisions) |
| 160 | assert.strictEqual(error.reason._tag, "NetworkError") |
| 161 | }).pipe( |
| 162 | Effect.provide(CloudflareClient.layer({ accountId: "test-account", apiKey: Redacted.make("test-token") })), |
| 163 | Effect.provide(httpLayer((request) => |
| 164 | Effect.fail( |
| 165 | new HttpClientError.HttpClientError({ |
| 166 | reason: new HttpClientError.TransportError({ request, cause: new Error("Connection refused") }) |
| 167 | }) |
| 168 | ) |
| 169 | )) |
| 170 | )) |
| 171 | }) |
| 172 | }) |