mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 20:32:20 +03:00
Adds hailuo-web as a new free web-cookie chat provider targeting the
MiniMax consumer chat product at hailuo.ai (chat.minimax.io), distinct
from the existing paid API-key minimax/minimax-cn providers.
Ported from the g4f reference implementation
(g4f/Provider/needs_auth/mini_max/{HailuoAI,crypt}.py):
- MD5-chain request signing (generate_yy_header/get_body_to_yy)
- Custom event:/data: SSE parsing (send_result/message_result/close_chunk),
where message_result.content is a cumulative snapshot diffed into deltas
- Device-fingerprint query params, derived deterministically per-connection
from the token when the user hasn't captured the real browser values
New catalog entry, executor, registry entry, dispatch wiring, tests
(17 cases covering signing test vectors independently verified via
Python hashlib.md5, SSE parsing, streaming/non-streaming dispatch, and
401-terminal vs 429-transient error mapping), and a regenerated
provider-translate-path golden snapshot (purely additive diff).
249 lines
9.4 KiB
TypeScript
249 lines
9.4 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const mod = await import("../../open-sse/executors/hailuo-web.ts");
|
|
|
|
describe("HailuoWebExecutor", () => {
|
|
it("can be instantiated", () => {
|
|
const executor = new mod.HailuoWebExecutor();
|
|
assert.ok(executor);
|
|
});
|
|
|
|
// Test vectors derived independently via Python's hashlib.md5 + urllib.parse.quote,
|
|
// reproducing generate_yy_header()/get_body_to_yy() from g4f's crypt.py bit-for-bit:
|
|
//
|
|
// import hashlib
|
|
// from urllib.parse import quote
|
|
// def hash_function(s): return hashlib.md5(s.encode()).hexdigest()
|
|
// def get_body_to_yy(characterID, msgContent, chatID):
|
|
// L = msgContent.replace("\r\n","").replace("\n","").replace("\r","")
|
|
// return hash_function(characterID) + hash_function(L) + hash_function(chatID) + hash_function("")
|
|
// def generate_yy_header(path, body_to_yy, t):
|
|
// encoded_path = quote(path, "")
|
|
// combined = f"{encoded_path}_{body_to_yy}{hash_function(str(t))}ooui"
|
|
// return hash_function(combined)
|
|
it("pyQuote percent-encodes exactly like Python's quote(s, safe='')", () => {
|
|
const path = "/v4/api/chat/msg?device_platform=web&biz_id=2&app_id=3001";
|
|
assert.equal(
|
|
mod.pyQuote(path),
|
|
"%2Fv4%2Fapi%2Fchat%2Fmsg%3Fdevice_platform%3Dweb%26biz_id%3D2%26app_id%3D3001"
|
|
);
|
|
// Unreserved chars (letters, digits, _.-~) pass through untouched.
|
|
assert.equal(mod.pyQuote("abcXYZ019_.-~"), "abcXYZ019_.-~");
|
|
});
|
|
|
|
it("getBodyToYy matches the independently-computed MD5 chain", () => {
|
|
const bodyToYy = mod.getBodyToYy("1", "hello world", "0");
|
|
assert.equal(
|
|
bodyToYy,
|
|
"c4ca4238a0b923820dcc509a6f75849b" +
|
|
"5eb63bbbe01eeed093cb22bb8f5acdc3" +
|
|
"cfcd208495d565ef66e7dff9f98764da" +
|
|
"d41d8cd98f00b204e9800998ecf8427e"
|
|
);
|
|
});
|
|
|
|
it("getBodyToYy normalizes CRLF/CR/LF in msgContent before hashing", () => {
|
|
const withCrlf = mod.getBodyToYy("1", "hello\r\nworld", "0");
|
|
const withLf = mod.getBodyToYy("1", "helloworld", "0");
|
|
assert.equal(withCrlf, withLf);
|
|
});
|
|
|
|
it("generateYyHeader matches the independently-computed signature", () => {
|
|
const path = "/v4/api/chat/msg?device_platform=web&biz_id=2&app_id=3001";
|
|
const bodyToYy = mod.getBodyToYy("1", "hello world", "0");
|
|
const yy = mod.generateYyHeader(path, bodyToYy, 1700000000000);
|
|
assert.equal(yy, "6893d64988ecf45b1de1808b91ae855b");
|
|
});
|
|
|
|
it("builds a stable path_and_query with derived device_id/uuid when none is supplied", () => {
|
|
const a = mod.buildHailuoPathAndQuery("token-abc", undefined, 1700000000000);
|
|
const b = mod.buildHailuoPathAndQuery("token-abc", undefined, 1700000000000);
|
|
assert.equal(a, b, "same token must derive the same fingerprint every time");
|
|
|
|
const params = new URL(`https://x${a}`).searchParams;
|
|
assert.equal(params.get("device_platform"), "web");
|
|
assert.equal(params.get("uuid")?.length, 32);
|
|
assert.equal(params.get("device_id")?.length, 32);
|
|
});
|
|
|
|
it("honors user-supplied device_id/uuid over the derived fallback", () => {
|
|
const path = mod.buildHailuoPathAndQuery(
|
|
"token-abc",
|
|
{ device_id: "real-device", uuid: "real-uuid" },
|
|
1700000000000
|
|
);
|
|
const params = new URL(`https://x${path}`).searchParams;
|
|
assert.equal(params.get("device_id"), "real-device");
|
|
assert.equal(params.get("uuid"), "real-uuid");
|
|
});
|
|
|
|
it("folds text-only OpenAI history into a single msgContent block", () => {
|
|
const folded = mod.foldHailuoMessages([
|
|
{ role: "system", content: "Be nice." },
|
|
{ role: "user", content: "hi" },
|
|
{ role: "assistant", content: "hello!" },
|
|
{ role: "user", content: "how are you?" },
|
|
]);
|
|
assert.equal(
|
|
folded,
|
|
"System: Be nice.\n\nUser: hi\n\nAssistant: hello!\n\nUser: how are you?"
|
|
);
|
|
});
|
|
|
|
it("throws on tool-call content it cannot faithfully forward", () => {
|
|
assert.throws(() =>
|
|
mod.foldHailuoMessages([{ role: "assistant", content: "", tool_calls: [{}] }])
|
|
);
|
|
assert.throws(() => mod.foldHailuoMessages([{ role: "tool", content: "result" }]));
|
|
});
|
|
|
|
it("diffs cumulative message_result content into deltas", () => {
|
|
const state = { emittedLen: 0 };
|
|
assert.equal(mod.extractHailuoMessageDelta("Hel", state), "Hel");
|
|
assert.equal(mod.extractHailuoMessageDelta("Hello", state), "lo");
|
|
assert.equal(mod.extractHailuoMessageDelta("Hello", state), "", "no growth => no delta");
|
|
});
|
|
|
|
it("parses event:/data: SSE lines and swallows malformed data without throwing", () => {
|
|
const eventLine = mod.parseHailuoLine("event: message_result");
|
|
assert.deepEqual(eventLine, { type: "event", value: "message_result" });
|
|
|
|
const dataLine = mod.parseHailuoLine('data: {"data":{"messageResult":{"content":"hi"}}}');
|
|
assert.deepEqual(dataLine, { type: "data", value: { data: { messageResult: { content: "hi" } } } });
|
|
|
|
// Truncated/malformed JSON must not throw — the stream must keep going.
|
|
assert.equal(mod.parseHailuoLine("data: {not json"), null);
|
|
assert.equal(mod.parseHailuoLine("not a recognized line"), null);
|
|
});
|
|
|
|
it("extracts message_result.content from a send_result/message_result event payload", () => {
|
|
const content = mod.extractHailuoMessageResultContent({
|
|
data: { messageResult: { content: "partial answer" } },
|
|
});
|
|
assert.equal(content, "partial answer");
|
|
assert.equal(mod.extractHailuoMessageResultContent({ data: { sendResult: { chatID: "1" } } }), null);
|
|
});
|
|
|
|
it("returns a 401 credential error when the token is missing", async () => {
|
|
const executor = new mod.HailuoWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "hailuo",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "" },
|
|
signal: null,
|
|
});
|
|
const text = await result.response.text();
|
|
|
|
assert.equal(result.response.status, 401);
|
|
assert.match(text, /token/i);
|
|
});
|
|
|
|
it("maps an upstream 401 (invalid/expired token) as a terminal, non-cooldown error", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async () =>
|
|
new Response(JSON.stringify({ error: "invalid token" }), { status: 401 })) as typeof fetch;
|
|
|
|
try {
|
|
const executor = new mod.HailuoWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "hailuo",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "expired-token" },
|
|
signal: null,
|
|
});
|
|
assert.equal(result.response.status, 401);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("maps an upstream 429 as a transient (retryable) error", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async () =>
|
|
new Response(JSON.stringify({ error: "rate limited" }), { status: 429 })) as typeof fetch;
|
|
|
|
try {
|
|
const executor = new mod.HailuoWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "hailuo",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "some-token" },
|
|
signal: null,
|
|
});
|
|
assert.equal(result.response.status, 429);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("collects a non-streaming completion from send_result/message_result/close_chunk SSE events", async () => {
|
|
const sse = [
|
|
"event: send_result",
|
|
'data: {"data":{"sendResult":{"chatID":"c1","chatTitle":"hi"}}}',
|
|
"event: message_result",
|
|
'data: {"data":{"messageResult":{"content":"Hel"}}}',
|
|
"event: message_result",
|
|
'data: {"data":{"messageResult":{"content":"Hello"}}}',
|
|
"event: close_chunk",
|
|
"",
|
|
].join("\n");
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async () => new Response(sse, { status: 200 })) as typeof fetch;
|
|
|
|
try {
|
|
const executor = new mod.HailuoWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "hailuo",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "some-token" },
|
|
signal: null,
|
|
});
|
|
const json = await result.response.json();
|
|
|
|
assert.equal(result.response.status, 200);
|
|
assert.equal(json.choices[0].message.content, "Hello");
|
|
assert.equal(new URL(result.url).pathname, "/v4/api/chat/msg");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("streams incremental deltas for a streaming request", async () => {
|
|
const sse = [
|
|
"event: message_result",
|
|
'data: {"data":{"messageResult":{"content":"Hi"}}}',
|
|
"event: message_result",
|
|
'data: {"data":{"messageResult":{"content":"Hi there"}}}',
|
|
"event: close_chunk",
|
|
"",
|
|
].join("\n");
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async () => new Response(sse, { status: 200 })) as typeof fetch;
|
|
|
|
try {
|
|
const executor = new mod.HailuoWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "hailuo",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: true,
|
|
credentials: { apiKey: "some-token" },
|
|
signal: null,
|
|
});
|
|
const text = await result.response.text();
|
|
|
|
assert.match(text, /"content":"Hi"/);
|
|
assert.match(text, /"content":" there"/);
|
|
assert.match(text, /data: \[DONE\]/);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|