mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 03:12:36 +03:00
feat(providers): translate OpenAI messages for chatgpt-session
This commit is contained in:
107
open-sse/executors/chatgpt-session/messages.ts
Normal file
107
open-sse/executors/chatgpt-session/messages.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
118
tests/unit/chatgpt-session-messages.test.ts
Normal file
118
tests/unit/chatgpt-session-messages.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user