| 1 | import { OpenAiClient } from "@effect/ai-openai-compat" |
| 2 | import * as Errors from "@effect/ai-openai-compat/internal/errors" |
| 3 | import { assert, describe, it } from "@effect/vitest" |
| 4 | import { Context, Effect, Layer, Redacted, type Schema, Stream } from "effect" |
| 5 | import { Headers, HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/http" |
| 6 | |
| 7 | describe("OpenAiClient", () => { |
| 8 | describe("request behavior", () => { |
| 9 | it.effect("sets auth and OpenAI headers on /chat/completions requests", () => |
| 10 | Effect.gen(function*() { |
| 11 | const client = yield* OpenAiClient.OpenAiClient |
| 12 | |
| 13 | yield* client.createResponse({ |
| 14 | model: "gpt-4o-mini", |
| 15 | messages: [{ role: "user", content: "hello" }] |
| 16 | }) |
| 17 | |
| 18 | const requests = yield* MockHttpClient.requests |
| 19 | const request = requests[0] |
| 20 | const body = yield* getRequestBody(request) |
| 21 | |
| 22 | assert.isTrue(request.url.endsWith("/chat/completions")) |
| 23 | assert.isTrue(request.url.startsWith("https://compat.example.test/v1")) |
| 24 | assert.strictEqual(request.headers["authorization"], "Bearer sk-test-key") |
| 25 | assert.strictEqual(request.headers["openai-organization"], "org_123") |
| 26 | assert.strictEqual(request.headers["openai-project"], "proj_456") |
| 27 | |
| 28 | assert.strictEqual(body.messages[0]?.role, "user") |
| 29 | assert.strictEqual(body.messages[0]?.content, "hello") |
| 30 | }).pipe(Effect.provide(makeTestLayer()))) |
| 31 | |
| 32 | it.effect("passes custom chat-completions request properties through", () => |
| 33 | Effect.gen(function*() { |
| 34 | const client = yield* OpenAiClient.OpenAiClient |
| 35 | |
| 36 | yield* client.createResponse({ |
| 37 | model: "gpt-4o-mini", |
| 38 | messages: [{ role: "user", content: "hello" }], |
| 39 | provider_feature: { |
| 40 | enabled: true |
| 41 | } |
| 42 | }) |
| 43 | |
| 44 | const requests = yield* MockHttpClient.requests |
| 45 | const request = requests[0] |
| 46 | const body = yield* getRequestBody(request) |
| 47 | assert.deepStrictEqual(body.provider_feature, { |
| 48 | enabled: true |
| 49 | }) |
| 50 | }).pipe(Effect.provide(makeTestLayer()))) |
| 51 | |
| 52 | it.effect("uses /embeddings path and decodes permissive embedding payloads", () => |
| 53 | Effect.gen(function*() { |
| 54 | const client = yield* OpenAiClient.OpenAiClient |
| 55 | |
| 56 | const embedding = yield* client.createEmbedding({ |
| 57 | model: "my-custom-embedding-model", |
| 58 | input: "embed this" |
| 59 | }) |
| 60 | |
| 61 | const requests = yield* MockHttpClient.requests |
| 62 | const request = requests[0] |
| 63 | |
| 64 | assert.isTrue(request.url.endsWith("/embeddings")) |
| 65 | assert.strictEqual(embedding.model, "my-custom-embedding-model") |
| 66 | assert.strictEqual(embedding.data[0]?.index, 0) |
| 67 | assert.strictEqual(typeof embedding.data[0]?.embedding, "string") |
| 68 | }).pipe(Effect.provide(makeTestLayer({ |
| 69 | _tag: "Json", |
| 70 | body: { |
| 71 | data: [{ |
| 72 | embedding: "YmFzZTY0LWRhdGE=", |
| 73 | index: 0, |
| 74 | object: "embedding", |
| 75 | vendor_payload: { future_field: true } |
| 76 | }], |
| 77 | model: "my-custom-embedding-model", |
| 78 | object: "list", |
| 79 | usage: { |
| 80 | prompt_tokens: 5, |
| 81 | total_tokens: 5 |
| 82 | }, |
| 83 | unknown_top_level: true |
| 84 | } |
| 85 | })))) |
| 86 | |
| 87 | it.effect("sets stream=true for createResponseStream and returns chat chunks", () => |
| 88 | Effect.gen(function*() { |
| 89 | const client = yield* OpenAiClient.OpenAiClient |
| 90 | |
| 91 | const events = yield* client.createResponseStream({ |
| 92 | model: "gpt-4o-mini", |
| 93 | messages: [{ role: "user", content: "hello" }] |
| 94 | }).pipe( |
| 95 | Effect.flatMap(([_, stream]) => Stream.runCollect(stream)) |
| 96 | ) |
| 97 | |
| 98 | const requests = yield* MockHttpClient.requests |
| 99 | const request = requests[0] |
| 100 | const body = yield* getRequestBody(request) |
| 101 | assert.strictEqual(body.stream, true) |
| 102 | assert.strictEqual(body.stream_options.include_usage, true) |
| 103 | assert.isTrue(request.url.endsWith("/chat/completions")) |
| 104 | |
| 105 | assert.propertyVal(events[0], "id", "chatcmpl_test_1") |
| 106 | assert.propertyVal(events[1], "id", "chatcmpl_test_1") |
| 107 | assert.strictEqual(events[2], "[DONE]") |
| 108 | }).pipe(Effect.provide(makeTestLayer({ |
| 109 | _tag: "Sse", |
| 110 | events: [ |
| 111 | { |
| 112 | id: "chatcmpl_test_1", |
| 113 | object: "chat.completion.chunk", |
| 114 | model: "gpt-4o-mini", |
| 115 | created: 1, |
| 116 | future_provider_field: { accepted: true }, |
| 117 | choices: [{ |
| 118 | index: 0, |
| 119 | delta: { content: "Hello" }, |
| 120 | finish_reason: null |
| 121 | }] |
| 122 | }, |
| 123 | { |
| 124 | id: "chatcmpl_test_1", |
| 125 | object: "chat.completion.chunk", |
| 126 | model: "gpt-4o-mini", |
| 127 | created: 1, |
| 128 | usage: { |
| 129 | prompt_tokens: 4, |
| 130 | completion_tokens: 2, |
| 131 | total_tokens: 6, |
| 132 | prompt_tokens_details: { cached_tokens: 1 }, |
| 133 | completion_tokens_details: { reasoning_tokens: 1 } |
| 134 | }, |
| 135 | choices: [{ |
| 136 | index: 0, |
| 137 | delta: {}, |
| 138 | finish_reason: "stop" |
| 139 | }] |
| 140 | }, |
| 141 | "[DONE]" |
| 142 | ] |
| 143 | })))) |
| 144 | |
| 145 | it.effect("surfaces schema-mismatched chat chunks and continues streaming", () => |
| 146 | Effect.gen(function*() { |
| 147 | const client = yield* OpenAiClient.OpenAiClient |
| 148 | |
| 149 | const events = yield* client.createResponseStream({ |
| 150 | model: "gpt-4o-mini", |
| 151 | messages: [{ role: "user", content: "hello" }] |
| 152 | }).pipe( |
| 153 | Effect.flatMap(([_, stream]) => Stream.runCollect(stream)) |
| 154 | ) |
| 155 | |
| 156 | assert.deepStrictEqual(events[0], { |
| 157 | _tag: "UnknownChatCompletionEvent", |
| 158 | data: { |
| 159 | type: "provider.chat.completion.delta", |
| 160 | provider_payload: { content: "provider-specific" } |
| 161 | } |
| 162 | }) |
| 163 | assert.propertyVal(events[1], "id", "chatcmpl_test_2") |
| 164 | assert.strictEqual(events[2], "[DONE]") |
| 165 | }).pipe(Effect.provide(makeTestLayer({ |
| 166 | _tag: "Sse", |
| 167 | events: [ |
| 168 | { |
| 169 | type: "provider.chat.completion.delta", |
| 170 | provider_payload: { content: "provider-specific" } |
| 171 | }, |
| 172 | { |
| 173 | id: "chatcmpl_test_2", |
| 174 | object: "chat.completion.chunk", |
| 175 | model: "gpt-4o-mini", |
| 176 | created: 1, |
| 177 | choices: [{ |
| 178 | index: 0, |
| 179 | delta: { content: "Hello" }, |
| 180 | finish_reason: null |
| 181 | }] |
| 182 | }, |
| 183 | "[DONE]" |
| 184 | ] |
| 185 | })))) |
| 186 | |
| 187 | it.effect("drops invalid JSON and continues streaming", () => |
| 188 | Effect.gen(function*() { |
| 189 | const client = yield* OpenAiClient.OpenAiClient |
| 190 | |
| 191 | const events = yield* client.createResponseStream({ |
| 192 | model: "gpt-4o-mini", |
| 193 | messages: [{ role: "user", content: "hello" }] |
| 194 | }).pipe( |
| 195 | Effect.flatMap(([_, stream]) => Stream.runCollect(stream)) |
| 196 | ) |
| 197 | |
| 198 | assert.strictEqual(events.length, 2) |
| 199 | assert.propertyVal(events[0], "id", "chatcmpl_test_3") |
| 200 | assert.strictEqual(events[1], "[DONE]") |
| 201 | }).pipe(Effect.provide(makeTestLayer({ |
| 202 | _tag: "RawSse", |
| 203 | body: [ |
| 204 | "data: {invalid-json\n\n", |
| 205 | `data: ${ |
| 206 | JSON.stringify({ |
| 207 | id: "chatcmpl_test_3", |
| 208 | object: "chat.completion.chunk", |
| 209 | model: "gpt-4o-mini", |
| 210 | created: 1, |
| 211 | choices: [{ |
| 212 | index: 0, |
| 213 | delta: { content: "Hello" }, |
| 214 | finish_reason: null |
| 215 | }] |
| 216 | }) |
| 217 | }\n\n`, |
| 218 | "data: [DONE]\n\n" |
| 219 | ].join("") |
| 220 | })))) |
| 221 | |
| 222 | it.effect("passes chat-completions tool_choice payload through unchanged", () => |
| 223 | Effect.gen(function*() { |
| 224 | const client = yield* OpenAiClient.OpenAiClient |
| 225 | |
| 226 | yield* client.createResponse({ |
| 227 | model: "gpt-4o-mini", |
| 228 | messages: [{ role: "user", content: "hello" }], |
| 229 | tool_choice: { |
| 230 | type: "function", |
| 231 | function: { |
| 232 | name: "TestTool" |
| 233 | } |
| 234 | }, |
| 235 | tools: [{ |
| 236 | type: "function", |
| 237 | function: { |
| 238 | name: "TestTool", |
| 239 | parameters: { |
| 240 | type: "object", |
| 241 | additionalProperties: false, |
| 242 | properties: { |
| 243 | input: { type: "string" } |
| 244 | }, |
| 245 | required: ["input"] |
| 246 | } |
| 247 | } |
| 248 | }] |
| 249 | }) |
| 250 | |
| 251 | const requests = yield* MockHttpClient.requests |
| 252 | const request = requests[0] |
| 253 | const body = yield* getRequestBody(request) |
| 254 | |
| 255 | assert.deepStrictEqual(body.tool_choice, { |
| 256 | type: "function", |
| 257 | function: { name: "TestTool" } |
| 258 | }) |
| 259 | }).pipe(Effect.provide(makeTestLayer()))) |
| 260 | |
| 261 | it.effect("accepts assistant tool-call and tool result chat history", () => |
| 262 | Effect.gen(function*() { |
| 263 | const client = yield* OpenAiClient.OpenAiClient |
| 264 | |
| 265 | yield* client.createResponse({ |
| 266 | model: "gpt-4o-mini", |
| 267 | messages: [ |
| 268 | { |
| 269 | role: "assistant", |
| 270 | content: null, |
| 271 | tool_calls: [{ |
| 272 | id: "patch_call_1", |
| 273 | type: "function", |
| 274 | function: { |
| 275 | name: "apply_patch", |
| 276 | arguments: JSON.stringify({ |
| 277 | call_id: "patch_call_1", |
| 278 | operation: { |
| 279 | type: "delete_file", |
| 280 | path: "src/obsolete.ts" |
| 281 | } |
| 282 | }) |
| 283 | } |
| 284 | }] |
| 285 | }, |
| 286 | { |
| 287 | role: "tool", |
| 288 | tool_call_id: "patch_call_1", |
| 289 | content: "deleted" |
| 290 | } |
| 291 | ] |
| 292 | }) |
| 293 | |
| 294 | const requests = yield* MockHttpClient.requests |
| 295 | const request = requests[0] |
| 296 | const body = yield* getRequestBody(request) |
| 297 | |
| 298 | const assistantMessages = body.messages.filter((message: any) => message.role === "assistant") |
| 299 | const patchMessage = assistantMessages.find((message: any) => |
| 300 | message.tool_calls?.[0]?.function?.name === "apply_patch" |
| 301 | ) |
| 302 | |
| 303 | assert.isDefined(patchMessage) |
| 304 | |
| 305 | assert.strictEqual(patchMessage.tool_calls[0].id, "patch_call_1") |
| 306 | assert.deepStrictEqual(JSON.parse(patchMessage.tool_calls[0].function.arguments), { |
| 307 | call_id: "patch_call_1", |
| 308 | operation: { |
| 309 | type: "delete_file", |
| 310 | path: "src/obsolete.ts" |
| 311 | } |
| 312 | }) |
| 313 | |
| 314 | const toolMessages = body.messages.filter((message: any) => message.role === "tool") |
| 315 | const patchOutput = toolMessages.find((message: any) => message.tool_call_id === "patch_call_1") |
| 316 | assert.isDefined(patchOutput) |
| 317 | assert.strictEqual(patchOutput.content, "deleted") |
| 318 | }).pipe(Effect.provide(makeTestLayer()))) |
| 319 | |
| 320 | it.effect("redacts OpenAI-specific headers in AI error context", () => |
| 321 | Effect.gen(function*() { |
| 322 | const client = yield* OpenAiClient.OpenAiClient |
| 323 | |
| 324 | const result = yield* client.createResponse({ |
| 325 | model: "gpt-4o-mini", |
| 326 | messages: [{ role: "user", content: "hello" }] |
| 327 | }).pipe(Effect.flip) |
| 328 | |
| 329 | const headers = result.reason._tag === "InvalidRequestError" |
| 330 | ? result.reason.http?.request.headers ?? Headers.empty |
| 331 | : Headers.empty |
| 332 | |
| 333 | assert.strictEqual(String(headers["authorization"]), "<redacted>") |
| 334 | assert.strictEqual(String(headers["openai-organization"]), "<redacted>") |
| 335 | assert.strictEqual(String(headers["openai-project"]), "<redacted>") |
| 336 | }).pipe(Effect.provide(makeTestLayer({ |
| 337 | _tag: "Json", |
| 338 | status: 400, |
| 339 | body: { |
| 340 | error: { |
| 341 | message: "Bad request", |
| 342 | type: "invalid_request_error", |
| 343 | code: null |
| 344 | } |
| 345 | } |
| 346 | })))) |
| 347 | }) |
| 348 | |
| 349 | describe("error mapping", () => { |
| 350 | it.effect("maps 400 responses to InvalidRequestError", () => |
| 351 | Effect.gen(function*() { |
| 352 | const client = yield* OpenAiClient.OpenAiClient |
| 353 | |
| 354 | const error = yield* client.createResponse({ |
| 355 | model: "gpt-4o-mini", |
| 356 | messages: [{ role: "user", content: "hello" }] |
| 357 | }).pipe(Effect.flip) |
| 358 | |
| 359 | assert.strictEqual(error._tag, "AiError") |
| 360 | assert.strictEqual(error.method, "createResponse") |
| 361 | assert.strictEqual(error.reason._tag, "InvalidRequestError") |
| 362 | }).pipe(Effect.provide(makeTestLayer({ |
| 363 | _tag: "Json", |
| 364 | status: 400, |
| 365 | body: { |
| 366 | error: { |
| 367 | message: "Bad request", |
| 368 | type: "invalid_request_error", |
| 369 | code: null |
| 370 | } |
| 371 | } |
| 372 | })))) |
| 373 | |
| 374 | it.effect("surfaces the provider message on 401 AuthenticationError", () => |
| 375 | Effect.gen(function*() { |
| 376 | const client = yield* OpenAiClient.OpenAiClient |
| 377 | |
| 378 | const error = yield* client.createResponse({ |
| 379 | model: "gpt-4o-mini", |
| 380 | messages: [{ role: "user", content: "hello" }] |
| 381 | }).pipe(Effect.flip) |
| 382 | |
| 383 | assert.strictEqual(error.reason._tag, "AuthenticationError") |
| 384 | if (error.reason._tag !== "AuthenticationError") { |
| 385 | return yield* Effect.die(new Error("Expected AuthenticationError")) |
| 386 | } |
| 387 | assert.strictEqual(error.reason.kind, "InvalidKey") |
| 388 | assert.strictEqual( |
| 389 | error.reason.description, |
| 390 | "Incorrect API key provided (POST https://compat.example.test/v1/chat/completions) [code: invalid_api_key] [requestId: req_openai_compat]" |
| 391 | ) |
| 392 | assert.include(error.reason.message, "Incorrect API key provided") |
| 393 | }).pipe(Effect.provide(makeTestLayer({ |
| 394 | _tag: "Json", |
| 395 | status: 401, |
| 396 | body: { |
| 397 | error: { |
| 398 | message: "Incorrect API key provided", |
| 399 | type: "invalid_request_error", |
| 400 | code: "invalid_api_key" |
| 401 | } |
| 402 | }, |
| 403 | headers: { "x-request-id": "req_openai_compat" } |
| 404 | })))) |
| 405 | |
| 406 | it("preserves and truncates a fallback HTTP response", () => { |
| 407 | const body = `${"a".repeat(200)}b` |
| 408 | const reason = Errors.mapStatusCodeToReason({ |
| 409 | status: 400, |
| 410 | headers: {}, |
| 411 | message: undefined, |
| 412 | metadata: { errorCode: null, errorType: null, requestId: null }, |
| 413 | http: makeHttpContext("https://compat.example.test/v1/chat/completions", body) |
| 414 | }) |
| 415 | |
| 416 | assert.strictEqual(reason._tag, "InvalidRequestError") |
| 417 | if (reason._tag !== "InvalidRequestError") { |
| 418 | throw new Error("Expected InvalidRequestError") |
| 419 | } |
| 420 | assert.strictEqual( |
| 421 | reason.description, |
| 422 | `HTTP 400 (POST https://compat.example.test/v1/chat/completions) Response: ${"a".repeat(200)}...` |
| 423 | ) |
| 424 | }) |
| 425 | |
| 426 | it.effect("surfaces the provider message on 403 AuthenticationError", () => |
| 427 | Effect.gen(function*() { |
| 428 | const client = yield* OpenAiClient.OpenAiClient |
| 429 | |
| 430 | const error = yield* client.createResponse({ |
| 431 | model: "gpt-4o-mini", |
| 432 | messages: [{ role: "user", content: "hello" }] |
| 433 | }).pipe(Effect.flip) |
| 434 | |
| 435 | assert.strictEqual(error.reason._tag, "AuthenticationError") |
| 436 | if (error.reason._tag !== "AuthenticationError") { |
| 437 | return yield* Effect.die(new Error("Expected AuthenticationError")) |
| 438 | } |
| 439 | assert.strictEqual(error.reason.kind, "InsufficientPermissions") |
| 440 | assert.include(error.reason.description ?? "", "Country, region, or territory not supported") |
| 441 | assert.include(error.reason.message, "Country, region, or territory not supported") |
| 442 | }).pipe(Effect.provide(makeTestLayer({ |
| 443 | _tag: "Json", |
| 444 | status: 403, |
| 445 | body: { |
| 446 | error: { |
| 447 | message: "Country, region, or territory not supported", |
| 448 | type: "permission_error", |
| 449 | code: null |
| 450 | } |
| 451 | } |
| 452 | })))) |
| 453 | |
| 454 | it.effect("maps insufficient quota errors to QuotaExhaustedError", () => |
| 455 | Effect.gen(function*() { |
| 456 | const client = yield* OpenAiClient.OpenAiClient |
| 457 | |
| 458 | const error = yield* client.createResponse({ |
| 459 | model: "gpt-4o-mini", |
| 460 | messages: [{ role: "user", content: "hello" }] |
| 461 | }).pipe(Effect.flip) |
| 462 | |
| 463 | assert.strictEqual(error._tag, "AiError") |
| 464 | assert.strictEqual(error.method, "createResponse") |
| 465 | assert.strictEqual(error.reason._tag, "QuotaExhaustedError") |
| 466 | }).pipe(Effect.provide(makeTestLayer({ |
| 467 | _tag: "Json", |
| 468 | status: 429, |
| 469 | body: { |
| 470 | error: { |
| 471 | message: "You exceeded your current quota", |
| 472 | type: "insufficient_quota", |
| 473 | code: "insufficient_quota" |
| 474 | } |
| 475 | } |
| 476 | })))) |
| 477 | }) |
| 478 | }) |
| 479 | |
| 480 | type MockResponse = |
| 481 | | { |
| 482 | readonly _tag: "Json" |
| 483 | readonly body: Schema.Json |
| 484 | readonly status?: number | undefined |
| 485 | readonly headers?: Record<string, string> | undefined |
| 486 | } |
| 487 | | { |
| 488 | readonly _tag: "Sse" |
| 489 | readonly events: ReadonlyArray<Schema.Json> |
| 490 | readonly status?: number | undefined |
| 491 | readonly headers?: Record<string, string> | undefined |
| 492 | } |
| 493 | | { |
| 494 | readonly _tag: "RawSse" |
| 495 | readonly body: string |
| 496 | readonly status?: number | undefined |
| 497 | readonly headers?: Record<string, string> | undefined |
| 498 | } |
| 499 | |
| 500 | class MockOpenAiResponse extends Context.Service<MockOpenAiResponse, { |
| 501 | readonly response: MockResponse |
| 502 | }>()("MockOpenAiResponse") {} |
| 503 | |
| 504 | class MockHttpClient extends Context.Service<MockHttpClient, { |
| 505 | readonly requests: Effect.Effect<ReadonlyArray<HttpClientRequest.HttpClientRequest>> |
| 506 | }>()("MockHttpClient") { |
| 507 | static requests = MockHttpClient.use((client) => client.requests) |
| 508 | } |
| 509 | |
| 510 | const makeHttpClientContext = Effect.gen(function*() { |
| 511 | const capturedRequests: Array<HttpClientRequest.HttpClientRequest> = [] |
| 512 | const mock = yield* MockOpenAiResponse |
| 513 | |
| 514 | const httpClient = HttpClient.makeWith( |
| 515 | Effect.fnUntraced(function*(requestEffect) { |
| 516 | const request = yield* requestEffect |
| 517 | capturedRequests.push(request) |
| 518 | return makeResponse(request, mock.response) |
| 519 | }), |
| 520 | Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never> |
| 521 | ) |
| 522 | |
| 523 | const mockHttpClient: MockHttpClient["Service"] = { |
| 524 | requests: Effect.sync(() => capturedRequests) |
| 525 | } |
| 526 | |
| 527 | return Context.make(HttpClient.HttpClient, httpClient).pipe( |
| 528 | Context.add(MockHttpClient, mockHttpClient) |
| 529 | ) |
| 530 | }) |
| 531 | |
| 532 | const HttpClientLayer = Layer.effectContext(makeHttpClientContext) |
| 533 | |
| 534 | const makeTestLayer = (response: MockResponse = { |
| 535 | _tag: "Json", |
| 536 | body: makeCreateResponse() |
| 537 | }) => |
| 538 | OpenAiClient.layer({ |
| 539 | apiKey: Redacted.make("sk-test-key"), |
| 540 | apiUrl: "https://compat.example.test/v1", |
| 541 | organizationId: Redacted.make("org_123"), |
| 542 | projectId: Redacted.make("proj_456") |
| 543 | }).pipe( |
| 544 | Layer.provideMerge(HttpClientLayer), |
| 545 | Layer.provide(Layer.succeed(MockOpenAiResponse, { |
| 546 | response |
| 547 | })) |
| 548 | ) |
| 549 | |
| 550 | const makeCreateResponse = ( |
| 551 | overrides: Partial<OpenAiClient.CreateResponse200> = {} |
| 552 | ) => ({ |
| 553 | id: "chatcmpl_test_1", |
| 554 | object: "chat.completion", |
| 555 | model: "gpt-4o-mini", |
| 556 | created: 1, |
| 557 | choices: [{ |
| 558 | index: 0, |
| 559 | finish_reason: "stop", |
| 560 | message: { |
| 561 | role: "assistant", |
| 562 | content: "Hello" |
| 563 | } |
| 564 | }], |
| 565 | usage: { |
| 566 | prompt_tokens: 1, |
| 567 | completion_tokens: 1, |
| 568 | total_tokens: 2 |
| 569 | }, |
| 570 | ...overrides |
| 571 | }) |
| 572 | |
| 573 | const makeResponse = ( |
| 574 | request: HttpClientRequest.HttpClientRequest, |
| 575 | response: MockResponse |
| 576 | ): HttpClientResponse.HttpClientResponse => { |
| 577 | const contentType = response._tag === "Json" |
| 578 | ? "application/json" |
| 579 | : "text/event-stream" |
| 580 | const body = response._tag === "Json" |
| 581 | ? JSON.stringify(response.body) |
| 582 | : response._tag === "Sse" |
| 583 | ? toSseBody(response.events) |
| 584 | : response.body |
| 585 | |
| 586 | return HttpClientResponse.fromWeb( |
| 587 | request, |
| 588 | new Response(body, { |
| 589 | status: response.status ?? 200, |
| 590 | headers: { |
| 591 | "content-type": contentType, |
| 592 | ...response.headers |
| 593 | } |
| 594 | }) |
| 595 | ) |
| 596 | } |
| 597 | |
| 598 | const getRequestBody = (request: HttpClientRequest.HttpClientRequest) => |
| 599 | Effect.gen(function*() { |
| 600 | const body = request.body |
| 601 | if (body._tag === "Uint8Array") { |
| 602 | const text = new TextDecoder().decode(body.body) |
| 603 | return JSON.parse(text) |
| 604 | } |
| 605 | return yield* Effect.die(new Error("Expected Uint8Array body")) |
| 606 | }) |
| 607 | |
| 608 | const toSseBody = (events: ReadonlyArray<Schema.Json>): string => |
| 609 | events.map((event) => { |
| 610 | const data = event === "[DONE]" ? event : JSON.stringify(event) |
| 611 | return `data: ${data}\n\n` |
| 612 | }).join("") |
| 613 | |
| 614 | const makeHttpContext = (url: string, body: string) => ({ |
| 615 | request: { |
| 616 | method: "POST" as const, |
| 617 | url, |
| 618 | urlParams: [], |
| 619 | hash: undefined, |
| 620 | headers: {} |
| 621 | }, |
| 622 | body |
| 623 | }) |