diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 30125a54..107e0774 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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); } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index b0b90555..217b1145 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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); } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 2b56f133..c41e1d37 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index dc5f240d..d57482dd 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -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) diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 6d8997a3..6bc6817e 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -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 diff --git a/internal/gui/tests/upstream-status.test.cjs b/internal/gui/tests/upstream-status.test.cjs new file mode 100644 index 00000000..d9306869 --- /dev/null +++ b/internal/gui/tests/upstream-status.test.cjs @@ -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, []); + }); + } + } +} diff --git a/internal/gui/upstream_test.go b/internal/gui/upstream_test.go new file mode 100644 index 00000000..01e1149c --- /dev/null +++ b/internal/gui/upstream_test.go @@ -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) + } +} diff --git a/internal/upstream/main_test.go b/internal/upstream/main_test.go new file mode 100644 index 00000000..e02d7fe7 --- /dev/null +++ b/internal/upstream/main_test.go @@ -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) } diff --git a/internal/upstream/upstream.go b/internal/upstream/upstream.go new file mode 100644 index 00000000..94195d14 --- /dev/null +++ b/internal/upstream/upstream.go @@ -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 +} diff --git a/internal/upstream/upstream_test.go b/internal/upstream/upstream_test.go new file mode 100644 index 00000000..66f12210 --- /dev/null +++ b/internal/upstream/upstream_test.go @@ -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(`Just a moment...`) + 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) + } + } +}