mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42eef98395 | |||
| 86ba4f57a0 | |||
| 4826837f9c |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.9.5",
|
"version": "1.9.6",
|
||||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||||
"author": "ModelRouter",
|
"author": "ModelRouter",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
|
|||||||
@ -389,8 +389,8 @@ local function collectSource(src, budget)
|
|||||||
-- A streak is a host that has failed repeatedly. Skip it outright until its
|
-- A streak is a host that has failed repeatedly. Skip it outright until its
|
||||||
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
|
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
|
||||||
if backoffSeconds(seen, known.streak) then
|
if backoffSeconds(seen, known.streak) then
|
||||||
return { name = src.name, host = host, skipped = true,
|
return { name = src.name, host = host, skipped = true, backing_off = true,
|
||||||
reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
|
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
||||||
end
|
end
|
||||||
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
|
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
|
||||||
known = nil
|
known = nil
|
||||||
@ -577,8 +577,12 @@ PLUGIN.due_sources = function(payload)
|
|||||||
local base = (s.base_url:gsub("/+$", ""))
|
local base = (s.base_url:gsub("/+$", ""))
|
||||||
local known = HOST_MEMO[hostOf(base)]
|
local known = HOST_MEMO[hostOf(base)]
|
||||||
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
|
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
|
||||||
backing[#backing + 1] = { name = s.name, host = hostOf(base),
|
-- skipped matters: the page filters on it. Without the flag a backed-off
|
||||||
reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
|
-- host reaches the card renderer, which has no field to match and falls
|
||||||
|
-- through to its "has data" default — so thirteen dead upstreams were
|
||||||
|
-- displayed as if they had answered.
|
||||||
|
backing[#backing + 1] = { name = s.name, host = hostOf(base), skipped = true, backing_off = true,
|
||||||
|
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
||||||
else
|
else
|
||||||
due[#due + 1] = s
|
due[#due + 1] = s
|
||||||
end
|
end
|
||||||
@ -824,7 +828,14 @@ PLUGIN.ui = {
|
|||||||
}
|
}
|
||||||
if(r.hard_limit!==undefined && r.hard_limit!==null)
|
if(r.hard_limit!==undefined && r.hard_limit!==null)
|
||||||
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
|
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
|
||||||
return {cls:"", pill:'<span class="qa-pill">有数据</span>'};
|
// Nothing here matched. Say so.
|
||||||
|
//
|
||||||
|
// The old default was a neutral "有数据", which is a claim: it says the probe
|
||||||
|
// answered. A record that reached this point answered with no field the page
|
||||||
|
// knows — a back-off notice, an error shape we do not recognise, or a vendor
|
||||||
|
// that added a new response. Calling that "has data" is how thirteen dead
|
||||||
|
// upstreams ended up rendered as healthy. Absence of evidence is not evidence.
|
||||||
|
return {cls:"unknown", pill:'<span class="qa-pill">未探测</span>'};
|
||||||
}
|
}
|
||||||
function card(r){
|
function card(r){
|
||||||
var st=state(r);
|
var st=state(r);
|
||||||
@ -837,6 +848,13 @@ PLUGIN.ui = {
|
|||||||
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
|
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
// A record with nothing but a reason has no balance, no month, no windows —
|
||||||
|
// the card renders as a bare header with an empty body. Say why instead.
|
||||||
|
if(r.reason && !r.error && (r.balance===undefined||r.balance===null)
|
||||||
|
&& (r.hard_limit===undefined||r.hard_limit===null)){
|
||||||
|
h+='<div class="qa-fail">'+escH(r.reason)+'</div>';
|
||||||
|
return h+'</div>';
|
||||||
|
}
|
||||||
h+=balLine(r);
|
h+=balLine(r);
|
||||||
var rows="";
|
var rows="";
|
||||||
rows+=month(r);
|
rows+=month(r);
|
||||||
@ -861,7 +879,10 @@ PLUGIN.ui = {
|
|||||||
var rs = (st && st.reports) || [];
|
var rs = (st && st.reports) || [];
|
||||||
if(!rs.length) return false;
|
if(!rs.length) return false;
|
||||||
var real = rs.filter(function(r){ return r && !r.skipped; });
|
var real = rs.filter(function(r){ return r && !r.skipped; });
|
||||||
var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; });
|
// Records, not names — same reason as the refresh path. Both call sites were
|
||||||
|
// mapping to names while render() was changed to group on the flag; fixing
|
||||||
|
// one and shipping it is what produced a footer with thirteen blank entries.
|
||||||
|
var skipped = rs.filter(function(r){ return r && r.skipped; });
|
||||||
render(real, skipped);
|
render(real, skipped);
|
||||||
var sub=document.getElementById("qa-sub");
|
var sub=document.getElementById("qa-sub");
|
||||||
if(sub && st.updated_at){
|
if(sub && st.updated_at){
|
||||||
@ -878,10 +899,22 @@ PLUGIN.ui = {
|
|||||||
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
|
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
|
||||||
} else {
|
} else {
|
||||||
host.innerHTML=real.map(card).join("");
|
host.innerHTML=real.map(card).join("");
|
||||||
if(skipped && skipped.length){
|
if(skipped && skipped.length){
|
||||||
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
|
// Two reasons a source has no card, and they are not interchangeable.
|
||||||
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</div>';
|
// "This upstream exposes no balance endpoint" and "this upstream is
|
||||||
}
|
// backing off after failing" are different facts; collapsing them into
|
||||||
|
// one list tells the operator their dead host simply has no dashboard.
|
||||||
|
var backing = skipped.filter(function(r){ return r.backing_off===true; });
|
||||||
|
var silent = skipped.filter(function(r){ return r.backing_off!==true; });
|
||||||
|
if(backing.length){
|
||||||
|
host.innerHTML+='<div class="qa-skip">'+backing.length+
|
||||||
|
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
|
||||||
|
}
|
||||||
|
if(silent.length){
|
||||||
|
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
|
||||||
|
' 个源(未暴露余额接口):'+escH(silent.map(function(r){return r.name;}).join("、"))+'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var sub=document.getElementById("qa-sub");
|
var sub=document.getElementById("qa-sub");
|
||||||
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
|
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
|
||||||
@ -924,8 +957,12 @@ PLUGIN.ui = {
|
|||||||
// re-probed on every refresh forever. Measured before this split: fanout left
|
// re-probed on every refresh forever. Measured before this split: fanout left
|
||||||
// the memo at 0 entries and every refresh cost the full 12s.
|
// the memo at 0 entries and every refresh cost the full 12s.
|
||||||
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
|
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
|
||||||
var due = (plan && plan.due) || [];
|
// An EMPTY Lua table decodes as {} on the Go side, not [] — and {} is truthy,
|
||||||
var out = (plan && plan.backing_off) || [];
|
// so `|| []` does not replace it. An empty object reaching out.concat()
|
||||||
|
// throws, and the whole refresh reports a failure. Array.isArray is the check
|
||||||
|
// that matches what the plugin actually returns.
|
||||||
|
var due = (plan && Array.isArray(plan.due)) ? plan.due : [];
|
||||||
|
var out = (plan && Array.isArray(plan.backing_off)) ? plan.backing_off : [];
|
||||||
|
|
||||||
if (due.length) {
|
if (due.length) {
|
||||||
out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){
|
out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){
|
||||||
@ -946,8 +983,10 @@ PLUGIN.ui = {
|
|||||||
await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){
|
await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){
|
||||||
if(!r) return null;
|
if(!r) return null;
|
||||||
// A host that is merely BACKING OFF has not just failed; recording it as a
|
// A host that is merely BACKING OFF has not just failed; recording it as a
|
||||||
// failure would grow the streak on a probe that never happened.
|
// failure would grow the streak on a probe that never happened. Keyed on the
|
||||||
if(r.reason && String(r.reason).indexOf("backing off")===0) return null;
|
// flag rather than the wording, so translating the message cannot silently
|
||||||
|
// turn every skipped host into an ever-growing streak.
|
||||||
|
if(r.backing_off===true) return null;
|
||||||
if(r.error) return {host:r.host};
|
if(r.error) return {host:r.host};
|
||||||
if(r.skipped) return {host:r.host};
|
if(r.skipped) return {host:r.host};
|
||||||
return {host:r.host, vendor:r.vendor};
|
return {host:r.host, vendor:r.vendor};
|
||||||
@ -959,7 +998,11 @@ PLUGIN.ui = {
|
|||||||
}
|
}
|
||||||
var all=out||[];
|
var all=out||[];
|
||||||
var real=all.filter(function(r){return !r.skipped;});
|
var real=all.filter(function(r){return !r.skipped;});
|
||||||
var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
|
// The records, not their names — render() groups skipped sources by the
|
||||||
|
// backing_off flag, which a bare name cannot carry. This call site was left
|
||||||
|
// mapping to names while the other was fixed, and the mismatch showed up as
|
||||||
|
// a footer listing thirteen unnamed sources.
|
||||||
|
var skipped=all.filter(function(r){return r.skipped;});
|
||||||
// real may already contain errors; collect_all keeps them for visibility.
|
// real may already contain errors; collect_all keeps them for visibility.
|
||||||
render(real, skipped);
|
render(real, skipped);
|
||||||
if(btn) btn.disabled=false;
|
if(btn) btn.disabled=false;
|
||||||
|
|||||||
@ -27,9 +27,16 @@ func TestQuotaBackoffIsAppliedInSource(t *testing.T) {
|
|||||||
t.Error("quota.lua never consults backoffSeconds for a host with a streak — " +
|
t.Error("quota.lua never consults backoffSeconds for a host with a streak — " +
|
||||||
"the backoff is computed but not applied")
|
"the backoff is computed but not applied")
|
||||||
}
|
}
|
||||||
if !strings.Contains(src, `reason = "backing off after "`) {
|
// The reason is required, but matched on the FLAG rather than the wording:
|
||||||
t.Error("the skip path does not say why it skipped; a silently skipped host " +
|
// the message is localised, and grouping by text would merge "backing off"
|
||||||
"looks exactly like a host that simply has no balance endpoint")
|
// into "exposes no balance endpoint" the moment someone translates it.
|
||||||
|
if strings.Count(src, "reason = \"连续探测失败 ") < 2 {
|
||||||
|
t.Error("both skip paths (collectSource and due_sources) must carry a reason; " +
|
||||||
|
"a silently skipped host looks exactly like a host with no balance endpoint")
|
||||||
|
}
|
||||||
|
if strings.Count(src, "backing_off = true") < 2 {
|
||||||
|
t.Error("both skip paths must set the explicit backing_off flag; the page " +
|
||||||
|
"groups skipped sources by it, not by the message text")
|
||||||
}
|
}
|
||||||
// The recording guard has to REPLACE the memo, not merge into it. The bug this
|
// The recording guard has to REPLACE the memo, not merge into it. The bug this
|
||||||
// guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a
|
// guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a
|
||||||
@ -554,3 +561,90 @@ func hostOnly(baseURL string) string {
|
|||||||
}
|
}
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuotaBackoffRecordIsSkippedAndFlagged pins the shape the page depends on.
|
||||||
|
//
|
||||||
|
// Two things went wrong in the same field, and both were silent:
|
||||||
|
//
|
||||||
|
// 1. The back-off record had no `skipped` flag, so the page's
|
||||||
|
// `filter(r => !r.skipped)` let it through to the card renderer. That
|
||||||
|
// renderer matches on known fields, and a record carrying only a reason
|
||||||
|
// matches none — so it fell through to a default that said "有数据".
|
||||||
|
// Thirteen dead upstreams were rendered as though they had answered.
|
||||||
|
// 2. Once it did carry `skipped`, the page grouped skipped sources by reading
|
||||||
|
// the REASON TEXT. Translating that string would have silently merged
|
||||||
|
// "backing off" into "exposes no balance endpoint" — a different claim
|
||||||
|
// about the upstream. The grouping keys on an explicit flag.
|
||||||
|
func TestQuotaBackoffRecordIsSkippedAndFlagged(t *testing.T) {
|
||||||
|
srv := newCountingServer(t)
|
||||||
|
ps := newQuotaPlugins(t, srv.URL)
|
||||||
|
|
||||||
|
// One failure, enough to put the host inside its window.
|
||||||
|
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||||
|
"results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ps.Call("quota", "due_sources", map[string]interface{}{
|
||||||
|
"sources": []interface{}{map[string]interface{}{
|
||||||
|
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, _ := out.(map[string]interface{})
|
||||||
|
backing, _ := m["backing_off"].([]interface{})
|
||||||
|
if len(backing) != 1 {
|
||||||
|
t.Fatalf("backing_off = %#v, want one record", m["backing_off"])
|
||||||
|
}
|
||||||
|
rec, _ := backing[0].(map[string]interface{})
|
||||||
|
if rec["skipped"] != true {
|
||||||
|
t.Errorf("back-off record has skipped=%v — the page filters cards on this "+
|
||||||
|
"field, and without it the record is rendered as a source with data",
|
||||||
|
rec["skipped"])
|
||||||
|
}
|
||||||
|
if rec["backing_off"] != true {
|
||||||
|
t.Errorf("back-off record has no explicit backing_off flag; the page groups " +
|
||||||
|
"by it instead of by reason text, so translating the message cannot " +
|
||||||
|
"merge this with 'no balance endpoint'")
|
||||||
|
}
|
||||||
|
if s, _ := rec["reason"].(string); s == "" {
|
||||||
|
t.Error("back-off record carries no reason; the card renders empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaRenderCallSitesPassRecordsNotNames guards a bug that only appeared
|
||||||
|
// because ONE of two identical call sites was fixed.
|
||||||
|
//
|
||||||
|
// render() groups skipped sources by the backing_off flag. A call site that maps
|
||||||
|
// the records to their names first ("skipped.map(r => r.name)") therefore hands
|
||||||
|
// render an array of strings: the flag is gone, every entry falls into the
|
||||||
|
// "exposes no balance endpoint" bucket, and the footer lists N unnamed sources.
|
||||||
|
//
|
||||||
|
// The first fix changed only the refresh path. The snapshot path kept mapping to
|
||||||
|
// names, and the symptom was a footer reading "未列出 13 个源:、、、、…" — which
|
||||||
|
// reads like a rendering bug rather than a contract mismatch between two call
|
||||||
|
// sites of one function.
|
||||||
|
func TestQuotaRenderCallSitesPassRecordsNotNames(t *testing.T) {
|
||||||
|
src := readShippedPlugin(t, "quota.lua")
|
||||||
|
if strings.Contains(src, ".map(function(r){ return r.name; })") ||
|
||||||
|
strings.Contains(src, ".map(function(r){return r.name;})") {
|
||||||
|
// A benign occurrence would be fine, but there is no other reason for a
|
||||||
|
// skipped list to be reduced to names, so treat any as the bug.
|
||||||
|
for _, line := range strings.Split(src, "\n") {
|
||||||
|
if strings.Contains(line, "skipped") && strings.Contains(line, ".map(") &&
|
||||||
|
strings.Contains(line, "r.name") {
|
||||||
|
t.Errorf("a skipped list is mapped to names before reaching render(): %s\n"+
|
||||||
|
"render() groups on the backing_off flag, which a name cannot carry",
|
||||||
|
strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both call sites must exist and both must hand over records.
|
||||||
|
if n := strings.Count(src, ".filter(function(r){return r.skipped;})") +
|
||||||
|
strings.Count(src, ".filter(function(r){ return r && r.skipped; })"); n < 2 {
|
||||||
|
t.Errorf("found %d skipped-list filters, want 2 (snapshot and refresh paths)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
71
packaging/upload-release.sh
Executable file
71
packaging/upload-release.sh
Executable file
@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Upload release assets to a GitCode release.
|
||||||
|
#
|
||||||
|
# The endpoint shapes are asymmetric and both differ from the "obvious" guess:
|
||||||
|
#
|
||||||
|
# GET /releases/tags/{tag} → read a release
|
||||||
|
# GET /releases/{tag}/upload_url → get a presigned PUT (NO "tags/")
|
||||||
|
# PUT {presigned url} → upload, REPLAYING the headers verbatim
|
||||||
|
#
|
||||||
|
# Two mistakes cost real time on v1.9.5 before this script existed:
|
||||||
|
#
|
||||||
|
# 1. POSTing straight to /releases/{tag}/assets returns 404 — that endpoint
|
||||||
|
# does not exist. It reads as "authentication failed" if you only check
|
||||||
|
# the status code.
|
||||||
|
# 2. PUTting without the headers fails with an opaque storage error, not a
|
||||||
|
# permission one, so it looks like a size limit or a corrupt file.
|
||||||
|
#
|
||||||
|
# The x-obs-* headers are part of the signature: dropping any one of them turns
|
||||||
|
# the request into an unsigned one.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
TOKEN="$(tr -d '\n\r' < /tmp/gc_token)"
|
||||||
|
REPO="JianFeeeee/ModelRouter"
|
||||||
|
TAG="${1:?usage: upload-release.sh <tag> <file>...}"
|
||||||
|
shift
|
||||||
|
|
||||||
|
API="https://gitcode.com/api/v5/repos/${REPO}"
|
||||||
|
|
||||||
|
for f in "$@"; do
|
||||||
|
name="$(basename "$f")"
|
||||||
|
size="$(stat -c%s "$f")"
|
||||||
|
printf ' %-46s %10s bytes ' "$name" "$size"
|
||||||
|
|
||||||
|
# 1) presigned upload location. The file name must be URL-encoded; the v1.9.4
|
||||||
|
# assets show "~" surviving in a stored name, so encoding is per-component.
|
||||||
|
enc="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$name")"
|
||||||
|
resp="$(curl -sS -m 60 -H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
"${API}/releases/${TAG}/upload_url?file_name=${enc}" 2>&1)"
|
||||||
|
url="$(printf '%s' "$resp" | python3 -c 'import json,sys
|
||||||
|
try:
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
print(d.get("url") or d.get("uploadUrl") or "")
|
||||||
|
except Exception:
|
||||||
|
print("")' 2>/dev/null)"
|
||||||
|
if [ -z "$url" ]; then
|
||||||
|
echo "✗ 取得上传地址失败: ${resp:0:140}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) PUT with the headers replayed exactly as returned.
|
||||||
|
code="$(printf '%s' "$resp" | python3 -c 'import json,sys
|
||||||
|
try:
|
||||||
|
print(json.dumps(json.load(sys.stdin).get("headers") or {}))
|
||||||
|
except Exception:
|
||||||
|
print("{}")' 2>/dev/null | python3 -c '
|
||||||
|
import json, subprocess, sys
|
||||||
|
headers = json.load(sys.stdin)
|
||||||
|
args = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT"]
|
||||||
|
for k, v in headers.items():
|
||||||
|
args += ["-H", f"{k}: {v}"]
|
||||||
|
args += ["--data-binary", "@" + sys.argv[1], sys.argv[2]]
|
||||||
|
print(subprocess.run(args, capture_output=True, text=True).stdout)
|
||||||
|
' "$f" "$url" 2>/dev/null)"
|
||||||
|
|
||||||
|
if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then
|
||||||
|
echo "✓ HTTP=$code"
|
||||||
|
else
|
||||||
|
echo "✗ HTTP=$code"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user