1import type { OpenAiSchema } from "@effect/ai-openai"
2import type * as Generated from "@effect/ai-openai/Generated"
3import * as Errors from "@effect/ai-openai/internal/errors"
4import * as OpenAiClient from "@effect/ai-openai/OpenAiClient"
5import * as OpenAiClientGenerated from "@effect/ai-openai/OpenAiClientGenerated"
6import * as OpenAiConfig from "@effect/ai-openai/OpenAiConfig"
7import * as OpenAiLanguageModel from "@effect/ai-openai/OpenAiLanguageModel"
8import { assert, describe, it } from "@effect/vitest"
9import { Config, ConfigProvider, Context, Effect, Layer, Redacted, Schema, Stream } from "effect"
10import { Chat } from "effect/ai"
11import * as HttpClient from "effect/http/HttpClient"
12import * as HttpClientError from "effect/http/HttpClientError"
13import * as HttpClientRequest from "effect/http/HttpClientRequest"
14import * as HttpClientResponse from "effect/http/HttpClientResponse"
15import * as Socket from "effect/socket/Socket"
16import { WS } from "vitest-websocket-mock"
17
18describe("OpenAiClient", () => {
19 describe("make", () => {
20 it.effect("sets Bearer token from apiKey", () =>
21 Effect.gen(function*() {
22 const client = yield* OpenAiClient.OpenAiClient
23 yield* client.createResponse({ model: "gpt-4o", input: "test" })
24
25 const requests = yield* MockHttpClient.requests
26 assert.strictEqual(requests[0]?.headers["authorization"], "Bearer sk-test-12345")
27 }).pipe(Effect.provide(makeTestLayer({
28 apiKey: Redacted.make("sk-test-12345")
29 }))))
30
31 it.effect("prepends default URL", () =>
32 Effect.gen(function*() {
33 const client = yield* OpenAiClient.OpenAiClient
34 yield* client.createResponse({ model: "gpt-4o", input: "test" })
35
36 const requests = yield* MockHttpClient.requests
37 assert.isTrue(requests[0]?.url.startsWith("https://api.openai.com/v1"))
38 }).pipe(Effect.provide(makeTestLayer())))
39
40 it.effect("uses custom apiUrl when provided", () =>
41 Effect.gen(function*() {
42 const client = yield* OpenAiClient.OpenAiClient
43 yield* client.createResponse({ model: "gpt-4o", input: "test" })
44
45 const requests = yield* MockHttpClient.requests
46 assert.isTrue(requests[0]?.url.startsWith("https://custom.api.com/v2"))
47 }).pipe(Effect.provide(makeTestLayer({
48 apiKey: Redacted.make("test-key"),
49 apiUrl: "https://custom.api.com/v2"
50 }))))
51
52 it.effect("sets OpenAI-Organization header when organizationId provided", () =>
53 Effect.gen(function*() {
54 const client = yield* OpenAiClient.OpenAiClient
55 yield* client.createResponse({ model: "gpt-4o", input: "test" })
56
57 const requests = yield* MockHttpClient.requests
58 assert.strictEqual(requests[0]?.headers["openai-organization"], "org-12345")
59 }).pipe(Effect.provide(makeTestLayer({
60 apiKey: Redacted.make("test-key"),
61 organizationId: Redacted.make("org-12345")
62 }))))
63
64 it.effect("sets OpenAI-Project header when projectId provided", () =>
65 Effect.gen(function*() {
66 const client = yield* OpenAiClient.OpenAiClient
67 yield* client.createResponse({ model: "gpt-4o", input: "test" })
68
69 const requests = yield* MockHttpClient.requests
70 assert.strictEqual(requests[0]?.headers["openai-project"], "proj-67890")
71 }).pipe(Effect.provide(makeTestLayer({
72 apiKey: Redacted.make("test-key"),
73 projectId: Redacted.make("proj-67890")
74 }))))
75
76 it.effect("applies transformClient option", () => {
77 let transformApplied = false
78 return Effect.gen(function*() {
79 const client = yield* OpenAiClient.OpenAiClient
80 yield* client.createResponse({ model: "gpt-4o", input: "test" })
81 assert.isTrue(transformApplied)
82 }).pipe(Effect.provide(makeTestLayer({
83 apiKey: Redacted.make("test-key"),
84 transformClient: (client) => {
85 transformApplied = true
86 return client
87 }
88 })))
89 })
90
91 it.effect("exposes transformed HttpClient via client field", () =>
92 Effect.gen(function*() {
93 const client = yield* OpenAiClient.OpenAiClient
94 yield* client.client.execute(HttpClientRequest.get("/responses"))
95
96 const requests = yield* MockHttpClient.requests
97 const request = requests[0]
98 assert.isTrue(request?.url.startsWith("https://api.openai.com/v1"))
99 assert.strictEqual(request?.headers["authorization"], "Bearer test-key")
100 assert.strictEqual(request?.headers["x-client-field"], "enabled")
101 }).pipe(Effect.provide(makeTestLayer({
102 apiKey: Redacted.make("test-key"),
103 transformClient: (client) =>
104 client.pipe(HttpClient.mapRequest(HttpClientRequest.setHeader("x-client-field", "enabled")))
105 }))))
106
107 it.effect("applies OpenAiConfig transformClient after options transformClient", () => {
108 let optionsTransformApplied = false
109 let configTransformApplied = false
110
111 return Effect.gen(function*() {
112 const client = yield* OpenAiClient.OpenAiClient
113 yield* client.createResponse({
114 model: "gpt-4o",
115 input: "test"
116 }).pipe(
117 OpenAiConfig.withClientTransform((client) => {
118 configTransformApplied = true
119 return client.pipe(
120 HttpClient.mapRequest(HttpClientRequest.setHeader("x-openai-transform", "config"))
121 )
122 })
123 )
124
125 const requests = yield* MockHttpClient.requests
126 assert.isTrue(optionsTransformApplied)
127 assert.isTrue(configTransformApplied)
128 assert.strictEqual(requests[0]?.headers["x-openai-transform"], "config")
129 }).pipe(Effect.provide(makeTestLayer({
130 apiKey: Redacted.make("test-key"),
131 transformClient: (client) => {
132 optionsTransformApplied = true
133 return client.pipe(
134 HttpClient.mapRequest(HttpClientRequest.setHeader("x-openai-transform", "options"))
135 )
136 }
137 })))
138 })
139 })
140
141 describe("OpenAiClientGenerated", () => {
142 it.effect("sets Bearer token from apiKey", () =>
143 Effect.gen(function*() {
144 const client = yield* OpenAiClientGenerated.OpenAiClientGenerated
145 yield* client.createResponse({
146 payload: { model: "gpt-4o", input: "test" }
147 })
148
149 const requests = yield* MockHttpClient.requests
150 assert.strictEqual(requests[0]?.headers["authorization"], "Bearer sk-generated-test")
151 }).pipe(Effect.provide(makeGeneratedTestLayer({
152 apiKey: Redacted.make("sk-generated-test")
153 }))))
154
155 it.effect("prepends custom apiUrl", () =>
156 Effect.gen(function*() {
157 const client = yield* OpenAiClientGenerated.OpenAiClientGenerated
158 yield* client.createResponse({
159 payload: { model: "gpt-4o", input: "test" }
160 })
161
162 const requests = yield* MockHttpClient.requests
163 assert.isTrue(requests[0]?.url.startsWith("https://generated.example.test/v2"))
164 }).pipe(Effect.provide(makeGeneratedTestLayer({
165 apiKey: Redacted.make("test-key"),
166 apiUrl: "https://generated.example.test/v2"
167 }))))
168
169 it.effect("applies OpenAiConfig transformClient after options transformClient", () => {
170 let optionsTransformApplied = false
171 let configTransformApplied = false
172
173 return Effect.gen(function*() {
174 const client = yield* OpenAiClientGenerated.OpenAiClientGenerated
175 yield* client.createResponse({
176 payload: { model: "gpt-4o", input: "test" }
177 }).pipe(
178 OpenAiConfig.withClientTransform((client) => {
179 configTransformApplied = true
180 return client.pipe(
181 HttpClient.mapRequest(HttpClientRequest.setHeader("x-openai-transform", "config"))
182 )
183 })
184 )
185
186 const requests = yield* MockHttpClient.requests
187 assert.isTrue(optionsTransformApplied)
188 assert.isTrue(configTransformApplied)
189 assert.strictEqual(requests[0]?.headers["x-openai-transform"], "config")
190 }).pipe(Effect.provide(makeGeneratedTestLayer({
191 apiKey: Redacted.make("test-key"),
192 transformClient: (client) => {
193 optionsTransformApplied = true
194 return client.pipe(
195 HttpClient.mapRequest(HttpClientRequest.setHeader("x-openai-transform", "options"))
196 )
197 }
198 })))
199 })
200 })
201
202 describe("layer", () => {
203 it.effect("creates working service", () =>
204 Effect.gen(function*() {
205 const client = yield* OpenAiClient.OpenAiClient
206 assert.isNotNull(client.client)
207 }).pipe(Effect.provide(makeTestLayer())))
208
209 it.effect("layerConfig loads from Config", () => {
210 const configProvider = ConfigProvider.fromEnv({
211 env: {
212 MY_API_KEY: "sk-config-key",
213 MY_API_URL: "https://config.api.com/v1"
214 }
215 })
216
217 return Effect.gen(function*() {
218 const client = yield* OpenAiClient.OpenAiClient
219 yield* client.createResponse({ model: "gpt-4o", input: "test" })
220
221 const requests = yield* MockHttpClient.requests
222 assert.strictEqual(requests[0]?.headers["authorization"], "Bearer sk-config-key")
223 assert.isTrue(requests[0]?.url.startsWith("https://config.api.com/v1"))
224 }).pipe(Effect.provide(makeConfigTestLayer(configProvider)))
225 })
226 })
227
228 describe("request behavior", () => {
229 it.effect("redacts OpenAI-specific headers in AI error context", () =>
230 Effect.gen(function*() {
231 const client = yield* OpenAiClient.OpenAiClient
232 const result = yield* client.createResponse({
233 model: "gpt-4o",
234 input: "test"
235 }).pipe(Effect.flip)
236
237 assert.strictEqual(result.reason._tag, "InvalidRequestError")
238 if (result.reason._tag !== "InvalidRequestError" || result.reason.http === undefined) {
239 return yield* Effect.die(new Error("Expected InvalidRequestError with HTTP context"))
240 }
241 const headers = result.reason.http.request.headers
242 assert.strictEqual(String(headers["authorization"]), "<redacted>")
243 assert.strictEqual(String(headers["openai-organization"]), "<redacted>")
244 assert.strictEqual(String(headers["openai-project"]), "<redacted>")
245 }).pipe(Effect.provide(makeTestLayer({
246 apiKey: Redacted.make("test-key"),
247 organizationId: Redacted.make("org-secret"),
248 projectId: Redacted.make("proj-secret")
249 }, {
250 _tag: "Json",
251 status: 400,
252 body: { error: { message: "Bad request" } }
253 }))))
254 })
255
256 describe("error mapping", () => {
257 it.effect("maps TransportError to NetworkError reason", () =>
258 Effect.gen(function*() {
259 const client = yield* OpenAiClient.make({
260 apiKey: Redacted.make("test-key")
261 })
262 const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(
263 Effect.flip
264 )
265
266 assert.strictEqual(result._tag, "AiError")
267 assert.strictEqual(result.module, "OpenAiClient")
268 assert.strictEqual(result.method, "createResponse")
269 assert.strictEqual(result.reason._tag, "NetworkError")
270 }).pipe(Effect.provide(Layer.succeed(HttpClient.HttpClient, makeTransportErrorHttpClient()))))
271
272 it.effect("maps 400 status to InvalidRequestError reason", () =>
273 Effect.gen(function*() {
274 const client = yield* OpenAiClient.OpenAiClient
275 const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(Effect.flip)
276
277 assert.strictEqual(result._tag, "AiError")
278 assert.strictEqual(result.module, "OpenAiClient")
279 assert.strictEqual(result.method, "createResponse")
280 assert.strictEqual(result.reason._tag, "InvalidRequestError")
281 }).pipe(Effect.provide(makeTestLayer(undefined, {
282 _tag: "Json",
283 status: 400,
284 body: { error: { message: "Bad request" } }
285 }))))
286
287 it.effect("maps 401 status to AuthenticationError reason", () =>
288 Effect.gen(function*() {
289 const client = yield* OpenAiClient.OpenAiClient
290 const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(Effect.flip)
291
292 assert.strictEqual(result._tag, "AiError")
293 assert.strictEqual(result.reason._tag, "AuthenticationError")
294 if (result.reason._tag === "AuthenticationError") {
295 assert.strictEqual(result.reason.kind, "InvalidKey")
296 assert.strictEqual(
297 result.reason.description,
298 "Invalid API key (POST https://api.openai.com/v1/responses) [code: invalid_api_key] [requestId: req_openai]"
299 )
300 assert.include(result.reason.message, "Invalid API key")
301 }
302 }).pipe(Effect.provide(makeTestLayer(undefined, {
303 _tag: "Json",
304 status: 401,
305 body: { error: { message: "Invalid API key", type: "invalid_request_error", code: "invalid_api_key" } },
306 headers: { "x-request-id": "req_openai" }
307 }))))
308
309 it("preserves and truncates a fallback HTTP response", () => {
310 const body = `${"a".repeat(200)}b`
311 const reason = Errors.mapStatusCodeToReason({
312 status: 400,
313 headers: {},
314 message: undefined,
315 metadata: { errorCode: null, errorType: null, requestId: null },
316 http: makeHttpContext("https://api.openai.com/v1/responses", body)
317 })
318
319 assert.strictEqual(reason._tag, "InvalidRequestError")
320 if (reason._tag !== "InvalidRequestError") {
321 throw new Error("Expected InvalidRequestError")
322 }
323 assert.strictEqual(
324 reason.description,
325 `HTTP 400 (POST https://api.openai.com/v1/responses) Response: ${"a".repeat(200)}...`
326 )
327 })
328
329 it.effect("maps 403 status to AuthenticationError with InsufficientPermissions", () =>
330 Effect.gen(function*() {
331 const client = yield* OpenAiClient.OpenAiClient
332 const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(Effect.flip)
333
334 assert.strictEqual(result._tag, "AiError")
335 assert.strictEqual(result.reason._tag, "AuthenticationError")
336 if (result.reason._tag === "AuthenticationError") {
337 assert.strictEqual(result.reason.kind, "InsufficientPermissions")
338 assert.include(result.reason.description ?? "", "Access denied")
339 assert.include(result.reason.message, "Access denied")
340 }
341 }).pipe(Effect.provide(makeTestLayer(undefined, {
342 _tag: "Json",
343 status: 403,
344 body: { error: { message: "Access denied" } }
345 }))))
346
347 it.effect("maps 429 status to RateLimitError reason", () =>
348 Effect.gen(function*() {
349 const client = yield* OpenAiClient.OpenAiClient
350 const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(Effect.flip)
351
352 assert.strictEqual(result._tag, "AiError")
353 assert.strictEqual(result.reason._tag, "RateLimitError")
354 assert.isTrue(result.isRetryable)
355 }).pipe(Effect.provide(makeTestLayer(undefined, {
356 _tag: "Json",
357 status: 429,
358 body: { error: { message: "Rate limit exceeded", type: "rate_limit_error", code: null } }
359 }))))
360
361 it.effect("maps 429 with insufficient_quota code to QuotaExhaustedError", () =>
362 Effect.gen(function*() {
363 const client = yield* OpenAiClient.OpenAiClient
364 const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(Effect.flip)
365
366 assert.strictEqual(result._tag, "AiError")
367 assert.strictEqual(result.reason._tag, "QuotaExhaustedError")
368 assert.isFalse(result.isRetryable)
369 }).pipe(Effect.provide(makeTestLayer(undefined, {
370 _tag: "Json",
371 status: 429,
372 body: {
373 error: {
374 message: "You exceeded your current quota",
375 type: "insufficient_quota",
376 code: "insufficient_quota"
377 }
378 }
379 }))))
380
381 it.effect("maps OpenAI-compatible 402 errors to QuotaExhaustedError", () =>
382 Effect.gen(function*() {
383 const client = yield* OpenAiClient.OpenAiClient
384 const result = yield* client.createResponse({ model: "grok-4", input: "test" }).pipe(Effect.flip)
385
386 assert.strictEqual(result._tag, "AiError")
387 assert.strictEqual(result.reason._tag, "QuotaExhaustedError")
388 assert.isFalse(result.isRetryable)
389 }).pipe(Effect.provide(makeTestLayer(undefined, {
390 _tag: "Json",
391 status: 402,
392 body: { error: "Your balance is too low", code: "billing_insufficient_balance" }
393 }))))
394
395 it.effect("maps OpenAI-compatible insufficient balance errors to QuotaExhaustedError", () =>
396 Effect.gen(function*() {
397 const client = yield* OpenAiClient.OpenAiClient
398 const result = yield* client.createResponse({ model: "grok-4", input: "test" }).pipe(Effect.flip)
399
400 assert.strictEqual(result._tag, "AiError")
401 assert.strictEqual(result.reason._tag, "QuotaExhaustedError")
402 assert.isFalse(result.isRetryable)
403 }).pipe(Effect.provide(makeTestLayer(undefined, {
404 _tag: "Json",
405 status: 429,
406 body: { error: "Your balance is too low", code: "billing_insufficient_balance" }
407 }))))
408
409 it("mapStatusCodeToReason detects insufficient_quota as QuotaExhaustedError", () => {
410 const http = {
411 request: {
412 method: "POST" as const,
413 url: "https://api.openai.com",
414 urlParams: [],
415 hash: undefined,
416 headers: {}
417 }
418 }
419 const reason = Errors.mapStatusCodeToReason({
420 status: 429,
421 headers: {},
422 message: "You exceeded your current quota",
423 metadata: {
424 errorCode: "insufficient_quota",
425 errorType: "insufficient_quota",
426 requestId: null
427 },
428 http
429 })
430 assert.strictEqual(reason._tag, "QuotaExhaustedError")
431 })
432
433 it("OpenAiErrorBody decodes error with type and code", () => {
434 const json = {
435 error: {
436 message: "You exceeded your current quota",
437 type: "insufficient_quota",
438 code: "insufficient_quota"
439 }
440 }
441 const decoded = Schema.decodeUnknownSync(Errors.OpenAiErrorBody)(json)
442 assert.strictEqual(decoded.error.message, "You exceeded your current quota")
443 assert.strictEqual(decoded.error.type, "insufficient_quota")
444 assert.strictEqual(decoded.error.code, "insufficient_quota")
445 })
446
447 it.effect("maps 5xx status to InternalProviderError reason", () =>
448 Effect.gen(function*() {
449 const client = yield* OpenAiClient.OpenAiClient
450 const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(Effect.flip)
451
452 assert.strictEqual(result._tag, "AiError")
453 assert.strictEqual(result.reason._tag, "InternalProviderError")
454 assert.isTrue(result.isRetryable)
455 }).pipe(Effect.provide(makeTestLayer(undefined, {
456 _tag: "Json",
457 status: 500,
458 body: { error: { message: "Internal server error" } }
459 }))))
460
461 it.effect("maps schema error to InvalidOutputError reason", () =>
462 Effect.gen(function*() {
463 const client = yield* OpenAiClient.OpenAiClient
464 const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(Effect.flip)
465
466 assert.strictEqual(result._tag, "AiError")
467 assert.strictEqual(result.method, "createResponse")
468 assert.strictEqual(result.reason._tag, "InvalidOutputError")
469 }).pipe(Effect.provide(makeTestLayer(undefined, {
470 _tag: "Json",
471 body: { invalid: "response" } as any
472 }))))
473 })
474
475 describe("createEmbedding", () => {
476 it.effect("maps 400 error to AiError", () =>
477 Effect.gen(function*() {
478 const client = yield* OpenAiClient.OpenAiClient
479 const result = yield* client.createEmbedding({
480 model: "invalid-model",
481 input: "test"
482 }).pipe(Effect.flip)
483
484 assert.strictEqual(result._tag, "AiError")
485 assert.strictEqual(result.method, "createEmbedding")
486 assert.strictEqual(result.reason._tag, "InvalidRequestError")
487 }).pipe(Effect.provide(makeTestLayer(undefined, {
488 _tag: "Json",
489 status: 400,
490 body: { error: { message: "Invalid model" } }
491 }))))
492
493 it.effect("maps 429 error to RateLimitError", () =>
494 Effect.gen(function*() {
495 const client = yield* OpenAiClient.OpenAiClient
496 const result = yield* client.createEmbedding({
497 model: "text-embedding-ada-002",
498 input: "test"
499 }).pipe(Effect.flip)
500
501 assert.strictEqual(result._tag, "AiError")
502 assert.strictEqual(result.reason._tag, "RateLimitError")
503 }).pipe(Effect.provide(makeTestLayer(undefined, {
504 _tag: "Json",
505 status: 429,
506 body: { error: { message: "Rate limit exceeded" } }
507 }))))
508 })
509
510 describe("createResponseStream", () => {
511 it.live("terminates an SSE stream at response.failed", () =>
512 Effect.gen(function*() {
513 const client = yield* OpenAiClient.OpenAiClient
514 const [, stream] = yield* client.createResponseStream({ model: "gpt-4o", input: "test" })
515 const result = yield* Stream.runCollect(stream).pipe(Effect.timeoutOption("100 millis"))
516
517 assert.strictEqual(result._tag, "Some")
518 }).pipe(Effect.provide(makeTestLayer(undefined, {
519 _tag: "Sse",
520 events: [{
521 type: "response.failed",
522 sequence_number: 1,
523 response: makeResponseBody({ status: "failed" })
524 }],
525 keepOpen: true
526 }))))
527
528 it.live("terminates a WebSocket stream at response.failed", () =>
529 Effect.gen(function*() {
530 const server = yield* Effect.acquireRelease(
531 Effect.sync(() => new WS("wss://api.openai.com/v1/responses", { jsonProtocol: true })),
532 (server) => Effect.sync(() => server.close())
533 )
534 const event = {
535 type: "response.failed",
536 sequence_number: 1,
537 response: makeResponseBody({ status: "failed" })
538 }
539 const result = yield* OpenAiClient.withWebSocketMode(
540 Effect.gen(function*() {
541 const client = yield* OpenAiClient.OpenAiClient
542 const [, stream] = yield* client.createResponseStream({ model: "gpt-4o", input: "test" })
543 const [events] = yield* Effect.all([
544 Stream.runCollect(stream),
545 Effect.promise(() => server.nextMessage).pipe(
546 Effect.tap(() => Effect.sync(() => server.send(event)))
547 )
548 ], { concurrency: "unbounded" })
549 return events
550 })
551 ).pipe(
552 Effect.provide(makeTestLayer()),
553 Effect.provideService(Socket.WebSocketConstructor, (url) => new globalThis.WebSocket(url)),
554 Effect.timeoutOption("1 second")
555 )
556
557 assert.strictEqual(result._tag, "Some")
558 }))
559
560 it.live("replaces the WebSocket after non-retryable error events", () =>
561 Effect.gen(function*() {
562 const server = yield* Effect.acquireRelease(
563 Effect.sync(() => new WS("wss://socket-errors.test/v1/responses", { jsonProtocol: true })),
564 (server) => Effect.sync(() => server.close())
565 )
566 let connections = 0
567
568 yield* OpenAiClient.withWebSocketMode(
569 Effect.gen(function*() {
570 const client = yield* OpenAiClient.OpenAiClient
571 const [, failedStream] = yield* client.createResponseStream({ model: "gpt-4o", input: "first" })
572 yield* Effect.all([
573 Stream.runDrain(failedStream).pipe(Effect.flip),
574 nextWebSocketCreate(server).pipe(
575 Effect.tap(() =>
576 Effect.sync(() =>
577 server.send({
578 type: "error",
579 code: "invalid_request_error",
580 message: "bad",
581 param: null
582 })
583 )
584 )
585 )
586 ], { concurrency: "unbounded" })
587
588 const [, secondFailedStream] = yield* client.createResponseStream({ model: "gpt-4o", input: "second" })
589 yield* Effect.all([
590 Stream.runDrain(secondFailedStream).pipe(Effect.flip),
591 nextWebSocketCreate(server).pipe(
592 Effect.tap(() =>
593 Effect.sync(() =>
594 server.send({
595 error: {
596 message: "gRPC error: permission denied",
597 type: "api_error"
598 }
599 })
600 )
601 )
602 )
603 ], { concurrency: "unbounded" })
604
605 const [, thirdFailedStream] = yield* client.createResponseStream({ model: "gpt-4o", input: "third" })
606 const [error] = yield* Effect.all([
607 Stream.runDrain(thirdFailedStream).pipe(Effect.flip),
608 nextWebSocketCreate(server).pipe(
609 Effect.tap(() =>
610 Effect.sync(() =>
611 server.send({
612 error: {
613 message: "bad request",
614 type: "invalid_request_error"
615 }
616 })
617 )
618 )
619 )
620 ], { concurrency: "unbounded" })
621 assert.strictEqual(error.reason._tag, "InvalidRequestError")
622
623 const [, nextStream] = yield* client.createResponseStream({ model: "gpt-4o", input: "fourth" })
624 yield* Effect.all([
625 Stream.runDrain(nextStream),
626 nextWebSocketCreate(server).pipe(
627 Effect.tap(() => Effect.sync(() => sendWebSocketCompleted(server, "resp_4", "msg_4", "ok")))
628 )
629 ], { concurrency: "unbounded" })
630 })
631 ).pipe(
632 Effect.provide(OpenAiClient.layer({
633 apiKey: Redacted.make("sk-test"),
634 apiUrl: "https://socket-errors.test/v1"
635 })),
636 Effect.provideService(Socket.WebSocketConstructor, (url) => {
637 connections++
638 return new globalThis.WebSocket(url)
639 }),
640 Effect.provideService(HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected http"))),
641 Effect.timeout("5 seconds")
642 )
643
644 assert.strictEqual(connections, 4)
645 }))
646
647 it.live("retries an untagged xAI response-not-found error with the full prompt", () =>
648 Effect.gen(function*() {
649 const server = yield* Effect.acquireRelease(
650 Effect.sync(() => new WS("wss://previous-response.test/v1/responses", { jsonProtocol: true })),
651 (server) => Effect.sync(() => server.close())
652 )
653
654 const drive = Effect.gen(function*() {
655 const chat = yield* Chat.empty
656 yield* chat.streamText({ prompt: "hello" }).pipe(Stream.runDrain)
657 yield* chat.streamText({ prompt: "again" }).pipe(Stream.runDrain)
658 yield* chat.streamText({ prompt: "later" }).pipe(Stream.runDrain)
659 }).pipe(
660 OpenAiClient.withWebSocketMode,
661 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini", { store: true })),
662 Effect.provide(OpenAiClient.layer({
663 apiKey: Redacted.make("sk-test"),
664 apiUrl: "https://previous-response.test/v1"
665 })),
666 Effect.provideService(Socket.WebSocketConstructor, (url) => new globalThis.WebSocket(url)),
667 Effect.provideService(HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected http")))
668 )
669
670 const respond = Effect.gen(function*() {
671 const first = yield* nextWebSocketCreate(server)
672 assert.isUndefined(first.previous_response_id)
673 assert.deepStrictEqual(first.input, [webSocketUserInput("hello")])
674 sendWebSocketCompleted(server, "resp_1", "msg_1", "ok")
675
676 const incremental = yield* nextWebSocketCreate(server)
677 assert.strictEqual(incremental.previous_response_id, "resp_1")
678 assert.deepStrictEqual(incremental.input, [webSocketUserInput("again")])
679 server.send({
680 error: {
681 message: "gRPC error: Response with id=resp_1 not found",
682 type: "api_error"
683 }
684 })
685
686 const retried = yield* nextWebSocketCreate(server)
687 assert.isUndefined(retried.previous_response_id)
688 assert.deepStrictEqual(retried.input, [
689 webSocketUserInput("hello"),
690 webSocketAssistantInput("msg_1", "ok"),
691 webSocketUserInput("again")
692 ])
693 sendWebSocketCompleted(server, "resp_2", "msg_2", "retried")
694
695 const later = yield* nextWebSocketCreate(server)
696 assert.strictEqual(later.previous_response_id, "resp_2")
697 assert.deepStrictEqual(later.input, [webSocketUserInput("later")])
698 sendWebSocketCompleted(server, "resp_3", "msg_3", "later")
699 })
700
701 yield* Effect.all([drive, respond], { concurrency: "unbounded" }).pipe(
702 Effect.timeout("5 seconds")
703 )
704 }))
705
706 it.live("round-trips streamed encrypted reasoning in a full-prompt retry", () =>
707 Effect.gen(function*() {
708 const server = yield* Effect.acquireRelease(
709 Effect.sync(() => new WS("wss://reasoning-recovery.test/v1/responses", { jsonProtocol: true })),
710 (server) => Effect.sync(() => server.close())
711 )
712 const requests: Array<WebSocketResponseCreate> = []
713
714 const drive = Effect.gen(function*() {
715 const chat = yield* Chat.empty
716 yield* chat.streamText({ prompt: "think" }).pipe(Stream.runDrain)
717 yield* chat.streamText({ prompt: "continue" }).pipe(Stream.runDrain)
718 }).pipe(
719 OpenAiClient.withWebSocketMode,
720 Effect.provide(OpenAiLanguageModel.model("o3-mini", { store: true })),
721 Effect.provide(OpenAiClient.layer({
722 apiKey: Redacted.make("sk-test"),
723 apiUrl: "https://reasoning-recovery.test/v1"
724 })),
725 Effect.provideService(Socket.WebSocketConstructor, (url) => new globalThis.WebSocket(url)),
726 Effect.provideService(HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected http")))
727 )
728
729 const respond = Effect.gen(function*() {
730 requests.push(yield* nextWebSocketCreate(server))
731 sendWebSocketReasoningCompleted(server, "resp_1", "rs_1", "thinking", "encrypted-reasoning")
732
733 requests.push(yield* nextWebSocketCreate(server))
734 server.send({
735 error: {
736 message: "gRPC error: Response with id=resp_1 not found",
737 type: "api_error"
738 }
739 })
740
741 requests.push(yield* nextWebSocketCreate(server))
742 sendWebSocketCompleted(server, "resp_2", "msg_2", "done")
743 })
744
745 yield* Effect.all([drive, respond], { concurrency: "unbounded" }).pipe(
746 Effect.timeout("5 seconds")
747 )
748
749 assert.deepStrictEqual({
750 include: requests[0]?.include,
751 reasoning: requests[2]?.input.find((item) => item.type === "reasoning")
752 }, {
753 include: ["reasoning.encrypted_content"],
754 reasoning: {
755 type: "reasoning",
756 id: "rs_1",
757 summary: [{ type: "summary_text", text: "thinking" }],
758 encrypted_content: "encrypted-reasoning"
759 }
760 })
761 }))
762
763 it.effect("accepts keepalive stream events", () =>
764 Effect.gen(function*() {
765 const client = yield* OpenAiClient.OpenAiClient
766 const [_, stream] = yield* client.createResponseStream({
767 model: "gpt-4o",
768 input: "test"
769 })
770
771 const events = yield* Stream.runCollect(stream)
772 const parts = globalThis.Array.from(events)
773
774 assert.strictEqual(parts.length, 3)
775 const keepAlive = parts[1]
776 assert.strictEqual(keepAlive.type, "keepalive")
777 if (keepAlive.type === "keepalive" && "sequence_number" in keepAlive) {
778 assert.strictEqual(keepAlive.sequence_number, 2)
779 }
780
781 const completed = parts[2]
782 assert.isTrue(typeof completed === "object" && completed !== null && "type" in completed)
783 if (typeof completed === "object" && completed !== null && "type" in completed) {
784 assert.strictEqual(completed.type, "response.completed")
785 if (
786 completed.type === "response.completed" &&
787 "response" in completed &&
788 typeof completed.response === "object" &&
789 completed.response !== null &&
790 "id" in completed.response
791 ) {
792 assert.strictEqual(completed.response.id, "resp_stream")
793 }
794 }
795 }).pipe(Effect.provide(makeTestLayer(undefined, {
796 _tag: "Sse",
797 events: [
798 {
799 type: "response.created",
800 sequence_number: 1,
801 response: makeResponseBody({ id: "resp_stream", status: "in_progress" })
802 },
803 {
804 type: "keepalive",
805 sequence_number: 2
806 },
807 {
808 type: "response.completed",
809 sequence_number: 3,
810 response: makeResponseBody({ id: "resp_stream" })
811 }
812 ]
813 }))))
814
815 it.effect("accepts response stream events without sequence numbers", () =>
816 Effect.gen(function*() {
817 const client = yield* OpenAiClient.OpenAiClient
818 const [, stream] = yield* client.createResponseStream({
819 model: "gpt-4o",
820 input: "test"
821 })
822
823 const events = yield* Stream.runCollect(stream)
824 const decoded = globalThis.Array.from(events)
825
826 assert.deepStrictEqual(decoded.map((event) => event.type), [
827 "response.created",
828 "response.output_item.added",
829 "response.content_part.added",
830 "response.output_text.delta",
831 "response.reasoning_text.delta",
832 "response.reasoning_text.done",
833 "response.content_part.done",
834 "response.completed"
835 ])
836 assert.deepStrictEqual(decoded[0], {
837 type: "response.created",
838 response: {
839 id: "resp_test123",
840 object: "response",
841 model: "gpt-4o-mini",
842 created_at: 1,
843 output: [],
844 error: null,
845 incomplete_details: null
846 }
847 })
848 assert.deepStrictEqual(decoded[3], {
849 type: "response.output_text.delta",
850 output_index: 0,
851 item_id: "msg_123",
852 content_index: 0,
853 delta: "hello",
854 sequence_number: 4
855 })
856 assert.deepStrictEqual([decoded[2], decoded[4], decoded[5], decoded[6]], [
857 {
858 type: "response.content_part.added",
859 output_index: 0,
860 item_id: "rs_tmp_123",
861 content_index: 0,
862 part: { type: "reasoning_text", text: "" }
863 },
864 {
865 type: "response.reasoning_text.delta",
866 output_index: 0,
867 item_id: "rs_tmp_123",
868 content_index: 0,
869 delta: "thinking..."
870 },
871 {
872 type: "response.reasoning_text.done",
873 output_index: 0,
874 item_id: "rs_tmp_123",
875 content_index: 0,
876 text: "thinking..."
877 },
878 {
879 type: "response.content_part.done",
880 output_index: 0,
881 item_id: "rs_tmp_123",
882 content_index: 0,
883 part: { type: "reasoning_text", text: "thinking..." }
884 }
885 ])
886 }).pipe(Effect.provide(makeTestLayer(undefined, {
887 _tag: "Sse",
888 events: [
889 {
890 type: "response.created",
891 response: makeResponseBody({ status: "in_progress" })
892 },
893 {
894 type: "response.output_item.added",
895 output_index: 0,
896 item: {
897 id: "msg_123",
898 type: "message",
899 role: "assistant",
900 status: "in_progress",
901 content: []
902 }
903 },
904 {
905 type: "response.content_part.added",
906 output_index: 0,
907 item_id: "rs_tmp_123",
908 content_index: 0,
909 part: { type: "reasoning_text", text: "" }
910 },
911 {
912 type: "response.output_text.delta",
913 output_index: 0,
914 item_id: "msg_123",
915 content_index: 0,
916 delta: "hello",
917 sequence_number: 4
918 },
919 {
920 type: "response.reasoning_text.delta",
921 output_index: 0,
922 item_id: "rs_tmp_123",
923 content_index: 0,
924 delta: "thinking..."
925 },
926 {
927 type: "response.reasoning_text.done",
928 output_index: 0,
929 item_id: "rs_tmp_123",
930 content_index: 0,
931 text: "thinking..."
932 },
933 {
934 type: "response.content_part.done",
935 output_index: 0,
936 item_id: "rs_tmp_123",
937 content_index: 0,
938 part: { type: "reasoning_text", text: "thinking..." }
939 },
940 {
941 type: "response.completed",
942 response: makeResponseBody()
943 }
944 ]
945 }))))
946
947 it.effect("maps HTTP error before stream starts", () =>
948 Effect.gen(function*() {
949 const client = yield* OpenAiClient.OpenAiClient
950 const result = yield* client.createResponseStream({
951 model: "gpt-4o",
952 input: "test"
953 }).pipe(
954 Effect.andThen(([_, stream]) => Stream.runDrain(stream)),
955 Effect.flip
956 )
957
958 assert.strictEqual(result._tag, "AiError")
959 assert.strictEqual(result.reason._tag, "InternalProviderError")
960 }).pipe(Effect.provide(makeTestLayer(undefined, {
961 _tag: "Json",
962 status: 500,
963 body: { error: { message: "Server error" } }
964 }))))
965 })
966})
967
968type WebSocketResponseCreate = {
969 readonly type: "response.create"
970 readonly previous_response_id?: string | undefined
971 readonly include?: ReadonlyArray<string> | undefined
972 readonly input: ReadonlyArray<Record<string, unknown>>
973}
974
975const nextWebSocketCreate = (server: WS) =>
976 Effect.promise(() => server.nextMessage).pipe(
977 Effect.map((message) => message as WebSocketResponseCreate)
978 )
979
980const webSocketUserInput = (text: string) => ({
981 role: "user",
982 content: [{ type: "input_text", text }]
983})
984
985const webSocketAssistantInput = (id: string, text: string) => ({
986 id,
987 type: "message",
988 role: "assistant",
989 status: "completed",
990 content: [{
991 type: "output_text",
992 text,
993 annotations: [],
994 logprobs: []
995 }]
996})
997
998const sendWebSocketCompleted = (server: WS, id: string, itemId: string, text: string) => {
999 const response = {
1000 id,
1001 object: "response",
1002 model: "gpt-4o-mini",
1003 created_at: 1,
1004 output: [],
1005 error: null,
1006 incomplete_details: null
1007 }
1008 const events = [
1009 { type: "response.created", response },
1010 {
1011 type: "response.output_item.added",
1012 output_index: 0,
1013 item: {
1014 id: itemId,
1015 type: "message",
1016 role: "assistant",
1017 status: "in_progress",
1018 content: []
1019 }
1020 },
1021 {
1022 type: "response.output_text.delta",
1023 item_id: itemId,
1024 output_index: 0,
1025 content_index: 0,
1026 delta: text
1027 },
1028 {
1029 type: "response.output_item.done",
1030 output_index: 0,
1031 item: {
1032 id: itemId,
1033 type: "message",
1034 role: "assistant",
1035 status: "completed",
1036 content: [{ type: "output_text", text, annotations: [] }]
1037 }
1038 },
1039 { type: "response.completed", response }
1040 ]
1041 for (const event of events) {
1042 server.send(event)
1043 }
1044}
1045
1046const sendWebSocketReasoningCompleted = (
1047 server: WS,
1048 id: string,
1049 itemId: string,
1050 summary: string,
1051 encryptedContent: string
1052) => {
1053 const response = {
1054 id,
1055 object: "response",
1056 model: "o3-mini",
1057 created_at: 1,
1058 output: [],
1059 error: null,
1060 incomplete_details: null
1061 }
1062 const events = [
1063 { type: "response.created", sequence_number: 1, response },
1064 {
1065 type: "response.output_item.added",
1066 sequence_number: 2,
1067 output_index: 0,
1068 item: { type: "reasoning", id: itemId, summary: [], encrypted_content: null }
1069 },
1070 {
1071 type: "response.reasoning_summary_part.added",
1072 sequence_number: 3,
1073 output_index: 0,
1074 item_id: itemId,
1075 summary_index: 0,
1076 part: { type: "summary_text", text: summary }
1077 },
1078 {
1079 type: "response.reasoning_summary_text.delta",
1080 sequence_number: 4,
1081 output_index: 0,
1082 item_id: itemId,
1083 summary_index: 0,
1084 delta: summary
1085 },
1086 {
1087 type: "response.reasoning_summary_part.done",
1088 sequence_number: 5,
1089 output_index: 0,
1090 item_id: itemId,
1091 summary_index: 0,
1092 part: { type: "summary_text", text: summary }
1093 },
1094 {
1095 type: "response.output_item.done",
1096 sequence_number: 6,
1097 output_index: 0,
1098 item: {
1099 type: "reasoning",
1100 id: itemId,
1101 summary: [{ type: "summary_text", text: summary }],
1102 encrypted_content: encryptedContent
1103 }
1104 },
1105 { type: "response.completed", sequence_number: 7, response }
1106 ]
1107 for (const event of events) {
1108 server.send(event)
1109 }
1110}
1111
1112type MockResponse =
1113 | {
1114 readonly _tag: "Json"
1115 readonly body: typeof Generated.Response.Encoded | {
1116 readonly error: {
1117 readonly message: string
1118 readonly type?: string
1119 readonly code?: string | null
1120 }
1121 } | {
1122 readonly error: string
1123 readonly code?: string
1124 }
1125 readonly status?: number | undefined
1126 readonly headers?: Record<string, string> | undefined
1127 }
1128 | {
1129 readonly _tag: "Sse"
1130 readonly events: ReadonlyArray<typeof OpenAiSchema.ResponseStreamEvent.Encoded>
1131 readonly keepOpen?: boolean | undefined
1132 readonly status?: number | undefined
1133 readonly headers?: Record<string, string> | undefined
1134 }
1135
1136class MockOpenAiResponse extends Context.Service<MockOpenAiResponse, {
1137 readonly response: MockResponse
1138}>()("MockOpenAiResponse") {}
1139
1140class MockHttpClient extends Context.Service<MockHttpClient, {
1141 readonly requests: Effect.Effect<ReadonlyArray<HttpClientRequest.HttpClientRequest>>
1142}>()("MockHttpClient") {
1143 static requests = MockHttpClient.use((client) => client.requests)
1144}
1145
1146const makeHttpClientContext = Effect.gen(function*() {
1147 const capturedRequests: Array<HttpClientRequest.HttpClientRequest> = []
1148 const mock = yield* MockOpenAiResponse
1149
1150 const httpClient = HttpClient.makeWith(
1151 Effect.fnUntraced(function*(requestEffect) {
1152 const request = yield* requestEffect
1153 capturedRequests.push(request)
1154 return makeResponse(request, mock.response)
1155 }),
1156 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
1157 )
1158
1159 const mockHttpClient: MockHttpClient["Service"] = {
1160 requests: Effect.sync(() => capturedRequests)
1161 }
1162
1163 return Context.make(HttpClient.HttpClient, httpClient).pipe(
1164 Context.add(MockHttpClient, mockHttpClient)
1165 )
1166})
1167
1168const HttpClientLayer = Layer.effectContext(makeHttpClientContext)
1169
1170const defaultResponse: MockResponse = {
1171 _tag: "Json",
1172 body: makeResponseBody()
1173}
1174
1175const makeTestLayer = (
1176 options: OpenAiClient.Options = { apiKey: Redacted.make("test-key") },
1177 response: MockResponse = defaultResponse
1178) =>
1179 OpenAiClient.layer(options).pipe(
1180 Layer.provideMerge(HttpClientLayer),
1181 Layer.provide(Layer.succeed(MockOpenAiResponse, { response }))
1182 )
1183
1184const makeGeneratedTestLayer = (
1185 options: OpenAiClientGenerated.Options,
1186 response: MockResponse = defaultResponse
1187) =>
1188 OpenAiClientGenerated.layer(options).pipe(
1189 Layer.provideMerge(HttpClientLayer),
1190 Layer.provide(Layer.succeed(MockOpenAiResponse, { response }))
1191 )
1192
1193const makeConfigTestLayer = (configProvider: ConfigProvider.ConfigProvider) =>
1194 OpenAiClient.layerConfig({
1195 apiKey: Config.Redacted("MY_API_KEY"),
1196 apiUrl: Config.String("MY_API_URL")
1197 }).pipe(
1198 Layer.provideMerge(HttpClientLayer),
1199 Layer.provide(Layer.succeed(MockOpenAiResponse, { response: defaultResponse })),
1200 Layer.provide(ConfigProvider.layer(configProvider))
1201 )
1202
1203const makeTransportErrorHttpClient = (): HttpClient.HttpClient =>
1204 HttpClient.makeWith<HttpClientError.HttpClientError, never, HttpClientError.HttpClientError, never>(
1205 (requestEffect) =>
1206 Effect.flatMap(requestEffect, (request) =>
1207 Effect.fail(
1208 new HttpClientError.HttpClientError({
1209 reason: new HttpClientError.TransportError({
1210 request,
1211 cause: new Error("Connection refused")
1212 })
1213 })
1214 )),
1215 Effect.succeed
1216 )
1217
1218function makeResponseBody(
1219 overrides: Partial<typeof Generated.Response.Encoded> = {}
1220): typeof Generated.Response.Encoded {
1221 return {
1222 id: "resp_test123",
1223 object: "response",
1224 created_at: 1,
1225 model: "gpt-4o-mini",
1226 status: "completed",
1227 output: [],
1228 metadata: null,
1229 temperature: null,
1230 top_p: null,
1231 tools: [],
1232 tool_choice: "auto",
1233 error: null,
1234 incomplete_details: null,
1235 instructions: null,
1236 parallel_tool_calls: false,
1237 ...overrides
1238 }
1239}
1240
1241const makeResponse = (
1242 request: HttpClientRequest.HttpClientRequest,
1243 response: MockResponse
1244): HttpClientResponse.HttpClientResponse => {
1245 const contentType = response._tag === "Json"
1246 ? "application/json"
1247 : "text/event-stream"
1248 const body = response._tag === "Json"
1249 ? JSON.stringify(response.body)
1250 : response.events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")
1251
1252 const httpResponse = HttpClientResponse.fromWeb(
1253 request,
1254 new Response(body, {
1255 status: response.status ?? 200,
1256 headers: {
1257 "content-type": contentType,
1258 ...response.headers
1259 }
1260 })
1261 )
1262 if (response._tag !== "Sse" || response.keepOpen !== true) return httpResponse
1263
1264 const stream = Stream.concat(
1265 Stream.succeed(new TextEncoder().encode(body)),
1266 Stream.never
1267 )
1268 // `fromWeb` stores the ReadableStream internally, so a proxy is needed to replace it with a non-terminating test stream.
1269 return new Proxy(httpResponse, {
1270 get(target, property) {
1271 if (property === "stream") return stream
1272 const value = Reflect.get(target, property, target)
1273 return typeof value === "function" ? value.bind(target) : value
1274 }
1275 })
1276}
1277
1278const makeHttpContext = (url: string, body: string) => ({
1279 request: {
1280 method: "POST" as const,
1281 url,
1282 urlParams: [],
1283 hash: undefined,
1284 headers: {}
1285 },
1286 body
1287})