Skip to content

Commit c351fb2

Browse files
test(openai): cover Codex Responses API non-streaming, streaming variants and formatting (#87)
Raises codex Responses-API patch coverage from ~54% to ~94% (target 80%) by exercising the previously untested branches: - non-streaming path: function_call/tool_call, top-level + message text, reasoning summary, usage (incl. cache tokens), object-arg stringify, errors - streaming variants: text.done-only, content_part, reasoning deltas, refusal, output_item.added/done message text, response.completed fallback + usage, legacy choices/usage shape - request body: max_output_tokens when includeMaxTokens is set - conversation formatting: image blocks, array tool_result content, assistant string content
1 parent 2402b0f commit c351fb2

1 file changed

Lines changed: 217 additions & 0 deletions

File tree

src/api/providers/__tests__/openai-codex-responses.spec.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,4 +538,221 @@ describe("OpenAiHandler - Codex model detection", () => {
538538
}).rejects.toThrow("Response failed: Unknown failure")
539539
})
540540
})
541+
542+
const codexOptions = (overrides: Partial<ApiHandlerOptions> = {}): ApiHandlerOptions => ({
543+
openAiApiKey: "test-key",
544+
openAiModelId: "gpt-5.3-codex",
545+
openAiUseAzure: true,
546+
...overrides,
547+
})
548+
549+
const collect = async (
550+
h: OpenAiHandler,
551+
messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }],
552+
) => {
553+
const chunks: any[] = []
554+
for await (const chunk of h.createMessage("System", messages, { taskId: "test" })) {
555+
chunks.push(chunk)
556+
}
557+
return chunks
558+
}
559+
560+
const streamOf = (...events: any[]) => ({
561+
[Symbol.asyncIterator]: async function* () {
562+
for (const e of events) yield e
563+
},
564+
})
565+
566+
describe("createMessage codex non-streaming path", () => {
567+
it("extracts tool calls, text, message content, reasoning and usage from a non-streaming response", async () => {
568+
handler = new OpenAiHandler(codexOptions({ openAiStreamingEnabled: false }))
569+
570+
mockResponsesCreate.mockResolvedValue({
571+
output: [
572+
{ type: "function_call", call_id: "c1", name: "read_file", arguments: '{"path":"a.ts"}' },
573+
{ type: "output_text", text: "top-level text" },
574+
{ type: "message", content: [{ type: "output_text", text: "message text" }] },
575+
{ type: "reasoning", summary: [{ text: "thinking out loud" }] },
576+
],
577+
usage: {
578+
input_tokens: 7,
579+
output_tokens: 11,
580+
cache_read_input_tokens: 2,
581+
cache_creation_input_tokens: 3,
582+
},
583+
})
584+
585+
const chunks = await collect(handler)
586+
587+
expect(mockResponsesCreate.mock.calls[0][0].stream).toBe(false)
588+
589+
const toolCalls = chunks.filter((c) => c.type === "tool_call")
590+
expect(toolCalls).toHaveLength(1)
591+
expect(toolCalls[0]).toMatchObject({ id: "c1", name: "read_file", arguments: '{"path":"a.ts"}' })
592+
593+
const texts = chunks.filter((c) => c.type === "text").map((c) => c.text)
594+
expect(texts).toEqual(["top-level text", "message text"])
595+
596+
const reasoning = chunks.filter((c) => c.type === "reasoning")
597+
expect(reasoning[0].text).toBe("thinking out loud")
598+
599+
const usage = chunks.find((c) => c.type === "usage")
600+
expect(usage).toMatchObject({ inputTokens: 7, outputTokens: 11, cacheReadTokens: 2, cacheWriteTokens: 3 })
601+
})
602+
603+
it("stringifies object tool-call arguments in the non-streaming path", async () => {
604+
handler = new OpenAiHandler(codexOptions({ openAiStreamingEnabled: false }))
605+
606+
mockResponsesCreate.mockResolvedValue({
607+
output: [{ type: "tool_call", id: "c2", name: "search", input: { query: "x" } }],
608+
})
609+
610+
const chunks = await collect(handler)
611+
const toolCalls = chunks.filter((c) => c.type === "tool_call")
612+
expect(toolCalls[0]).toMatchObject({ id: "c2", name: "search", arguments: '{"query":"x"}' })
613+
})
614+
615+
it("wraps non-streaming API errors via handleOpenAIError", async () => {
616+
handler = new OpenAiHandler(codexOptions({ openAiStreamingEnabled: false }))
617+
mockResponsesCreate.mockRejectedValue(new Error("boom"))
618+
await expect(collect(handler)).rejects.toThrow()
619+
})
620+
})
621+
622+
describe("createMessage codex streaming event variants", () => {
623+
it("emits text from a done-only event when no delta was seen", async () => {
624+
handler = new OpenAiHandler(codexOptions())
625+
mockResponsesCreate.mockResolvedValue(streamOf({ type: "response.output_text.done", text: "final text" }))
626+
const chunks = await collect(handler)
627+
expect(chunks.filter((c) => c.type === "text").map((c) => c.text)).toEqual(["final text"])
628+
})
629+
630+
it("emits text from a content_part event when no delta was seen", async () => {
631+
handler = new OpenAiHandler(codexOptions())
632+
mockResponsesCreate.mockResolvedValue(
633+
streamOf({ type: "response.content_part.added", part: { type: "output_text", text: "part text" } }),
634+
)
635+
const chunks = await collect(handler)
636+
expect(chunks.filter((c) => c.type === "text").map((c) => c.text)).toEqual(["part text"])
637+
})
638+
639+
it("emits reasoning from reasoning delta events", async () => {
640+
handler = new OpenAiHandler(codexOptions())
641+
mockResponsesCreate.mockResolvedValue(
642+
streamOf({ type: "response.reasoning_summary_text.delta", delta: "step 1" }),
643+
)
644+
const chunks = await collect(handler)
645+
expect(chunks.filter((c) => c.type === "reasoning").map((c) => c.text)).toEqual(["step 1"])
646+
})
647+
648+
it("emits refusal text from refusal delta events", async () => {
649+
handler = new OpenAiHandler(codexOptions())
650+
mockResponsesCreate.mockResolvedValue(streamOf({ type: "response.refusal.delta", delta: "cannot help" }))
651+
const chunks = await collect(handler)
652+
expect(chunks.filter((c) => c.type === "text").map((c) => c.text)).toEqual(["[Refusal] cannot help"])
653+
})
654+
655+
it("emits text from an output_item.added message", async () => {
656+
handler = new OpenAiHandler(codexOptions())
657+
mockResponsesCreate.mockResolvedValue(
658+
streamOf({
659+
type: "response.output_item.added",
660+
item: { type: "message", content: [{ type: "output_text", text: "added msg" }] },
661+
}),
662+
)
663+
const chunks = await collect(handler)
664+
expect(chunks.filter((c) => c.type === "text").map((c) => c.text)).toEqual(["added msg"])
665+
})
666+
667+
it("falls back to text from an output_item.done message when no text was streamed", async () => {
668+
handler = new OpenAiHandler(codexOptions())
669+
mockResponsesCreate.mockResolvedValue(
670+
streamOf({
671+
type: "response.output_item.done",
672+
item: { type: "message", content: [{ type: "output_text", text: "done msg" }] },
673+
}),
674+
)
675+
const chunks = await collect(handler)
676+
expect(chunks.filter((c) => c.type === "text").map((c) => c.text)).toEqual(["done msg"])
677+
})
678+
679+
it("extracts fallback text and usage from a response.done payload when nothing streamed", async () => {
680+
handler = new OpenAiHandler(codexOptions())
681+
mockResponsesCreate.mockResolvedValue(
682+
streamOf({
683+
type: "response.completed",
684+
response: {
685+
output: [{ type: "message", content: [{ type: "output_text", text: "completed text" }] }],
686+
usage: { input_tokens: 4, output_tokens: 6 },
687+
},
688+
}),
689+
)
690+
const chunks = await collect(handler)
691+
expect(chunks.filter((c) => c.type === "text").map((c) => c.text)).toEqual(["completed text"])
692+
expect(chunks.find((c) => c.type === "usage")).toMatchObject({ inputTokens: 4, outputTokens: 6 })
693+
})
694+
695+
it("supports the older choices/usage fallback shape", async () => {
696+
handler = new OpenAiHandler(codexOptions())
697+
mockResponsesCreate.mockResolvedValue(
698+
streamOf({
699+
choices: [{ delta: { content: "legacy chunk" } }],
700+
usage: { prompt_tokens: 9, completion_tokens: 2 },
701+
}),
702+
)
703+
const chunks = await collect(handler)
704+
expect(chunks.filter((c) => c.type === "text").map((c) => c.text)).toEqual(["legacy chunk"])
705+
expect(chunks.find((c) => c.type === "usage")).toMatchObject({ inputTokens: 9, outputTokens: 2 })
706+
})
707+
})
708+
709+
describe("createMessage codex request body + conversation formatting", () => {
710+
it("includes max_output_tokens when includeMaxTokens is enabled", async () => {
711+
handler = new OpenAiHandler(codexOptions({ includeMaxTokens: true, modelMaxTokens: 1234 }))
712+
mockResponsesCreate.mockResolvedValue(streamOf({ type: "response.output_text.delta", delta: "x" }))
713+
await collect(handler)
714+
expect(mockResponsesCreate.mock.calls[0][0].max_output_tokens).toBe(1234)
715+
})
716+
717+
it("formats image blocks, array tool_result content and assistant string content", async () => {
718+
handler = new OpenAiHandler(codexOptions())
719+
mockResponsesCreate.mockResolvedValue(streamOf({ type: "response.output_text.delta", delta: "ok" }))
720+
721+
const messages: Anthropic.Messages.MessageParam[] = [
722+
{
723+
role: "user",
724+
content: [
725+
{ type: "text", text: "look" },
726+
{
727+
type: "image",
728+
source: { type: "base64", media_type: "image/png", data: "AAAA" },
729+
},
730+
],
731+
},
732+
{ role: "assistant", content: "sure thing" },
733+
{
734+
role: "user",
735+
content: [
736+
{
737+
type: "tool_result",
738+
tool_use_id: "call_1",
739+
content: [{ type: "text", text: "result body" }],
740+
},
741+
],
742+
},
743+
]
744+
745+
await collect(handler, messages)
746+
const input = mockResponsesCreate.mock.calls[0][0].input
747+
748+
const userImage = input[0].content.find((c: any) => c.type === "input_image")
749+
expect(userImage.image_url).toBe("data:image/png;base64,AAAA")
750+
751+
const assistant = input.find((i: any) => i.role === "assistant")
752+
expect(assistant.content[0]).toMatchObject({ type: "output_text", text: "sure thing" })
753+
754+
const toolOutput = input.find((i: any) => i.type === "function_call_output")
755+
expect(toolOutput.output).toBe("result body")
756+
})
757+
})
541758
})

0 commit comments

Comments
 (0)