/** * HailuoWebExecutor — Hailuo AI (MiniMax) web chat via chat.minimax.io. * * Distinct from the paid API-key `minimax`/`minimax-cn` providers * (open-sse/config/providers/registry/minimax/) — this targets the free * consumer chat product at chat.minimax.io. * * Endpoint: POST https://chat.minimax.io/v4/api/chat/msg? * 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://chat.minimax.io"; 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; 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; 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 { 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; 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, form: FormData, signal: AbortSignal | null | undefined, body: unknown, bodyObj: JsonRecord ): Promise<{ upstream: Response } | { errorResult: ReturnType }> { 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, 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); } }