/** * roamai-eval:Claude API(Anthropic Messages 协议)真伪 / 能力 / 协议检测,跑在 Cloudflare Workers 上。 * * 移植自 canarybyte/veridrop(https://github.com/canarybyte/veridrop,AGPL-3.0),commit e87ed92(2026-09-20)的 * src/relay_detector 里 Anthropic 协议那一套:同样的探针、子检查、阈值、权重、档位 → 检测项、主动 / 被动检测项、 * 限流客户端(并发 3、429 / 5xx 全局退避重试)、SSE 解析、count_tokens。 * * 2026-11-08 起也是**公开的检测工具**(veridrop.roamai.cloud):谁都可以填接口地址、API key、型号来测。 * 按 AGPL-3.0 第 13 条,经网络使用本程序的人能拿到对应的源代码:/source 列出部署出去的全部源文件(src/source-bundle.ts, * 由 scripts/gen-source.mjs 在 build / test / deploy 之前生成)与 LICENSE。内部的 /run(要 EVAL_TOKEN)照旧。 * * 与原仓库的偏差: * 1. 型号表补了 claude-opus-5-5 / opus-5 / sonnet-5-5 / sonnet-5 / haiku-5-5 / fable-5-1 / fable-5 * (1M 上下文、128K 输出、PDF 600 页、只有 adaptive 思考、新分词器)。原仓库不认识它们,thinking / PDF 会被跳过。 * 2. Claude 5 系列与 Fable 的请求剥掉 temperature / top_p / top_k(官方 API 对它们回 400);原仓库只剥 Opus 4.7 / 4.8 的 temperature。 * 3. thinking_signature 对只支持 adaptive 的型号走原仓库 Opus 4.7 那条路:{type: adaptive, display: summarized} * + output_config.effort(4.7 / 4.8 用 xhigh,其余 high),非流式。 * 4. long_context 没有移植:永远 skip(不进总分分母),不发长上下文请求。 * 5. structured_output 对官方不收强制工具调用的型号(Fable 5.1 / Opus 5.5 / Sonnet 5.5)把 tool_choice 从 any 改成 auto。 * 6. 超时:非流式按整个请求算,流式按两次收到数据之间算(httpx 是每次读之间);整轮超时会中止在飞的请求。 * 7. 正则按 Python re 的语义编(\b 按 Unicode 判、内联 (?m) 挪成 flags),字符串长度与切片按码点。 * 8. token_usage 的 count_tokens 子项和总输入(input + 缓存读 + 缓存写)比,照 veridrop.app 线上版;原仓库和 input_tokens 单独比, * 遇到提示缓存(官方也有)会误判。 * 9. 网页与接口是重写的(原仓库是 FastAPI + Jinja 模板 + 异步任务队列):src/web/ 下的检测页、报告页、NDJSON 流式检测接口、 * /source 源代码页;文案与样式都是新的,不沿用原项目的界面。 * 10. 公开入口加了限流(src/web/limits.ts,KV LIMITS):每个 IP 10 分钟 3 次、每天 20 次、同时 1 个;每个目标主机每天 60 次。 * 接口地址只许公网主机(src/web/validate.ts)。公开入口不收 max_concurrent / timeout_s(固定 3 与 60 秒)。 * 11. 存报告之前把报告里出现的完整 key 换成打码的(有的中转站在错误体里回显 key)。 * * 接口: * GET / 检测页(中文;?lang=en 或 Accept-Language 偏英文时英文) * POST /api/detect {base_url, api_key, model, mode?} → NDJSON 流(src/web/detect.ts);限流、只许公网主机 * GET /r/ 报告页(HTML);Accept: application/json 时回 JSON * GET /r/.json 报告 JSON(CORS *) * GET /source[/] 源代码目录 / 单个文件(text/plain) * GET /app.css /app.js 页面的样式与脚本 * POST /run 内部:Authorization: Bearer ,{base_url, api_key, model, mode?="full", max_concurrent?=3, * timeout_s?=60},同步跑完回报告 JSON(带 id),存进 KV REPORTS。不限流、不查主机。 * GET /health "ok" */ import type { FetchLike } from "./client.js"; import { MODES, type DetectionReport, type Mode } from "./models.js"; import { runDetection } from "./runner.js"; import { SOURCE_FILES } from "./source-bundle.js"; import { handleDetect, scrubKey, type DetectDeps } from "./web/detect.js"; import { APP_CSS, APP_JS, htmlResponse } from "./web/html.js"; import { pickLang } from "./web/i18n.js"; import type { LimitsKV } from "./web/limits.js"; import { notFoundPage, reportPage, sourceIndexPage, toolPage } from "./web/pages.js"; export interface Env { EVAL_TOKEN: string; REPORTS: KVNamespace; /** 公开入口的限流计数(src/web/limits.ts)。没绑就不放公开检测(503)。 */ LIMITS?: KVNamespace; } const ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; export function newReportId(): string { const bytes = new Uint8Array(8); crypto.getRandomValues(bytes); // 256 % 62 ≠ 0 有一点点偏,报告 id 不是秘密,够用。 return Array.from(bytes, (b) => ID_ALPHABET[b % ID_ALPHABET.length]).join(""); } /** 常数时间比较:长度不同也照样走完一遍。 */ export function safeEqual(a: string, b: string): boolean { const enc = new TextEncoder(); const x = enc.encode(a); const y = enc.encode(b); const n = Math.max(x.length, y.length); let diff = x.length ^ y.length; for (let i = 0; i < n; i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0); return diff === 0; } function json(data: unknown, status = 200, extra: Record = {}): Response { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", ...extra }, }); } export interface RunRequest { base_url: string; api_key: string; model: string; mode: Mode; max_concurrent: number; timeout_s: number; } /** 校验 /run 的请求体;不对就回一句错误。 */ export function parseRunRequest(raw: unknown): RunRequest | string { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "body must be a JSON object"; const b = raw as Record; const baseUrl = typeof b["base_url"] === "string" ? b["base_url"].trim() : ""; const apiKey = typeof b["api_key"] === "string" ? b["api_key"].trim() : ""; const model = typeof b["model"] === "string" ? b["model"].trim() : ""; let url: URL; try { url = new URL(baseUrl); } catch { return "base_url must be an https URL"; } if (url.protocol !== "https:") return "base_url must be an https URL"; if (apiKey.length < 8) return "api_key looks invalid"; if (!model || model.length > 200) return "model must be 1–200 chars"; const mode = (b["mode"] ?? "full") as Mode; if (!MODES.includes(mode)) return `mode must be one of ${MODES.join(", ")}`; const maxConcurrent = b["max_concurrent"] ?? 3; if (typeof maxConcurrent !== "number" || !Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > 8) { return "max_concurrent must be an integer 1–8"; } const timeoutS = b["timeout_s"] ?? 60; if (typeof timeoutS !== "number" || !(timeoutS > 0) || timeoutS > 600) return "timeout_s must be a number in (0, 600]"; return { base_url: baseUrl, api_key: apiKey, model, mode, max_concurrent: maxConcurrent, timeout_s: timeoutS }; } /** 测试注入:假上游、心跳间隔、时钟。线上用默认(全局 fetch、5 秒、Date.now)。 */ export interface WorkerDeps { fetchImpl?: FetchLike; heartbeatMs?: number; now?: () => number; run?: DetectDeps["run"]; } function wantsJson(request: Request): boolean { const accept = (request.headers.get("accept") ?? "").toLowerCase(); return accept.includes("application/json") && !accept.includes("text/html"); } function textFile(body: string, contentType: string): Response { return new Response(body, { headers: { "content-type": contentType, "cache-control": "public, max-age=300", "x-content-type-options": "nosniff", }, }); } export function makeWorker(deps: WorkerDeps = {}): ExportedHandler { return { async fetch(request: Request, env: Env, ctx?: ExecutionContext): Promise { return route(request, env, ctx, deps); }, }; } async function route(request: Request, env: Env, ctx: ExecutionContext | undefined, deps: WorkerDeps): Promise { const url = new URL(request.url); const lang = pickLang(url, request.headers); const get = request.method === "GET" || request.method === "HEAD"; if (url.pathname === "/health") return new Response("ok"); if (get && url.pathname === "/") return htmlResponse(toolPage(lang)); if (get && url.pathname === "/app.css") return textFile(APP_CSS, "text/css; charset=utf-8"); if (get && url.pathname === "/app.js") return textFile(APP_JS, "text/javascript; charset=utf-8"); if (get && url.pathname === "/robots.txt") return textFile("User-agent: *\nDisallow: /r/\nDisallow: /api/\n", "text/plain; charset=utf-8"); if (get && (url.pathname === "/source" || url.pathname === "/source/")) { return htmlResponse(sourceIndexPage(lang, Object.keys(SOURCE_FILES))); } if (get && url.pathname.startsWith("/source/")) { const path = decodeURIComponent(url.pathname.slice("/source/".length)); const file = Object.prototype.hasOwnProperty.call(SOURCE_FILES, path) ? SOURCE_FILES[path] : undefined; if (file === undefined) return htmlResponse(notFoundPage(lang, url.pathname), 404); return textFile(file, "text/plain; charset=utf-8"); } const r = /^\/r\/([A-Za-z0-9]{8})(\.json)?$/.exec(url.pathname); if (r && get) { const asJson = r[2] === ".json" || wantsJson(request); const cors = { "access-control-allow-origin": "*" }; const stored = await env.REPORTS.get(r[1]!); if (asJson) { if (stored === null) return json({ error: "not found" }, 404, cors); return new Response(stored, { headers: { "content-type": "application/json; charset=utf-8", ...cors } }); } if (stored === null) return htmlResponse(notFoundPage(lang, url.pathname), 404); let rep: DetectionReport & { id: string }; try { rep = JSON.parse(stored) as DetectionReport & { id: string }; } catch { return htmlResponse(notFoundPage(lang, url.pathname), 404); } return htmlResponse(reportPage(lang, { ...rep, id: r[1]! })); } if (url.pathname === "/api/detect") { if (request.method !== "POST") return json({ error: "method not allowed" }, 405, { allow: "POST" }); return handleDetect(request, lang, env.LIMITS as LimitsKV | undefined, env.REPORTS, parseRunRequest, { fetchImpl: deps.fetchImpl, heartbeatMs: deps.heartbeatMs, now: deps.now, run: deps.run, newId: newReportId, waitUntil: ctx ? (p) => ctx.waitUntil(p) : undefined, }); } if (url.pathname === "/run") { if (request.method !== "POST") return json({ error: "method not allowed" }, 405, { allow: "POST" }); const auth = request.headers.get("authorization") ?? ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; if (!env.EVAL_TOKEN || !safeEqual(token, env.EVAL_TOKEN)) return json({ error: "unauthorized" }, 401); let raw: unknown; try { raw = await request.json(); } catch { return json({ error: "invalid JSON body" }, 400); } const req = parseRunRequest(raw); if (typeof req === "string") return json({ error: req }, 400); const report = await runDetection({ baseUrl: req.base_url, apiKey: req.api_key, model: req.model, mode: req.mode, maxConcurrent: req.max_concurrent, timeoutS: req.timeout_s, fetchImpl: deps.fetchImpl, }); const id = newReportId(); const stored = scrubKey(JSON.stringify({ id, ...report }), req.api_key, report.api_key_masked); await env.REPORTS.put(id, stored); return new Response(stored, { headers: { "content-type": "application/json; charset=utf-8" } }); } if (get) return htmlResponse(notFoundPage(lang, url.pathname), 404); return json({ error: "not found" }, 404); } export default makeWorker();