feat(providers): translate OpenAI messages for chatgpt-session

This commit is contained in:
Markus Hartung
2026-09-02 01:35:36 -03:00
parent baa45e9a67
commit cc85011dd4
2 changed files with 225 additions and 0 deletions

View File

@@ -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<string, unknown>;
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<OpenAiMessage>;
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 } : {}),
};
}

View File

@@ -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);
});