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:
1 parent
62f9612ba6
commit
2a990175e5
10 files changed
+887
No files matched your search
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user