Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
77 changes: 77 additions & 0 deletions enrollment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `<option value="${escapeHtml(model)}"></option>`).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} 个候选模型`;
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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); }
Expand Down
12 changes: 10 additions & 2 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@
<form id="api-test-form" class="form-panel">
<div class="form-grid two">
<label><span>Base URL</span><input id="test-api-base" required placeholder="https://example.com/v1"></label>
<label><span>接口模型名</span><input id="test-api-model" required placeholder="模型名"></label>
<div class="field-with-button">
<label><span>接口模型名</span><input id="test-api-model" list="test-model-options" required placeholder="例如 gpt-4o-mini"></label>
<button type="button" id="fetch-test-models" class="button secondary">获取模型</button>
</div>
<label><span>API Key</span><input id="test-api-key" type="password" required autocomplete="off"></label>
<label><span>温度(可选)</span><input id="test-temperature" type="number" min="0" max="2" step="0.1" placeholder="接口默认"></label>
</div>
Expand Down Expand Up @@ -85,7 +88,10 @@ <h1>指纹库管理</h1>
<div class="form-heading"><strong>新建指纹</strong></div>
<div class="form-grid two">
<label><span>Base URL</span><input id="api-base" required placeholder="https://example.com/v1"></label>
<label><span>接口模型名</span><input id="api-model" required placeholder="实际请求使用的模型名"></label>
<div class="field-with-button">
<label><span>接口模型名</span><input id="api-model" list="enroll-model-options" required placeholder="实际请求使用的模型名"></label>
<button type="button" id="fetch-enroll-models" class="button secondary">获取模型</button>
</div>
<label><span>API Key</span><input id="api-key" type="password" required autocomplete="off"></label>
<label><span>指纹名称</span><input id="auto-model" list="model-options" required placeholder="例如 model-version-name"></label>
<label><span>采集回答数</span><input id="sample-count" type="number" min="3" max="36" value="36" required></label>
Expand All @@ -100,6 +106,8 @@ <h1>指纹库管理</h1>
</div>

<datalist id="model-options">{% for model in bank.models %}<option value="{{ model.id }}">{% endfor %}</datalist>
<datalist id="test-model-options"></datalist>
<datalist id="enroll-model-options"></datalist>
<script>window.BANK_SUMMARIES = {{ banks | tojson }}; window.UNIFIED_SUMMARY = {{ unified | tojson }}; window.DEFAULT_BANK_ID = {{ default_bank_id | tojson }};</script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
Expand Down