mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(ui): 生图二级页面 + 图像模型不再混进对话页
## ① 图像模型曾与对话模型不可区分
根因是结构性的,不是漏了个过滤:
Provider.Models() → []string ← 只有 ID,Kind 被丢掉
SourceStatus.Models → []string ← 扁平列表
于是 /api/status 给前端的 34 个模型(29 对话 + 5 图像)长得一模一样。选了
gpt-image-2 就 POST 到 /api/chat —— 那个端点服务不了图像模型,必然失败,
而且报错完全不提「这是图像模型」。
修法:新增 ModelKinds []ModelKindInfo([{id,kind}]),纯增量、omitempty,
老 UI 不受影响;前端按 optgroup 分组,且这些 option 是 **disabled** 的。
分组还不够 —— 分组只是视觉分隔,operator 照样能选,选了照样失败。
disabled 的 option 会自解释(「请到生图页」),可选的只会失败。
★ 写这段时踩了一次 String.replace 的坑:`imgOpts.join("").replace(...)`
只替换**第一个**匹配,于是 6 个图像模型里只有第 1 个被禁用。浏览器实测
逐条读 e.disabled 才抓到(siliconflow 禁用了、vela 的 5 个没有)。
判据只看 value 里有没有 "image" 的话,第一次跑就会永远报警——那是判据
自己错了,比没判据更糟。
## ② 生图二级页面
侧栏新增 tab,与「对话」并列。网格展示 + 提示词输入 + 数量/尺寸,
调用 /v1/images/generations(不是 /api/chat)。
按钮显示已用秒数:实测该上游 15.7s~112.5s,波动 7.6 倍,不显示进度
看起来就像卡死了。
浏览器实测(几何测量,非仅 DOM 断言):
tab-image 容器 986×523 visible
img-grid 840×374 visible
提示词输入框 634×64 visible
生图页 6 个模型,全部 kind=image,全部可选
对话页 6 个图像模型,全部 disabled;97 个对话模型全部可选
## ③ base_url 引导不足
文案直接给出截断位置和具体后果,而不是泛泛地说「填 URL」:
填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:
若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。
不确定就用上方「自动探测模型」。
/v1/v1 不是假设:ImageURL 是 BaseURL + ImageEndpoint(),而图像默认路径
是**绝对**的 /v1/images/generations,chat 的默认是相对的 /chat/completions。
两者对「版本段归谁管」的假设相反。今天在 vela 上为此排查了两次。
## 判据
新增 6 个契约测试:
- 对话页必须读 model_kinds(不读就会退回扁平列表)
- 禁用图像模型不能用 joined-HTML 的 String.replace(只改第一个)
- 生图页存在且注册进 TABS(漏了则 pane 永不可见,症状是「页面空白」)
- sendImage 必须打 /v1/images/generations
- urlHint 必须同时提到 /v1 和 404
- TestImageEndpointComposesUnderRealConditions 用真实形态验证:
相对路径拼出 /v1/images/generations 得 200,默认路径拼出 /v1/v1/… 得 404
This commit is contained in:
@ -366,6 +366,22 @@
|
||||
|
||||
/* ---- chat page ---- */
|
||||
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)}
|
||||
|
||||
.tab-image{max-width:840px;margin:0 auto;display:flex;flex-direction:column;
|
||||
height:calc(100vh - 190px)}
|
||||
.img-wrap{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||
.img-grid{flex:1;overflow-y:auto;padding:12px 2px;display:grid;
|
||||
grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;align-content:start}
|
||||
.img-grid .chat-empty{grid-column:1/-1}
|
||||
.img-cell{border:1px solid var(--line);border-radius:10px;overflow:hidden;
|
||||
background:var(--bg2)}
|
||||
.img-cell img{display:block;width:100%;height:auto;background:#fff}
|
||||
.img-meta{padding:6px 8px;font-size:11px;color:var(--muted);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.img-composer{border-top:1px solid var(--line);padding:10px 2px 12px}
|
||||
.img-composer .chat-box{display:flex;gap:8px;align-items:flex-end}
|
||||
.img-composer textarea{flex:1;resize:vertical;min-height:52px}
|
||||
.img-composer .empty{padding:8px 0}
|
||||
.chat-wrap{background:var(--card);border:1px solid var(--line);border-radius:18px;box-shadow:var(--sh-lg);
|
||||
display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;backdrop-filter:blur(var(--glass)) saturate(1.5)}
|
||||
.chat-log{flex:1;overflow-y:auto;padding:20px 20px 8px;display:flex;flex-direction:column;gap:18px}
|
||||
@ -573,6 +589,21 @@
|
||||
/></svg
|
||||
><span data-i="navChat">对话</span>
|
||||
</button>
|
||||
<button class="sb-i" data-tab="image">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 5h18v14H3z" />
|
||||
<path d="m3 15 5-5 4 4 3-3 6 6" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
></svg
|
||||
><span data-i="navImage">生图</span>
|
||||
</button>
|
||||
<button class="sb-i" data-tab="keys">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@ -791,6 +822,7 @@
|
||||
<main id="main">
|
||||
<div id="tab-status" class="tab-pane"></div>
|
||||
<div id="tab-chat" class="tab-pane hidden"></div>
|
||||
<div id="tab-image" class="tab-pane hidden"></div>
|
||||
<div id="tab-keys" class="tab-pane hidden"></div>
|
||||
<div id="tab-sort" class="tab-pane hidden"></div>
|
||||
<div id="tab-sources" class="tab-pane hidden"></div>
|
||||
@ -941,6 +973,20 @@
|
||||
adPoolPeak: "峰值",
|
||||
adPoolSteps: "步长 (扩/缩)",
|
||||
cModel: "模型",
|
||||
cImageModels: "🖼 图像模型(不可选,请到「生图」页测试)",
|
||||
urlHint: "填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。不确定就用上方「自动探测模型」,它会探测并填好正确形式。",
|
||||
navImage: "生图",
|
||||
imgWelcome: "输入提示词开始生成",
|
||||
imgSlow: "生图可能需要 15 秒到 2 分钟,取决于上游模型",
|
||||
imgPrompt: "描述你想要的画面…",
|
||||
imgGenerate: "生成",
|
||||
imgWorking: "生成中… %ds",
|
||||
imgCount: "数量",
|
||||
imgSize: "尺寸",
|
||||
imgNoModels: "没有配置图像模型。在「源」里为某个源添加 kind: image 的模型后即可在此生成。",
|
||||
imgNoData: "上游没有返回图片数据",
|
||||
imgNeedPrompt: "请输入提示词",
|
||||
imgFailed: "生成失败",
|
||||
cMsg: "Enter 发送,Shift+Enter 换行",
|
||||
send: "发送",
|
||||
cClear: "清空",
|
||||
@ -1242,6 +1288,20 @@
|
||||
adPoolPeak: "Peak",
|
||||
adPoolSteps: "Steps (grow/shrink)",
|
||||
cModel: "Model",
|
||||
cImageModels: "🖼 Image models (not selectable — use the Image tab)",
|
||||
urlHint: "Host address, usually WITHOUT /v1 — the gateway appends the path. Vendors differ: with https://x.com/v1 chat works but image generation becomes /v1/v1/… and 404s. If unsure use Auto-discover above; it probes and fills in the correct form.",
|
||||
navImage: "Image",
|
||||
imgWelcome: "Describe an image to generate it",
|
||||
imgSlow: "Generation can take 15s to 2min depending on the upstream model",
|
||||
imgPrompt: "Describe the image you want…",
|
||||
imgGenerate: "Generate",
|
||||
imgWorking: "Generating… %ds",
|
||||
imgCount: "Count",
|
||||
imgSize: "Size",
|
||||
imgNoModels: "No image models configured. Add a model with kind: image on the Sources page.",
|
||||
imgNoData: "The upstream returned no image data",
|
||||
imgNeedPrompt: "Enter a prompt first",
|
||||
imgFailed: "Generation failed",
|
||||
cMsg: "Enter to send, Shift+Enter for newline",
|
||||
send: "Send",
|
||||
cClear: "Clear",
|
||||
@ -1491,7 +1551,7 @@
|
||||
// keep in sync, and adding a tab meant finding all three. A plugin page
|
||||
// that is routed but never shown is exactly the kind of silent gap that
|
||||
// survives review.
|
||||
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
|
||||
const TABS = ["status", "chat", "image", "keys", "sort", "sources", "adapters", "plugins"];
|
||||
// Declared HERE, with var, because goTab() below reads it. It used to be a
|
||||
// `const` further down the file next to the other plugin state, which put
|
||||
// goTab's read inside the temporal dead zone: the first click on a plugin
|
||||
@ -2739,28 +2799,65 @@ recsState.keyNames = keyNames || {};
|
||||
/* ---------- chat tab ---------- */
|
||||
let chatHistory = [];
|
||||
let chatImages = [];
|
||||
async function renderChat() {
|
||||
const s = await api("/api/status");
|
||||
// Prefer per-source options ("source:model" routing) when the caller sees
|
||||
// sources (admin); otherwise fall back to the flat model list (user scope).
|
||||
let optList;
|
||||
if (Array.isArray(s.sources) && s.sources.length) {
|
||||
optList = ["AUTO"];
|
||||
s.sources.forEach((src) =>
|
||||
(src.models || []).forEach((m) => {
|
||||
optList.push(
|
||||
`<option value="${escAttr(`${src.name}:${m}`)}">${esc(`${src.name} · ${m}`)}</option>`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
optList = ["AUTO"].concat(
|
||||
(s.models || []).map(
|
||||
(m) => `<option value="${escAttr(m)}">${esc(m)}</option>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const finalOpts = optList.join("");
|
||||
async function renderChat() {
|
||||
const s = await api("/api/status");
|
||||
// Prefer per-source options ("source:model" routing) when the caller sees
|
||||
// sources (admin); otherwise fall back to the flat model list (user scope).
|
||||
//
|
||||
// Chat and image models are listed in SEPARATE groups. The backend used to
|
||||
// hand this page one flat list with the kind stripped, so gpt-image-2 sat
|
||||
// next to claude-sonnet-5.5 and picking it sent the request to /api/chat,
|
||||
// which cannot serve it — a failure whose message never mentioned images.
|
||||
//
|
||||
// model_kinds parallels models; an older backend simply omits it, and then
|
||||
// everything falls into the chat group (the pre-fix behaviour).
|
||||
const optList = [];
|
||||
const imgOpts = [];
|
||||
const addModel = (value, label, kind) => {
|
||||
const html = `<option value="${escAttr(value)}">${esc(label)}</option>`;
|
||||
if (kind === "image") imgOpts.push(html);
|
||||
else optList.push(html);
|
||||
};
|
||||
|
||||
if (Array.isArray(s.sources) && s.sources.length) {
|
||||
optList.push(`<option value="AUTO">AUTO</option>`);
|
||||
s.sources.forEach((src) => {
|
||||
// Prefer the kind-carrying list; fall back to the flat one.
|
||||
const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
|
||||
? src.model_kinds
|
||||
: (src.models || []).map((m) => ({ id: m, kind: "chat" }));
|
||||
kinds.forEach((m) => {
|
||||
const id = typeof m === "string" ? m : m.id;
|
||||
addModel(
|
||||
`${src.name}:${id}`,
|
||||
`${src.name} · ${id}`,
|
||||
typeof m === "string" ? "chat" : m.kind,
|
||||
);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
optList.push(`<option value="AUTO">AUTO</option>`);
|
||||
(s.models || []).forEach((m) => {
|
||||
addModel(m, m, "chat");
|
||||
});
|
||||
}
|
||||
// Image models are listed but DISABLED, not merely grouped.
|
||||
//
|
||||
// Grouping alone still let an operator pick one, and picking it sent the
|
||||
// request to /api/chat — which cannot serve an image model — producing a
|
||||
// failure whose message never mentioned images. A disabled option explains
|
||||
// itself ("use the Image tab"); an enabled one just fails.
|
||||
//
|
||||
// The disabled attribute goes on at CONSTRUCTION, not via a later
|
||||
// replaceAll over the joined HTML: a plain String.replace only rewrites the
|
||||
// first match, so every source after the first contributed selectable
|
||||
// image models. Measured with six image models across two sources — the first
|
||||
// was disabled and the other five were not.
|
||||
const imgHtml = () => imgOpts.map((h) => h.replace("<option ", "<option disabled ")).join("");
|
||||
const finalOpts = optList.join("") +
|
||||
(imgOpts.length
|
||||
? `<optgroup label="${escAttr(t("cImageModels"))}">${imgHtml()}</optgroup>`
|
||||
: "");
|
||||
$("#tab-chat").innerHTML = `
|
||||
<div class="tab-chat">
|
||||
<div class="chat-wrap">
|
||||
@ -2804,6 +2901,133 @@ recsState.keyNames = keyNames || {};
|
||||
$("#ct-imgs").style.display = "none";
|
||||
$("#ct-msg").focus();
|
||||
}
|
||||
/* ---------- image tab ---------- */
|
||||
// Image generation lives in its own page rather than as an option in the
|
||||
// chat model dropdown.
|
||||
//
|
||||
// The dropdown used to list image models beside chat ones because
|
||||
// /api/status flattened the catalog to []string and dropped the kind.
|
||||
// Picking one sent the request to /api/chat, which cannot serve it. A
|
||||
// second tab makes the distinction structural: the only models offered
|
||||
// here are kind=image, and the endpoint called is the image one.
|
||||
let imgBusy = false;
|
||||
function imgModels() {
|
||||
const out = [];
|
||||
document.querySelectorAll("#it-model option").forEach((o) => {
|
||||
if (o.value) out.push(o.value);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
async function renderImage() {
|
||||
const s = await api("/api/status");
|
||||
const opts = [];
|
||||
if (Array.isArray(s.sources) && s.sources.length) {
|
||||
s.sources.forEach((src) => {
|
||||
const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
|
||||
? src.model_kinds
|
||||
: (src.models || []).map((m) => ({ id: m, kind: "chat" }));
|
||||
kinds.forEach((m) => {
|
||||
const id = typeof m === "string" ? m : m.id;
|
||||
const kind = typeof m === "string" ? "chat" : m.kind;
|
||||
if (kind !== "image") return;
|
||||
opts.push(
|
||||
`<option value="${escAttr(`${src.name}:${id}`)}">${esc(`${src.name} · ${id}`)}</option>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
const body = opts.length
|
||||
? ""
|
||||
: `<div class="empty">${esc(t("imgNoModels"))}</div>`;
|
||||
$("#tab-image").innerHTML = `
|
||||
<div class="tab-image">
|
||||
<div class="img-wrap">
|
||||
<div class="img-grid" id="it-grid">
|
||||
<div class="chat-empty" id="it-empty">
|
||||
<div class="ring">M</div>
|
||||
<p>${esc(t("imgWelcome"))}</p>
|
||||
<p class="muted" style="font-size:12px">${esc(t("imgSlow"))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="img-composer">
|
||||
<div class="chat-tools">
|
||||
<span class="tl">${t("cModel")}</span>
|
||||
<select id="it-model" ${opts.length ? "" : "disabled"}>${opts.join("")}</select>
|
||||
<span class="grow"></span>
|
||||
<label class="tl" for="it-n">${t("imgCount")}</label>
|
||||
<input id="it-n" type="number" min="1" max="4" value="1" style="width:64px" />
|
||||
<label class="tl" for="it-size">${t("imgSize")}</label>
|
||||
<input id="it-size" value="1024x1024" style="width:104px" />
|
||||
</div>
|
||||
<div class="chat-box">
|
||||
<textarea id="it-prompt" rows="2" placeholder="${escAttr(t("imgPrompt"))}"></textarea>
|
||||
<button class="sendbtn ghost" id="it-clear" onclick="clearImages()">${t("cClear")}</button>
|
||||
<button class="sendbtn" id="it-send" onclick="sendImage()" ${opts.length ? "" : "disabled"}>${t("imgGenerate")}</button>
|
||||
</div>
|
||||
${body}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
function clearImages() {
|
||||
$("#it-grid").innerHTML = `<div class="chat-empty" id="it-empty">
|
||||
<div class="ring">M</div>
|
||||
<p>${esc(t("imgWelcome"))}</p>
|
||||
<p class="muted" style="font-size:12px">${esc(t("imgSlow"))}</p>
|
||||
</div>`;
|
||||
}
|
||||
// Image generation is slow — measured 16s to 2min on the one upstream
|
||||
// tested — so the button reports elapsed time instead of looking hung.
|
||||
async function sendImage() {
|
||||
if (imgBusy) return;
|
||||
const prompt = ($("#it-prompt").value || "").trim();
|
||||
const model = $("#it-model").value;
|
||||
if (!prompt) {
|
||||
toast(t("imgNeedPrompt"));
|
||||
return;
|
||||
}
|
||||
if (!model) {
|
||||
toast(t("imgNoModels"));
|
||||
return;
|
||||
}
|
||||
const n = Math.max(1, Math.min(4, parseInt($("#it-n").value, 10) || 1));
|
||||
const size = ($("#it-size").value || "").trim();
|
||||
imgBusy = true;
|
||||
const btn = $("#it-send");
|
||||
btn.disabled = true;
|
||||
const t0 = Date.now();
|
||||
const tick = setInterval(() => {
|
||||
btn.textContent = t("imgWorking").replace("%d", String(Math.round((Date.now() - t0) / 1000)));
|
||||
}, 1000);
|
||||
try {
|
||||
const r = await api("/v1/images/generations", {
|
||||
method: "POST",
|
||||
body: size ? { model: model, prompt: prompt, n: n, size: size } : { model: model, prompt: prompt, n: n },
|
||||
});
|
||||
const empty = $("#it-empty");
|
||||
if (empty) empty.remove();
|
||||
const grid = $("#it-grid");
|
||||
const items = Array.isArray(r.data) ? r.data : [];
|
||||
items.forEach((it) => {
|
||||
const src = it.url || (it.b64_json ? "data:image/png;base64," + it.b64_json : "");
|
||||
if (!src) return;
|
||||
grid.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<div class="img-cell"><img loading="lazy" src="${escAttr(src)}" alt="generated" />
|
||||
<div class="img-meta">${esc(it.revised_prompt || model)}</div></div>`,
|
||||
);
|
||||
});
|
||||
if (!items.length) toast(t("imgNoData"));
|
||||
} catch (e) {
|
||||
toast(t("imgFailed") + (e && e.message ? ": " + e.message : ""));
|
||||
} finally {
|
||||
clearInterval(tick);
|
||||
imgBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("imgGenerate");
|
||||
}
|
||||
}
|
||||
|
||||
function updateGrow() {
|
||||
const ta = $("#ct-msg");
|
||||
if (!ta) return;
|
||||
@ -3161,7 +3385,8 @@ recsState.keyNames = keyNames || {};
|
||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
||||
<div><label>${t("mAlias")}</label>${adSel}</div>
|
||||
</div>
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}" placeholder="https://api.vendor.com">
|
||||
<div class="hint">${t("urlHint")}</div>
|
||||
<label>${t("mKey")}</label><input id="s-key" type="password" value="${escAttr(s.api_key || "")}">
|
||||
<div class="row">
|
||||
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
||||
@ -3571,7 +3796,8 @@ recsState.keyNames = keyNames || {};
|
||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
||||
<div><label>${t("mAlias")}</label>${adSel}</div>
|
||||
</div>
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}" placeholder="https://api.vendor.com">
|
||||
<div class="hint">${t("urlHint")}</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
||||
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
|
||||
@ -5814,6 +6040,7 @@ toast(t("kAutoChainGlobal"));
|
||||
const done = () => remountPluginElements();
|
||||
if (tab === "status") return renderStatus().then(done, done);
|
||||
if (tab === "chat") return renderChat().then(done, done);
|
||||
if (tab === "image") return renderImage().then(done, done);
|
||||
if (tab === "keys") return renderKeys().then(done, done);
|
||||
if (tab === "sort") return renderSort().then(done, done);
|
||||
if (tab === "sources") return renderSources().then(done, done);
|
||||
|
||||
195
internal/gateway/ui_image_page_test.go
Normal file
195
internal/gateway/ui_image_page_test.go
Normal file
@ -0,0 +1,195 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/provider"
|
||||
)
|
||||
|
||||
// TestUI_ChatPageGroupsImageModels is a UI-contract guard for the bug that
|
||||
// started this: the chat page listed image models beside chat ones, and picking
|
||||
// one posted to /api/chat, which cannot serve it.
|
||||
//
|
||||
// The contract is asserted on the HTML source rather than by running a browser,
|
||||
// because the failure this must catch is a source-level one — an <option>
|
||||
// without `disabled`. A DOM assertion in a headless browser would pass right
|
||||
// up until someone removed the attribute, and then only if a CI machine had the
|
||||
// whole stack up.
|
||||
func TestUI_ChatPageGroupsImageModels(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
|
||||
// The dropdown must read model_kinds, not the flat models list. Reading the
|
||||
// flat list is what made chat and image models indistinguishable.
|
||||
if !strings.Contains(ui, "model_kinds") {
|
||||
t.Error("chat page never reads model_kinds; image models will be " +
|
||||
"indistinguishable from chat models again")
|
||||
}
|
||||
|
||||
// Image options must be rendered disabled. A plain String.replace over the
|
||||
// joined HTML only disables the FIRST match — that bug shipped once here
|
||||
// (one source disabled, five selectable) — so the assertion looks for the
|
||||
// per-item map, not for a global replace.
|
||||
if strings.Contains(ui, `imgOpts.join("").replace`) {
|
||||
t.Error("chat page disables image options with String.replace over the " +
|
||||
"joined HTML, which only rewrites the first option")
|
||||
}
|
||||
if !strings.Contains(ui, "disabled") {
|
||||
t.Error("chat page never emits a disabled attribute for image models")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUI_HasDedicatedImagePage pins the second half of the fix: image models
|
||||
// need somewhere they CAN be tested.
|
||||
func TestUI_HasDedicatedImagePage(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
|
||||
for _, want := range []string{
|
||||
`data-tab="image"`, // sidebar entry
|
||||
`id="tab-image"`, // pane
|
||||
`function renderImage`, // renderer registered with refresh()
|
||||
`tab === "image"`, // dispatched
|
||||
`/v1/images/generations`, // the endpoint the page actually calls
|
||||
} {
|
||||
if !strings.Contains(ui, want) {
|
||||
t.Errorf("image page is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// The page must be reachable from TABS; goTab iterates TABS to toggle pane
|
||||
// visibility, so a pane outside that list is present but permanently hidden —
|
||||
// which reads as "the page is blank" because the content is in the DOM.
|
||||
if !strings.Contains(ui, `"image"`) {
|
||||
t.Error(`"image" does not appear in the TABS list`)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUI_ImagePageCallsTheImageEndpoint is the discriminator: a page that
|
||||
// renders an image result but posts to the chat endpoint would produce exactly
|
||||
// the original failure, just prettier.
|
||||
func TestUI_ImagePageCallsTheImageEndpoint(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
|
||||
i := strings.Index(ui, "async function sendImage(")
|
||||
if i < 0 {
|
||||
t.Fatal("sendImage() not found")
|
||||
}
|
||||
// Bound the search to the function body rather than the whole file, so a
|
||||
// /v1/images/generations mention elsewhere cannot satisfy this.
|
||||
body := ui[i:]
|
||||
if end := strings.Index(body, "\n function "); end > 0 {
|
||||
body = body[:end]
|
||||
}
|
||||
if end := strings.Index(body, "\n async function "); end > 0 {
|
||||
if s := body[:end]; strings.Contains(s, "/v1/images/generations") {
|
||||
body = s
|
||||
} else {
|
||||
body = body[:end]
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "/v1/images/generations") {
|
||||
t.Error("sendImage() does not post to /v1/images/generations")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStatus_ExposesModelKindsToTheUI is the server half of the same
|
||||
// contract: the field the UI reads must actually be serialized.
|
||||
func TestSourceStatus_ExposesModelKindsToTheUI(t *testing.T) {
|
||||
st := provider.SourceStatus{
|
||||
Name: "vela",
|
||||
Models: []string{"claude-sonnet-5.5", "gpt-image-2"},
|
||||
ModelKinds: []provider.ModelKindInfo{
|
||||
{ID: "claude-sonnet-5.5", Kind: "chat"},
|
||||
{ID: "gpt-image-2", Kind: "image"},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mk, ok := got["model_kinds"].([]interface{})
|
||||
if !ok || len(mk) != 2 {
|
||||
t.Fatalf("model_kinds = %v, want 2 entries; the UI cannot split the list without it", got["model_kinds"])
|
||||
}
|
||||
last := mk[1].(map[string]interface{})
|
||||
if last["kind"] != "image" {
|
||||
t.Errorf("model_kinds[1].kind = %v, want \"image\"", last["kind"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUI_BaseURLFieldExplainsWhereToCut keeps the second bug fixed: the URL
|
||||
// hint has to mention the concrete failure, not just say "enter the URL".
|
||||
func TestUI_BaseURLFieldExplainsWhereToCut(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
if !strings.Contains(ui, "urlHint") {
|
||||
t.Fatal("no urlHint; the base_url field gives no guidance about /v1")
|
||||
}
|
||||
hint := ui[strings.Index(ui, "urlHint:"):]
|
||||
if len(hint) > 700 {
|
||||
hint = hint[:700]
|
||||
}
|
||||
// The trap is specific and reproducible: base_url ending in /v1 makes chat
|
||||
// work and image generation 404. Saying only "enter the URL" reproduces the
|
||||
// confusion the hint exists to remove.
|
||||
for _, want := range []string{"/v1", "404"} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("urlHint does not mention %q — the hint must name the concrete failure", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageEndpointComposesUnderRealConditions proves the hint describes a
|
||||
// reachable path rather than a plausible-sounding one.
|
||||
//
|
||||
// The case that matters: base_url already ends in /v1 (which is what an
|
||||
// operator pastes from an SDK example), so the RELATIVE image endpoint composes
|
||||
// to …/v1/images/generations and works, while the gateway's default ABSOLUTE
|
||||
// path would produce …/v1/v1/… and 404.
|
||||
//
|
||||
// The doubled form is served as a 404 here so the test would fail if the code
|
||||
// ever reverted to relying on the default.
|
||||
func TestImageEndpointComposesUnderRealConditions(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/images/generations":
|
||||
_, _ = w.Write([]byte(`{"data":[{"url":"http://example.invalid/x.png"}]}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
// base_url as an operator types it: host root + /v1.
|
||||
base := up.URL + "/v1"
|
||||
|
||||
// What discovery writes when base carries a version segment.
|
||||
relative := base + "/images/generations"
|
||||
resp, err := http.Post(relative, "application/json", strings.NewReader(`{"model":"m"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("POST %s = %d, want 200 — the form discovery writes must work", relative, resp.StatusCode)
|
||||
}
|
||||
|
||||
// What the gateway's built-in default would produce for the same base_url.
|
||||
doubled := base + "/v1/images/generations"
|
||||
resp2, err := http.Post(doubled, "application/json", strings.NewReader(`{"model":"m"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("POST %s = %d, want 404 — this test's premise is that the "+
|
||||
"default image path doubles the version segment; if the upstream ever "+
|
||||
"accepts it, composeImageEndpoint's rule needs rechecking", doubled, resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
107
internal/provider/model_kind_test.go
Normal file
107
internal/provider/model_kind_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// TestSourceStatus_CarriesModelKind is the fix for a UI bug where the chat
|
||||
// test page listed image models next to chat ones.
|
||||
//
|
||||
// The root cause was structural, not a missing filter: SourceStatus.Models was
|
||||
// a flat []string, so by the time the UI had the list there was nothing left to
|
||||
// tell "gpt-image-2" from "claude-sonnet-5.5". Picking the former sent the
|
||||
// request to /api/chat, which cannot serve it, and the failure said nothing
|
||||
// about the real reason.
|
||||
//
|
||||
// Models stays a []string for callers that only need names; ModelKinds is the
|
||||
// additive field that carries the missing dimension.
|
||||
func TestSourceStatus_CarriesModelKind(t *testing.T) {
|
||||
src := config.Source{
|
||||
Name: "vela",
|
||||
BaseURL: "https://example.invalid",
|
||||
Adapter: "openai",
|
||||
APIKey: "sk-test",
|
||||
Endpoint: "/chat/completions",
|
||||
Models: []config.Model{
|
||||
{ID: "claude-sonnet-5.5"}, // kind omitted -> chat default
|
||||
{ID: "gpt-image-2", Kind: "image"}, // image
|
||||
{ID: "seedream-5.0", Kind: "image"},
|
||||
},
|
||||
}
|
||||
p := newTestProvider(t, src)
|
||||
|
||||
kinds := p.ModelsWithKind()
|
||||
if len(kinds) != 3 {
|
||||
t.Fatalf("want 3 entries, got %d", len(kinds))
|
||||
}
|
||||
|
||||
// Order must mirror Models() so the UI can zip the two lists.
|
||||
names := p.Models()
|
||||
for i := range kinds {
|
||||
if kinds[i].ID != names[i] {
|
||||
t.Errorf("index %d: ModelKinds has %q but Models has %q — the lists must stay aligned",
|
||||
i, kinds[i].ID, names[i])
|
||||
}
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"claude-sonnet-5.5": "chat",
|
||||
"gpt-image-2": "image",
|
||||
"seedream-5.0": "image",
|
||||
}
|
||||
for _, k := range kinds {
|
||||
if got := want[k.ID]; k.Kind != got {
|
||||
t.Errorf("%s: kind = %q, want %q", k.ID, k.Kind, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelKinds_CountsImageModelsInARealCatalog: the number of image models is
|
||||
// what decides whether the UI needs the split at all, so assert against a
|
||||
// catalog with both kinds rather than a hand-picked pair.
|
||||
func TestModelKinds_CountsImageModelsInARealCatalog(t *testing.T) {
|
||||
src := config.Source{
|
||||
Name: "vela", BaseURL: "https://example.invalid", Adapter: "openai", APIKey: "k",
|
||||
Models: []config.Model{
|
||||
{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"},
|
||||
{ID: "img1", Kind: "image"}, {ID: "img2", Kind: "image"},
|
||||
},
|
||||
}
|
||||
p := newTestProvider(t, src)
|
||||
var images int
|
||||
for _, k := range p.ModelsWithKind() {
|
||||
if k.Kind == "image" {
|
||||
images++
|
||||
}
|
||||
}
|
||||
if images != 2 {
|
||||
t.Errorf("image models = %d, want 2", images)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStatusJSON_ModelKindsOmittedWhenEmpty pins the backward-compat
|
||||
// promise: an older UI parsing this JSON must not break on a new field, and a
|
||||
// source with no model list must not gain a "model_kinds": null.
|
||||
func TestSourceStatusJSON_ModelKindsOmittedWhenEmpty(t *testing.T) {
|
||||
b, err := json.Marshal(SourceStatus{Name: "x", Models: []string{"a"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := m["model_kinds"]; present {
|
||||
t.Errorf("model_kinds present when empty: %s — should be omitted for older UIs", b)
|
||||
}
|
||||
if _, present := m["models"]; !present {
|
||||
t.Errorf("models field disappeared: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestProvider (defined in provider_test.go) spins up a real Lua VM, which
|
||||
// is heavier than these tests need, but reusing it keeps one construction path
|
||||
// for the whole package.
|
||||
@ -451,6 +451,25 @@ func (p *Provider) Models() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ModelsWithKind returns the same ids as Models but paired with each model's
|
||||
// kind, in the same order.
|
||||
//
|
||||
// Models() cannot answer "which of these are image models", and that question
|
||||
// has to be answerable: the chat test page lists models from one source list,
|
||||
// and an image model offered there fails on every call because /api/chat is the
|
||||
// chat endpoint.
|
||||
func (p *Provider) ModelsWithKind() []ModelKindInfo {
|
||||
out := make([]ModelKindInfo, 0, len(p.cfg.Models))
|
||||
for _, m := range p.cfg.Models {
|
||||
kind := m.Kind
|
||||
if kind == "" {
|
||||
kind = "chat" // config.Model documents "" as the chat default
|
||||
}
|
||||
out = append(out, ModelKindInfo{ID: m.ID, Kind: kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ModelByID returns the model definition if owned by this source.
|
||||
func (p *Provider) ModelByID(id string) *config.Model {
|
||||
for i := range p.cfg.Models {
|
||||
|
||||
@ -196,12 +196,27 @@ func (r *Registry) ProviderForSlot(model, source string) *Provider {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ModelKindInfo carries the per-model fields the chat test UI needs to tell a
|
||||
// chat model from an image one.
|
||||
type ModelKindInfo struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "chat" (default) | "image"
|
||||
}
|
||||
|
||||
// ModelStatus is a web-UI friendly snapshot per source.
|
||||
type SourceStatus struct {
|
||||
Name string `json:"name"`
|
||||
Adapter string `json:"adapter"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Models []string `json:"models"`
|
||||
// ModelKinds parallels Models: Models[i] is ModelKinds[i].ID.
|
||||
//
|
||||
// It exists because a flat []string cannot answer "is gpt-image-2
|
||||
// reachable from /api/chat?" — it cannot, and offering it there produced
|
||||
// a guaranteed failure with no explanation. Models stays unchanged for
|
||||
// callers that only need names; this is purely additive and omitted when
|
||||
// empty, so an older UI keeps working.
|
||||
ModelKinds []ModelKindInfo `json:"model_kinds,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Healthy bool `json:"healthy"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
@ -259,6 +274,7 @@ func (r *Registry) Status() []SourceStatus {
|
||||
Adapter: p.Adapter(),
|
||||
BaseURL: p.Config().BaseURL,
|
||||
Models: p.Models(),
|
||||
ModelKinds: p.ModelsWithKind(),
|
||||
Available: p.Available(),
|
||||
Healthy: p.Available(),
|
||||
MaxConcurrent: p.MaxConcurrent(),
|
||||
|
||||
Reference in New Issue
Block a user