From cc85011dd4e42b9d1b9a8d6de65a862c79f6eb6d Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 2 Sep 2026 01:35:36 -0300 Subject: [PATCH] feat(providers): translate OpenAI messages for chatgpt-session --- .../executors/chatgpt-session/messages.ts | 107 ++++++++++++++++ tests/unit/chatgpt-session-messages.test.ts | 118 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 open-sse/executors/chatgpt-session/messages.ts create mode 100644 tests/unit/chatgpt-session-messages.test.ts diff --git a/open-sse/executors/chatgpt-session/messages.ts b/open-sse/executors/chatgpt-session/messages.ts new file mode 100644 index 0000000000..efbe49fc2d --- /dev/null +++ b/open-sse/executors/chatgpt-session/messages.ts @@ -0,0 +1,107 @@ +/** + * Pure translation from an OpenAI chat-completions message list into the synthetic + * CodexParsedRequest the vendored browser adapter consumes. + * + * Tools are deliberately never placed in `context.tools`: this provider uses the shared + * prompt-emulated tool contract (translator/webTools.ts), so the adapter must not try to + * attach the turn-bound Codex connector capability. + */ + +import type { + CodexMessage, + CodexParsedRequest, + CodexTextContent, +} from "../../vendor/codex-chatgpt-web/types.ts"; +import type { ChatGptSessionRoute } from "./models.ts"; + +export class ChatGptSessionInputError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "ChatGptSessionInputError"; + this.code = code; + } +} + +interface OpenAiMessage { + role: string; + content: unknown; +} + +function textFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const chunks: string[] = []; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const typed = part as Record; + if (typed.type === "text" && typeof typed.text === "string") { + chunks.push(typed.text); + continue; + } + if (typed.type === "image_url" || typed.type === "image") { + throw new ChatGptSessionInputError( + "vision_unsupported", + "ChatGPT Session does not accept image input yet" + ); + } + } + return chunks.join("\n"); +} + +function assistantParts(content: unknown): CodexTextContent[] { + const text = textFromContent(content); + return text ? [{ type: "text", text }] : []; +} + +export function buildParsedRequest(input: { + route: ChatGptSessionRoute; + messages: ReadonlyArray; + stream: boolean; + rawBody?: unknown; +}): CodexParsedRequest { + const systemPrompt: string[] = []; + const messages: CodexMessage[] = []; + let sawUser = false; + + for (const message of input.messages) { + const role = typeof message.role === "string" ? message.role : ""; + if (role === "system" || role === "developer") { + const text = textFromContent(message.content); + if (text) systemPrompt.push(text); + continue; + } + if (role === "assistant") { + const content = assistantParts(message.content); + if (content.length > 0) { + messages.push({ role: "assistant", content, timestamp: Date.now() }); + } + continue; + } + if (role === "user" || role === "tool" || role === "function") { + const text = textFromContent(message.content); + if (!text) continue; + messages.push({ role: "user", content: text, timestamp: Date.now() }); + sawUser = true; + } + } + + if (!sawUser) { + throw new ChatGptSessionInputError( + "no_user_message", + "ChatGPT Session requires at least one user message" + ); + } + + return { + modelId: input.route.backendModel, + context: { + ...(systemPrompt.length > 0 ? { systemPrompt } : {}), + messages, + }, + stream: input.stream, + options: { reasoning: input.route.effort }, + ...(input.rawBody !== undefined ? { _rawBody: input.rawBody } : {}), + }; +} diff --git a/tests/unit/chatgpt-session-messages.test.ts b/tests/unit/chatgpt-session-messages.test.ts new file mode 100644 index 0000000000..be0e91de58 --- /dev/null +++ b/tests/unit/chatgpt-session-messages.test.ts @@ -0,0 +1,118 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + ChatGptSessionInputError, + buildParsedRequest, +} from "../../open-sse/executors/chatgpt-session/messages.ts"; +import { requireChatGptSessionRoute } from "../../open-sse/executors/chatgpt-session/models.ts"; + +const route = requireChatGptSessionRoute("high"); + +test("system messages become the system prompt, not conversation turns", () => { + const parsed = buildParsedRequest({ + route, + messages: [ + { role: "system", content: "Be terse." }, + { role: "user", content: "Hi" }, + ], + stream: true, + }); + assert.deepEqual(parsed.context.systemPrompt, ["Be terse."]); + assert.equal(parsed.context.messages.length, 1); + assert.equal(parsed.context.messages[0].role, "user"); +}); + +test("developer messages join the system prompt in order", () => { + const parsed = buildParsedRequest({ + route, + messages: [ + { role: "system", content: "A" }, + { role: "developer", content: "B" }, + { role: "user", content: "Hi" }, + ], + stream: false, + }); + assert.deepEqual(parsed.context.systemPrompt, ["A", "B"]); +}); + +test("pins the backend model and the route effort", () => { + const parsed = buildParsedRequest({ + route: requireChatGptSessionRoute("luna"), + messages: [{ role: "user", content: "Hi" }], + stream: true, + }); + assert.equal(parsed.modelId, "gpt-5.6-luna"); + assert.equal(parsed.options.reasoning, "low"); + assert.equal(parsed.stream, true); +}); + +test("preserves multi-turn history with assistant text parts", () => { + const parsed = buildParsedRequest({ + route, + messages: [ + { role: "user", content: "one" }, + { role: "assistant", content: "two" }, + { role: "user", content: "three" }, + ], + stream: true, + }); + assert.equal(parsed.context.messages.length, 3); + assert.deepEqual(parsed.context.messages[1], { + role: "assistant", + content: [{ type: "text", text: "two" }], + timestamp: parsed.context.messages[1].timestamp, + }); +}); + +test("flattens OpenAI text content parts into one string", () => { + const parsed = buildParsedRequest({ + route, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }, + ], + stream: true, + }); + assert.equal(parsed.context.messages[0].content, "a\nb"); +}); + +test("rejects image parts in phase 1", () => { + assert.throws( + () => + buildParsedRequest({ + route, + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,AA" } }], + }, + ], + stream: true, + }), + (error: unknown) => + error instanceof ChatGptSessionInputError && error.code === "vision_unsupported" + ); +}); + +test("rejects a request with no user turn", () => { + assert.throws( + () => buildParsedRequest({ route, messages: [{ role: "system", content: "x" }], stream: true }), + (error: unknown) => + error instanceof ChatGptSessionInputError && error.code === "no_user_message" + ); +}); + +test("never carries tools into the parsed context", () => { + const parsed = buildParsedRequest({ + route, + messages: [{ role: "user", content: "Hi" }], + stream: true, + }); + assert.equal(parsed.context.tools, undefined); +});