Files
OmniRoute/open-sse/executors/lmarena.ts
Diego Rodrigues de Sa e Souza 500568a1cd fix(providers): migrate web cookie TLS transport to wreq-js (#12429)
Migrates the Claude, Grok, LMArena, Notion and Perplexity web-cookie transports from the tls-client-node/Koffi sidecar to the exactly pinned wreq-js 3.2.0 runtime, keeping the per-provider browser/OS profiles, making request cookies ephemeral, bounding and generation-protecting the shared native transport pool, removing the legacy downloader and repair path, and carrying the native binding and license evidence through the npm, standalone, Electron, Docker and Bun packaging surfaces.

This is the consolidation of the two competing migrations, and the consolidation was decided by evidence rather than by preference. #11753's six suites were installed over this implementation and run as an independent specification: 31 of 36 passed. All five failures are artefacts of #11753 being the older design, not coverage gaps —

- two hardcode the 3.0.0 pin in their assertions (this branch pins 3.2.0, which is what the release tip already resolves; #11753's 3.0.0 would have conflicted);
- one reads open-sse/services/chatgptTlsClient.ts, deleted when #11754 retired ChatGPT Web, so the test is stale against the current tip;
- two import WREQ_JS_NATIVE_BINARY_NAMES / resolveWreqJsNativeBinaryName, which this branch redesigned into WREQ_JS_NATIVE_BINDINGS / resolveWreqJsNativeBinding plus WREQ_JS_VERSION — a rename from modelling natives as file names to modelling them as package bindings, verified as an API difference rather than a lost capability (the linux-x64-gnu .node is present and serviceable).

This branch is also the strict superset by scope: 7 files exclusive to it, including the wreq-js Rust license inventory and notices, .trivyignore, open-sse/utils/tlsClient.ts and assembleStandalone.mjs. #11753 had one exclusive file, its changelog fragment. Nothing needed porting, so #11753 is superseded rather than merged, and the changelog entry credits both.

Reconciled on merge: clean against the tip. The new migration suite (tests/unit/tls-client-wreq-migration.test.ts, 1374 lines, 31 cases) is frozen at its exact LOC with the rationale — it shares one native-transport harness, so splitting it mid-merge would duplicate that harness for no coverage gain. Verified that no existing cap moves.

Verified: 182/182 across the eight TLS, native-manifest, postinstall, standalone-bundle, pack-artifact and provider-validation suites, typecheck:core clean, check:cycles OK, check-changelog-integrity OK, check-file-size OK, and every changed TypeScript file parses.
2026-09-02 10:41:00 -03:00

225 lines
6.9 KiB
TypeScript

/**
* LMArenaExecutor — Arena (formerly LMArena) web-session provider.
*
* Routes requests through arena.ai create-evaluation with session cookies.
* Upstream sits behind Cloudflare; traffic goes through wreq-js Chrome
* impersonation with isolated ephemeral cookies (see services/lmarenaTlsClient.ts).
*
* Helpers: open-sse/executors/lmarena/{cookie,models,stream,response}.ts
*/
import { v7 as uuidv7 } from "uuid";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { tlsFetchLMArena, TlsClientUnavailableError } from "../services/lmarenaTlsClient.ts";
import { readLMArenaCookie, reconstructLMArenaCookie } from "./lmarena/cookie.ts";
import {
LMARENA_STREAM_URL,
LMARENA_USER_AGENT,
buildLmarenaBrowserHeaders,
markLMArenaCatalogModelDead,
normalizeLMArenaModelsForCatalog,
parseLMArenaInitialModels,
pickLMArenaModelId,
resolveLMArenaModelId,
type LMArenaModelMetadata,
} from "./lmarena/models.ts";
import { formatArenaPrompt, parseArenaSSE } from "./lmarena/stream.ts";
import {
buildArenaUpstreamHttpResponse,
createOpenAIArenaStream,
handleNonStreamingArenaResponse,
mapFailedTlsResult,
mapNetworkError,
mapTlsUnavailable,
missingCookieResult,
} from "./lmarena/response.ts";
export {
reconstructLMArenaCookie,
normalizeLMArenaModelsForCatalog,
parseLMArenaInitialModels,
pickLMArenaModelId,
parseArenaSSE,
markLMArenaCatalogModelDead,
LMARENA_USER_AGENT,
};
export { clearLMArenaDeadCatalogModels } from "./lmarena/models.ts";
export type { LMArenaModelMetadata };
interface OpenAIMessage {
role?: string;
content?: unknown;
}
/** Optional browser-issued reCAPTCHA v3 token (operator-supplied). */
function readRecaptchaToken(credentials: unknown, body: unknown): string | null {
const fromObj = (v: unknown): string | null => {
if (!v || typeof v !== "object") return null;
const rec = v as Record<string, unknown>;
const direct = rec.recaptchaV3Token ?? rec.recaptchaToken;
if (typeof direct === "string" && direct.trim()) return direct.trim();
const psd = rec.providerSpecificData;
if (psd && typeof psd === "object") {
const nested = psd as Record<string, unknown>;
const t = nested.recaptchaV3Token ?? nested.recaptchaToken;
if (typeof t === "string" && t.trim()) return t.trim();
}
return null;
};
return fromObj(credentials) ?? fromObj(body);
}
export class LMArenaExecutor extends BaseExecutor {
constructor(providerConfig = {}) {
super("lmarena", { format: "openai", ...providerConfig });
}
// Public to match BaseExecutor.buildUrl — a subclass may widen visibility but not
// narrow it. This was masked behind the buildHeaders TS2416 until that one cleared.
buildUrl(_model: string, _credentials: unknown): string {
return LMARENA_STREAM_URL;
}
protected buildRequestHeaders(
_model: string,
credentials: unknown,
_body: unknown
): Record<string, string> {
const cookie = readLMArenaCookie(credentials);
const headers = buildLmarenaBrowserHeaders({
"Content-Type": "application/json",
Accept: "text/event-stream",
});
if (cookie) headers.Cookie = cookie;
return headers;
}
transformRequest(body: unknown, model: string, credentials?: unknown): unknown {
const openaiBody = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const messages = Array.isArray(openaiBody.messages)
? (openaiBody.messages as OpenAIMessage[])
: [];
return {
id: uuidv7(),
mode: "direct-battle",
modelAId: model,
userMessageId: uuidv7(),
modelAMessageId: uuidv7(),
userMessage: {
content: formatArenaPrompt(messages),
experimental_attachments: [],
metadata: {},
},
modality: "chat",
recaptchaV3Token: readRecaptchaToken(credentials, body),
};
}
async execute(input: ExecuteInput) {
const { model, body, stream, credentials, signal, log } = input;
const url = this.buildUrl(model, credentials);
const headers = this.buildRequestHeaders(model, credentials, body);
const cookie = readLMArenaCookie(credentials);
if (!cookie) {
return missingCookieResult(url, headers, this.transformRequest(body, model, credentials));
}
const arenaModelId = await resolveLMArenaModelId(model, log);
const transformedBody = this.transformRequest(body, arenaModelId, credentials) as Record<
string,
unknown
>;
log?.info?.(
"LMArenaExecutor",
arenaModelId === model
? `Executing request for model: ${model}`
: `Executing request for model: ${model} (${arenaModelId})`
);
try {
return await this.dispatchTls(url, headers, transformedBody, {
model,
arenaModelId,
stream: !!stream,
signal,
log,
});
} catch (error) {
if (error instanceof TlsClientUnavailableError) {
log?.error?.("LMArenaExecutor", `TLS client unavailable: ${error.message}`);
return mapTlsUnavailable(error, url, headers, transformedBody);
}
const message = error instanceof Error ? error.message : String(error);
log?.error?.("LMArenaExecutor", `Request failed: ${message}`);
return mapNetworkError(message, url, headers, transformedBody);
}
}
private async dispatchTls(
url: string,
headers: Record<string, string>,
transformedBody: Record<string, unknown>,
ctx: {
model: string;
arenaModelId: string;
stream: boolean;
signal?: AbortSignal;
log?: ExecuteInput["log"];
}
) {
const tlsResult = await tlsFetchLMArena(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal: ctx.signal,
stream: ctx.stream,
});
const failed = mapFailedTlsResult({
status: tlsResult.status,
text: tlsResult.text,
hasRecaptcha: transformedBody.recaptchaV3Token != null,
model: ctx.model,
arenaModelId: ctx.arenaModelId,
url,
headers,
transformedBody,
});
if (failed) return failed;
const upstream = buildArenaUpstreamHttpResponse({
stream: ctx.stream,
status: tlsResult.status,
text: tlsResult.text,
body: tlsResult.body,
});
const response = ctx.stream
? await this.handleStreamingResponse(upstream, ctx.model, ctx.signal, ctx.log)
: await handleNonStreamingArenaResponse(upstream, ctx.model);
return { response, url, headers, transformedBody };
}
private async handleStreamingResponse(
response: Response,
model: string,
signal?: AbortSignal,
log?: ExecuteInput["log"]
): Promise<Response> {
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body for streaming");
const out = createOpenAIArenaStream({ reader, model, signal, log });
return new Response(out, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
}