mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 10:47:24 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42eef98395 | |||
| 86ba4f57a0 | |||
| 4826837f9c |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.6",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"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
|
||||
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
|
||||
if backoffSeconds(seen, known.streak) then
|
||||
return { name = src.name, host = host, skipped = true,
|
||||
reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
|
||||
return { name = src.name, host = host, skipped = true, backing_off = true,
|
||||
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
||||
end
|
||||
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
|
||||
known = nil
|
||||
@ -577,8 +577,12 @@ PLUGIN.due_sources = function(payload)
|
||||
local base = (s.base_url:gsub("/+$", ""))
|
||||
local known = HOST_MEMO[hostOf(base)]
|
||||
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
|
||||
backing[#backing + 1] = { name = s.name, host = hostOf(base),
|
||||
reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
|
||||
-- skipped matters: the page filters on it. Without the flag a backed-off
|
||||
-- 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
|
||||
due[#due + 1] = s
|
||||
end
|
||||
@ -824,7 +828,14 @@ PLUGIN.ui = {
|
||||
}
|
||||
if(r.hard_limit!==undefined && r.hard_limit!==null)
|
||||
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){
|
||||
var st=state(r);
|
||||
@ -837,6 +848,13 @@ PLUGIN.ui = {
|
||||
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
|
||||
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);
|
||||
var rows="";
|
||||
rows+=month(r);
|
||||
@ -861,7 +879,10 @@ PLUGIN.ui = {
|
||||
var rs = (st && st.reports) || [];
|
||||
if(!rs.length) return false;
|
||||
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);
|
||||
var sub=document.getElementById("qa-sub");
|
||||
if(sub && st.updated_at){
|
||||
@ -878,10 +899,22 @@ PLUGIN.ui = {
|
||||
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
|
||||
} else {
|
||||
host.innerHTML=real.map(card).join("");
|
||||
if(skipped && skipped.length){
|
||||
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
|
||||
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</div>';
|
||||
}
|
||||
if(skipped && skipped.length){
|
||||
// Two reasons a source has no card, and they are not interchangeable.
|
||||
// "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");
|
||||
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
|
||||
// the memo at 0 entries and every refresh cost the full 12s.
|
||||
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
|
||||
var due = (plan && plan.due) || [];
|
||||
var out = (plan && plan.backing_off) || [];
|
||||
// An EMPTY Lua table decodes as {} on the Go side, not [] — and {} is truthy,
|
||||
// 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) {
|
||||
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){
|
||||
if(!r) return null;
|
||||
// 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.
|
||||
if(r.reason && String(r.reason).indexOf("backing off")===0) return null;
|
||||
// failure would grow the streak on a probe that never happened. Keyed on the
|
||||
// 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.skipped) return {host:r.host};
|
||||
return {host:r.host, vendor:r.vendor};
|
||||
@ -959,7 +998,11 @@ PLUGIN.ui = {
|
||||
}
|
||||
var all=out||[];
|
||||
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.
|
||||
render(real, skipped);
|
||||
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 — " +
|
||||
"the backoff is computed but not applied")
|
||||
}
|
||||
if !strings.Contains(src, `reason = "backing off after "`) {
|
||||
t.Error("the skip path does not say why it skipped; a silently skipped host " +
|
||||
"looks exactly like a host that simply has no balance endpoint")
|
||||
// The reason is required, but matched on the FLAG rather than the wording:
|
||||
// the message is localised, and grouping by text would merge "backing off"
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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