1import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai-compat"
2import { assert, describe, it } from "@effect/vitest"
3import { Effect, Layer, Redacted, Ref, Schema, Stream } from "effect"
4import { type AiError, LanguageModel, Prompt, Tool, Toolkit } from "effect/ai"
5import { HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/http"
6
7describe("OpenAiLanguageModel", () => {
8 describe("generateText", () => {
9 it.effect("sends model in request and decodes text output", () =>
10 Effect.gen(function*() {
11 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
12
13 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
14 Layer.provide(Layer.succeed(
15 HttpClient.HttpClient,
16 makeHttpClient((request) => {
17 capturedRequest = request
18 return Effect.succeed(jsonResponse(
19 request,
20 makeChatCompletion({
21 choices: [{
22 index: 0,
23 finish_reason: "stop",
24 message: {
25 role: "assistant",
26 content: "Hello, compat!"
27 }
28 }]
29 })
30 ))
31 })
32 ))
33 )
34
35 const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
36 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
37 Effect.provide(layer)
38 )
39
40 assert.strictEqual(result.text, "Hello, compat!")
41 assert.isDefined(capturedRequest)
42 if (capturedRequest === undefined) {
43 return
44 }
45
46 const requestBody = yield* getRequestBody(capturedRequest)
47 assert.strictEqual(requestBody.model, "gpt-4o-mini")
48 assert.strictEqual(requestBody.messages[0]?.content, "hello")
49 }))
50
51 it.effect("routes invalid tool call params through failureMode: return without failing the effect", () =>
52 Effect.gen(function*() {
53 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
54 Layer.provide(Layer.succeed(
55 HttpClient.HttpClient,
56 makeHttpClient((request) =>
57 Effect.succeed(jsonResponse(
58 request,
59 makeChatCompletion({
60 choices: [{
61 index: 0,
62 finish_reason: "tool_calls",
63 message: {
64 role: "assistant",
65 content: null,
66 tool_calls: [{
67 id: "call_1",
68 type: "function",
69 function: {
70 name: "ReturnModeTool",
71 arguments: JSON.stringify({ input: 123 })
72 }
73 }]
74 }
75 }]
76 })
77 ))
78 )
79 ))
80 )
81
82 const ReturnModeTool = Tool.make("ReturnModeTool", {
83 description: "A test tool",
84 failureMode: "return",
85 parameters: Schema.Struct({ input: Schema.String }),
86 success: Schema.Struct({ output: Schema.String }),
87 failure: Schema.Struct({ error: Schema.String })
88 })
89
90 const toolkit = Toolkit.make(ReturnModeTool)
91 const toolkitLayer = toolkit.toLayer({
92 ReturnModeTool: ({ input }) => Effect.succeed({ output: `processed: ${input}` })
93 })
94
95 const result = yield* LanguageModel.generateText({
96 prompt: "use the tool",
97 toolkit
98 }).pipe(
99 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
100 Effect.provide(toolkitLayer),
101 Effect.provide(layer)
102 )
103
104 assert.strictEqual(result.toolResults.length, 1)
105 const toolResult = result.toolResults[0]!
106 assert.strictEqual(toolResult.isFailure, true)
107 const failure = toolResult.result as AiError.AiError
108 assert.strictEqual(failure._tag, "AiError")
109 assert.strictEqual(failure.reason._tag, "ToolParameterValidationError")
110 }))
111
112 it.effect("forwards reasoning config to chat completions request", () =>
113 Effect.gen(function*() {
114 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
115
116 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
117 Layer.provide(Layer.succeed(
118 HttpClient.HttpClient,
119 makeHttpClient((request) => {
120 capturedRequest = request
121 return Effect.succeed(jsonResponse(
122 request,
123 makeChatCompletion({
124 choices: [{
125 index: 0,
126 finish_reason: "stop",
127 message: {
128 role: "assistant",
129 content: "Done"
130 }
131 }]
132 })
133 ))
134 })
135 ))
136 )
137
138 yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
139 Effect.provide(OpenAiLanguageModel.model("gpt-5", {
140 reasoning: {
141 effort: "medium",
142 summary: "auto"
143 }
144 })),
145 Effect.provide(layer)
146 )
147
148 assert.isDefined(capturedRequest)
149 if (capturedRequest === undefined) {
150 return
151 }
152
153 const requestBody = yield* getRequestBody(capturedRequest)
154 assert.deepStrictEqual(requestBody.reasoning, {
155 effort: "medium",
156 summary: "auto"
157 })
158 }))
159
160 it.effect("forwards custom model config properties to chat completions request", () =>
161 Effect.gen(function*() {
162 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
163
164 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
165 Layer.provide(Layer.succeed(
166 HttpClient.HttpClient,
167 makeHttpClient((request) => {
168 capturedRequest = request
169 return Effect.succeed(jsonResponse(
170 request,
171 makeChatCompletion({
172 choices: [{
173 index: 0,
174 finish_reason: "stop",
175 message: {
176 role: "assistant",
177 content: "Done"
178 }
179 }]
180 })
181 ))
182 })
183 ))
184 )
185
186 yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
187 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini", {
188 vendor_setting: {
189 mode: "strict"
190 }
191 })),
192 Effect.provide(layer)
193 )
194
195 assert.isDefined(capturedRequest)
196 if (capturedRequest === undefined) {
197 return
198 }
199
200 const requestBody = yield* getRequestBody(capturedRequest)
201 assert.deepStrictEqual(requestBody.vendor_setting, {
202 mode: "strict"
203 })
204 }))
205
206 it.effect("preserves URL and base64 images in multimodal content order", () =>
207 Effect.gen(function*() {
208 const base64 = "iVBORw0KGgo="
209 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
210
211 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
212 Layer.provide(Layer.succeed(
213 HttpClient.HttpClient,
214 makeHttpClient((request) => {
215 capturedRequest = request
216 return Effect.succeed(jsonResponse(
217 request,
218 makeChatCompletion({
219 choices: [{
220 index: 0,
221 finish_reason: "stop",
222 message: {
223 role: "assistant",
224 content: "done"
225 }
226 }]
227 })
228 ))
229 })
230 ))
231 )
232
233 yield* LanguageModel.generateText({
234 prompt: Prompt.make([{
235 role: "user",
236 content: [
237 Prompt.textPart({ text: "first text" }),
238 Prompt.filePart({
239 mediaType: "image/png",
240 data: new URL("https://example.com/image.png")
241 }),
242 Prompt.filePart({ mediaType: "image/png", data: "https://example.com/string-image.png" }),
243 Prompt.filePart({ mediaType: "image/png", data: base64 }),
244 Prompt.textPart({ text: "second text" })
245 ]
246 }])
247 }).pipe(
248 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
249 Effect.provide(layer)
250 )
251
252 assert.isDefined(capturedRequest)
253 if (capturedRequest === undefined) {
254 return
255 }
256
257 const requestBody = yield* getRequestBody(capturedRequest)
258 const content = requestBody.messages[0]?.content
259 assert.isTrue(Array.isArray(content))
260 assert.deepStrictEqual(content, [
261 {
262 type: "text",
263 text: "first text"
264 },
265 {
266 type: "image_url",
267 image_url: {
268 url: "https://example.com/image.png",
269 detail: "auto"
270 }
271 },
272 {
273 type: "image_url",
274 image_url: {
275 url: "https://example.com/string-image.png",
276 detail: "auto"
277 }
278 },
279 {
280 type: "image_url",
281 image_url: {
282 url: `data:image/png;base64,${base64}`,
283 detail: "auto"
284 }
285 },
286 {
287 type: "text",
288 text: "second text"
289 }
290 ])
291 }))
292
293 it.effect("normalizes empty assistant message content to an empty string", () =>
294 Effect.gen(function*() {
295 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
296
297 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
298 Layer.provide(Layer.succeed(
299 HttpClient.HttpClient,
300 makeHttpClient((request) => {
301 capturedRequest = request
302 return Effect.succeed(jsonResponse(
303 request,
304 makeChatCompletion({
305 choices: [{
306 index: 0,
307 finish_reason: "stop",
308 message: {
309 role: "assistant",
310 content: "done"
311 }
312 }]
313 })
314 ))
315 })
316 ))
317 )
318
319 yield* LanguageModel.generateText({
320 prompt: Prompt.make([
321 { role: "user", content: "hello" },
322 {
323 role: "assistant",
324 content: [Prompt.textPart({ text: "" })]
325 },
326 { role: "user", content: "continue" }
327 ])
328 }).pipe(
329 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
330 Effect.provide(layer)
331 )
332
333 assert.isDefined(capturedRequest)
334 if (capturedRequest === undefined) {
335 return
336 }
337
338 const requestBody = yield* getRequestBody(capturedRequest)
339 const assistantMessage = requestBody.messages.find((message: any) => message.role === "assistant")
340 assert.isDefined(assistantMessage)
341 assert.strictEqual(assistantMessage.content, "")
342 assert.isUndefined(assistantMessage.tool_calls)
343 }))
344
345 it.effect("maps function_call output to tool-call part and sends function tool schema", () =>
346 Effect.gen(function*() {
347 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
348
349 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
350 Layer.provide(Layer.succeed(
351 HttpClient.HttpClient,
352 makeHttpClient((request) => {
353 capturedRequest = request
354 return Effect.succeed(jsonResponse(
355 request,
356 makeChatCompletion({
357 choices: [{
358 index: 0,
359 finish_reason: "tool_calls",
360 message: {
361 role: "assistant",
362 content: null,
363 tool_calls: [{
364 id: "call_1",
365 type: "function",
366 function: {
367 name: "TestTool",
368 arguments: JSON.stringify({ input: "hello" })
369 }
370 }]
371 }
372 }]
373 })
374 ))
375 })
376 ))
377 )
378
379 const result = yield* LanguageModel.generateText({
380 prompt: "use the tool",
381 toolkit: TestToolkit,
382 disableToolCallResolution: true
383 }).pipe(
384 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
385 Effect.provide(TestToolkitLayer),
386 Effect.provide(layer)
387 )
388
389 const toolCall = result.content.find((part) => part.type === "tool-call")
390 assert.isDefined(toolCall)
391 if (toolCall?.type !== "tool-call") {
392 return
393 }
394
395 assert.strictEqual(toolCall.name, "TestTool")
396 assert.deepStrictEqual(toolCall.params, { input: "hello" })
397
398 assert.isDefined(capturedRequest)
399 if (capturedRequest === undefined) {
400 return
401 }
402
403 const requestBody = yield* getRequestBody(capturedRequest)
404 const functionTool = requestBody.tools.find((tool: any) => tool.type === "function")
405 assert.isDefined(functionTool)
406 assert.strictEqual(functionTool.function.name, "TestTool")
407 assert.strictEqual(functionTool.function.strict, true)
408 }))
409
410 it.effect("decodes tool call params with the OpenAI codec", () =>
411 Effect.gen(function*() {
412 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
413 Layer.provide(Layer.succeed(
414 HttpClient.HttpClient,
415 makeHttpClient((request) =>
416 Effect.succeed(jsonResponse(
417 request,
418 makeChatCompletion({
419 choices: [{
420 index: 0,
421 finish_reason: "tool_calls",
422 message: {
423 role: "assistant",
424 content: null,
425 tool_calls: [{
426 id: "call_record_1",
427 type: "function",
428 function: {
429 name: "RecordTool",
430 arguments: JSON.stringify({ env: [{ 0: "PATH", 1: "/usr/bin" }] })
431 }
432 }]
433 }
434 }]
435 })
436 ))
437 )
438 ))
439 )
440
441 const result = yield* LanguageModel.generateText({
442 prompt: "read the environment",
443 toolkit: RecordToolkit,
444 disableToolCallResolution: true
445 }).pipe(
446 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
447 Effect.provide(RecordToolkitLayer),
448 Effect.provide(layer)
449 )
450
451 const toolCall = result.content.find((part) => part.type === "tool-call")
452 assert.isDefined(toolCall)
453 if (toolCall?.type !== "tool-call") {
454 return
455 }
456 assert.deepStrictEqual(toolCall.params, { env: { PATH: "/usr/bin" } })
457 }))
458
459 it.effect("preserves raw JSON Schema dynamic tool call params", () =>
460 Effect.gen(function*() {
461 const params = { query: "effect" }
462 const client = makeHttpClient((request) =>
463 Effect.succeed(jsonResponse(
464 request,
465 makeChatCompletion({
466 choices: [{
467 index: 0,
468 finish_reason: "tool_calls",
469 message: {
470 role: "assistant",
471 content: null,
472 tool_calls: [{
473 id: "call_dynamic_1",
474 type: "function",
475 function: { name: "DynamicTool", arguments: JSON.stringify(params) }
476 }]
477 }
478 }]
479 })
480 ))
481 )
482 const DynamicTool = Tool.dynamic("DynamicTool", {
483 parameters: {
484 type: "object",
485 properties: { query: { type: "string" } },
486 required: ["query"],
487 additionalProperties: false
488 } as const
489 })
490 const result = yield* LanguageModel.generateText({
491 prompt: "use the dynamic tool",
492 toolkit: Toolkit.make(DynamicTool),
493 disableToolCallResolution: true
494 }).pipe(
495 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
496 Effect.provide(OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") })),
497 Effect.provideService(HttpClient.HttpClient, client)
498 )
499
500 assert.deepStrictEqual(result.toolCalls[0]?.params, params)
501 }))
502
503 it.effect("decodes Effect Schema dynamic tool call params with the OpenAI codec", () =>
504 Effect.gen(function*() {
505 const DynamicTool = Tool.dynamic("DynamicTool", {
506 parameters: Schema.Struct({
507 env: Schema.Record(Schema.String, Schema.String)
508 })
509 })
510 const client = makeHttpClient((request) =>
511 Effect.succeed(jsonResponse(
512 request,
513 makeChatCompletion({
514 choices: [{
515 index: 0,
516 finish_reason: "tool_calls",
517 message: {
518 role: "assistant",
519 content: null,
520 tool_calls: [{
521 id: "call_dynamic_1",
522 type: "function",
523 function: {
524 name: "DynamicTool",
525 arguments: JSON.stringify({ env: [{ 0: "PATH", 1: "/usr/bin" }] })
526 }
527 }]
528 }
529 }]
530 })
531 ))
532 )
533 const result = yield* LanguageModel.generateText({
534 prompt: "use the dynamic tool",
535 toolkit: Toolkit.make(DynamicTool),
536 disableToolCallResolution: true
537 }).pipe(
538 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
539 Effect.provide(OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") })),
540 Effect.provideService(HttpClient.HttpClient, client)
541 )
542
543 assert.deepStrictEqual(result.toolCalls[0]?.params, { env: { PATH: "/usr/bin" } })
544 }))
545
546 it.effect("groups parallel tool calls into one assistant message", () =>
547 Effect.gen(function*() {
548 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
549
550 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
551 Layer.provide(Layer.succeed(
552 HttpClient.HttpClient,
553 makeHttpClient((request) => {
554 capturedRequest = request
555 return Effect.succeed(jsonResponse(request, makeChatCompletion()))
556 })
557 ))
558 )
559
560 yield* LanguageModel.generateText({
561 prompt: Prompt.make([
562 { role: "user", content: "use both tools" },
563 {
564 role: "assistant",
565 content: [
566 Prompt.toolCallPart({
567 id: "call_1",
568 name: "TestTool",
569 params: { input: "first" },
570 providerExecuted: false
571 }),
572 Prompt.toolCallPart({
573 id: "call_2",
574 name: "TestTool",
575 params: { input: "second" },
576 providerExecuted: false
577 })
578 ]
579 },
580 {
581 role: "tool",
582 content: [
583 Prompt.toolResultPart({
584 id: "call_1",
585 name: "TestTool",
586 isFailure: false,
587 result: { output: "first" },
588 providerExecuted: false
589 }),
590 Prompt.toolResultPart({
591 id: "call_2",
592 name: "TestTool",
593 isFailure: false,
594 result: { output: "second" },
595 providerExecuted: false
596 })
597 ]
598 }
599 ]),
600 toolkit: TestToolkit
601 }).pipe(
602 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
603 Effect.provide(TestToolkitLayer),
604 Effect.provide(layer)
605 )
606
607 assert.isDefined(capturedRequest)
608 if (capturedRequest === undefined) {
609 return
610 }
611
612 const requestBody = yield* getRequestBody(capturedRequest)
613 assert.deepStrictEqual(requestBody.messages, [
614 { role: "user", content: "use both tools" },
615 {
616 role: "assistant",
617 content: null,
618 tool_calls: [
619 {
620 id: "call_1",
621 type: "function",
622 function: { name: "TestTool", arguments: JSON.stringify({ input: "first" }) }
623 },
624 {
625 id: "call_2",
626 type: "function",
627 function: { name: "TestTool", arguments: JSON.stringify({ input: "second" }) }
628 }
629 ]
630 },
631 { role: "tool", tool_call_id: "call_1", content: JSON.stringify({ output: "first" }) },
632 { role: "tool", tool_call_id: "call_2", content: JSON.stringify({ output: "second" }) }
633 ])
634 }))
635
636 it.effect("converts dynamic tools to function type", () =>
637 Effect.gen(function*() {
638 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
639
640 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
641 Layer.provide(Layer.succeed(
642 HttpClient.HttpClient,
643 makeHttpClient((request) => {
644 capturedRequest = request
645 return Effect.succeed(jsonResponse(
646 request,
647 makeChatCompletion({
648 choices: [{
649 index: 0,
650 finish_reason: "stop",
651 message: {
652 role: "assistant",
653 content: "Done"
654 }
655 }]
656 })
657 ))
658 })
659 ))
660 )
661
662 const inputSchema = {
663 type: "object",
664 properties: {
665 query: { type: "string" },
666 limit: { type: "number" }
667 },
668 required: ["query"],
669 additionalProperties: false
670 } as const
671
672 const DynamicTool = Tool.dynamic("DynamicTool", {
673 description: "A dynamic tool",
674 parameters: inputSchema
675 })
676
677 yield* LanguageModel.generateText({
678 prompt: "use dynamic tool",
679 toolkit: Toolkit.make(DynamicTool),
680 disableToolCallResolution: true
681 }).pipe(
682 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
683 Effect.provide(layer)
684 )
685
686 assert.isDefined(capturedRequest)
687 if (capturedRequest === undefined) {
688 return
689 }
690
691 const requestBody = yield* getRequestBody(capturedRequest)
692 const functionTool = requestBody.tools?.find((tool: any) =>
693 tool.type === "function" && tool.function?.name === "DynamicTool"
694 )
695 assert.isDefined(functionTool)
696 assert.strictEqual(functionTool.function.description, "A dynamic tool")
697 assert.deepStrictEqual(functionTool.function.parameters, inputSchema)
698 }))
699
700 it.effect("maps provider apply_patch function call back to custom provider-defined tool", () =>
701 Effect.gen(function*() {
702 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
703
704 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
705 Layer.provide(Layer.succeed(
706 HttpClient.HttpClient,
707 makeHttpClient((request) => {
708 capturedRequest = request
709 return Effect.succeed(jsonResponse(
710 request,
711 makeChatCompletion({
712 choices: [{
713 index: 0,
714 finish_reason: "tool_calls",
715 message: {
716 role: "assistant",
717 content: null,
718 tool_calls: [{
719 id: "call_1",
720 type: "function",
721 function: {
722 name: "apply_patch",
723 arguments: JSON.stringify({
724 call_id: "call_1",
725 operation: {
726 type: "delete_file",
727 path: "src/obsolete.ts"
728 }
729 })
730 }
731 }]
732 }
733 }]
734 })
735 ))
736 })
737 ))
738 )
739
740 const toolkit = Toolkit.make(CompatApplyPatchTool({}))
741 const toolkitLayer = toolkit.toLayer({
742 CompatApplyPatch: () =>
743 Effect.succeed({
744 status: "completed",
745 output: "deleted"
746 })
747 })
748
749 const result = yield* LanguageModel.generateText({
750 prompt: "delete src/obsolete.ts",
751 toolkit,
752 toolChoice: { tool: "CompatApplyPatch" },
753 disableToolCallResolution: true
754 }).pipe(
755 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
756 Effect.provide(toolkitLayer),
757 Effect.provide(layer)
758 )
759
760 const toolCall = result.content.find((part) => part.type === "tool-call")
761 assert.isDefined(toolCall)
762 if (toolCall?.type !== "tool-call") {
763 return
764 }
765
766 assert.strictEqual(toolCall.name, "CompatApplyPatch")
767 assert.deepStrictEqual(toolCall.params, {
768 call_id: "call_1",
769 operation: {
770 type: "delete_file",
771 path: "src/obsolete.ts"
772 }
773 })
774
775 assert.isDefined(capturedRequest)
776 if (capturedRequest === undefined) {
777 return
778 }
779
780 const requestBody = yield* getRequestBody(capturedRequest)
781
782 const functionTool = requestBody.tools.find((tool: any) => tool.type === "function")
783 assert.isDefined(functionTool)
784 assert.strictEqual(functionTool.function.name, "apply_patch")
785 assert.deepStrictEqual(requestBody.tool_choice, {
786 type: "function",
787 function: {
788 name: "apply_patch"
789 }
790 })
791 }))
792
793 it.effect("decodes usage when token detail fields are absent", () =>
794 Effect.gen(function*() {
795 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
796 Layer.provide(Layer.succeed(
797 HttpClient.HttpClient,
798 makeHttpClient((request) =>
799 Effect.succeed(jsonResponse(
800 request,
801 makeChatCompletion({
802 choices: [{
803 index: 0,
804 finish_reason: "stop",
805 message: {
806 role: "assistant",
807 content: "Hello"
808 }
809 }],
810 usage: {
811 prompt_tokens: 4,
812 completion_tokens: 5,
813 total_tokens: 9,
814 provider_future_field: true
815 }
816 })
817 ))
818 )
819 ))
820 )
821
822 const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
823 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
824 Effect.provide(layer)
825 )
826
827 const finish = result.content.find((part) => part.type === "finish")
828 assert.isDefined(finish)
829 if (finish?.type !== "finish") {
830 return
831 }
832
833 assert.deepStrictEqual(finish.usage.inputTokens, {
834 uncached: 4,
835 total: 4,
836 cacheRead: 0,
837 cacheWrite: undefined
838 })
839 assert.deepStrictEqual(finish.usage.outputTokens, {
840 total: 5,
841 text: 5,
842 reasoning: 0
843 })
844 }))
845
846 it.effect("surfaces reasoning from non-streaming responses", () =>
847 Effect.gen(function*() {
848 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
849 Layer.provide(Layer.succeed(
850 HttpClient.HttpClient,
851 makeHttpClient((request) =>
852 Effect.succeed(jsonResponse(
853 request,
854 makeChatCompletion({
855 choices: [{
856 index: 0,
857 finish_reason: "stop",
858 message: {
859 role: "assistant",
860 reasoning: "I should greet the user.",
861 content: "Hello!"
862 }
863 }]
864 })
865 ))
866 )
867 ))
868 )
869
870 const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
871 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
872 Effect.provide(layer)
873 )
874
875 const reasoning = result.content.find((part) => part.type === "reasoning")
876 assert.isDefined(reasoning)
877 if (reasoning?.type !== "reasoning") {
878 return
879 }
880 assert.strictEqual(reasoning.text, "I should greet the user.")
881 assert.strictEqual(result.text, "Hello!")
882 }))
883
884 it.effect("surfaces reasoning_content from non-streaming responses", () =>
885 Effect.gen(function*() {
886 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
887 Layer.provide(Layer.succeed(
888 HttpClient.HttpClient,
889 makeHttpClient((request) =>
890 Effect.succeed(jsonResponse(
891 request,
892 makeChatCompletion({
893 choices: [{
894 index: 0,
895 finish_reason: "stop",
896 message: {
897 role: "assistant",
898 reasoning_content: "I should greet the user.",
899 content: "Hello!"
900 }
901 }]
902 })
903 ))
904 )
905 ))
906 )
907
908 const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
909 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
910 Effect.provide(layer)
911 )
912
913 const reasoning = result.content.find((part) => part.type === "reasoning")
914 assert.isDefined(reasoning)
915 if (reasoning?.type !== "reasoning") {
916 return
917 }
918 assert.strictEqual(reasoning.text, "I should greet the user.")
919 assert.strictEqual(result.text, "Hello!")
920 }))
921 })
922
923 describe("generateObject", () => {
924 it.effect("uses json_schema format for structured output", () =>
925 Effect.gen(function*() {
926 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
927
928 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
929 Layer.provide(Layer.succeed(
930 HttpClient.HttpClient,
931 makeHttpClient((request) => {
932 capturedRequest = request
933 return Effect.succeed(jsonResponse(
934 request,
935 makeChatCompletion({
936 choices: [{
937 index: 0,
938 finish_reason: "stop",
939 message: {
940 role: "assistant",
941 content: JSON.stringify({ name: "Ada", age: 37 })
942 }
943 }]
944 })
945 ))
946 })
947 ))
948 )
949
950 const person = yield* LanguageModel.generateObject({
951 prompt: "Return a person",
952 schema: Schema.Struct({
953 name: Schema.String,
954 age: Schema.Number
955 })
956 }).pipe(
957 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
958 Effect.provide(layer)
959 )
960
961 assert.strictEqual(person.value.name, "Ada")
962 assert.strictEqual(person.value.age, 37)
963
964 assert.isDefined(capturedRequest)
965 if (capturedRequest === undefined) {
966 return
967 }
968
969 const requestBody = yield* getRequestBody(capturedRequest)
970 assert.strictEqual(requestBody.response_format.type, "json_schema")
971 assert.strictEqual(requestBody.response_format.json_schema.strict, true)
972 }))
973
974 it.effect("uses OpenAI codec transformer for optional structured fields", () =>
975 Effect.gen(function*() {
976 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
977
978 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
979 Layer.provide(Layer.succeed(
980 HttpClient.HttpClient,
981 makeHttpClient((request) => {
982 capturedRequest = request
983 return Effect.succeed(jsonResponse(
984 request,
985 makeChatCompletion({
986 choices: [{
987 index: 0,
988 finish_reason: "stop",
989 message: {
990 role: "assistant",
991 content: JSON.stringify({ name: "Ada", nickname: null })
992 }
993 }]
994 })
995 ))
996 })
997 ))
998 )
999
1000 const person = yield* LanguageModel.generateObject({
1001 prompt: "Return a person",
1002 schema: Schema.Struct({
1003 name: Schema.String,
1004 nickname: Schema.optionalKey(Schema.String)
1005 })
1006 }).pipe(
1007 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1008 Effect.provide(layer)
1009 )
1010
1011 assert.strictEqual(person.value.name, "Ada")
1012 assert.isUndefined(person.value.nickname)
1013
1014 assert.isDefined(capturedRequest)
1015 if (capturedRequest === undefined) {
1016 return
1017 }
1018
1019 const requestBody = yield* getRequestBody(capturedRequest)
1020 assert.deepStrictEqual(requestBody.response_format.json_schema.schema.required, ["name", "nickname"])
1021 assert.deepStrictEqual(requestBody.response_format.json_schema.schema.properties.nickname, {
1022 anyOf: [{ type: "string" }, { type: "null" }]
1023 })
1024 }))
1025 })
1026
1027 describe("streamText", () => {
1028 it.effect("handles chat completion stream chunks", () =>
1029 Effect.gen(function*() {
1030 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1031 Layer.provide(Layer.succeed(
1032 HttpClient.HttpClient,
1033 makeHttpClient((request) =>
1034 Effect.succeed(sseResponse(request, [
1035 {
1036 id: "chatcmpl_test123",
1037 object: "chat.completion.chunk",
1038 model: "gpt-4o-mini",
1039 created: 1,
1040 choices: [{
1041 index: 0,
1042 delta: {},
1043 finish_reason: "stop"
1044 }]
1045 },
1046 "[DONE]"
1047 ]))
1048 )
1049 ))
1050 )
1051
1052 const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
1053 Stream.runCollect,
1054 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1055 Effect.provide(layer)
1056 )
1057
1058 const parts = Array.from(partsChunk)
1059
1060 assert.isTrue(parts.some((part) => part.type === "response-metadata"))
1061
1062 const finish = parts.find((part) => part.type === "finish")
1063 assert.isDefined(finish)
1064 if (finish?.type === "finish") {
1065 assert.strictEqual(finish.reason, "stop")
1066 }
1067 }))
1068
1069 it.effect("emits text when streamed tool_calls is null", () =>
1070 Effect.gen(function*() {
1071 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1072 Layer.provide(Layer.succeed(
1073 HttpClient.HttpClient,
1074 makeHttpClient((request) =>
1075 Effect.succeed(sseResponse(request, [
1076 {
1077 id: "chatcmpl_nullable_tool_calls",
1078 object: "chat.completion.chunk",
1079 model: "inception/mercury-2",
1080 created: 1,
1081 choices: [{
1082 index: 0,
1083 delta: {
1084 content: "Hello",
1085 role: "assistant",
1086 tool_calls: null
1087 },
1088 finish_reason: null
1089 }]
1090 },
1091 "[DONE]"
1092 ]))
1093 )
1094 ))
1095 )
1096
1097 const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
1098 Stream.runCollect,
1099 Effect.provide(OpenAiLanguageModel.model("inception/mercury-2")),
1100 Effect.provide(layer)
1101 )
1102
1103 const text = Array.from(partsChunk)
1104 .filter((part) => part.type === "text-delta")
1105 .map((part) => part.delta)
1106 .join("")
1107
1108 assert.strictEqual(text, "Hello")
1109 }))
1110
1111 it.effect("preserves streamed text and tool args with nullable delta fields", () =>
1112 Effect.gen(function*() {
1113 const chunk = (delta: Record<string, unknown>) => ({
1114 id: "chatcmpl_nullable_delta_fields",
1115 object: "chat.completion.chunk",
1116 model: "gpt-4o-mini",
1117 created: 1,
1118 choices: [{ index: 0, delta, finish_reason: null }]
1119 })
1120
1121 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1122 Layer.provide(Layer.succeed(
1123 HttpClient.HttpClient,
1124 makeHttpClient((request) =>
1125 Effect.succeed(sseResponse(request, [
1126 chunk({ content: "Hello", role: null }),
1127 chunk({
1128 tool_calls: [{
1129 index: 0,
1130 id: "call_1",
1131 type: "function",
1132 function: { name: "TestTool", arguments: "" }
1133 }]
1134 }),
1135 chunk({
1136 tool_calls: [{ index: 0, id: null, function: { name: null, arguments: "{\"in" } }]
1137 }),
1138 chunk({
1139 tool_calls: [{ index: 0, id: null, function: { name: null, arguments: "put\":\"hel" } }]
1140 }),
1141 chunk({
1142 tool_calls: [{ index: 0, id: null, function: { name: null, arguments: "lo\"}" } }]
1143 }),
1144 {
1145 id: "chatcmpl_nullable_delta_fields",
1146 object: "chat.completion.chunk",
1147 model: "gpt-4o-mini",
1148 created: 1,
1149 choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }]
1150 },
1151 "[DONE]"
1152 ]))
1153 )
1154 ))
1155 )
1156
1157 const partsChunk = yield* LanguageModel.streamText({
1158 prompt: "use the tool",
1159 toolkit: TestToolkit,
1160 disableToolCallResolution: true
1161 }).pipe(
1162 Stream.runCollect,
1163 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1164 Effect.provide(TestToolkitLayer),
1165 Effect.provide(layer)
1166 )
1167
1168 const parts = globalThis.Array.from(partsChunk)
1169 const text = parts
1170 .filter((part) => part.type === "text-delta")
1171 .map((part) => part.delta)
1172 .join("")
1173 const toolCall = parts.find((part) => part.type === "tool-call")
1174
1175 assert.strictEqual(text, "Hello")
1176 assert.isDefined(toolCall)
1177 if (toolCall?.type !== "tool-call") {
1178 return
1179 }
1180 assert.strictEqual(toolCall.id, "call_1")
1181 assert.deepStrictEqual(toolCall.params, { input: "hello" })
1182 }))
1183
1184 it.effect("decodes streamed tool call params with the OpenAI codec", () =>
1185 Effect.gen(function*() {
1186 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1187 Layer.provide(Layer.succeed(
1188 HttpClient.HttpClient,
1189 makeHttpClient((request) =>
1190 Effect.succeed(sseResponse(request, [
1191 {
1192 id: "chatcmpl_record_1",
1193 object: "chat.completion.chunk",
1194 model: "gpt-4o-mini",
1195 created: 1,
1196 choices: [{
1197 index: 0,
1198 delta: {
1199 tool_calls: [{
1200 index: 0,
1201 id: "call_record_1",
1202 type: "function",
1203 function: {
1204 name: "RecordTool",
1205 arguments: JSON.stringify({ env: [{ 0: "PATH", 1: "/usr/bin" }] })
1206 }
1207 }]
1208 },
1209 finish_reason: "tool_calls"
1210 }]
1211 },
1212 "[DONE]"
1213 ]))
1214 )
1215 ))
1216 )
1217
1218 const partsChunk = yield* LanguageModel.streamText({
1219 prompt: "read the environment",
1220 toolkit: RecordToolkit,
1221 disableToolCallResolution: true
1222 }).pipe(
1223 Stream.runCollect,
1224 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1225 Effect.provide(RecordToolkitLayer),
1226 Effect.provide(layer)
1227 )
1228
1229 const toolCall = globalThis.Array.from(partsChunk).find((part) => part.type === "tool-call")
1230 assert.isDefined(toolCall)
1231 if (toolCall?.type !== "tool-call") {
1232 return
1233 }
1234 assert.deepStrictEqual(toolCall.params, { env: { PATH: "/usr/bin" } })
1235 }))
1236
1237 it.effect("maps local shell stream tool calls to local_shell call outputs", () =>
1238 Effect.gen(function*() {
1239 const capturedRequests = yield* Ref.make<ReadonlyArray<HttpClientRequest.HttpClientRequest>>([])
1240 const requestCount = yield* Ref.make(0)
1241
1242 const httpClient = HttpClient.makeWith(
1243 Effect.fnUntraced(function*(requestEffect) {
1244 const request = yield* requestEffect
1245 yield* Ref.update(capturedRequests, (requests) => [...requests, request])
1246 const index = yield* Ref.getAndUpdate(requestCount, (value) => value + 1)
1247
1248 if (index === 0) {
1249 return sseResponse(request, [makeLocalShellChunk(), "[DONE]"])
1250 }
1251
1252 return jsonResponse(request, makeChatCompletion())
1253 }),
1254 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
1255 )
1256
1257 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1258 Layer.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
1259 )
1260
1261 const toolkit = Toolkit.make(CompatLocalShellTool({}))
1262 const toolkitLayer = toolkit.toLayer({
1263 CompatLocalShell: () => Effect.succeed("done")
1264 })
1265
1266 const partsChunk = yield* LanguageModel.streamText({
1267 prompt: "Run pwd",
1268 toolkit,
1269 disableToolCallResolution: true
1270 }).pipe(
1271 Stream.runCollect,
1272 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1273 Effect.provide(toolkitLayer),
1274 Effect.provide(layer)
1275 )
1276
1277 const toolCall = globalThis.Array.from(partsChunk).find((part) => part.type === "tool-call")
1278 assert.isDefined(toolCall)
1279 if (toolCall?.type !== "tool-call") {
1280 return
1281 }
1282
1283 assert.strictEqual(toolCall.name, "CompatLocalShell")
1284 assert.deepStrictEqual(toolCall.params, { action: localShellAction })
1285
1286 yield* LanguageModel.generateText({
1287 prompt: Prompt.make([
1288 { role: "user", content: "Run pwd" },
1289 {
1290 role: "assistant",
1291 content: [Prompt.toolCallPart({
1292 id: toolCall.id,
1293 name: toolCall.name,
1294 params: { action: localShellAction },
1295 providerExecuted: false,
1296 options: {
1297 openai: {
1298 itemId: "ls_call_1"
1299 }
1300 }
1301 })]
1302 },
1303 {
1304 role: "tool",
1305 content: [Prompt.toolResultPart({
1306 id: toolCall.id,
1307 name: toolCall.name,
1308 isFailure: false,
1309 result: "done",
1310 providerExecuted: false
1311 })]
1312 }
1313 ]),
1314 toolkit
1315 }).pipe(
1316 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1317 Effect.provide(toolkitLayer),
1318 Effect.provide(layer)
1319 )
1320
1321 const requests = yield* Ref.get(capturedRequests)
1322 const followUpRequest = requests[1]
1323 assert.isDefined(followUpRequest)
1324 if (followUpRequest === undefined) {
1325 return
1326 }
1327
1328 const followUpBody = yield* getRequestBody(followUpRequest)
1329
1330 const localShellCall = followUpBody.messages.find((item: any) =>
1331 item.role === "assistant" && item.tool_calls?.[0]?.function?.name === "local_shell"
1332 )
1333 assert.isDefined(localShellCall)
1334 assert.strictEqual(localShellCall.content, null)
1335 assert.strictEqual(localShellCall.tool_calls[0].id, toolCall.id)
1336
1337 const localShellOutput = followUpBody.messages.find((item: any) => item.role === "tool")
1338 assert.isDefined(localShellOutput)
1339 assert.strictEqual(localShellOutput.tool_call_id, toolCall.id)
1340 assert.strictEqual(localShellOutput.content, "done")
1341 }))
1342
1343 it.effect("maps apply_patch stream tool calls to custom provider-defined tool", () =>
1344 Effect.gen(function*() {
1345 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1346 Layer.provide(Layer.succeed(
1347 HttpClient.HttpClient,
1348 makeHttpClient((request) =>
1349 Effect.succeed(sseResponse(request, [
1350 {
1351 id: "chatcmpl_apply_patch_1",
1352 object: "chat.completion.chunk",
1353 model: "gpt-4o-mini",
1354 created: 1,
1355 choices: [{
1356 index: 0,
1357 delta: {
1358 tool_calls: [{
1359 index: 0,
1360 id: "patch_call_1",
1361 type: "function",
1362 function: {
1363 name: "apply_patch",
1364 arguments: JSON.stringify({
1365 call_id: "patch_call_1",
1366 operation: {
1367 type: "delete_file",
1368 path: "src/legacy.ts"
1369 }
1370 })
1371 }
1372 }]
1373 },
1374 finish_reason: "tool_calls"
1375 }]
1376 },
1377 "[DONE]"
1378 ]))
1379 )
1380 ))
1381 )
1382
1383 const toolkit = Toolkit.make(CompatApplyPatchTool({}))
1384 const toolkitLayer = toolkit.toLayer({
1385 CompatApplyPatch: () =>
1386 Effect.succeed({
1387 status: "completed",
1388 output: "deleted"
1389 })
1390 })
1391
1392 const partsChunk = yield* LanguageModel.streamText({
1393 prompt: "Delete src/legacy.ts",
1394 toolkit,
1395 disableToolCallResolution: true
1396 }).pipe(
1397 Stream.runCollect,
1398 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1399 Effect.provide(toolkitLayer),
1400 Effect.provide(layer)
1401 )
1402
1403 const toolCall = globalThis.Array.from(partsChunk).find((part) => part.type === "tool-call")
1404 assert.isDefined(toolCall)
1405 if (toolCall?.type !== "tool-call") {
1406 return
1407 }
1408
1409 assert.strictEqual(toolCall.name, "CompatApplyPatch")
1410 assert.deepStrictEqual(toolCall.params, {
1411 call_id: "patch_call_1",
1412 operation: {
1413 type: "delete_file",
1414 path: "src/legacy.ts"
1415 }
1416 })
1417 }))
1418
1419 it.effect("preserves fragmented stream tool call ids and names", () =>
1420 Effect.gen(function*() {
1421 const expectedParams = {
1422 call_id: "patch_call_2",
1423 operation: {
1424 type: "delete_file",
1425 path: "src/fragmented.ts"
1426 }
1427 }
1428 const toolArguments = JSON.stringify(expectedParams)
1429
1430 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1431 Layer.provide(Layer.succeed(
1432 HttpClient.HttpClient,
1433 makeHttpClient((request) =>
1434 Effect.succeed(sseResponse(request, [
1435 {
1436 id: "chatcmpl_apply_patch_fragmented_1",
1437 object: "chat.completion.chunk",
1438 model: "gpt-4o-mini",
1439 created: 1,
1440 choices: [{
1441 index: 0,
1442 delta: {
1443 tool_calls: [{
1444 index: 0,
1445 id: "patch_call_2",
1446 type: "function",
1447 function: {
1448 name: "apply_patch",
1449 arguments: toolArguments.slice(0, 24)
1450 }
1451 }]
1452 },
1453 finish_reason: null
1454 }]
1455 },
1456 {
1457 id: "chatcmpl_apply_patch_fragmented_1",
1458 object: "chat.completion.chunk",
1459 model: "gpt-4o-mini",
1460 created: 1,
1461 choices: [{
1462 index: 0,
1463 delta: {
1464 tool_calls: [{
1465 index: 0,
1466 function: {
1467 arguments: toolArguments.slice(24, 48)
1468 }
1469 }]
1470 },
1471 finish_reason: null
1472 }]
1473 },
1474 {
1475 id: "chatcmpl_apply_patch_fragmented_1",
1476 object: "chat.completion.chunk",
1477 model: "gpt-4o-mini",
1478 created: 1,
1479 choices: [{
1480 index: 0,
1481 delta: {
1482 tool_calls: [{
1483 index: 0,
1484 function: {
1485 arguments: toolArguments.slice(48)
1486 }
1487 }]
1488 },
1489 finish_reason: "tool_calls"
1490 }]
1491 },
1492 "[DONE]"
1493 ]))
1494 )
1495 ))
1496 )
1497
1498 const toolkit = Toolkit.make(CompatApplyPatchTool({}))
1499 const toolkitLayer = toolkit.toLayer({
1500 CompatApplyPatch: () =>
1501 Effect.succeed({
1502 status: "completed",
1503 output: "deleted"
1504 })
1505 })
1506
1507 const partsChunk = yield* LanguageModel.streamText({
1508 prompt: "Delete src/fragmented.ts",
1509 toolkit,
1510 disableToolCallResolution: true
1511 }).pipe(
1512 Stream.runCollect,
1513 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1514 Effect.provide(toolkitLayer),
1515 Effect.provide(layer)
1516 )
1517
1518 const parts = globalThis.Array.from(partsChunk)
1519
1520 const start = parts.find((part) => part.type === "tool-params-start" && part.id === "patch_call_2")
1521 assert.isDefined(start)
1522 if (start?.type !== "tool-params-start") {
1523 return
1524 }
1525 assert.strictEqual(start.name, "CompatApplyPatch")
1526
1527 assert.deepStrictEqual(decodeToolParamsFromStream(parts, "patch_call_2"), expectedParams)
1528
1529 const toolCall = parts.find((part) => part.type === "tool-call")
1530 assert.isDefined(toolCall)
1531 if (toolCall?.type !== "tool-call") {
1532 return
1533 }
1534
1535 assert.strictEqual(toolCall.id, "patch_call_2")
1536 assert.strictEqual(toolCall.name, "CompatApplyPatch")
1537 assert.deepStrictEqual(toolCall.params, expectedParams)
1538 }))
1539
1540 it.effect("continues after invalid JSON and schema-mismatched events", () =>
1541 Effect.gen(function*() {
1542 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
1543
1544 const events = [
1545 "{invalid-json",
1546 {
1547 id: "chatcmpl_stream_1",
1548 object: "chat.completion.chunk",
1549 model: "gpt-4o-mini",
1550 created: 1,
1551 choices: [{
1552 index: 0,
1553 delta: { content: "Hello" },
1554 finish_reason: null
1555 }]
1556 },
1557 {
1558 type: "provider.chat.completion.delta",
1559 provider_payload: { content: "provider-specific" }
1560 },
1561 {
1562 id: "chatcmpl_stream_1",
1563 object: "chat.completion.chunk",
1564 model: "gpt-4o-mini",
1565 created: 1,
1566 choices: [{
1567 index: 0,
1568 delta: {},
1569 finish_reason: "stop"
1570 }],
1571 usage: {
1572 prompt_tokens: 10,
1573 completion_tokens: 7,
1574 total_tokens: 17,
1575 prompt_tokens_details: {
1576 cached_tokens: 3
1577 },
1578 completion_tokens_details: {
1579 reasoning_tokens: 2
1580 }
1581 },
1582 provider_future_field: { accepted: true }
1583 },
1584 "[DONE]"
1585 ]
1586
1587 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1588 Layer.provide(Layer.succeed(
1589 HttpClient.HttpClient,
1590 makeHttpClient((request) => {
1591 capturedRequest = request
1592 return Effect.succeed(sseResponse(request, events))
1593 })
1594 ))
1595 )
1596
1597 const partsChunk = yield* LanguageModel.streamText({ prompt: "hello" }).pipe(
1598 Stream.runCollect,
1599 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1600 Effect.provide(layer)
1601 )
1602
1603 const parts = globalThis.Array.from(partsChunk)
1604 const metadata = parts.find((part) => part.type === "response-metadata")
1605 const finish = parts.find((part) => part.type === "finish")
1606 const deltas = parts.filter((part) => part.type === "text-delta")
1607
1608 assert.isDefined(metadata)
1609 assert.isDefined(finish)
1610 assert.strictEqual(deltas.length, 1)
1611 assert.strictEqual(deltas[0]?.delta, "Hello")
1612 if (finish?.type === "finish") {
1613 assert.strictEqual(finish.reason, "stop")
1614 assert.deepStrictEqual(finish.usage.inputTokens, {
1615 uncached: 7,
1616 total: 10,
1617 cacheRead: 3,
1618 cacheWrite: undefined
1619 })
1620 assert.deepStrictEqual(finish.usage.outputTokens, {
1621 total: 7,
1622 text: 5,
1623 reasoning: 2
1624 })
1625 }
1626
1627 assert.isDefined(capturedRequest)
1628 if (capturedRequest === undefined) {
1629 return
1630 }
1631
1632 const requestBody = yield* getRequestBody(capturedRequest)
1633 assert.strictEqual(requestBody.stream, true)
1634 assert.isTrue(capturedRequest.url.endsWith("/chat/completions"))
1635 }))
1636
1637 it.effect("assembles streamed tool args when continuation fragments have function.name: null", () =>
1638 Effect.gen(function*() {
1639 // Some OpenAI-compatible providers (e.g. Fireworks) only send the tool
1640 // name on the first fragment and `function.name: null` on every
1641 // continuation. The argument fragments live on those continuations, so
1642 // they must not be dropped during chunk validation.
1643 const chunk = (fnDelta: Record<string, unknown>) => ({
1644 id: "chatcmpl_null_name_1",
1645 object: "chat.completion.chunk",
1646 model: "gpt-4o-mini",
1647 created: 1,
1648 choices: [{
1649 index: 0,
1650 delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: fnDelta }] }
1651 }]
1652 })
1653
1654 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1655 Layer.provide(Layer.succeed(
1656 HttpClient.HttpClient,
1657 makeHttpClient((request) =>
1658 Effect.succeed(sseResponse(request, [
1659 chunk({ name: "TestTool", arguments: "" }),
1660 chunk({ name: null, arguments: "{\"in" }),
1661 chunk({ name: null, arguments: "put\":\"hel" }),
1662 chunk({ name: null, arguments: "lo\"}" }),
1663 {
1664 id: "chatcmpl_null_name_1",
1665 object: "chat.completion.chunk",
1666 model: "gpt-4o-mini",
1667 created: 1,
1668 choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }]
1669 },
1670 "[DONE]"
1671 ]))
1672 )
1673 ))
1674 )
1675
1676 const partsChunk = yield* LanguageModel.streamText({
1677 prompt: "use the tool",
1678 toolkit: TestToolkit,
1679 disableToolCallResolution: true
1680 }).pipe(
1681 Stream.runCollect,
1682 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1683 Effect.provide(TestToolkitLayer),
1684 Effect.provide(layer)
1685 )
1686
1687 const parts = globalThis.Array.from(partsChunk)
1688 const paramsDeltas = parts.filter((part) => part.type === "tool-params-delta")
1689 assert.isAbove(paramsDeltas.length, 0)
1690
1691 const toolCall = parts.find((part) => part.type === "tool-call")
1692 assert.isDefined(toolCall)
1693 if (toolCall?.type !== "tool-call") {
1694 return
1695 }
1696 assert.strictEqual(toolCall.name, "TestTool")
1697 assert.deepStrictEqual(toolCall.params, { input: "hello" })
1698 }))
1699
1700 it.effect("emits reasoning lifecycle parts for delta.reasoning", () =>
1701 Effect.gen(function*() {
1702 const chunk = (delta: Record<string, unknown>, finishReason: string | null = null) => ({
1703 id: "chatcmpl_reasoning_1",
1704 object: "chat.completion.chunk",
1705 model: "gpt-4o-mini",
1706 created: 1,
1707 choices: [{ index: 0, delta, finish_reason: finishReason }]
1708 })
1709
1710 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1711 Layer.provide(Layer.succeed(
1712 HttpClient.HttpClient,
1713 makeHttpClient((request) =>
1714 Effect.succeed(sseResponse(request, [
1715 chunk({ reasoning: "Let me think" }),
1716 chunk({ reasoning: " about this." }),
1717 chunk({ content: "Hello" }),
1718 chunk({ content: " there" }, "stop"),
1719 "[DONE]"
1720 ]))
1721 )
1722 ))
1723 )
1724
1725 const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
1726 Stream.runCollect,
1727 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1728 Effect.provide(layer)
1729 )
1730
1731 const parts = globalThis.Array.from(partsChunk)
1732 assert.deepStrictEqual(parts.map((part) => part.type), [
1733 "response-metadata",
1734 "reasoning-start",
1735 "reasoning-delta",
1736 "reasoning-delta",
1737 "reasoning-end",
1738 "text-start",
1739 "text-delta",
1740 "text-delta",
1741 "text-end",
1742 "finish"
1743 ])
1744
1745 const reasoningText = parts
1746 .flatMap((part) => part.type === "reasoning-delta" ? [part.delta] : [])
1747 .join("")
1748 assert.strictEqual(reasoningText, "Let me think about this.")
1749
1750 const reasoningIds = parts.flatMap((part) =>
1751 part.type === "reasoning-start" || part.type === "reasoning-delta" || part.type === "reasoning-end"
1752 ? [part.id]
1753 : []
1754 )
1755 assert.strictEqual(new Set(reasoningIds).size, 1)
1756
1757 const textIds = parts.flatMap((part) => part.type === "text-start" ? [part.id] : [])
1758 assert.notStrictEqual(reasoningIds[0], textIds[0])
1759 }))
1760
1761 it.effect("emits reasoning lifecycle parts for delta.reasoning_content", () =>
1762 Effect.gen(function*() {
1763 const chunk = (delta: Record<string, unknown>, finishReason: string | null = null) => ({
1764 id: "chatcmpl_reasoning_2",
1765 object: "chat.completion.chunk",
1766 model: "gpt-4o-mini",
1767 created: 1,
1768 choices: [{ index: 0, delta, finish_reason: finishReason }]
1769 })
1770
1771 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1772 Layer.provide(Layer.succeed(
1773 HttpClient.HttpClient,
1774 makeHttpClient((request) =>
1775 Effect.succeed(sseResponse(request, [
1776 chunk({ reasoning_content: "Thinking..." }),
1777 chunk({ content: "Answer." }, "stop"),
1778 "[DONE]"
1779 ]))
1780 )
1781 ))
1782 )
1783
1784 const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
1785 Stream.runCollect,
1786 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1787 Effect.provide(layer)
1788 )
1789
1790 const parts = globalThis.Array.from(partsChunk)
1791 assert.deepStrictEqual(parts.map((part) => part.type), [
1792 "response-metadata",
1793 "reasoning-start",
1794 "reasoning-delta",
1795 "reasoning-end",
1796 "text-start",
1797 "text-delta",
1798 "text-end",
1799 "finish"
1800 ])
1801
1802 const reasoningText = parts
1803 .flatMap((part) => part.type === "reasoning-delta" ? [part.delta] : [])
1804 .join("")
1805 assert.strictEqual(reasoningText, "Thinking...")
1806 }))
1807
1808 it.effect("closes an open reasoning part at stream end", () =>
1809 Effect.gen(function*() {
1810 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1811 Layer.provide(Layer.succeed(
1812 HttpClient.HttpClient,
1813 makeHttpClient((request) =>
1814 Effect.succeed(sseResponse(request, [
1815 {
1816 id: "chatcmpl_reasoning_3",
1817 object: "chat.completion.chunk",
1818 model: "gpt-4o-mini",
1819 created: 1,
1820 choices: [{ index: 0, delta: { reasoning: "Only thoughts." }, finish_reason: "stop" }]
1821 },
1822 "[DONE]"
1823 ]))
1824 )
1825 ))
1826 )
1827
1828 const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
1829 Stream.runCollect,
1830 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
1831 Effect.provide(layer)
1832 )
1833
1834 const parts = globalThis.Array.from(partsChunk)
1835 assert.deepStrictEqual(parts.map((part) => part.type), [
1836 "response-metadata",
1837 "reasoning-start",
1838 "reasoning-delta",
1839 "reasoning-end",
1840 "finish"
1841 ])
1842 }))
1843 })
1844
1845 describe("config", () => {
1846 it.effect("does not leak library-only fields into request body", () =>
1847 Effect.gen(function*() {
1848 let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
1849
1850 const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
1851 Layer.provide(Layer.succeed(
1852 HttpClient.HttpClient,
1853 makeHttpClient((request) => {
1854 capturedRequest = request
1855 return Effect.succeed(jsonResponse(request, makeChatCompletion()))
1856 })
1857 ))
1858 )
1859
1860 yield* LanguageModel.generateText({ prompt: "test" }).pipe(
1861 Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini", {
1862 fileIdPrefixes: ["file-"],
1863 strictJsonSchema: false,
1864 temperature: 0.5
1865 })),
1866 Effect.provide(layer)
1867 )
1868
1869 assert.isDefined(capturedRequest)
1870 if (capturedRequest === undefined) return
1871
1872 const requestBody = yield* getRequestBody(capturedRequest)
1873 assert.strictEqual(requestBody.fileIdPrefixes, undefined)
1874 assert.strictEqual(requestBody.strictJsonSchema, undefined)
1875 assert.strictEqual(requestBody.temperature, 0.5)
1876 }))
1877 })
1878})
1879
1880const TestTool = Tool.make("TestTool", {
1881 description: "A test tool",
1882 parameters: Schema.Struct({ input: Schema.String }),
1883 success: Schema.Struct({ output: Schema.String })
1884})
1885
1886const TestToolkit = Toolkit.make(TestTool)
1887
1888const TestToolkitLayer = TestToolkit.toLayer({
1889 TestTool: ({ input }) => Effect.succeed({ output: input })
1890})
1891
1892const RecordTool = Tool.make("RecordTool", {
1893 parameters: Schema.Struct({
1894 env: Schema.Record(Schema.String, Schema.String)
1895 }),
1896 success: Schema.String
1897})
1898
1899const RecordToolkit = Toolkit.make(RecordTool)
1900
1901const RecordToolkitLayer = RecordToolkit.toLayer({
1902 RecordTool: () => Effect.succeed("done")
1903})
1904
1905const CompatApplyPatchTool = Tool.providerDefined({
1906 id: "compat.apply_patch",
1907 customName: "CompatApplyPatch",
1908 providerName: "apply_patch",
1909 requiresHandler: true,
1910 parameters: Schema.Struct({
1911 call_id: Schema.String,
1912 operation: Schema.Any
1913 }),
1914 success: Schema.Struct({
1915 status: Schema.Literals(["completed", "failed"]),
1916 output: Schema.optionalKey(Schema.NullOr(Schema.String))
1917 })
1918})
1919
1920const localShellAction = {
1921 type: "exec",
1922 command: ["pwd"],
1923 env: {}
1924}
1925
1926const CompatLocalShellTool = Tool.providerDefined({
1927 id: "compat.local_shell",
1928 customName: "CompatLocalShell",
1929 providerName: "local_shell",
1930 requiresHandler: true,
1931 parameters: Schema.Struct({
1932 action: Schema.Any
1933 }),
1934 success: Schema.String
1935})
1936
1937const makeHttpClient = (
1938 handler: (
1939 request: HttpClientRequest.HttpClientRequest
1940 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
1941) =>
1942 HttpClient.makeWith(
1943 Effect.fnUntraced(function*(requestEffect) {
1944 const request = yield* requestEffect
1945 return yield* handler(request)
1946 }),
1947 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
1948 )
1949
1950const makeChatCompletion = (overrides: Record<string, unknown> = {}) => ({
1951 id: "chatcmpl_test_1",
1952 object: "chat.completion",
1953 model: "gpt-4o-mini",
1954 created: 1,
1955 choices: [{
1956 index: 0,
1957 finish_reason: "stop",
1958 message: {
1959 role: "assistant",
1960 content: ""
1961 }
1962 }],
1963 ...overrides
1964})
1965
1966const makeLocalShellChunk = () => ({
1967 id: "chatcmpl_local_shell_1",
1968 object: "chat.completion.chunk",
1969 model: "gpt-4o-mini",
1970 created: 1,
1971 choices: [{
1972 index: 0,
1973 delta: {
1974 tool_calls: [{
1975 index: 0,
1976 id: "local_shell_call_1",
1977 type: "function",
1978 function: {
1979 name: "local_shell",
1980 arguments: JSON.stringify({ action: localShellAction })
1981 }
1982 }]
1983 },
1984 finish_reason: "tool_calls"
1985 }]
1986})
1987
1988const jsonResponse = (
1989 request: HttpClientRequest.HttpClientRequest,
1990 body: unknown
1991): HttpClientResponse.HttpClientResponse =>
1992 HttpClientResponse.fromWeb(
1993 request,
1994 new Response(JSON.stringify(body), {
1995 status: 200,
1996 headers: {
1997 "content-type": "application/json"
1998 }
1999 })
2000 )
2001
2002const sseResponse = (
2003 request: HttpClientRequest.HttpClientRequest,
2004 events: ReadonlyArray<unknown>
2005): HttpClientResponse.HttpClientResponse =>
2006 HttpClientResponse.fromWeb(
2007 request,
2008 new Response(toSseBody(events), {
2009 status: 200,
2010 headers: {
2011 "content-type": "text/event-stream"
2012 }
2013 })
2014 )
2015
2016const getRequestBody = (request: HttpClientRequest.HttpClientRequest) =>
2017 Effect.gen(function*() {
2018 const body = request.body
2019 if (body._tag === "Uint8Array") {
2020 const text = new TextDecoder().decode(body.body)
2021 return JSON.parse(text)
2022 }
2023 return yield* Effect.die(new Error("Expected Uint8Array body"))
2024 })
2025
2026const decodeToolParamsFromStream = (
2027 parts: ReadonlyArray<any>,
2028 toolCallId: string
2029): Record<string, unknown> => {
2030 const start = parts.find((part) => part.type === "tool-params-start" && part.id === toolCallId)
2031 const end = parts.find((part) => part.type === "tool-params-end" && part.id === toolCallId)
2032 assert.isDefined(start)
2033 assert.isDefined(end)
2034
2035 const deltas = parts
2036 .filter((part) => part.type === "tool-params-delta" && part.id === toolCallId)
2037 .map((part) => part.delta)
2038 .join("")
2039
2040 return JSON.parse(deltas) as Record<string, unknown>
2041}
2042
2043const toSseBody = (events: ReadonlyArray<unknown>): string =>
2044 events.map((event) => {
2045 if (typeof event === "string") {
2046 return `data: ${event}\n\n`
2047 }
2048 return `data: ${JSON.stringify(event)}\n\n`
2049 }).join("")