mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
feat(gateway): 源自动探测模型列表
只需填 URL 和 key 即可自动扫描上游模型并配置,不用再手抄模型 id。 端点 POST /api/sources/discover 只探测,不写任何东西 POST /api/sources/discover/apply 探测后落库 核心难点是 URL 形态而不是 /models 调用本身。网关按 base_url + adapter endpoint 拼路径(openai 提供 "/chat/completions"),而各家厂商对版本段 放在 base 还是 path 上没有共识,也没有哪个响应头能说明。所以探测不信任 调用方给的 URL,而是按偏好顺序试若干候选,返回答的那个,并给出归一化 后的 base_url + endpoint 组合。这正是第一次配 vela 时 /v1/images/ generations 返回 404 的成因:base_url 里已有 /v1,默认图像路径再拼一次。 归一化的不变量是「版本段在 base_url + endpoint 里恰好出现一次」。留在两边 会重复;两边都没有则 chat 请求在要求版本段的厂商上会打错路径。 kind 只信上游自己声明的 modality,绝不从模型名猜。"gpt-image-2" 这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持图像 生成的 id,其后每一层都被它拖垮。 不做的事: - 不逐模型发单 token 探活。34 个模型的目录跑一轮要花钱且耗时数分钟, /models 是足够便宜的信号;模型是否真能应答留给正常请求路径。 - 不猜能力。「vision」出现在 id 里不等于该模型接受图片。 - 不自动写盘。探测只读,保存仍需操作员按按钮——错误的 base_url 会产出 一个看起来合理却无法使用的源。 已存在的源必须显式 replace_models=true 才会覆盖模型列表:重新探测顺手 抹掉为 AUTO 链手工调过的 priority 是最难排查的一类数据丢失。 测试用真实抓取的 amr-link /models 响应做夹具,它把 modality 嵌在 architecture 下、metadata 下还有 cost/context_limit,34 个模型里混着 5 个 图像生成 id——朴素解析器正好在这三处出错。 变异测试 9/9 全杀(TestRealMutants,baseline 43 个测试)。过程中发现并 修掉四个真缺陷:嵌套 metadata 丢失、null 被当空目录、候选盲目拼接出 /v1/v1、以及 discoveryResult 零值序列化成 null 会让前端渲染出 "undefined"。早先的 shell 版变异脚本因 -run 转义损坏报出 4 个"存活", 实际是零个测试被执行——判据分不清"通过"和"没跑"就不是判据。
This commit is contained in:
@ -989,6 +989,16 @@
|
||||
mModels: "模型列表",
|
||||
mAddModel: "+ 模型",
|
||||
mMeta: "Meta",
|
||||
discScan: "自动探测模型",
|
||||
discScanning: "探测中…",
|
||||
discHint: "填好 URL 和密钥后点「自动探测模型」,网关会拉取上游的模型列表并填入下方;仍需点保存才会生效。",
|
||||
discNeedUrl: "请先填写 URL",
|
||||
discEmpty: "上游返回的模型列表为空",
|
||||
discFound: "已找到 %d 个模型",
|
||||
discChatN: "对话 %d 个",
|
||||
discImageN: "图像 %d 个",
|
||||
discUnsaved: "尚未保存,点保存后生效",
|
||||
discFailed: "探测失败",
|
||||
mSave: "保存",
|
||||
mCancel: "取消",
|
||||
toastCopied: "已复制",
|
||||
@ -1281,6 +1291,16 @@
|
||||
mModels: "Models",
|
||||
mAddModel: "+ model",
|
||||
mMeta: "Meta",
|
||||
discScan: "Auto-discover",
|
||||
discScanning: "Scanning…",
|
||||
discHint: "Fill in the URL and key, then press Auto-discover: the gateway fetches the upstream catalog and fills the list below. You still have to save.",
|
||||
discNeedUrl: "Enter the base URL first",
|
||||
discEmpty: "The upstream returned an empty model list",
|
||||
discFound: "Found %d models",
|
||||
discChatN: "%d chat",
|
||||
discImageN: "%d image",
|
||||
discUnsaved: "not saved yet — press Save",
|
||||
discFailed: "Discovery failed",
|
||||
mSave: "Save",
|
||||
mCancel: "Cancel",
|
||||
toastCopied: "Copied",
|
||||
@ -3134,6 +3154,7 @@ recsState.keyNames = keyNames || {};
|
||||
.join("") +
|
||||
"</select>";
|
||||
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
|
||||
<button class="ghost small" onclick="srcDiscover()">${t("discScan")}</button>
|
||||
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
|
||||
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
|
||||
<div class="row">
|
||||
@ -3160,6 +3181,7 @@ recsState.keyNames = keyNames || {};
|
||||
</div>
|
||||
<label>${t("mModels")}</label>
|
||||
<div id="s-models"></div>
|
||||
<div class="hint" id="s-disc-hint">${t("discHint")}</div>
|
||||
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
|
||||
<label>${t("mMeta")}</label>
|
||||
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
|
||||
@ -3208,6 +3230,83 @@ recsState.keyNames = keyNames || {};
|
||||
modelRow({ id: "", priority: 0, kind: "chat" }, div.children.length),
|
||||
);
|
||||
}
|
||||
// srcDiscover asks the gateway to probe the upstream for its catalog and
|
||||
// fills the model list from the answer. It never writes anything: the
|
||||
// operator still has to press Save, because a wrong base_url produces a
|
||||
// plausible-looking but useless source, and writing automatically would
|
||||
// overwrite a working configuration with it.
|
||||
async function srcDiscover() {
|
||||
const urlEl = $("#s-url");
|
||||
const keyEl = $("#s-key");
|
||||
const baseUrl = (urlEl && urlEl.value.trim()) || "";
|
||||
const apiKey = (keyEl && keyEl.value.trim()) || "";
|
||||
const hint = $("#s-disc-hint");
|
||||
const say = (msg) => {
|
||||
if (hint) hint.textContent = msg;
|
||||
};
|
||||
if (!baseUrl) {
|
||||
toast(t("discNeedUrl"));
|
||||
return;
|
||||
}
|
||||
const btn = document.querySelector('button[onclick="srcDiscover()"]');
|
||||
const restore = btn ? btn.textContent : null;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("discScanning");
|
||||
}
|
||||
say(t("discScanning"));
|
||||
try {
|
||||
const r = await api("/api/sources/discover", {
|
||||
method: "POST",
|
||||
body: { base_url: baseUrl, api_key: apiKey },
|
||||
});
|
||||
// The server normalized the URL form. Showing the operator exactly
|
||||
// what will be saved is the whole point of probing server-side
|
||||
// instead of guessing in the browser.
|
||||
if (r.base_url && urlEl) urlEl.value = r.base_url;
|
||||
if (r.endpoint && $("#s-ep")) $("#s-ep").value = r.endpoint;
|
||||
|
||||
// Array.isArray, not a truthy check: a JSON null here would make
|
||||
// .concat fail and leave the form looking empty with no error.
|
||||
const chat = Array.isArray(r.models) ? r.models : [];
|
||||
const image = Array.isArray(r.image_models) ? r.image_models : [];
|
||||
const all = chat.concat(image);
|
||||
if (!all.length) {
|
||||
say(t("discEmpty"));
|
||||
return;
|
||||
}
|
||||
const box = $("#s-models");
|
||||
if (box) {
|
||||
box.innerHTML = all
|
||||
.map((id, i) =>
|
||||
modelRow(
|
||||
{
|
||||
id: id,
|
||||
priority: image.indexOf(id) >= 0 ? 70 : 50,
|
||||
kind: image.indexOf(id) >= 0 ? "image" : "chat",
|
||||
},
|
||||
i,
|
||||
),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
const bits = [t("discFound").replace("%d", String(all.length))];
|
||||
if (chat.length) bits.push(t("discChatN").replace("%d", String(chat.length)));
|
||||
if (image.length) bits.push(t("discImageN").replace("%d", String(image.length)));
|
||||
if (r.note) bits.push(r.note);
|
||||
for (const w of Array.isArray(r.warnings) ? r.warnings : []) bits.push(w);
|
||||
bits.push(t("discUnsaved"));
|
||||
say(bits.join(" · "));
|
||||
} catch (e) {
|
||||
say(t("discFailed") + (e && e.message ? ": " + e.message : ""));
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = restore;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSource(btn) {
|
||||
const models = [...document.querySelectorAll("#s-models .model-row")]
|
||||
.map((row) => ({
|
||||
@ -3481,6 +3580,7 @@ recsState.keyNames = keyNames || {};
|
||||
</div>
|
||||
<label>${t("mModels")}</label>
|
||||
<div id="s-models"></div>
|
||||
<div class="hint" id="s-disc-hint-tpl">${t("discHint")}</div>
|
||||
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
|
||||
<label>${t("mMeta")}</label>
|
||||
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
|
||||
|
||||
Reference in New Issue
Block a user