upstream: the vendors' own status pages say when their API is down (#971, emo172: an Anthropic or OpenAI outage looked like a sign-in or quota problem) — a provider that calls Anthropic's, OpenAI's or Moonshot's API (by its base URL's host: api.anthropic.com, which the Claude subscription calls too; api.openai.com and chatgpt.com, Codex's; api.moonshot.cn/.ai) carries "API outage" (red), "API degraded" or "API maintenance" beside its name on its Providers row and its Usage card while the vendor's page says so; its title names the parts and the open incidents, and a click opens the incident (or the page), not the row. internal/upstream reads each page's Statuspage-style summary.json (status.claude.com; status.openai.com, incident.io's; status.moonshot.cn), checked by hand on 2026-10-06, and only the API's own parts (Claude API and Claude Code, not claude.ai; Chat Completions, Responses, Codex, Codex CLI, not "Codex in ChatGPT Desktop", which was degraded that day); an incident naming no parts (OpenAI's) counts only while those parts are affected. Pages are read only when the Providers or Usage page asks (GET /api/upstream), at most once in five minutes, never on a request's way; a page that fails, isn't JSON or no longer lists the API's parts is unknown and marks nothing. A relay serving the same models is never marked. DeepSeek's page (Flashduty) has nothing readable and is only known for its link; OpenRouter, xAI, Mistral and z.ai have no page read. The gateway's failure messages, TUI, CLI and tray aren't changed. zh/ja/de added. upstream_test (fake pages from the real summaries: API down, claude.ai down isn't the API, Claude Code degraded, OpenAI's app isn't the API, 503/HTML/changed page unknown, read once in Fresh and Of never waits, hosts; -race -count=5) and gui's TestUpstreamRoute pass; upstream-status (Anthropic down: its provider and the Claude subscription marked, relay/OpenAI/unread Moonshot not, click opens the incident not the row, all well marks nothing; 900 and 440px, the badge whole, the name giving way) passes in Chromium and WebKit, en and zh, and fails 8/8 without the app.js change. i18n-keys, gui-ja, gui-de, click-scroll, account-return, account-models, usage-keys-fold, deprecated-subscriptions and add-sheet pass; go vet, go test ./internal/gui/, linux/windows/nogui builds pass.

This commit is contained in:
yetone committed 2026-10-06 12:36:05 +08:00
1 parent 62f9612ba6
commit 2a990175e5
10 files changed
+887

No files matched your search

+12
View File
@@ -1770,6 +1770,10 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
/* a provider row: logo, name, the agents on it, key status */
.provider .who { flex: 1 1 auto; }
.provider .name .badge { margin-left: 6px; }
/* a row whose name holds a vendor's outage badge (#971): the name gives
way to it, the badge is never the part cut */
.provider .name:has(> .badge.upstream) { display: flex; align-items: center; }
.provider .name > .nm { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.badge { font-size: 9.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; padding: 1px 5px; border-radius: 4px; background: var(--amber-soft); color: var(--amber); vertical-align: 1px; }
.provider.selected { background: var(--card-2); }
/* switched off (#163): kept, dimmed, and said so by its name */
@@ -2068,6 +2072,14 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
/* a built-in subscription a community plugin can run: an outlined amber
word, apart from the filled "sponsored" */
.badge.deprecated { background: transparent; color: var(--amber); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--amber) 45%, transparent); }
/* a vendor's status page says its API is affected (#971): a button, its
page a click away; red for an outage, amber when degraded */
.badge.upstream { border: 0; font-family: inherit; cursor: pointer; white-space: nowrap; flex: none; display: inline-flex; align-items: center; gap: 4px; letter-spacing: .02em; text-transform: none; }
.badge.upstream::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.badge.upstream.partial, .badge.upstream.major { background: var(--red-soft); color: var(--red); }
.badge.upstream.maintenance { background: var(--pill); color: var(--muted); }
.badge.upstream:hover { filter: brightness(.95); }
.badge.upstream:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* over the list: what is deprecated, why, and the way on */
.deprecation { display: flex; align-items: center; gap: 10px; padding: 10px 10px 10px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); animation: fade-in .2s; }
.deprecation .dep-dot { flex: none; align-self: flex-start; width: 7px; height: 7px; margin-top: 6px; border-radius: 50%; background: var(--amber); }
+71
View File
@@ -4377,6 +4377,7 @@ async function loadProviders() {
} else renderProviders();
renderArchive();
providersWhileFetching();
if (view === "providers") loadUpstream();
}
// Accounts' lists still on their way from their vendors (#541: the page no
@@ -4401,6 +4402,73 @@ function providersWhileFetching() {
}, 2500);
}
// What the vendors' own status pages say of the APIs the providers call
// (#971, emo172): a vendor's API degraded or down is marked on its
// providers' rows and usage cards, so its outage isn't taken for a sign-in
// or quota problem. The gateway reads each page at most once in five
// minutes; it is asked when the Providers or Usage page loads, and again
// every five minutes while one stays open. A page that couldn't be read
// marks nothing: unknown is not "all well", and not an outage either.
let upstream = null, upstreamAsking = null;
function loadUpstream() {
if (upstreamAsking) return upstreamAsking;
clearTimeout(loadUpstream.timer);
upstreamAsking = api("upstream").then((u) => {
if (Array.isArray(u?.vendors)) { upstream = u; markUpstream(); }
}, () => {}).finally(() => {
upstreamAsking = null;
loadUpstream.timer = setTimeout(() => { if (!document.hidden && (view === "providers" || view === "usage")) loadUpstream(); }, 5 * 60e3);
});
return upstreamAsking;
}
const UPSTREAM_LEVEL = { degraded: "API degraded", partial: "API outage", major: "API outage", maintenance: "API maintenance" };
const UPSTREAM_PART = { degraded_performance: "degraded performance", partial_outage: "partial outage", major_outage: "major outage", under_maintenance: "under maintenance" };
// the vendor's reading for a provider, when its page says the API is affected
function upstreamOf(id) {
const v = upstream?.providers?.[id];
const s = v && upstream.vendors.find((x) => x.vendor === v);
return s && UPSTREAM_LEVEL[s.level] ? s : null;
}
function upstreamBadge(s) {
const b = el("button", "badge upstream " + s.level, t(UPSTREAM_LEVEL[s.level]));
b.type = "button";
const lines = [t("{vendor}'s status page says its API is affected", { vendor: s.name })];
for (const p of s.parts || []) lines.push("· " + p.name + ": " + t(UPSTREAM_PART[p.status] || p.status));
for (const i of s.incidents || []) lines.push("· " + i.name);
lines.push(t("Click to open the status page"));
b.title = lines.join("\n");
// the incident's own page when it has one, else the vendor's
b.onclick = (e) => { e.stopPropagation(); api("open", { url: s.incidents?.find((i) => i.url)?.url || s.page }).catch(() => {}); };
return b;
}
// marks (or unmarks) what is drawn; the lists call it after each redraw
function markUpstream() {
const put = (box, s, after) => {
if (!box) return;
box.querySelector(":scope > .badge.upstream")?.remove();
if (!s) return;
// a row's name as a box of its own, to be the part cut short
if (!after && box.firstChild?.nodeType === Node.TEXT_NODE) {
const nm = el("span", "nm");
nm.append(box.firstChild);
box.prepend(nm);
}
const b = upstreamBadge(s);
if (after) after.after(b); else box.append(b);
};
for (const row of document.querySelectorAll("#providers .row.provider[data-id], #offProviders .row.provider[data-id]")) {
put(row.querySelector(".name"), upstreamOf(row.dataset.id));
}
for (const card of document.querySelectorAll("#subscriptionUsage > .subscription-card[data-provider]")) {
const head = card.querySelector(":scope > .subscription-head");
put(head, upstreamOf(card.dataset.provider), head?.querySelector(":scope > b"));
}
}
// Rows in the shape of the list while it is first asked for; a reload keeps
// the list it has until the new one is in.
function renderProvidersLoading() {
@@ -4525,6 +4593,7 @@ function renderProviders() {
if (importing) dialog = renderImport(importing);
if (importingApps) dialog = renderImportApps(importingApps);
keptIcons = null;
markUpstream();
view.scrollTop = top; // first: a closing dialog folds into its row where it is
if (dialog) openModal(dialog); else closeModal();
}
@@ -12000,6 +12069,7 @@ function loadQuotas(asked, again) {
.finally(() => {
if (quotasLoading === p) quotasLoading = null;
if (!same) renderQuotas();
if (view === "usage") loadUpstream();
if (reading && !quotasLater && ++quotasTries <= 20) quotasLater = setTimeout(() => { quotasLater = 0; loadQuotas(false, true); }, 1500);
});
p.asked = !!asked;
@@ -12217,6 +12287,7 @@ function renderQuotas() {
if (accts && (folded.size || usageAcctsAll.has(first.provider))) card.append(acctsMore(first.provider, folded.size));
subscriptions.append(card);
}
markUpstream();
restoreFlash();
requestAnimationFrame(focusQuotaCard);
}
+27
View File
@@ -755,6 +755,15 @@ const I18N = {
"no key": "无需密钥",
"needs a key": "需要密钥",
"Switched off": "已关闭",
"API degraded": "API 降级",
"API outage": "API 故障",
"API maintenance": "API 维护中",
"{vendor}'s status page says its API is affected": "{vendor} 的状态页显示其 API 受影响",
"degraded performance": "性能下降",
"partial outage": "部分中断",
"major outage": "严重中断",
"under maintenance": "维护中",
"Click to open the status page": "点击打开状态页",
"Turned off": "已关闭的供应商",
"Show the providers turned off": "展开已关闭的供应商",
"Fold the providers turned off away": "收起已关闭的供应商",
@@ -4184,6 +4193,15 @@ const I18N = {
"no key": "キー不要",
"needs a key": "キーが必要",
"Switched off": "オフ",
"API degraded": "API 低下",
"API outage": "API 障害",
"API maintenance": "API メンテナンス",
"{vendor}'s status page says its API is affected": "{vendor} のステータスページで API に影響が出ています",
"degraded performance": "パフォーマンス低下",
"partial outage": "一部障害",
"major outage": "重大な障害",
"under maintenance": "メンテナンス中",
"Click to open the status page": "クリックでステータスページを開く",
"Turned off": "オフのプロバイダ",
"Show the providers turned off": "オフのプロバイダを表示",
"Fold the providers turned off away": "オフのプロバイダを折りたたむ",
@@ -7621,6 +7639,15 @@ const I18N = {
"no key": "kein Schlüssel",
"needs a key": "braucht einen Schlüssel",
"Switched off": "Ausgeschaltet",
"API degraded": "API eingeschränkt",
"API outage": "API-Ausfall",
"API maintenance": "API-Wartung",
"{vendor}'s status page says its API is affected": "Laut Statusseite von {vendor} ist die API betroffen",
"degraded performance": "eingeschränkte Leistung",
"partial outage": "teilweiser Ausfall",
"major outage": "schwerer Ausfall",
"under maintenance": "in Wartung",
"Click to open the status page": "Klicken, um die Statusseite zu öffnen",
"Turned off": "Ausgeschaltet",
"Show the providers turned off": "Ausgeschaltete Anbieter anzeigen",
"Fold the providers turned off away": "Ausgeschaltete Anbieter einklappen",
+18
View File
@@ -18,6 +18,7 @@ import (
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/update"
"github.com/yetone/magpie/internal/upstream"
)
// The providers page: the vendors the user added, the presets they can add
@@ -705,6 +706,23 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
}
writeJSON(rw, lanes)
})
// what the vendors' own status pages say of the APIs the providers
// call (#971), so a vendor's outage isn't taken for a sign-in or quota
// problem. Asked by the Providers and Usage pages; a page is read at
// most once in upstream.Fresh, and the first look waits a little for it.
mux.HandleFunc("GET /api/upstream", func(rw http.ResponseWriter, r *http.Request) {
of := map[string]string{}
var ids []string
for _, p := range provider.All() {
if v := upstream.VendorOf(p.Chat, p.Responses, p.Anthropic); v != "" {
of[p.ID] = v
if !slices.Contains(ids, v) {
ids = append(ids, v)
}
}
}
writeJSON(rw, map[string]any{"vendors": upstream.Wait(4*time.Second, ids...), "providers": of})
})
mux.HandleFunc("GET /api/providers", func(rw http.ResponseWriter, r *http.Request) {
// ?wait: an account just signed in opens in the editor with its
// vendor's list, worth the wait there (#204)
+10
View File
@@ -830,6 +830,16 @@ reload; DeepSeek's keys fold too, four keys aren't folded, a subscription's
accounts aren't keys, an unread key leaves the sum out, and at 440px nothing
is cut; in Chromium and WebKit, English and Chinese.
`upstream-status.test.cjs` checks the vendors' status pages on the
Providers and Usage pages (#971): with Anthropic's page saying its API is
down, the Anthropic provider's row and the Claude subscription's row and
usage card carry "API outage" beside the name, its title naming the part and
the incident; a click opens the incident and not the row; a relay serving
the same models, OpenAI (its page all well) and Moonshot (its page unread)
carry nothing, and nothing does once the page is well again; at 440px the
badge is whole (the name gives way) and nothing spills; in Chromium and
WebKit, English and Chinese.
`usage-accounts-fold.test.cjs` checks the Usage page's card of a subscription
with many accounts (Linx on X: twenty-odd on one plugin made a very long
card): of 22 WorkBuddy AI accounts the first five are in sight, the rest
+141
View File
@@ -0,0 +1,141 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// #971 (emo172): when a vendor's own status page says its API is degraded or
// down, that vendor's providers say so — a badge on the provider's row and
// on its usage card, beside the name — so an outage isn't taken for a
// sign-in or quota problem. A click opens the incident (or the status page)
// and doesn't open the row. A relay serving the same models isn't marked,
// nor a vendor whose page says all is well, nor one whose page couldn't be
// read (unknown, not an outage). At 440px the badge is whole and nothing
// spills. English and Chinese, Chromium and WebKit; the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const base = { chat: "", responses: "", anthropic: "", catalog: "", routing: "smart", models: [{ id: "m", name: "M", on: true }], agents: [], fallback: [], headers: {}, keyList: [] };
const list = [
{ ...base, id: "anthropic", name: "Anthropic", icon: "anthropic", anthropic: "https://api.anthropic.com", host: "api.anthropic.com", key: { set: true, masked: "sk-ant-…abcd" } },
{ ...base, id: "relay", name: "Claude Relay", icon: "generic", anthropic: "https://relay.example.com", host: "relay.example.com", key: { set: true, masked: "sk-…wxyz" } },
{ ...base, id: "claude", name: "Claude Code", icon: "claudecode-color", anthropic: "https://api.anthropic.com", key: { set: false },
account: { agent: "claude", agentName: "Claude Code", user: "a-rather-long-address@example.com", plan: "max", logins: [{ user: "a-rather-long-address@example.com", plan: "max", active: true, on: true }] } },
{ ...base, id: "codex", name: "Codex", icon: "codex-color", responses: "https://chatgpt.com/backend-api/codex", key: { set: false },
account: { agent: "codex", agentName: "Codex", user: "c@example.com", plan: "PLUS", logins: [{ user: "c@example.com", plan: "PLUS", active: true, on: true }] } },
{ ...base, id: "kimi", name: "Moonshot", icon: "moonshot", chat: "https://api.moonshot.cn/v1", host: "api.moonshot.cn", key: { set: true, masked: "sk-…kimi" } },
];
const quotas = [
{ provider: "claude", name: "Claude Code", kind: "subscription", icon: "claudecode-color", plan: "Max 20x", windows: [{ name: "5 hours", used: 30 }] },
{ provider: "codex", name: "Codex", kind: "subscription", icon: "codex-color", plan: "Plus", windows: [{ name: "5 hours", used: 10 }] },
];
const providersOf = { anthropic: "anthropic", claude: "anthropic", codex: "openai", kimi: "moonshot" };
const outage = {
providers: providersOf,
vendors: [
{ vendor: "anthropic", name: "Anthropic", page: "https://status.claude.com", level: "major", parts: [{ name: "Claude API (api.anthropic.com)", status: "major_outage" }], incidents: [{ name: "Elevated errors on Claude API", impact: "critical", url: "https://stspg.io/abc" }] },
{ vendor: "openai", name: "OpenAI", page: "https://status.openai.com", level: "ok" },
// read and failed: unknown, never marked
{ vendor: "moonshot", name: "Moonshot AI", page: "https://status.moonshot.cn", level: "", error: "answered 503" },
],
};
const allWell = { providers: providersOf, vendors: outage.vendors.map((v) => ({ vendor: v.vendor, name: v.name, page: v.page, level: v.vendor === "moonshot" ? "" : "ok" })) };
function serve(lang, upstream) {
const settings = { theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd" };
const providers = { providers: list, presets: [], excluded: [], gateway: { running: true, window: true } };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings });
if (url.pathname === "/api/settings") return json(settings);
if (url.pathname === "/api/providers") return json(providers);
if (url.pathname === "/api/upstream") return json(upstream.now);
if (url.pathname === "/api/usage/quotas") return json(quotas);
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const words = {
en: { outage: "API outage", says: "Anthropic's status page says its API is affected", part: "major outage", open: "Click to open the status page" },
zh: { outage: "API 故障", says: "Anthropic 的状态页显示其 API 受影响", part: "严重中断", open: "点击打开状态页" },
};
// the badges drawn, by row or card, and whether each is whole and inside
const badges = (page, sel, box) => page.evaluate(([sel, box]) => {
const out = {};
for (const r of document.querySelectorAll(sel)) {
const b = r.querySelector(".badge.upstream");
const id = r.dataset.id || r.dataset.provider;
if (!b) { out[id] = null; continue; }
const at = b.getBoundingClientRect(), clip = b.closest(box).getBoundingClientRect(), name = b.parentElement.getBoundingClientRect();
const whole = at.width > 0 && at.right <= clip.right + 0.5 && at.left >= clip.left - 0.5
&& (!b.parentElement.matches(".name") || at.right <= name.right + 0.5) && b.scrollWidth <= b.clientWidth + 0.5;
out[id] = { text: b.textContent, title: b.title, whole, after: b.previousElementSibling?.tagName };
}
out.sideways = document.documentElement.scrollWidth > innerWidth;
return out;
}, [sel, box]);
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
for (const width of [900, 440]) {
test(`${engine} ${lang} ${width}px: a vendor's API outage is marked on its providers and usage cards, nothing else`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width, height: 800 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const upstream = { now: outage };
await page.route("**/*", serve(lang, upstream));
await page.addInitScript(() => { window.open = (u) => { window.opened = u; return null; }; });
await page.goto("http://magpie.test/?view=providers");
await page.waitForSelector("#providers .row.provider[data-id=anthropic] .badge.upstream");
let got = await badges(page, "#providers .row.provider[data-id]", ".row");
assert.equal(got.anthropic.text, w.outage);
assert.equal(got.claude.text, w.outage, "the Claude subscription calls the same API");
assert.equal(got.relay, null, "a relay isn't the vendor");
assert.equal(got.codex, null, "OpenAI's page says all is well");
assert.equal(got.kimi, null, "a page that couldn't be read is unknown, not an outage");
assert.ok(got.anthropic.title.includes(w.says) && got.anthropic.title.includes("Elevated errors on Claude API")
&& got.anthropic.title.includes(w.part) && got.anthropic.title.includes(w.open), got.anthropic.title);
assert.ok(got.anthropic.whole && got.claude.whole, "the badge is whole: " + JSON.stringify(got));
assert.equal(got.sideways, false);
// a click opens the incident, not the row
await page.click("#providers .row.provider[data-id=anthropic] .badge.upstream");
await page.waitForFunction(() => window.opened);
assert.equal(await page.evaluate(() => window.opened), "https://stspg.io/abc");
assert.equal(await page.locator("#providers .row.provider.selected").count(), 0, "the row didn't open");
assert.equal(await page.locator(".modal:visible, dialog[open]").count(), 0, "no editor");
// the Usage page: on the subscription's card, after its name
await page.goto("http://magpie.test/?view=usage");
await page.waitForSelector("#subscriptionUsage > .subscription-card[data-provider=claude] .badge.upstream");
got = await badges(page, "#subscriptionUsage > .subscription-card[data-provider]", ".subscription-card");
assert.equal(got.claude.text, w.outage);
assert.equal(got.claude.after, "B", "beside the card's name");
assert.ok(got.claude.whole, "the badge is whole: " + JSON.stringify(got.claude));
assert.equal(got.codex, null);
assert.equal(got.sideways, false);
// all well again: nothing is marked
upstream.now = allWell;
await page.goto("http://magpie.test/?view=providers");
await page.waitForSelector("#providers .row.provider[data-id=anthropic]");
await page.waitForTimeout(300);
assert.equal(await page.locator(".badge.upstream").count(), 0);
assert.deepEqual(errors, []);
});
}
}
}
+71
View File
@@ -0,0 +1,71 @@
package gui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/upstream"
)
// /api/upstream says what the vendor's page says for the providers that
// call its API, and nothing for a relay serving the same models (#971)
func TestUpstreamRoute(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"components":[{"id":"api","name":"Claude API (api.anthropic.com)","status":"major_outage","group":false},
{"id":"web","name":"claude.ai","status":"operational","group":false}],
"incidents":[{"name":"Claude API errors","status":"investigating","impact":"critical","shortlink":"https://stspg.io/z","components":[{"id":"api"}]}]}`))
}))
defer page.Close()
was := slices.Clone(upstream.Vendors)
defer func() { upstream.Vendors = was; upstream.Forget() }()
for i := range upstream.Vendors {
upstream.Vendors[i].Summary = "" // no other page is asked
if upstream.Vendors[i].ID == "anthropic" {
upstream.Vendors[i].Summary = page.URL
}
}
upstream.Forget()
for _, p := range []provider.Provider{
{ID: "anth", Name: "Anthropic", Anthropic: "https://api.anthropic.com", Key: "k"},
{ID: "relay", Name: "Relay", Anthropic: "https://relay.example.invalid", Key: "k"},
{ID: "ds", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k"},
} {
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
}
mux := http.NewServeMux()
providerRoutes(mux, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/upstream", nil))
if w.Code != 200 {
t.Fatalf("%d %s", w.Code, w.Body)
}
var got struct {
Vendors []upstream.Status
Providers map[string]string
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Providers["anth"] != "anthropic" || got.Providers["ds"] != "deepseek" || got.Providers["relay"] != "" {
t.Fatalf("providers %v", got.Providers)
}
i := slices.IndexFunc(got.Vendors, func(s upstream.Status) bool { return s.Vendor == "anthropic" })
if i < 0 || got.Vendors[i].Level != "major" || len(got.Vendors[i].Incidents) != 1 || got.Vendors[i].Incidents[0].URL != "https://stspg.io/z" {
t.Fatalf("vendors %+v", got.Vendors)
}
j := slices.IndexFunc(got.Vendors, func(s upstream.Status) bool { return s.Vendor == "deepseek" })
if j < 0 || got.Vendors[j].Level != "" || got.Vendors[j].Page != "https://status.deepseek.com" {
t.Fatalf("deepseek %+v", got.Vendors)
}
}
+10
View File
@@ -0,0 +1,10 @@
package upstream
import (
"testing"
"github.com/yetone/magpie/internal/testenv"
)
// The tests run in a home of their own, never the user's real files.
func TestMain(m *testing.M) { testenv.Main(m) }
+290
View File
@@ -0,0 +1,290 @@
// Package upstream reads the vendors' own status pages (#971, emo172): a
// request that fails while Anthropic's or OpenAI's API is down looks, in
// magpie, like a sign-in or quota problem, and the user signs in again and
// again for nothing. A vendor whose page says its API is degraded or down
// is said so where its providers are listed.
//
// Only the page's own parts that the API magpie calls stands on are read
// (the Claude API and Claude Code, not claude.ai; OpenAI's Chat
// Completions, Responses and Codex, not ChatGPT's or its desktop app's): a vendor's
// app being down says nothing of its API. A page that couldn't be read is
// unknown, never "all well". The pages are read only when the GUI asks,
// at most once in Fresh, and never on a request's way.
package upstream
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"slices"
"strings"
"sync"
"time"
)
// Vendor is a vendor whose status page magpie knows.
type Vendor struct {
ID string
Name string
// Page is the status page people read; Summary its Statuspage-style
// JSON (/api/v2/summary.json), "" when it has none magpie can read
// (DeepSeek's, on Flashduty): the page is then only linked.
Page string
Summary string
// Hosts are the API hosts a provider calls the vendor on.
Hosts []string
// Part says whether a component of the page is one the API stands on.
Part func(name string) bool
}
// Vendors are the ones whose pages were checked by hand (2026-10-06):
// each one's summary.json answered with its components.
var Vendors = []Vendor{
{ID: "anthropic", Name: "Anthropic", Page: "https://status.claude.com",
Summary: "https://status.claude.com/api/v2/summary.json",
Hosts: []string{"api.anthropic.com"},
// "Claude API (api.anthropic.com)", and "Claude Code", which a
// Claude subscription's requests run through
Part: func(n string) bool { return strings.HasPrefix(n, "Claude API") || n == "Claude Code" }},
{ID: "openai", Name: "OpenAI", Page: "https://status.openai.com",
Summary: "https://status.openai.com/api/v2/summary.json",
Hosts: []string{"api.openai.com", "chatgpt.com"},
// a ChatGPT account's Codex requests are Responses requests; the
// page's "Codex in ChatGPT Desktop" is the app, not the API
Part: func(n string) bool {
return slices.Contains([]string{"Chat Completions", "Responses", "Codex", "Codex CLI"}, n)
}},
{ID: "moonshot", Name: "Moonshot AI", Page: "https://status.moonshot.cn",
Summary: "https://status.moonshot.cn/api/v2/summary.json",
Hosts: []string{"api.moonshot.cn", "api.moonshot.ai"},
// the Open API and its models, not Kimi's website or sign-up
Part: func(n string) bool {
return n == "Open API" || n == "API Service" || n == "Model" || strings.HasSuffix(n, " Model")
}},
{ID: "deepseek", Name: "DeepSeek", Page: "https://status.deepseek.com",
Hosts: []string{"api.deepseek.com"}},
}
// VendorOf is the vendor a provider calling these base URLs reaches, ""
// for none magpie knows (a relay, a local server).
func VendorOf(bases ...string) string {
for _, b := range bases {
h := hostOf(b)
for _, v := range Vendors {
if slices.Contains(v.Hosts, h) {
return v.ID
}
}
}
return ""
}
func hostOf(u string) string {
u = strings.TrimSpace(u)
if i := strings.Index(u, "://"); i >= 0 {
u = u[i+3:]
}
if i := strings.IndexAny(u, "/?#"); i >= 0 {
u = u[:i]
}
if i := strings.LastIndex(u, ":"); i >= 0 && !strings.Contains(u[i:], "]") {
u = u[:i]
}
return strings.ToLower(u)
}
// Status is what a vendor's page says of its API.
type Status struct {
Vendor string `json:"vendor"`
Name string `json:"name"`
Page string `json:"page"`
// Level is the worst of its API's parts: "ok", "degraded", "partial"
// (a partial outage), "major" (a major outage) or "maintenance"; ""
// when the page wasn't read (none to read, or it failed: Error).
Level string `json:"level"`
Parts []Part `json:"parts,omitempty"` // the API's parts that aren't operational
Incidents []Incident `json:"incidents,omitempty"` // open incidents on them
Read time.Time `json:"read,omitzero"`
Error string `json:"error,omitempty"`
}
// Part is a component of the page and its state as the page words it
// (degraded_performance, partial_outage, major_outage, under_maintenance).
type Part struct {
Name string `json:"name"`
Status string `json:"status"`
}
// Incident is an open incident the page lists.
type Incident struct {
Name string `json:"name"`
Impact string `json:"impact,omitempty"`
URL string `json:"url,omitempty"`
}
// Fresh is how long a page read is kept: the pages change by the
// minute in an outage, but nobody needs them to the second.
const Fresh = 5 * time.Minute
var (
mu sync.Mutex
kept = map[string]Status{}
asking = map[string]bool{}
// now and client are for tests to stand in for
now = time.Now
client = func() *http.Client { return http.DefaultClient }
)
// Of is what each of these vendors' pages said when last read. One not
// read within Fresh is asked for again in the background; until it
// answers, the last reading (or an unread one) stands. Of never waits.
func Of(ids ...string) []Status {
mu.Lock()
defer mu.Unlock()
var out []Status
for _, id := range ids {
i := slices.IndexFunc(Vendors, func(v Vendor) bool { return v.ID == id })
if i < 0 {
continue
}
v := Vendors[i]
s, ok := kept[id]
if !ok {
s = Status{Vendor: v.ID, Name: v.Name, Page: v.Page}
}
if v.Summary != "" && !asking[id] && (!ok || now().Sub(s.Read) >= Fresh) {
asking[id] = true
go ask(v)
}
out = append(out, s)
}
return out
}
// Wait is Of, after waiting up to d for the pages being asked for: for
// tests, and the GUI's first look.
func Wait(d time.Duration, ids ...string) []Status {
Of(ids...)
for end := time.Now().Add(d); time.Now().Before(end); time.Sleep(20 * time.Millisecond) {
mu.Lock()
busy := slices.ContainsFunc(ids, func(id string) bool { return asking[id] })
mu.Unlock()
if !busy {
break
}
}
return Of(ids...)
}
func ask(v Vendor) {
s := read(v)
mu.Lock()
defer mu.Unlock()
delete(asking, v.ID)
if s.Error != "" {
// a page that failed is unknown, not "all well", and not asked
// again for a while either; what it said before is not kept as
// if it were still so
s.Level = ""
}
kept[v.ID] = s
}
// Forget drops every reading, for tests.
func Forget() {
mu.Lock()
defer mu.Unlock()
kept = map[string]Status{}
}
// summary is the part of a Statuspage summary.json (and incident.io's,
// which OpenAI's is) that is read.
type summary struct {
Components []struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Group *bool `json:"group"`
} `json:"components"`
Incidents []struct {
Name string `json:"name"`
Status string `json:"status"`
Impact string `json:"impact"`
Shortlink string `json:"shortlink"`
Components []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"components"`
} `json:"incidents"`
}
// level ranks a component's status; 0 is operational (or one unknown).
var level = map[string]int{"under_maintenance": 1, "degraded_performance": 2, "partial_outage": 3, "major_outage": 4}
var levelName = []string{"ok", "maintenance", "degraded", "partial", "major"}
func read(v Vendor) Status {
s := Status{Vendor: v.ID, Name: v.Name, Page: v.Page, Read: now()}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", v.Summary, nil)
if err != nil {
s.Error = err.Error()
return s
}
req.Header.Set("Accept", "application/json")
res, err := client().Do(req)
if err != nil {
s.Error = err.Error()
return s
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, 4<<20))
if err != nil {
s.Error = err.Error()
return s
}
if res.StatusCode != http.StatusOK {
s.Error = fmt.Sprintf("%s answered %d", v.Page, res.StatusCode)
return s
}
var sum summary
if err := json.Unmarshal(body, &sum); err != nil || sum.Components == nil {
s.Error = fmt.Sprintf("%s's summary couldn't be read", v.Page)
return s
}
worst, mine := 0, map[string]bool{}
for _, c := range sum.Components {
if c.Group != nil && *c.Group || !v.Part(c.Name) {
continue
}
mine[c.ID], mine[c.Name] = true, true
if n := level[c.Status]; n > 0 {
worst = max(worst, n)
s.Parts = append(s.Parts, Part{c.Name, c.Status})
}
}
if len(mine) == 0 {
// none of the parts it is known by: the page changed, and what
// it says can't be told for the API
s.Error = fmt.Sprintf("%s lists none of its API's parts", v.Page)
return s
}
s.Level = levelName[worst]
for _, in := range sum.Incidents {
if in.Status == "resolved" || in.Status == "postmortem" {
continue
}
// one on the API's parts; one that names no parts (OpenAI's do
// not) only while those parts are said to be affected
on := len(in.Components) == 0 && worst > 0
for _, c := range in.Components {
on = on || mine[c.ID] || mine[c.Name]
}
if on {
s.Incidents = append(s.Incidents, Incident{in.Name, in.Impact, in.Shortlink})
}
}
return s
}
+237
View File
@@ -0,0 +1,237 @@
package upstream
import (
"net/http"
"net/http/httptest"
"slices"
"sync/atomic"
"testing"
"time"
)
// claudePage is status.claude.com's summary.json as it was read on
// 2026-10-06, its components' states set by the test.
func claudePage(api, code, web string, incidents string) string {
return `{"page":{"id":"tymt9n04zgry","name":"Claude","url":"https://status.claude.com"},
"status":{"indicator":"none","description":"All Systems Operational"},
"components":[{"id":"rwppv331jlwc","name":"claude.ai","status":"` + web + `","group":false},
{"id":"0qbwn08sd68x","name":"Claude Console (platform.claude.com)","status":"operational","group":false},
{"id":"k8w3r06qmzrp","name":"Claude API (api.anthropic.com)","status":"` + api + `","group":false},
{"id":"yyzkbfz2thpt","name":"Claude Code","status":"` + code + `","group":false}],
"incidents":[` + incidents + `]}`
}
// openAIPage is status.openai.com's (incident.io): no groups, and
// incidents that name no components.
func openAIPage(responses, desktop string, incidents string) string {
return `{"page":{"id":"01JMDK9XYNY6RXSED6SDWW50WY","name":"OpenAI","url":"https://status.openai.com/"},
"status":{"description":"Partial System Degradation","indicator":"minor"},
"components":[{"id":"01JMXBRMFE6N2NNT7DG6XZQ6PW","name":"Chat Completions","status":"operational","group":null},
{"id":"01JP8CD9JR3HR6Y7G4Q75N4DVW","name":"Responses","status":"` + responses + `","group":null},
{"id":"01KMKFAMWKQ81YWSE1Z18R6VHR","name":"Codex in ChatGPT Desktop","status":"` + desktop + `","group":null},
{"id":"01JMXBNJXGGT5SR5DB9J7GYY48","name":"Voice mode","status":"major_outage","group":null}],
"incidents":[` + incidents + `]}`
}
// serve points the vendors at a fake page and counts what it is asked.
func serve(t *testing.T, id string, body *atomic.Value, code *atomic.Int32) *atomic.Int32 {
t.Helper()
var asked atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
asked.Add(1)
if c := code.Load(); c != 0 && c != 200 {
w.WriteHeader(int(c))
return
}
w.Write([]byte(body.Load().(string)))
}))
t.Cleanup(srv.Close)
was := slices.Clone(Vendors)
t.Cleanup(func() { Vendors = was; Forget() })
for i := range Vendors {
if Vendors[i].ID == id {
Vendors[i].Summary = srv.URL + "/api/v2/summary.json"
}
}
Forget()
return &asked
}
func one(t *testing.T, id string) Status {
t.Helper()
s := Wait(5*time.Second, id)
if len(s) != 1 {
t.Fatalf("%d statuses for %s", len(s), id)
}
return s[0]
}
func TestClaudeAPIDownIsSaid(t *testing.T) {
var body atomic.Value
var code atomic.Int32
body.Store(claudePage("partial_outage", "operational", "operational",
`{"name":"Elevated errors on Claude API","status":"investigating","impact":"major","shortlink":"https://stspg.io/x","components":[{"id":"k8w3r06qmzrp","name":"Claude API (api.anthropic.com)"}]},
{"name":"claude.ai login issues","status":"investigating","impact":"minor","shortlink":"https://stspg.io/y","components":[{"id":"rwppv331jlwc","name":"claude.ai"}]},
{"name":"Old one","status":"resolved","impact":"major","components":[{"id":"k8w3r06qmzrp"}]}`))
serve(t, "anthropic", &body, &code)
s := one(t, "anthropic")
if s.Level != "partial" || s.Error != "" {
t.Fatalf("level %q (error %q), want partial", s.Level, s.Error)
}
if len(s.Parts) != 1 || s.Parts[0].Name != "Claude API (api.anthropic.com)" {
t.Fatalf("parts %+v", s.Parts)
}
// the API's own incident, not claude.ai's, not a resolved one
if len(s.Incidents) != 1 || s.Incidents[0].Name != "Elevated errors on Claude API" || s.Incidents[0].URL != "https://stspg.io/x" {
t.Fatalf("incidents %+v", s.Incidents)
}
if s.Page != "https://status.claude.com" {
t.Fatalf("page %q", s.Page)
}
}
// claude.ai down says nothing of the API
func TestClaudeAppDownIsNotTheAPI(t *testing.T) {
var body atomic.Value
var code atomic.Int32
body.Store(claudePage("operational", "operational", "major_outage",
`{"name":"claude.ai down","status":"identified","impact":"critical","components":[{"id":"rwppv331jlwc","name":"claude.ai"}]}`))
serve(t, "anthropic", &body, &code)
if s := one(t, "anthropic"); s.Level != "ok" || len(s.Parts) != 0 || len(s.Incidents) != 0 {
t.Fatalf("got %+v, want ok with nothing listed", s)
}
}
// Claude Code is what a Claude subscription's requests run through
func TestClaudeCodeDegraded(t *testing.T) {
var body atomic.Value
var code atomic.Int32
body.Store(claudePage("operational", "degraded_performance", "operational", ""))
serve(t, "anthropic", &body, &code)
if s := one(t, "anthropic"); s.Level != "degraded" {
t.Fatalf("level %q, want degraded", s.Level)
}
}
// OpenAI's page as it was on 2026-10-06: its desktop app's Codex
// degraded and voice mode down, the API well — and an incident that
// names no parts is the API's only while the API's parts are affected.
func TestOpenAIAppIsNotTheAPI(t *testing.T) {
var body atomic.Value
var code atomic.Int32
inc := `{"name":"Elevated Work Mode errors","status":"monitoring","impact":"minor","shortlink":null,"components":null}`
body.Store(openAIPage("operational", "degraded_performance", inc))
serve(t, "openai", &body, &code)
if s := one(t, "openai"); s.Level != "ok" || len(s.Incidents) != 0 {
t.Fatalf("got %+v, want ok", s)
}
body.Store(openAIPage("major_outage", "operational", inc))
Forget()
s := one(t, "openai")
if s.Level != "major" || len(s.Incidents) != 1 || s.Incidents[0].Name != "Elevated Work Mode errors" {
t.Fatalf("got %+v, want major with its incident", s)
}
}
// a page that fails is unknown, never "all well", and it isn't kept
// saying the outage it said before either
func TestFailedPageIsUnknown(t *testing.T) {
var body atomic.Value
var code atomic.Int32
body.Store(claudePage("major_outage", "operational", "operational", ""))
serve(t, "anthropic", &body, &code)
if s := one(t, "anthropic"); s.Level != "major" {
t.Fatalf("level %q", s.Level)
}
code.Store(503)
defer func(n func() time.Time) { now = n }(now)
now = func() time.Time { return time.Now().Add(Fresh) }
s := one(t, "anthropic")
if s.Level != "" || s.Error == "" {
t.Fatalf("got %+v, want unknown with an error", s)
}
code.Store(0)
body.Store(`<html>Just a moment...</html>`)
now = func() time.Time { return time.Now().Add(3 * Fresh) }
if s := one(t, "anthropic"); s.Level != "" || s.Error == "" {
t.Fatalf("got %+v from a page that isn't JSON", s)
}
// a page that no longer lists the API's parts can't say anything of it
body.Store(`{"components":[{"id":"a","name":"Something else","status":"operational"}],"incidents":[]}`)
now = func() time.Time { return time.Now().Add(5 * Fresh) }
if s := one(t, "anthropic"); s.Level != "" || s.Error == "" {
t.Fatalf("got %+v from a page without the API's parts", s)
}
}
// a page is read once in Fresh however often it is asked for, and the
// asking never waits for it
func TestReadOnceInFresh(t *testing.T) {
var body atomic.Value
var code atomic.Int32
body.Store(claudePage("operational", "operational", "operational", ""))
slow := make(chan struct{})
asked := serve(t, "anthropic", &body, &code)
one(t, "anthropic")
for range 20 {
Of("anthropic")
}
if n := asked.Load(); n != 1 {
t.Fatalf("asked %d times within Fresh, want 1", n)
}
// stale: asked again, and Of answers at once with the last reading
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-slow
w.Write([]byte(claudePage("major_outage", "operational", "operational", "")))
}))
defer srv.Close()
for i := range Vendors {
if Vendors[i].ID == "anthropic" {
Vendors[i].Summary = srv.URL
}
}
defer func(n func() time.Time) { now = n }(now)
now = func() time.Time { return time.Now().Add(Fresh) }
start := time.Now()
s := Of("anthropic")
if took := time.Since(start); took > 100*time.Millisecond {
t.Fatalf("Of waited %v for the page", took)
}
if s[0].Level != "ok" {
t.Fatalf("level %q while the page is read again, want the last one", s[0].Level)
}
close(slow)
if s := one(t, "anthropic"); s.Level != "major" {
t.Fatalf("level %q once read again, want major", s.Level)
}
}
// DeepSeek's page has nothing to read: it is only linked, never asked
func TestLinkedOnly(t *testing.T) {
s := Of("deepseek")
if len(s) != 1 || s[0].Level != "" || s[0].Error != "" || s[0].Page != "https://status.deepseek.com" {
t.Fatalf("got %+v", s)
}
}
func TestVendorOf(t *testing.T) {
for _, c := range []struct {
bases []string
want string
}{
{[]string{"", "", "https://api.anthropic.com"}, "anthropic"},
{[]string{"https://api.openai.com/v1"}, "openai"},
{[]string{"", "https://chatgpt.com/backend-api/codex"}, "openai"},
{[]string{"https://api.deepseek.com/v1", "", "https://api.deepseek.com/anthropic"}, "deepseek"},
{[]string{"https://api.moonshot.cn/v1"}, "moonshot"},
{[]string{"https://API.Moonshot.ai:443/v1"}, "moonshot"},
// a relay that serves the same models is not the vendor
{[]string{"https://relay.example.com/v1", "", "https://relay.example.com"}, ""},
{[]string{"https://api.anthropic.com.evil.example/v1"}, ""},
{[]string{"http://127.0.0.1:11434/v1"}, ""},
} {
if got := VendorOf(c.bases...); got != c.want {
t.Errorf("VendorOf(%q) = %q, want %q", c.bases, got, c.want)
}
}
}