mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
fix(sources): 源编辑不再清零 proxy_url / api_key_env / timeout
bf0657b 修好了 api_key,但 upsert 仍会重写整个源,于是请求无法表达的字段
一律被重置为零值。这四个字段的后果都不是"少个配置项":
- api_key_env 丢失 ⇒ 盘上无明文密钥的源变成无凭据源,写操作返回 200,
下一次调用上游才 401。而 README 恰恰把这个特性当作卖点在宣传。
- proxy_url 丢失 ⇒ 一个走代理的上游变成直连(或反之),且完全无声。
- timeout / queue_timeout 丢失 ⇒ 退回默认 120s / 60s。
触发路径不是只有脚本:WebUI 的 saveSource() 发的 payload 只含表单上的
11 个字段,而 editSource() 表单里根本没有这 4 项 ⇒ 运维在界面上改个并发数
就会静默清掉它们。
修法用「存在性」语义而不是「空即继承」:
- 不传 → 保留已存值(部分更新的客户端要的就是这个)
- 传了 → 覆盖,包括传空串表示清空
api_key 刻意保留它原有的「空即继承」规则,不跟着改成指针:该规则已随
v1.7.6 发布,脚本依赖它;而凭据丢失比代理丢失严重得多。两个字段的失败
模式相反,所以规则相反——这一条写进了两处注释。
另一处是差一点的:config.Source 把 Timeout/QueueTimeout 标成 json:"-",
所以 reveal 接口的结构体序列化**根本不返回它们**。表单读不到 → 输入框
恒空 → 而输入框每次都回传 → 每存一次就把 timeout 清零。等于把刚修好的
丢字段换个方向又造了一个。因此 reveal 分支现在显式返回 duration 字符串。
判据:
- TestWebUIEditPayloadPreservesRoutingFields 用的是 WebUI 真实 payload
的逐字节副本,并同时断言"确实改动的字段生效",否则"什么都不写"也能过
- TestSourceEditPreservesAPIKeyEnv 单独盯 api_key_env(唯一造成凭据丢失的)
- CanBeSet / CanBeCleared 分别盯两个方向:只有"空即继承"的实现过不了
CanBeCleared(清空代理框会永远保留旧代理)
- 持久化判据**重新加载 config.yaml 并按语义比对**:300s 会被重新序列化成
5m0s,按字符串匹配是假红(我自己先踩了一次)
- TestSourcePayloadCoversEveryEditableField 用反射卡住"这一类":新增
Source 字段而没接到 API 上时立刻变红。反射查结构体而非 marshal 结果,
因为指针 + omitempty 会合法地从序列化输出里消失,那正是"未提及"信号
- 三个 UI 契约判据把 JS 侧也钉住(表单必须回传、必须从 reveal 读)
变异验证(每次都先确认 build 通过,再数红格):
1. 去掉覆盖逻辑 → 7 个判据红
2. 改成"空即继承" → CanBeCleared + ClearIsScoped 红
3. reveal 不返回 duration → TestSourceRevealExposesDurations 红
4. 表单不回传 api_key_env → TestUIEditFormRoundTrips... 红
顺带修正 /api/v1 索引:DELETE /api/keys 的路径段写的是 {name},实际是 key
本身;PUT /api/keys/{key} 实现了却没列。
全量 + vet + race 全绿;WebUI 内联脚本过 node --check。
This commit is contained in:
@ -904,6 +904,12 @@
|
||||
mConc: "并发上限",
|
||||
mRPM: "RPM 限速 (0=不限)",
|
||||
mTemp: "温度",
|
||||
mKeyEnv: "Key 环境变量",
|
||||
mKeyEnvPh: "优先于 API Key,不落盘明文",
|
||||
mProxy: "代理 URL",
|
||||
mProxyPh: "如 http://127.0.0.1:7890,留空直连",
|
||||
mTimeout: "请求超时",
|
||||
mTimeoutPh: "如 300s,留空用默认 120s",
|
||||
mModels: "模型列表",
|
||||
mAddModel: "+ 模型",
|
||||
mMeta: "Meta",
|
||||
@ -1135,6 +1141,12 @@
|
||||
mConc: "Max concurrency",
|
||||
mRPM: "RPM limit (0 = unlimited)",
|
||||
mTemp: "Temperature",
|
||||
mKeyEnv: "Key env var",
|
||||
mKeyEnvPh: "Takes precedence over API Key; nothing written to disk",
|
||||
mProxy: "Proxy URL",
|
||||
mProxyPh: "e.g. http://127.0.0.1:7890 — empty means direct",
|
||||
mTimeout: "Request timeout",
|
||||
mTimeoutPh: "e.g. 300s — empty uses the 120s default",
|
||||
mModels: "Models",
|
||||
mAddModel: "+ model",
|
||||
mMeta: "Meta",
|
||||
@ -2866,7 +2878,11 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mRPM")}</label><input id="s-rpm" type="number" min="0" placeholder="0 = 不限" value="${s.rpm || 0}"></div>
|
||||
<div></div>
|
||||
<div><label>${t("mProxy")}</label><input id="s-proxy" value="${escAttr(s.proxy_url || "")}" placeholder="${t("mProxyPh")}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mKeyEnv")}</label><input id="s-keyenv" value="${escAttr(s.api_key_env || "")}" placeholder="${t("mKeyEnvPh")}"></div>
|
||||
<div><label>${t("mTimeout")}</label><input id="s-timeout" value="${escAttr(timeoutText(s.timeout))}" placeholder="${t("mTimeoutPh")}"></div>
|
||||
</div>
|
||||
<label>${t("mModels")}</label>
|
||||
<div id="s-models"></div>
|
||||
@ -2896,6 +2912,21 @@
|
||||
<button class="ghost del small" onclick="this.closest('.model-row').remove()">×</button>
|
||||
</div>`;
|
||||
}
|
||||
// timeoutText renders a stored Go duration for the timeout input.
|
||||
//
|
||||
// The API returns timeout as a json:"-"
|
||||
// field (time.Duration), so the JSON number it arrives as is NANOSECONDS,
|
||||
// not seconds. Showing the raw number would offer "300000000000" in a box
|
||||
// that expects "300s"; an unset timeout (0) must show as empty so the
|
||||
// box reads as "default" rather than "0s".
|
||||
function timeoutText(v) {
|
||||
const n = typeof v === "string" ? parseFloat(v) : v;
|
||||
if (!n || !isFinite(n) || n <= 0) return "";
|
||||
const secs = Math.round(n / 1e9);
|
||||
if (secs % 3600 === 0) return secs / 3600 + "h";
|
||||
if (secs % 60 === 0) return secs / 60 + "m";
|
||||
return secs + "s";
|
||||
}
|
||||
function addModelRow() {
|
||||
const div = $("#s-models");
|
||||
div.insertAdjacentHTML(
|
||||
@ -2930,6 +2961,14 @@
|
||||
temperature: parseFloat($("#s-temp").value) || 0,
|
||||
models,
|
||||
meta,
|
||||
// These four are always SENT, so the server can tell "the user left
|
||||
// this empty on purpose" from "this form does not know about it".
|
||||
// Omitting them made the server keep the stored value, which is right
|
||||
// for an API client with a partial payload but wrong here: emptying
|
||||
// the proxy box would have kept the old proxy forever.
|
||||
proxy_url: $("#s-proxy").value.trim(),
|
||||
api_key_env: $("#s-keyenv").value.trim(),
|
||||
timeout: $("#s-timeout").value.trim(),
|
||||
};
|
||||
btn.disabled = true;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user