1import { OpenAiClient } from "@effect/ai-openai-compat"
2import * as Errors from "@effect/ai-openai-compat/internal/errors"
3import { assert, describe, it } from "@effect/vitest"
4import { Context, Effect, Layer, Redacted, type Schema, Stream } from "effect"
5import { Headers, HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/http"
6
7describe("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
480type 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
500class MockOpenAiResponse extends Context.Service<MockOpenAiResponse, {
501 readonly response: MockResponse
502}>()("MockOpenAiResponse") {}
503
504class 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
510const 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
532const HttpClientLayer = Layer.effectContext(makeHttpClientContext)
533
534const 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
550const 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
573const 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
598const 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
608const 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
614const 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})