From cde860d0f070faad6bd71f21ac419991bf850df5 Mon Sep 17 00:00:00 2001 From: Llj158 <1694522680@qq.com> Date: Wed, 23 Sep 2026 22:20:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=B8=8E=E9=87=87=E9=9B=86?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E6=94=AF=E6=8C=81=E4=BB=8E=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E5=8F=AF=E9=80=89=E6=A8=A1=E5=9E=8B=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /api/models:自动探测 OpenAI/Anthropic 格式,GET {base_url}/v1/models 拉取模型列表,兼容 data/models 数组、id/name 字段与纯字符串数组等响应结构,复用现有 User-Agent 伪装与 WAF 拦截错误提示 - “API 自动测试”与“新建指纹”表单的“接口模型名”旁新增“获取模型”按钮,获取后经 datalist 下拉选择,无需手工输入模型名 - 未填 Base URL/API Key 时提示先填写;成功提示模型数量;列表为空与请求失败分别给出提示 - 输入框与按钮同行排版、底部对齐,窄屏自动退化为单列 --- app.py | 11 ++++++- enrollment.py | 77 ++++++++++++++++++++++++++++++++++++++++++++ static/app.js | 42 ++++++++++++++++++++++++ static/styles.css | 3 ++ templates/index.html | 12 +++++-- 5 files changed, 142 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 8c04709..3256ea2 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,7 @@ from flask import Flask, jsonify, render_template, request -from enrollment import bank_summary, enroll_automatic, request_completion, test_automatic +from enrollment import bank_summary, enroll_automatic, list_models, request_completion, test_automatic from fingerprint import analyze_global_outputs, generate_challenges, load_bank, parse_numbers from bank_builder import build_bank, read_rows @@ -201,6 +201,15 @@ def automatic_test(): return jsonify({"error": str(error)}), 400 +@app.post("/api/models") +def fetch_models(): + payload = request.get_json() + try: + return jsonify({"models": list_models(payload["base_url"].strip(), payload["api_key"])}) + except RuntimeError as error: + return jsonify({"error": str(error)}), 502 + + @app.post("/api/test/probe") def automatic_test_probe(): payload = request.get_json() diff --git a/enrollment.py b/enrollment.py index a1c9168..8382465 100644 --- a/enrollment.py +++ b/enrollment.py @@ -169,6 +169,21 @@ def completion_url(base_url: str, api_format: str = "openai") -> str: return normalized + "/v1/chat/completions" +def models_url(base_url: str, api_format: str = "openai") -> str: + normalized = base_url.rstrip("/") + if api_format == "anthropic": + if normalized.endswith("/messages"): + return normalized[: -len("/messages")] + "/models" + if normalized.endswith("/v1"): + return normalized + "/models" + return normalized + "/v1/models" + if normalized.endswith("/chat/completions"): + return normalized[: -len("/chat/completions")] + "/models" + if normalized.endswith("/v1"): + return normalized + "/models" + return normalized + "/v1/models" + + def _looks_like_waf_block(text: str) -> bool: lowered = text.lower() return any( @@ -314,6 +329,68 @@ def request_completion( raise RuntimeError("接口格式自动探测失败;" + ";".join(errors)) +def _model_ids_from_payload(payload) -> list[str]: + if isinstance(payload, dict): + items = payload.get("data") + if items is None: + items = payload.get("models", []) + elif isinstance(payload, list): + items = payload + else: + items = [] + ids = set() + for item in items: + if isinstance(item, str): + ids.add(item) + elif isinstance(item, dict): + model_id = item.get("id") or item.get("name") + if model_id: + ids.add(str(model_id)) + return sorted(ids) + + +def _list_models(base_url: str, api_key: str, api_format: str) -> list[str]: + if api_format == "anthropic": + headers = { + "x-api-key": api_key, + "Authorization": f"Bearer {api_key}", + "anthropic-version": "2023-06-01", + "Accept": "application/json", + "User-Agent": upstream_user_agent(), + } + else: + headers = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": upstream_user_agent(), + } + request = urllib.request.Request(models_url(base_url, api_format), headers=headers, method="GET") + try: + with urllib.request.urlopen(request, timeout=60) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + details = error.read().decode("utf-8", errors="replace").strip() + raise RuntimeError(f"HTTP {error.code}: {_compact_upstream_error(details, error.reason)}") from error + except urllib.error.URLError as error: + reason = getattr(error, "reason", str(error)) + raise RuntimeError(f"无法连接接口:{reason}") from error + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise RuntimeError("接口返回的不是有效 JSON,请确认 base_url 指向 API 端点") from error + return _model_ids_from_payload(payload) + + +def list_models(base_url: str, api_key: str, api_format: str = "auto") -> list[str]: + if api_format != "auto": + return _list_models(base_url, api_key, api_format) + errors = [] + for candidate in ("openai", "anthropic"): + try: + return _list_models(base_url, api_key, candidate) + except RuntimeError as error: + errors.append(f"{candidate}: {error}") + raise RuntimeError("无法获取模型列表;" + ";".join(errors)) + + def test_automatic( base_url: str, api_key: str, diff --git a/static/app.js b/static/app.js index 537cc14..734d211 100644 --- a/static/app.js +++ b/static/app.js @@ -221,6 +221,45 @@ async function testViaApi(event) { button.disabled = false; } +const modelFetchers = { + "fetch-test-models": { base: "test-api-base", key: "test-api-key", list: "test-model-options", message: "test-message" }, + "fetch-enroll-models": { base: "api-base", key: "api-key", list: "enroll-model-options", message: "enrollment-message" }, +}; + +async function fetchModelOptions(button, config) { + const base = byId(config.base).value.trim(); + const apiKey = byId(config.key).value; + const messageElement = byId(config.message); + if (!base || !apiKey) { + setMessage(messageElement, "请先填写 Base URL 和 API Key,再获取模型列表。"); + return; + } + button.disabled = true; + const originalText = button.textContent; + button.textContent = "获取中……"; + setMessage(messageElement, "正在获取模型列表……", "working"); + try { + const response = await fetch("/api/models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_url: base, api_key: apiKey }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "无法获取模型列表。"); + byId(config.list).innerHTML = payload.models.map((model) => ``).join(""); + if (payload.models.length) { + setMessage(messageElement, `已获取 ${payload.models.length} 个模型,点击“接口模型名”输入框即可选择。`, "success"); + } else { + setMessage(messageElement, "接口未返回任何模型,请手动填写模型名。"); + } + } catch (error) { + setMessage(messageElement, error.message, "error"); + } finally { + button.disabled = false; + button.textContent = originalText; + } +} + function updateUnifiedSummary(summary) { state.unified = summary; byId("topbar-bank-count").textContent = `${summary.model_count} 个候选模型`; @@ -330,6 +369,9 @@ byId("bank-select").addEventListener("change", (event) => selectBank(event.targe byId("regenerate").addEventListener("click", loadChallenges); byId("analyze").addEventListener("click", analyzeManual); byId("api-test-form").addEventListener("submit", testViaApi); +Object.entries(modelFetchers).forEach(([buttonId, config]) => { + byId(buttonId).addEventListener("click", (event) => fetchModelOptions(event.currentTarget, config)); +}); byId("auto-enrollment").addEventListener("submit", enrollAutomatically); byId("show-create-bank").addEventListener("click", () => { byId("create-bank-form").hidden = !byId("create-bank-form").hidden; }); byId("create-bank-form").addEventListener("submit", createBank); diff --git a/static/styles.css b/static/styles.css index d693487..732cb22 100644 --- a/static/styles.css +++ b/static/styles.css @@ -98,6 +98,8 @@ textarea:focus, input:focus, .form-grid select:focus { border-color: #84adff; bo .form-heading span { color: var(--muted); font-size: 11px; } .form-grid { display: grid; gap: 14px; padding: 17px; } .form-grid.two { grid-template-columns: 1fr 1fr; } +.field-with-button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; } +.field-with-button .button { height: 38px; min-height: 38px; white-space: nowrap; } .form-grid.one-narrow { grid-template-columns: minmax(220px, .35fr) 1fr; } .form-grid .wide textarea { min-height: 235px; } label small { color: var(--muted); font-weight: 400; line-height: 1.45; } @@ -173,6 +175,7 @@ tbody tr.winner { background: var(--primary-soft); } .nav-button { justify-content: center; } .nav-button > span:last-child, .sidebar-note { display: none; } .challenge-columns, .form-grid.two, .form-grid.one-narrow { grid-template-columns: 1fr; } + .field-with-button { grid-template-columns: 1fr; } .result-summary { grid-template-columns: 1fr 1fr; } .result-summary > div:nth-child(3) { border-left: 0; border-top: 1px solid var(--line); } .result-summary > div:nth-child(4) { border-top: 1px solid var(--line); } diff --git a/templates/index.html b/templates/index.html index 7489380..e2da72f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -45,7 +45,10 @@
- +
+ + +
@@ -85,7 +88,10 @@

指纹库管理

新建指纹
- +
+ + +
@@ -100,6 +106,8 @@

指纹库管理

{% for model in bank.models %} + +