diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 77772b4907..e57383d911 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -158,11 +158,11 @@ export class LMArenaExecutor extends BaseExecutor { } catch (error) { if (isTlsClientUnavailableError(error)) { log?.error?.("LMArenaExecutor", `TLS client unavailable: ${sanitizeLMArenaError(error)}`); - return mapTlsUnavailable(error, url, headers, transformedBody); + return mapTlsUnavailable(url, headers, transformedBody); } - const publicMessage = sanitizeLMArenaError(error); - log?.error?.("LMArenaExecutor", `Request failed: ${publicMessage}`); - return mapNetworkError(error, url, headers, transformedBody); + const logMessage = sanitizeLMArenaError(error); + log?.error?.("LMArenaExecutor", `Request failed: ${logMessage}`); + return mapNetworkError(url, headers, transformedBody); } } diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts index 78575722b5..801a339fdd 100644 --- a/open-sse/executors/lmarena/response.ts +++ b/open-sse/executors/lmarena/response.ts @@ -3,6 +3,7 @@ * the executor methods stay under complexity / max-lines gates. */ import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts"; +import { buildErrorBody } from "../../utils/error.ts"; import { sanitizeLMArenaError } from "./error.ts"; import { markLMArenaCatalogModelDead } from "./models.ts"; import { parseArenaSSE } from "./stream.ts"; @@ -15,9 +16,133 @@ const SAFE_ARENA_STREAM_ERROR_NAMES = new Set([ "BodyTimeoutError", ]); -function projectArenaStreamError(error: unknown, publicMessage: string): Error { - const projected = new Error(publicMessage) as Error & { statusCode?: number }; +type ArenaPublicFailure = + | { kind: "missing-cookie" } + | { + kind: "bot-block"; + status: number; + reason: "cloudflare" | "token-present" | "token-needed"; + } + | { kind: "http-status"; status: number } + | { kind: "tls-unavailable" } + | { kind: "network" } + | { kind: "upstream-event" } + | { kind: "stream-transport" }; + +interface ArenaProjectedPublicFailure { + status: number; + message: string; + type: string; + code: string; +} + +type ArenaProjectedStreamError = Error & { + statusCode?: number; + type?: string; + code?: string; +}; + +const ARENA_PUBLIC_MESSAGES = { + missingCookie: + "Arena requires a session cookie. Paste the full Cookie header from arena.ai (include arena-auth-prod-v1.* chunks and ideally cf_clearance).", + cloudflareBot: + "Arena blocked by Cloudflare bot management. Use a residential/browser-grade network if needed, paste a fresh full Cookie header (include cf_clearance / __cf_bm when present), and optionally set providerSpecificData.recaptchaV3Token from a live browser session.", + botTokenNeeded: + "If this persists, supply a browser reCAPTCHA v3 token via credentials.providerSpecificData.recaptchaV3Token (in addition to the session cookie).", + tlsUnavailable: + "Arena TLS impersonation unavailable: Arena upstream error. Install/repair tls-client-node native binary.", + upstream: "Arena upstream error", + upstreamStream: "Arena upstream stream error", +} as const; + +function normalizeArenaStatus(status: number, fallback: number): number { + return Number.isInteger(status) && status >= 200 && status <= 599 ? status : fallback; +} + +function projectArenaPublicFailure(failure: ArenaPublicFailure): ArenaProjectedPublicFailure { + switch (failure.kind) { + case "missing-cookie": + return { + status: 401, + message: ARENA_PUBLIC_MESSAGES.missingCookie, + type: "authentication_error", + code: "missing_cookie", + }; + case "bot-block": { + const status = normalizeArenaStatus(failure.status, 403); + let message: string; + switch (failure.reason) { + case "cloudflare": + message = ARENA_PUBLIC_MESSAGES.cloudflareBot; + break; + case "token-present": + message = `Arena API error: ${status}`; + break; + case "token-needed": + message = `Arena API error: ${status}. ${ARENA_PUBLIC_MESSAGES.botTokenNeeded}`; + break; + default: { + const exhaustiveReason: never = failure.reason; + return exhaustiveReason; + } + } + return { + status, + message, + type: "api_error", + code: "cloudflare_or_bot", + }; + } + case "http-status": { + const status = normalizeArenaStatus(failure.status, 502); + return { + status, + message: `Arena API error: ${status}`, + type: "api_error", + code: String(status), + }; + } + case "tls-unavailable": + return { + status: 502, + message: ARENA_PUBLIC_MESSAGES.tlsUnavailable, + type: "upstream_error", + code: "TLS_CLIENT_UNAVAILABLE", + }; + case "network": + return { + status: 502, + message: ARENA_PUBLIC_MESSAGES.upstream, + type: "network_error", + code: "request_failed", + }; + case "upstream-event": + return { + status: 502, + message: ARENA_PUBLIC_MESSAGES.upstream, + type: "api_error", + code: "lmarena_error", + }; + case "stream-transport": + return { + status: 502, + message: ARENA_PUBLIC_MESSAGES.upstreamStream, + type: "upstream_error", + code: "lmarena_stream_error", + }; + default: { + const exhaustiveFailure: never = failure; + return exhaustiveFailure; + } + } +} + +function projectArenaStreamError(error: unknown): Error { + const publicError = buildArenaPublicError({ kind: "stream-transport" }).body.error; + const projected = new Error(publicError.message) as ArenaProjectedStreamError; projected.stack = undefined; + projected.type = publicError.type; + projected.code = publicError.code; if (!error || typeof error !== "object") return projected; try { @@ -25,9 +150,14 @@ function projectArenaStreamError(error: unknown, publicMessage: string): Error { if (typeof name === "string" && SAFE_ARENA_STREAM_ERROR_NAMES.has(name)) { projected.name = name; } - const statusCode = Number((error as { statusCode?: unknown }).statusCode); - if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) { - projected.statusCode = statusCode; + const rawStatusCode = (error as { statusCode?: unknown }).statusCode; + if ( + typeof rawStatusCode === "number" && + Number.isInteger(rawStatusCode) && + rawStatusCode >= 400 && + rawStatusCode <= 599 + ) { + projected.statusCode = rawStatusCode; } } catch { // Hostile thrown values must not escape through coercing metadata accessors. @@ -35,19 +165,23 @@ function projectArenaStreamError(error: unknown, publicMessage: string): Error { return projected; } -export function errorResponse( - status: number, - message: string, - type: string, - code: string -): Response { - const publicMessage = sanitizeLMArenaError(message); - return new Response( - JSON.stringify({ - error: { message: publicMessage, type, code }, +function buildArenaPublicError(failure: ArenaPublicFailure) { + const projected = projectArenaPublicFailure(failure); + return { + status: projected.status, + body: buildErrorBody(projected.status, projected.message, undefined, { + type: projected.type, + code: projected.code, }), - { status, headers: { "Content-Type": "application/json" } } - ); + }; +} + +function errorResponse(failure: ArenaPublicFailure): Response { + const projected = buildArenaPublicError(failure); + return new Response(JSON.stringify(projected.body), { + status: projected.status, + headers: { "Content-Type": "application/json" }, + }); } export function missingCookieResult( @@ -56,12 +190,7 @@ export function missingCookieResult( transformedBody: unknown ) { return { - response: errorResponse( - 401, - "Arena requires a session cookie. Paste the full Cookie header from arena.ai (include arena-auth-prod-v1.* chunks and ideally cf_clearance).", - "authentication_error", - "missing_cookie" - ), + response: errorResponse({ kind: "missing-cookie" }), url, headers, transformedBody, @@ -74,12 +203,17 @@ function isBotOrChallenge(status: number, text: string | null | undefined): bool return Boolean(text && text.trimStart().startsWith(", transformedBody: unknown ) { return { - response: errorResponse( - 502, - `Arena TLS impersonation unavailable: ${sanitizeLMArenaError(error)}. Install/repair tls-client-node native binary.`, - "upstream_error", - "TLS_CLIENT_UNAVAILABLE" - ), + response: errorResponse({ kind: "tls-unavailable" }), url, headers, transformedBody, @@ -143,13 +266,12 @@ export function mapTlsUnavailable( } export function mapNetworkError( - message: unknown, url: string, headers: Record, transformedBody: unknown ) { return { - response: errorResponse(502, sanitizeLMArenaError(message), "network_error", "request_failed"), + response: errorResponse({ kind: "network" }), url, headers, transformedBody, @@ -214,10 +336,11 @@ function handleArenaEventLine( return false; } if (event.type === "error") { + const upstreamError = buildArenaPublicError({ kind: "upstream-event" }); enqueueSse(controller, { ...baseChunk(model), choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - error: { message: sanitizeLMArenaError(event.content) }, + error: upstreamError.body.error, }); controller.close(); return true; @@ -290,9 +413,9 @@ export function createOpenAIArenaStream(opts: { } emitStopAndDone(controller, model); } catch (error) { - const publicMessage = sanitizeLMArenaError(error, "Arena upstream stream error"); - log?.error?.("LMArenaExecutor", `Streaming error: ${publicMessage}`); - controller.error(projectArenaStreamError(error, publicMessage)); + const logMessage = sanitizeLMArenaError(error, "Arena upstream stream error"); + log?.error?.("LMArenaExecutor", `Streaming error: ${logMessage}`); + controller.error(projectArenaStreamError(error)); } finally { await cleanupReader(); if (signal) signal.removeEventListener("abort", onAbort); @@ -312,7 +435,7 @@ export async function handleNonStreamingArenaResponse( ): Promise { const text = await response.text(); let fullText = ""; - let error: string | null = null; + let hasUpstreamError = false; for (const line of text.split("\n")) { if (!line.trim()) continue; @@ -321,12 +444,14 @@ export async function handleNonStreamingArenaResponse( if (!event) continue; if (event.type === "text" && event.content) fullText += event.content; else if (event.type === "error") { - error = event.content || "Unknown error"; + hasUpstreamError = true; break; } else if (event.type === "done") break; } - if (error) return errorResponse(502, error, "api_error", "lmarena_error"); + if (hasUpstreamError) { + return errorResponse({ kind: "upstream-event" }); + } return new Response( JSON.stringify({ diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts index 7afa04187e..b4578ae2d9 100644 --- a/tests/unit/lmarena-provider.test.ts +++ b/tests/unit/lmarena-provider.test.ts @@ -647,6 +647,36 @@ describe("LMArena Executor", () => { } }); + it("normalizes an invalid upstream status before building the public error", async () => { + __setTlsFetchOverrideForTesting(async () => ({ + status: 700, + headers: new Headers({ "Content-Type": "text/plain" }), + text: "InvalidStatusInternalFailure secret-status-id", + body: null, + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + assert.deepEqual(JSON.parse(responseText).error, { + message: "Arena API error: 502", + type: "api_error", + code: "502", + }); + assert.doesNotMatch(responseText, /InvalidStatusInternalFailure|secret-status-id/i); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("does not expose structured upstream error details while preserving classification", async () => { const executor = new LMArenaExecutor(); __setTlsFetchOverrideForTesting(async () => ({ @@ -749,6 +779,7 @@ describe("LMArena Executor", () => { assert.equal(errorLogs.length, 1); const responseText = await result.response.text(); const json = JSON.parse(responseText); + assert.equal(json.error?.message, "Arena upstream error"); assert.equal(json.error?.type, "network_error"); assert.equal(json.error?.code, "request_failed"); const publicOutput = `${errorLogs.join("\n")}\n${responseText}`; @@ -866,6 +897,42 @@ describe("LMArena Executor", () => { } }); + it("does not expose TLS-client failure details in the public response", async () => { + const tlsFailure = new TlsClientUnavailableError( + "NativeTlsBridgeFailure secret-adapter-id at /srv/private/lmarena-native.ts:22:4" + ) as TlsClientUnavailableError & { cause?: unknown }; + tlsFailure.cause = new Error("NativeTlsCause secret-cause-id"); + __setTlsFetchOverrideForTesting(async () => { + throw tlsFailure; + }); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + const json = JSON.parse(responseText); + assert.deepEqual(json.error, { + message: + "Arena TLS impersonation unavailable: Arena upstream error. Install/repair tls-client-node native binary.", + type: "upstream_error", + code: "TLS_CLIENT_UNAVAILABLE", + }); + assert.doesNotMatch( + responseText, + /NativeTlsBridgeFailure|secret-adapter-id|lmarena-native|NativeTlsCause|secret-cause-id/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("uses a stable public fallback for blank network and upstream event errors", async (t) => { const stackOnly = "\n at SecretOnlyFrame (/srv/private/lmarena-stack-only.ts:2:3)"; const cases = [ @@ -918,6 +985,43 @@ describe("LMArena Executor", () => { } }); + it("does not expose non-streaming upstream event details", async () => { + const upstreamFailure = + "ArenaEventInternalFailure secret-event-id\n" + + " at ArenaEventAdapter (/srv/private/lmarena-event.ts:31:8)"; + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: `3:${JSON.stringify(upstreamFailure)}\n`, + body: null, + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + const json = JSON.parse(responseText); + assert.deepEqual(json.error, { + message: "Arena upstream error", + type: "api_error", + code: "lmarena_error", + }); + assert.doesNotMatch( + responseText, + /ArenaEventInternalFailure|secret-event-id|ArenaEventAdapter|lmarena-event/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("uses a stable public fallback for blank streaming event errors", async () => { const stackOnly = "\n at SecretOnlyFrame (/srv/private/lmarena-stream-stack-only.ts:2:3)"; const encoded = new TextEncoder().encode(`data: 3:${JSON.stringify(stackOnly)}\n\n`); @@ -950,13 +1054,118 @@ describe("LMArena Executor", () => { .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]") .map((line) => JSON.parse(line.slice(6))) .find((chunk) => chunk.error); - assert.equal(payload?.error?.message, "Arena upstream error"); + assert.deepEqual(payload?.error, { + message: "Arena upstream error", + type: "api_error", + code: "lmarena_error", + }); assert.doesNotMatch(responseText, /SecretOnlyFrame|lmarena-stream-stack-only/); } finally { __setTlsFetchOverrideForTesting(null); } }); + it("does not expose streaming upstream event details", async () => { + const upstreamFailure = + "StreamingArenaInternalFailure secret-stream-id\n" + + " at StreamingArenaAdapter (/srv/private/lmarena-stream-event.ts:44:9)"; + const encoded = new TextEncoder().encode(`data: 3:${JSON.stringify(upstreamFailure)}\n\n`); + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.enqueue(encoded); + controller.close(); + }, + }), + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }], stream: true }, + stream: true, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 200); + const responseText = await result.response.text(); + const payload = responseText + .split("\n") + .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]") + .map((line) => JSON.parse(line.slice(6))) + .find((chunk) => chunk.error); + assert.deepEqual(payload?.error, { + message: "Arena upstream error", + type: "api_error", + code: "lmarena_error", + }); + assert.doesNotMatch( + responseText, + /StreamingArenaInternalFailure|secret-stream-id|StreamingArenaAdapter|lmarena-stream-event/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("does not expose transport errors rejected by the upstream stream", async () => { + const streamFailure = new Error( + "ArenaStreamTransportFailure secret-transport-id at /srv/private/lmarena-reader.ts:52:6" + ) as Error & { cause?: unknown; statusCode?: number }; + streamFailure.cause = new Error("ArenaStreamCause secret-stream-cause-id"); + streamFailure.statusCode = 502; + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.error(streamFailure); + }, + }), + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }], stream: true }, + stream: true, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 200); + await assert.rejects(result.response.text(), (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, "Arena upstream stream error"); + assert.equal(error.stack, undefined); + const projected = error as Error & { + cause?: unknown; + statusCode?: number; + type?: string; + code?: string; + }; + assert.equal(projected.cause, undefined); + assert.equal(projected.statusCode, 502); + assert.equal(projected.type, "upstream_error"); + assert.equal(projected.code, "lmarena_stream_error"); + assert.doesNotMatch( + error.message, + /ArenaStreamTransportFailure|secret-transport-id|lmarena-reader|ArenaStreamCause|secret-stream-cause-id/i + ); + return true; + }); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("forwards optional browser reCAPTCHA token from credentials", () => { const executor = new LMArenaExecutor(); const body = access(executor).transformRequest(