import { once } from "node:events"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; import { convertMessages, streamOpenAICompletions } from "../src/providers/openai-completions.ts"; import type { AssistantMessage, AssistantMessageEvent, Context, Model, OpenAICompletionsCompat, Usage, } from "../src/types.ts"; const emptyUsage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; const compat = { supportsStore: true, supportsDeveloperRole: true, supportsReasoningEffort: true, supportsUsageInStreaming: true, maxTokensField: "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: true, requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, zaiToolStream: false, supportsStrictMode: true, cacheControlFormat: undefined, sendSessionAffinityHeaders: false, supportsLongCacheRetention: true, } satisfies Required> & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; }; function buildModel(baseUrl = "http://127.0.0.1:1"): Model<"openai-completions"> { return { id: "repro-model", name: "Repro Model", api: "openai-completions", provider: "repro-provider", baseUrl, reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096, compat, }; } function buildAssistant(content: AssistantMessage["content"]): AssistantMessage { return { role: "assistant", content, api: "openai-completions", provider: "repro-provider", model: "repro-model", usage: emptyUsage, stopReason: "stop", timestamp: 2, }; } function buildContext(assistant: AssistantMessage): Context { return { messages: [ { role: "user", content: "hello", timestamp: 1 }, assistant, { role: "user", content: "continue", timestamp: 3 }, ], }; } async function collectEvents(stream: AsyncIterable): Promise { const events: AssistantMessageEvent[] = []; for await (const event of stream) { events.push(event); } return events; } interface ChatCompletionsRequestBody { model: string; messages: Array<{ role: string; content?: unknown }>; stream: boolean; stream_options?: { include_usage?: boolean }; } describe("openai-completions thinking-as-text replay", () => { afterEach(() => { delete process.env.OPENAI_API_KEY; }); it("serializes same-model thinking-plus-text replay as assistant text parts", () => { const messages = convertMessages( buildModel(), buildContext( buildAssistant([ { type: "thinking", thinking: "internal reasoning" }, { type: "text", text: "visible answer" }, ]), ), compat, ); expect(messages[1]).toEqual({ role: "assistant", content: [ { type: "text", text: "internal reasoning" }, { type: "text", text: "visible answer" }, ], }); }); it("serializes same-model thinking-only replay as assistant text parts", () => { const messages = convertMessages( buildModel(), buildContext(buildAssistant([{ type: "thinking", thinking: "internal reasoning" }])), compat, ); expect(messages[1]).toEqual({ role: "assistant", content: [{ type: "text", text: "internal reasoning" }], }); }); it("reaches the endpoint when replay contains both thinking and text", async () => { const requestBodies: ChatCompletionsRequestBody[] = []; const server = http.createServer(async (req, res) => { if (req.method !== "POST" || req.url !== "/chat/completions") { res.writeHead(404).end(); return; } let body = ""; for await (const chunk of req) { body += chunk.toString(); } requestBodies.push(JSON.parse(body) as ChatCompletionsRequestBody); res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", }); res.write( `data: ${JSON.stringify({ id: "chatcmpl-repro", object: "chat.completion.chunk", created: 0, model: "repro-model", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }], })}\n\n`, ); res.write( `data: ${JSON.stringify({ id: "chatcmpl-repro", object: "chat.completion.chunk", created: 0, model: "repro-model", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 }, })}\n\n`, ); res.write("data: [DONE]\n\n"); res.end(); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); try { const { port } = server.address() as AddressInfo; const events = await collectEvents( streamOpenAICompletions( buildModel(`http://127.0.0.1:${port}`), buildContext( buildAssistant([ { type: "thinking", thinking: "internal reasoning" }, { type: "text", text: "visible answer" }, ]), ), { apiKey: "test-key" }, ), ); expect(requestBodies).toHaveLength(1); expect(requestBodies[0]?.messages[1]).toEqual({ role: "assistant", content: [ { type: "text", text: "internal reasoning" }, { type: "text", text: "visible answer" }, ], }); const terminalEvent = events.at(-1); expect(terminalEvent?.type).toBe("done"); } finally { server.close(); await once(server, "close"); } }); });