Files
OmniRoute/open-sse/executors/hailuo-web.ts
backryun 06326a3c80 refactor(sse): stop three executors shadowing BaseExecutor.buildHeaders (#8498)
`BaseExecutor.buildHeaders(credentials, stream?, clientHeaders?, model?, health?)` was
shadowed in three executors by same-named helpers with unrelated signatures:

  hailuo-web  private   buildHeaders(token: string, yy: string)
  lmarena     protected buildHeaders(_model: string, credentials: unknown, _body: unknown)
  qwen-web    private   buildHeaders(token: string, cookieHeader: string, chatId?: string)

Name collisions, not overrides — each reported TS2416. They are renamed to
`buildStreamHeaders` / `buildRequestHeaders` / `buildApiHeaders`; the two lmarena test
files that called the helper directly are updated with them.

Worth stating precisely, because the shadow sat on a live dispatch path without being a
live bug: `BaseExecutor.countTokens()` calls `this.buildHeaders(credentials, false)`, and
all three inherit `countTokens()`. It is unreachable today only because
`buildCountTokensUrl()` returns null unless `config.format === "claude"` and the URL
carries `/messages` — hailuo-web and qwen-web set no format, lmarena sets `"openai"` — so
`countTokens()` returns at the guard above. Latent, not live; one `format` change away
from passing a credentials object where a token string is expected.

Two more, surfaced by clearing the above:

* `lmarena` declared `buildUrl` and `transformRequest` `protected` while both are public
  on BaseExecutor (TS2415 — a subclass may widen visibility, never narrow it). Both were
  masked behind the buildHeaders TS2416 and appeared one at a time as it cleared. Runtime
  is unaffected; JavaScript has no member visibility.

* `GithubExecutor.refreshCredentials` had no declared return type, so TypeScript inferred
  the union of its four literal returns. `GheCopilotExecutor` legitimately overrides it
  with a wider `providerSpecificData` (it also records the enterprise proxy URL) and no
  `expiresIn`, which is not assignable to that inferred union. Declared as
  `RefreshedCopilotCredentials | null` — same shape of fix as #8489, on a different method.

Validation: full tsc error-set diff against the base config — 335 -> 331, zero new errors
(line-number-agnostic). `typecheck:core` clean; the 15 existing test files importing a
touched executor pass, including lmarena's 44 across the two updated files.
`plan3-p0.test.ts` fails identically with and without this change (it reads the
developer's real ~/.omniroute DB rather than a test-scoped DATA_DIR).

The new test pins that the inherited method is no longer shadowed — verified to fail on
the base, where all three prototypes still carry their own `buildHeaders` — and that the
`countTokens()` early return which kept it harmless still holds.
2026-07-26 03:51:42 -03:00

547 lines
18 KiB
TypeScript

/**
* HailuoWebExecutor — Hailuo AI (MiniMax) web chat via www.hailuo.ai.
*
* Distinct from the paid API-key `minimax`/`minimax-cn` providers
* (open-sse/config/providers/registry/minimax/) — this targets the free
* consumer chat product at hailuo.ai / chat.minimax.io.
*
* Endpoint: POST https://www.hailuo.ai/v4/api/chat/msg?<fingerprint query>
* Auth: `token` header — value read from the site's `_token` localStorage
* entry, plus a per-request `yy` signature header.
* Body: multipart/form-data — characterID, msgContent, chatID, searchMode.
* Response: text/event-stream lines (`event:` / `data:`) carrying
* `send_result` (chat title + chatID, once) and `message_result`
* (cumulative — not delta — `content` field per event) until a
* `close_chunk` event ends the stream.
*
* Ported from the g4f reference implementation
* (g4f/Provider/needs_auth/mini_max/{HailuoAI,crypt}.py) — request signing
* (`generate_yy_header`/`get_body_to_yy`) and the SSE event shape are ported
* 1:1. The device-fingerprint fields (device_id, uuid, os/browser name,
* screen dims) are normally generated by the browser and stored in
* localStorage; when the user hasn't captured them, this executor derives
* stable per-connection values from the token via MD5 so the signature stays
* consistent across requests without server-side state.
*
* ⚠️ Not yet validated against a live hailuo.ai session — see PR description
* for the exact VPS live-check command that must be run before this is
* treated as fully verified. The host, API path, header shape, and signing
* scheme are ported directly from the (actively maintained) g4f source, but
* upstream reverse-engineered protocols can change without notice.
*/
import { createHash } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts";
const BASE_URL = "https://www.hailuo.ai";
const API_PATH = "/v4/api/chat/msg";
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const DEFAULT_CHARACTER_ID = "1";
const DEFAULT_CHAT_ID = "0";
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function md5(input: string): string {
return createHash("md5").update(input, "utf8").digest("hex");
}
/**
* Percent-encode matching Python's `urllib.parse.quote(s, safe="")` — encode
* every byte except the always-safe RFC 3986 unreserved set (letters,
* digits, `_.-~`). `encodeURIComponent` leaves a few extra characters
* (`!*'()`) unescaped, so it is not a drop-in replacement for the upstream
* signature to match byte-for-byte.
*/
export function pyQuote(input: string): string {
const bytes = new TextEncoder().encode(input);
let out = "";
for (const byte of bytes) {
const ch = String.fromCharCode(byte);
if (/[A-Za-z0-9_.\-~]/.test(ch)) {
out += ch;
} else {
out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
}
}
return out;
}
/** Port of `get_body_to_yy()` from crypt.py. */
export function getBodyToYy(characterID: string, msgContent: string, chatID: string): string {
const normalized = msgContent.replace(/\r\n/g, "").replace(/\n/g, "").replace(/\r/g, "");
return md5(characterID) + md5(normalized) + md5(chatID) + md5("");
}
/** Port of `generate_yy_header()` from crypt.py. */
export function generateYyHeader(
pathAndQuery: string,
bodyToYy: string,
timestampMs: number
): string {
const encodedPath = pyQuote(pathAndQuery);
const timeHash = md5(String(timestampMs));
const combined = `${encodedPath}_${bodyToYy}${timeHash}ooui`;
return md5(combined);
}
/**
* Derive a stable per-connection fingerprint id from the token when the user
* hasn't captured the real browser-generated value from localStorage. Pure
* function of the token, so it stays identical across requests without
* needing to persist any new state.
*/
function deriveFingerprintId(token: string, salt: string): string {
return md5(`${token}:${salt}`);
}
export function buildHailuoPathAndQuery(
token: string,
providerSpecificData: unknown,
unixMs: number
): string {
const data = asRecord(providerSpecificData);
const deviceId =
toStringOrEmpty(data.device_id) ||
toStringOrEmpty(data.deviceId) ||
deriveFingerprintId(token, "device_id");
const uuid = toStringOrEmpty(data.uuid) || deriveFingerprintId(token, "uuid");
const params = new URLSearchParams({
device_platform: "web",
biz_id: "2",
app_id: "3001",
version_code: "22201",
lang: "en",
uuid,
device_id: deviceId,
os_name: toStringOrEmpty(data.os_name) || "Windows",
browser_name: toStringOrEmpty(data.browser_name) || "chrome",
cpu_core_num: toStringOrEmpty(data.cpu_core_num) || "8",
browser_language: toStringOrEmpty(data.browser_language) || "en-US",
browser_platform: toStringOrEmpty(data.browser_platform) || "Win32",
screen_width: toStringOrEmpty(data.screen_width) || "1920",
screen_height: toStringOrEmpty(data.screen_height) || "1080",
unix: String(unixMs),
});
return `${API_PATH}?${params.toString()}`;
}
type HailuoInputMessage = {
role: string;
content: unknown;
tool_calls?: unknown;
};
function textFromContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) {
throw new Error("Hailuo Web only supports text message content");
}
return content
.map((part) => {
if (!part || typeof part !== "object" || Array.isArray(part)) {
throw new Error("Hailuo Web only supports text message content");
}
const record = part as Record<string, unknown>;
if (
(record.type === "text" || record.type === "input_text") &&
typeof record.text === "string"
) {
return record.text;
}
throw new Error("Hailuo Web does not support image, audio, file, or tool content");
})
.join("");
}
/** Fold text-only OpenAI history into the single msgContent field Hailuo accepts. */
export function foldHailuoMessages(messages: HailuoInputMessage[]): string {
const parts: string[] = [];
for (const message of messages) {
if (message.role === "tool" || message.role === "function") {
throw new Error("Hailuo Web does not support tool result messages");
}
if (message.tool_calls !== undefined) {
throw new Error("Hailuo Web does not support assistant tool calls");
}
const text = textFromContent(message.content);
if (!text) continue;
if (message.role === "system" || message.role === "developer") {
parts.push(`System: ${text}`);
} else if (message.role === "user") {
parts.push(parts.length > 0 ? `User: ${text}` : text);
} else if (message.role === "assistant") {
parts.push(`Assistant: ${text}`);
} else {
throw new Error(`Hailuo Web does not support message role ${message.role}`);
}
}
return parts.join("\n\n").trim();
}
export interface HailuoStreamState {
emittedLen: number;
}
/** `message_result.content` is a cumulative snapshot, not a delta — diff it. */
export function extractHailuoMessageDelta(content: string, state: HailuoStreamState): string {
if (typeof content !== "string" || content.length <= state.emittedLen) return "";
const delta = content.slice(state.emittedLen);
state.emittedLen = content.length;
return delta;
}
export type HailuoSseLine =
| { type: "event"; value: string }
| { type: "data"; value: unknown }
| null;
/** Parse a single raw SSE line. Malformed/truncated `data:` lines are swallowed, not thrown. */
export function parseHailuoLine(line: string): HailuoSseLine {
if (line.startsWith("event:")) {
return { type: "event", value: line.slice(6).trim() };
}
if (line.startsWith("data:")) {
const raw = line.slice(5).trim();
try {
return { type: "data", value: JSON.parse(raw) };
} catch {
return null;
}
}
return null;
}
export function extractHailuoMessageResultContent(data: unknown): string | null {
const root = asRecord(data);
const payload = asRecord(root.data);
const messageResult = asRecord(payload.messageResult);
return typeof messageResult.content === "string" ? messageResult.content : null;
}
function openAiChunk(id: string, created: number, modelId: string, content: string): JsonRecord {
return {
id,
object: "chat.completion.chunk",
created,
model: modelId,
choices: [{ index: 0, delta: { content }, finish_reason: null }],
};
}
function openAiCompletion(id: string, created: number, modelId: string, content: string): JsonRecord {
return {
id,
object: "chat.completion",
created,
model: modelId,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
};
}
export class HailuoWebExecutor extends BaseExecutor {
constructor() {
super("hailuo-web", { id: "hailuo-web", baseUrl: BASE_URL });
}
private buildStreamHeaders(token: string, yy: string): Record<string, string> {
return {
Accept: "text/event-stream",
"User-Agent": USER_AGENT,
Origin: BASE_URL,
Referer: `${BASE_URL}/`,
token,
yy,
};
}
private async streamToText(
upstream: Response,
onDelta: (delta: string) => void
): Promise<{ ok: boolean; errorMessage?: string }> {
const reader = upstream.body?.getReader();
if (!reader) return { ok: true };
const decoder = new TextDecoder();
const state: HailuoStreamState = { emittedLen: 0 };
let currentEvent = "";
let buffer = "";
const processLine = (line: string): "continue" | "close" => {
const parsed = parseHailuoLine(line);
if (!parsed) return "continue";
if (parsed.type === "event") {
currentEvent = parsed.value;
if (currentEvent === "close_chunk") return "close";
return "continue";
}
if (currentEvent === "message_result") {
const content = extractHailuoMessageResultContent(parsed.value);
if (content !== null) {
const delta = extractHailuoMessageDelta(content, state);
if (delta) onDelta(delta);
}
}
return "continue";
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || "";
for (const line of lines) {
if (processLine(line) === "close") return { ok: true };
}
}
if (buffer) processLine(buffer);
return { ok: true };
} catch (error) {
return {
ok: false,
errorMessage: error instanceof Error ? error.message : "Hailuo stream read failed",
};
}
}
/** Validate tool/function-call fields and fold messages into a single msgContent string. */
private prepareMsgContent(bodyObj: JsonRecord): { msgContent: string } | { error: string } {
const tools = bodyObj.tools;
const functions = bodyObj.functions;
if (tools != null && (!Array.isArray(tools) || tools.length > 0)) {
return { error: "Hailuo Web does not support OpenAI function tools" };
}
if (functions != null && (!Array.isArray(functions) || functions.length > 0)) {
return { error: "Hailuo Web does not support legacy function tools" };
}
try {
const messages = Array.isArray(bodyObj.messages)
? (bodyObj.messages as HailuoInputMessage[])
: [];
const msgContent = foldHailuoMessages(messages);
if (!msgContent) throw new Error("Hailuo Web requires a non-empty user message");
return { msgContent };
} catch (error) {
return { error: error instanceof Error ? error.message : "Invalid Hailuo Web request" };
}
}
/** Build the signed request: URL, headers, and the multipart form body. */
private buildSignedRequest(
token: string,
providerSpecificData: unknown,
msgContent: string
): { url: string; headers: Record<string, string>; form: FormData } {
const now = Date.now();
const pathAndQuery = buildHailuoPathAndQuery(token, providerSpecificData, now);
const psd = asRecord(providerSpecificData);
const characterID = toStringOrEmpty(psd.characterID) || DEFAULT_CHARACTER_ID;
const chatID = toStringOrEmpty(psd.chatID) || DEFAULT_CHAT_ID;
const bodyToYy = getBodyToYy(characterID, msgContent, chatID);
const yy = generateYyHeader(pathAndQuery, bodyToYy, now);
const form = new FormData();
form.set("characterID", characterID);
form.set("msgContent", msgContent);
form.set("chatID", chatID);
form.set("searchMode", "0");
return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildStreamHeaders(token, yy), form };
}
/** POST the signed multipart request and normalize both network + upstream-status errors. */
private async dispatch(
url: string,
reqHeaders: Record<string, string>,
form: FormData,
signal: AbortSignal | null | undefined,
body: unknown,
bodyObj: JsonRecord
): Promise<{ upstream: Response } | { errorResult: ReturnType<typeof makeErrorResult> }> {
let upstream: Response;
try {
upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: form, signal });
} catch (err) {
return {
errorResult: {
...makeErrorResult(
502,
`Hailuo fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
body,
url
),
headers: reqHeaders,
transformedBody: bodyObj,
},
};
}
if (!upstream.ok) {
const errText = await upstream.text().catch(() => "");
return {
errorResult: {
...makeErrorResult(
upstream.status,
`Hailuo error: ${sanitizeErrorMessage(errText)}`,
body,
url
),
headers: reqHeaders,
transformedBody: bodyObj,
},
};
}
return { upstream };
}
/** Buffer the SSE stream into a single OpenAI-shaped chat.completion response. */
private async buildNonStreamingResponse(
upstream: Response,
id: string,
created: number,
modelId: string,
url: string,
reqHeaders: Record<string, string>,
body: unknown,
bodyObj: JsonRecord
) {
let answer = "";
const result = await this.streamToText(upstream, (delta) => {
answer += delta;
});
if (!result.ok) {
return {
...makeErrorResult(
502,
`Hailuo protocol error: ${sanitizeErrorMessage(result.errorMessage || "unknown")}`,
body,
url
),
headers: reqHeaders,
transformedBody: bodyObj,
};
}
return {
response: new Response(JSON.stringify(openAiCompletion(id, created, modelId, answer)), {
headers: { "Content-Type": "application/json" },
}),
url,
headers: reqHeaders,
transformedBody: bodyObj,
};
}
private buildStreamingResponse(
upstream: Response,
id: string,
created: number,
modelId: string,
signal?: AbortSignal | null
): ReadableStream {
const encoder = new TextEncoder();
return new ReadableStream({
start: async (controller) => {
let emittedRole = false;
const result = await this.streamToText(upstream, (delta) => {
if (!emittedRole) {
emittedRole = true;
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(openAiChunk(id, created, modelId, ""))}\n\n`)
);
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(openAiChunk(id, created, modelId, delta))}\n\n`)
);
});
if (!result.ok) {
if (!signal?.aborted) {
controller.error(new Error(result.errorMessage || "Hailuo stream error"));
} else {
try {
controller.close();
} catch {
/* already closed */
}
}
return;
}
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
id,
object: "chat.completion.chunk",
created,
model: modelId,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})}\n\n`
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
}
async execute(input: ExecuteInput) {
const { body, credentials, signal, stream: wantStream } = input;
const bodyObj = asRecord(body);
const token = toStringOrEmpty(credentials?.apiKey) || toStringOrEmpty(credentials?.accessToken);
if (!token) {
return makeErrorResult(
401,
"Missing Hailuo _token — log in at hailuo.ai and capture _token from localStorage.",
body,
`${BASE_URL}${API_PATH}`
);
}
const prepared = this.prepareMsgContent(bodyObj);
if ("error" in prepared) {
return makeErrorResult(400, prepared.error, body, BASE_URL);
}
const { url, headers: reqHeaders, form } = this.buildSignedRequest(
token,
credentials?.providerSpecificData,
prepared.msgContent
);
const dispatched = await this.dispatch(url, reqHeaders, form, signal, body, bodyObj);
if ("errorResult" in dispatched) return dispatched.errorResult;
const { upstream } = dispatched;
const id = `chatcmpl-hailuo-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const modelId = input.model || "hailuo";
if (wantStream) {
const outStream = this.buildStreamingResponse(upstream, id, created, modelId, signal);
return {
response: new Response(outStream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url,
headers: reqHeaders,
transformedBody: bodyObj,
};
}
return this.buildNonStreamingResponse(upstream, id, created, modelId, url, reqHeaders, body, bodyObj);
}
}