mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
`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.
547 lines
18 KiB
TypeScript
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);
|
|
}
|
|
}
|