* Add German translation and harden paths against unset env vars Add a German locale to the GUI, tray menu, usage alerts and website home page, and refuse to start when the home folder is unset or relative so nothing is written under the working folder; the pi plugin no longer adopts the user's PI_CODING_AGENT_DIR, and an inherited APPIMAGE no longer selects portable mode. Scripts refuse to run from the wrong folder or empty a shared out folder, and a new internal/testenv sandboxes every test package from the user's real files, which also fixes the Claude picker and Termux installer tests on Windows. * Makefile: know magpie's folder by its go.mod, so paths with spaces work The check compared $(CURDIR) with $(dir $(abspath …)), which make splits on spaces, so a checkout under 'My Projects' or 'John Doe' refused every target. It now reads go.mod relative to the working folder and wants magpie's module there: make -C and a symlinked Makefile work, make -f from another folder still stops before any recipe runs. * i18n: German for the strings main added since, with a parity test 75 strings main added (group order, patterns, slow-start wait, usage caps, RTK savings …) get their German, and two that main dropped go. gui-de.test.cjs checks, as gui-ja does, that de has every zh string with its placeholders and tags, and that the window reads in German when picked or on a German system. * appdir: ignore relative folder variables when reading, keep them for children CheckEnv no longer unsets anything. magpie reads the XDG folders, APPDATA/LOCALAPPDATA and every agent's folder variable through appdir.Getenv/LookupEnv, which pass over a relative value; the environment stays as it is, so the agents magpie starts get the variable as the user set it. That covers what proc.UserPath lends from the login shell too, which is set after main's check and reached the desktop app unchecked. On Windows a path rooted on the current drive (\Users\x, Git Bash's /c/x) counts as usable, and magpie version and help answer without a home, before run makes any folder. * Sandbox the tests main added since internal/wslrun gets testenv's TestMain, and four tests main added that set HOME alone set their home with testenv.SetHome, so on Windows they don't read the runner's profile through USERPROFILE. * agentenv: PI_CONFIG_DIR and OPENCODE_DB take relative names on purpose omp puts $PI_CONFIG_DIR under the home and OpenCode a relative $OPENCODE_DB in its data folder, so neither depends on the working folder: appdir no longer passes them over. * i18n: German for the Library's multi-select and System One's decision API The 19 strings main added since (#791's skill multi-select, the decision API snippets) get their German; the one main replaced (four APIs → five APIs) goes. * appdir: tidy the import into magpie's group; say what Go makes of /c/x The appdir import joins the group of magpie's own imports where a file has one. rooted's comment now says that Go takes a Git Bash path like /c/x as \c\x on the current drive, not C:\x.
1145 lines
92 KiB
HTML
1145 lines
92 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>magpie</title>
|
||
<meta name="description" data-i18n-attr="content:meta.desc" content="One place to pick every agent's model. Codex on DeepSeek, Claude Code on Kimi, Gemini CLI on GLM, from the menu bar, through one local gateway.">
|
||
<meta name="color-scheme" content="light dark">
|
||
<meta name="theme-color" content="#fafafa">
|
||
<script>
|
||
// The chosen theme (light, dark, or the system's) is on <html data-theme>
|
||
// before anything paints.
|
||
// The page is English, or the language the worker served it in at /zh/,
|
||
// /ja/ or /de/; T(en, zh, ja, de) is a script's words in it.
|
||
window.LANG = document.documentElement.lang.slice(0, 2);
|
||
window.T = (en, zh, ja, de) => ({ zh, ja, de })[LANG] ?? en;
|
||
window.themeTitle = (m) => T("Theme: " + m, "主题:" + { system: "跟随系统", light: "浅色", dark: "深色" }[m], "テーマ:" + { system: "システム", light: "ライト", dark: "ダーク" }[m], "Farbschema: " + { system: "System", light: "Hell", dark: "Dunkel" }[m]);
|
||
(() => {
|
||
let mode = "system";
|
||
try { mode = localStorage.getItem("theme") || "system"; } catch {}
|
||
const q = matchMedia("(prefers-color-scheme: dark)");
|
||
const apply = () => {
|
||
const t = mode === "system" ? (q.matches ? "dark" : "light") : mode;
|
||
document.documentElement.dataset.theme = t;
|
||
document.querySelector('meta[name="theme-color"]').content = t === "dark" ? "#0b0b0c" : "#fafafa";
|
||
document.querySelectorAll("button.theme:not(.menu):not(.lang)").forEach((b) => { b.dataset.mode = mode; b.title = themeTitle(mode); });
|
||
};
|
||
q.addEventListener("change", () => mode === "system" && apply());
|
||
window.setTheme = (m) => { mode = m; try { m === "system" ? localStorage.removeItem("theme") : localStorage.setItem("theme", m); } catch {} apply(); };
|
||
window.themeMode = () => mode;
|
||
apply();
|
||
})();
|
||
</script>
|
||
<script>try{if(/Linux/.test(navigator.userAgent)&&!/Android/.test(navigator.userAgent)||localStorage.getItem("omarchy")||/[?&]omarchy=/.test(location.search))document.write('<script src="/omarchy.js"><\/script>')}catch(e){}</script>
|
||
<link rel="icon" href="/favicon.png" type="image/png">
|
||
<link rel="apple-touch-icon" href="/img/icon-256.png">
|
||
<meta property="og:title" content="magpie">
|
||
<meta property="og:description" data-i18n-attr="content:meta.tagline" content="Every agent’s model. One place.">
|
||
<meta property="og:image" content="https://usemagpie.ai/img/og.png">
|
||
<meta property="og:image:width" content="2400">
|
||
<meta property="og:image:height" content="1260">
|
||
<meta property="og:image:alt" data-i18n-attr="content:meta.ogalt" content="magpie: every agent’s model, one place">
|
||
<meta name="twitter:title" content="magpie">
|
||
<meta name="twitter:description" data-i18n-attr="content:meta.tagline" content="Every agent’s model. One place.">
|
||
<meta name="twitter:image" content="https://usemagpie.ai/img/og.png">
|
||
<meta property="og:url" data-i18n-attr="content:meta.url" content="https://usemagpie.ai/">
|
||
<link rel="canonical" data-i18n-attr="href:meta.url" href="https://usemagpie.ai/">
|
||
<link rel="alternate" hreflang="en" href="https://usemagpie.ai/">
|
||
<link rel="alternate" hreflang="zh" href="https://usemagpie.ai/zh/">
|
||
<link rel="alternate" hreflang="ja" href="https://usemagpie.ai/ja/">
|
||
<link rel="alternate" hreflang="de" href="https://usemagpie.ai/de/">
|
||
<link rel="alternate" hreflang="x-default" href="https://usemagpie.ai/">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<style>
|
||
:root {
|
||
--bg: #fafafa;
|
||
--bg2: #f1f1f2;
|
||
--card: #ffffff;
|
||
--ink: #111112;
|
||
--ink2: #4a4a50;
|
||
--ink3: #85858c;
|
||
--line: #e4e4e7;
|
||
--btn: #111112;
|
||
--btn-ink: #fafafa;
|
||
--code: #f4f4f5;
|
||
--shadow: 0 1px 2px rgba(0,0,0,.04), 0 12px 40px -12px rgba(0,0,0,.18);
|
||
}
|
||
:root[data-theme="dark"] {
|
||
--bg: #0b0b0c;
|
||
--bg2: #121214;
|
||
--card: #161618;
|
||
--ink: #f2f2f3;
|
||
--ink2: #b3b3ba;
|
||
--ink3: #7a7a82;
|
||
--line: #26262a;
|
||
--btn: #f2f2f3;
|
||
--btn-ink: #0b0b0c;
|
||
--code: #151517;
|
||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 20px 60px -20px rgba(0,0,0,.8);
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
--bg: #0b0b0c;
|
||
--bg2: #121214;
|
||
--card: #161618;
|
||
--ink: #f2f2f3;
|
||
--ink2: #b3b3ba;
|
||
--ink3: #7a7a82;
|
||
--line: #26262a;
|
||
--btn: #f2f2f3;
|
||
--btn-ink: #0b0b0c;
|
||
--code: #151517;
|
||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 20px 60px -20px rgba(0,0,0,.8);
|
||
}
|
||
}
|
||
[data-theme="dark"] .bird { filter: invert(1); }
|
||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .bird { filter: invert(1); } }
|
||
.for-dark { display: none !important; }
|
||
[data-theme="dark"] .for-light { display: none !important; }
|
||
[data-theme="dark"] .for-dark { display: block !important; }
|
||
.theme { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--line); background: transparent; color: var(--ink2); cursor: pointer; padding: 0; }
|
||
.theme:hover { color: var(--ink); }
|
||
.theme svg { width: 16px; height: 16px; display: none; }
|
||
.theme.gh svg { display: block; }
|
||
nav .wrap > .gh, nav .wrap > .langs { margin-right: -18px; }
|
||
.theme[data-mode="system"] .i-system, .theme[data-mode="light"] .i-light, .theme[data-mode="dark"] .i-dark { display: block; }
|
||
* { box-sizing: border-box; }
|
||
html { -webkit-text-size-adjust: 100%; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", system-ui, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
a { color: inherit; }
|
||
code, pre, kbd { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
|
||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
|
||
|
||
nav { position: sticky; top: 0; z-index: 10; backdrop-filter: saturate(1.4) blur(14px); -webkit-backdrop-filter: saturate(1.4) blur(14px); background: color-mix(in srgb, var(--bg) 78%, transparent); border-bottom: 1px solid transparent; transition: border-color .2s; }
|
||
nav.scrolled { border-bottom-color: var(--line); }
|
||
nav .wrap { display: flex; align-items: center; gap: 28px; height: 60px; }
|
||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 17px; letter-spacing: -.01em; text-decoration: none; }
|
||
.brand img { width: 24px; height: 24px; }
|
||
nav .links { display: flex; gap: 24px; margin-left: auto; font-size: 14px; color: var(--ink2); }
|
||
nav .links a { text-decoration: none; }
|
||
nav .links a:hover { color: var(--ink); }
|
||
nav .links .in-menu, nav .menu { display: none; }
|
||
.langs { position: relative; }
|
||
.theme.lang svg { display: block; }
|
||
.lang-menu { position: absolute; right: 0; top: calc(100% + 8px); z-index: 11; display: grid; min-width: 150px; padding: 4px; background: var(--card); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); }
|
||
.lang-menu[hidden] { display: none; }
|
||
.lang-menu a { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 6px; font-size: 14px; color: var(--ink2); text-decoration: none; }
|
||
.lang-menu a:hover, .lang-menu a:focus-visible { background: var(--code); color: var(--ink); outline: none; }
|
||
.lang-menu a[aria-current] { color: var(--ink); font-weight: 560; }
|
||
.lang-menu a[aria-current]::after { content: "✓"; font-weight: 400; }
|
||
.menu svg { width: 16px; height: 16px; display: block; }
|
||
.menu .i-close, nav.open .menu .i-open { display: none; }
|
||
nav.open .menu .i-close { display: block; }
|
||
:lang(zh) .hero h1, :lang(zh) h2, :lang(ja) .hero h1, :lang(ja) h2 { letter-spacing: -.01em; }
|
||
:lang(zh) .eyebrow, :lang(zh) .agents p, :lang(zh) .sponsor p, :lang(ja) .eyebrow, :lang(ja) .agents p, :lang(ja) .sponsor p { letter-spacing: .04em; }
|
||
/* Japanese kana and kanji in Japanese faces, not the Chinese ones above */
|
||
:lang(ja) body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic UI", "Yu Gothic", Meiryo, "Noto Sans CJK JP", system-ui, sans-serif; }
|
||
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 0 18px; height: 42px; border-radius: 10px; background: var(--btn); color: var(--btn-ink); text-decoration: none; font-weight: 560; font-size: 15px; white-space: nowrap; border: 1px solid var(--btn); transition: transform .12s, opacity .12s; }
|
||
.btn:hover { opacity: .88; }
|
||
.btn:active { transform: translateY(1px); }
|
||
.btn.small { height: 34px; padding: 0 14px; font-size: 14px; border-radius: 8px; }
|
||
.btn.ghost { background: transparent; color: var(--ink); border-color: var(--line); }
|
||
.btn svg { width: 16px; height: 16px; }
|
||
|
||
.hero { padding: 80px 0 32px; display: grid; grid-template-columns: 1.05fr .95fr; gap: 56px; align-items: center; }
|
||
.hero h1 { font-size: clamp(40px, 6vw, 64px); line-height: 1.04; letter-spacing: -.035em; font-weight: 700; margin: 0 0 22px; }
|
||
.hero h1 span { color: var(--ink3); }
|
||
.hero p.lede { font-size: 19px; color: var(--ink2); margin: 0 0 32px; max-width: 30em; }
|
||
.cta { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||
.meta { margin-top: 14px; font-size: 13px; color: var(--ink3); }
|
||
.meta a { color: var(--ink2); }
|
||
.shot { position: relative; }
|
||
.shot picture img { width: 100%; height: auto; height: auto; max-width: 440px; display: block; margin: 0 auto; border-radius: 14px; box-shadow: var(--shadow); border: 1px solid var(--line); }
|
||
|
||
.sponsor { padding: 24px 0 8px; text-align: center; }
|
||
.sponsor p { font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink3); margin: 0 0 14px; }
|
||
.sponsor .card { display: inline-flex; align-items: center; gap: 20px; max-width: 100%; padding: 12px 20px 12px 22px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; text-decoration: none; text-align: left; transition: border-color .2s, box-shadow .2s, transform .2s; }
|
||
.sponsor .card:hover { border-color: color-mix(in srgb, var(--ink) 18%, var(--line)); box-shadow: var(--shadow); transform: translateY(-1px); }
|
||
.sponsor .card img { flex: none; width: 124px; height: auto; opacity: .92; }
|
||
.sponsor .card hr { flex: none; width: 1px; height: 28px; margin: 0; border: 0; background: var(--line); }
|
||
.sponsor .card span { font-size: 14px; line-height: 1.5; color: var(--ink2); }
|
||
.sponsor .card i { flex: none; font-style: normal; color: var(--ink3); transition: color .2s, transform .2s; }
|
||
.sponsor .card:hover i { color: var(--ink); transform: translateX(2px); }
|
||
.sponsor .more { display: inline-block; margin-top: 10px; font-size: 12.5px; color: var(--ink3); text-decoration: none; }
|
||
.sponsor .more:hover { color: var(--ink2); }
|
||
|
||
.community { text-align: center; padding: 110px 0 0; }
|
||
.community a { display: inline-flex; align-items: center; gap: 16px; max-width: 100%; padding: 16px 22px 16px 16px; background: var(--card); border: 1px solid var(--line); border-radius: 16px; text-decoration: none; text-align: left; transition: border-color .2s, box-shadow .2s, transform .2s; }
|
||
.community a:hover { border-color: color-mix(in srgb, var(--ink) 18%, var(--line)); box-shadow: var(--shadow); transform: translateY(-1px); }
|
||
.community .glyph { flex: none; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; color: #fff; background: #5865f2; }
|
||
.community .glyph svg { width: 24px; height: 24px; }
|
||
.community b { display: block; font-size: 15px; font-weight: 600; color: var(--ink); }
|
||
.community span { display: block; font-size: 13.5px; line-height: 1.5; color: var(--ink2); }
|
||
.community i { flex: none; margin-left: 8px; font-style: normal; color: var(--ink3); transition: color .2s, transform .2s; }
|
||
.community a:hover i { color: var(--ink); transform: translateX(2px); }
|
||
.community + .final { padding-top: 90px; }
|
||
|
||
.agents { padding: 40px 0 16px; text-align: center; }
|
||
.agents p { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink3); margin: 0 0 22px; }
|
||
.agents ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 22px; }
|
||
.agents li { display: flex; align-items: center; gap: 8px; font-size: 14.5px; color: var(--ink2); }
|
||
.agents img { width: 22px; height: 22px; border-radius: 5px; }
|
||
|
||
section { padding: 88px 0 0; }
|
||
.eyebrow { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink3); margin: 0 0 12px; }
|
||
h2 { font-size: clamp(30px, 4vw, 42px); line-height: 1.1; letter-spacing: -.03em; margin: 0 0 16px; font-weight: 680; }
|
||
.sub { font-size: 18px; color: var(--ink2); max-width: 36em; margin: 0 0 40px; }
|
||
|
||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
|
||
.grid > div { background: var(--card); padding: 28px 26px 30px; }
|
||
.grid h3 { margin: 0 0 8px; font-size: 16.5px; letter-spacing: -.01em; font-weight: 640; }
|
||
.grid p { margin: 0; color: var(--ink2); font-size: 15px; }
|
||
.grid code { font-size: 13px; background: var(--code); padding: 1px 5px; border-radius: 4px; }
|
||
|
||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center; }
|
||
.split img { width: 100%; height: auto; height: auto; max-width: 440px; display: block; margin: 0 auto; border-radius: 14px; box-shadow: var(--shadow); border: 1px solid var(--line); }
|
||
.split ul { padding: 0; margin: 0; list-style: none; }
|
||
.split li { padding: 14px 0; border-top: 1px solid var(--line); color: var(--ink2); font-size: 15.5px; }
|
||
.split li b { color: var(--ink); font-weight: 600; }
|
||
|
||
pre { margin: 0; background: var(--code); border: 1px solid var(--line); border-radius: 12px; padding: 20px 22px; overflow-x: auto; font-size: 13.5px; line-height: 1.75; color: var(--ink); }
|
||
pre .c { color: var(--ink3); }
|
||
.table { width: 100%; border-collapse: collapse; font-size: 14.5px; }
|
||
.table th, .table td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--line); }
|
||
.table th { font-weight: 560; color: var(--ink3); font-size: 13px; }
|
||
.table td:first-child { font-weight: 560; }
|
||
.table code { font-size: 13px; }
|
||
|
||
.install { display: flex; align-items: center; gap: 12px; background: var(--code); border: 1px solid var(--line); border-radius: 12px; padding: 6px 6px 6px 18px; max-width: 560px; }
|
||
.install code { flex: 1; font-size: 14px; overflow-x: auto; white-space: nowrap; }
|
||
.install code::before { content: "$ "; color: var(--ink3); }
|
||
.copy { flex: none; height: 34px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--ink); font: inherit; font-size: 13px; cursor: pointer; }
|
||
.copy:hover { border-color: var(--ink3); }
|
||
|
||
.final { text-align: center; padding: 120px 0 100px; }
|
||
.final img { width: 88px; height: 88px; margin-bottom: 18px; }
|
||
.final .cta { justify-content: center; }
|
||
.btn .os-windows, .btn .os-linux { display: none; }
|
||
html[data-os="windows"] .btn .os-mac, html[data-os="linux"] .btn .os-mac { display: none; }
|
||
html[data-os="windows"] .btn .os-windows, html[data-os="linux"] .btn .os-linux { display: block; }
|
||
.plats { display: flex; justify-content: center; flex-wrap: wrap; gap: 12px; margin-top: 40px; scroll-margin-top: 90px; }
|
||
.plats > div { display: grid; grid-template-columns: auto auto; gap: 4px 14px; align-items: baseline; text-align: left; padding: 14px 18px; flex: 0 1 280px; min-width: 250px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); font-size: 13.5px; }
|
||
.plats b { font-weight: 600; font-size: 14.5px; color: var(--ink); }
|
||
.plats span { color: var(--ink3); font-size: 12.5px; justify-self: end; }
|
||
.plats a { color: var(--ink2); text-decoration: none; }
|
||
.plats a:hover { color: var(--ink); text-decoration: underline; }
|
||
footer { border-top: 1px solid var(--line); padding: 28px 0 40px; font-size: 13.5px; color: var(--ink3); }
|
||
footer .wrap { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; }
|
||
footer .links { margin-left: auto; display: flex; gap: 20px; }
|
||
footer a { text-decoration: none; }
|
||
footer a:hover { color: var(--ink); }
|
||
|
||
.shot.stack { position: relative; }
|
||
.shot img.pick { width: 100%; height: auto; height: auto; max-width: 440px; display: block; margin: 0 auto; border-radius: 14px; box-shadow: var(--shadow); border: 1px solid var(--line); }
|
||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 30px; }
|
||
.chip { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; color: var(--ink2); background: var(--card); border: 1px solid var(--line); border-radius: 999px; padding: 5px 12px 5px 8px; }
|
||
.chip img { width: 17px; height: 17px; }
|
||
.chip b { color: var(--ink); font-weight: 580; }
|
||
.chip i { font-style: normal; color: var(--ink3); }
|
||
|
||
.flow { display: grid; grid-template-columns: 1fr auto 1.1fr auto 1fr; gap: 18px; align-items: center; }
|
||
.col { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 18px; }
|
||
.col h4 { margin: 0 0 12px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink3); font-weight: 560; }
|
||
.col ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 9px; }
|
||
.col li { display: flex; align-items: center; gap: 9px; font-size: 14.5px; }
|
||
.col li img { width: 18px; height: 18px; }
|
||
.col li span { color: var(--ink3); font-size: 13px; margin-left: auto; }
|
||
.hub { background: var(--card); color: var(--ink); border: 1px solid color-mix(in srgb, var(--ink) 22%, var(--line)); border-radius: 18px; padding: 24px 22px; text-align: center; box-shadow: var(--shadow); }
|
||
.hub img { width: 44px; height: 44px; }
|
||
.hub b { display: block; font-size: 18px; margin: 8px 0 4px; letter-spacing: -.01em; }
|
||
.hub code { font-size: 12.5px; color: var(--ink3); }
|
||
.hub ul { list-style: none; padding: 0; margin: 16px 0 0; display: grid; gap: 6px; font-size: 13px; color: var(--ink2); }
|
||
.hub li { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; }
|
||
.arrow { color: var(--ink3); font-size: 22px; }
|
||
|
||
|
||
@keyframes live { 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
|
||
|
||
.rt { background: var(--card); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); padding: 18px 20px 16px; margin: 0 0 28px; }
|
||
.rt-top { display: flex; align-items: center; gap: 12px 16px; flex-wrap: wrap; }
|
||
.rt-tabs { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; max-width: 100%; overflow-x: auto; }
|
||
.rt-tabs button { font: inherit; font-size: 13.5px; border: 0; background: none; color: var(--ink2); padding: 6px 12px; border-radius: 7px; cursor: pointer; white-space: nowrap; }
|
||
.rt-tabs button:hover { color: var(--ink); }
|
||
.rt-tabs button[aria-selected="true"] { background: var(--card); color: var(--ink); font-weight: 560; box-shadow: 0 0 0 1px var(--line), 0 1px 2px rgb(0 0 0 / .08); }
|
||
.rt-mode { margin: 0; flex: 1 1 20em; font-size: 14px; color: var(--ink2); }
|
||
.rt-stage { position: relative; display: grid; grid-template-columns: 150px 150px minmax(0, 1fr); column-gap: 76px; align-items: center; padding: 28px 2px; }
|
||
.rt-wires { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
||
.rt-wires path { fill: none; stroke: var(--line); stroke-width: 1.5; transition: opacity .3s; }
|
||
.rt-wires path.live { stroke: var(--ink3); stroke-dasharray: 3 5; animation: rtflow .7s linear infinite; }
|
||
.rt-wires path.rest { stroke-dasharray: 2 6; opacity: .45; }
|
||
@keyframes rtflow { to { stroke-dashoffset: -8; } }
|
||
.rt-wires .pkt { fill: var(--ink); filter: drop-shadow(0 0 3px var(--ink3)); }
|
||
.rt-wires .pkt.back { fill: var(--card); stroke: var(--ink); stroke-width: 2; }
|
||
.rt-node { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 16px 10px 14px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; text-align: center; }
|
||
.rt-node img { width: 30px; height: 30px; margin-bottom: 4px; }
|
||
.rt-node b { font-size: 14px; font-weight: 600; }
|
||
.rt-node small { font-size: 12px; color: var(--ink3); }
|
||
.rt-hub { border-color: var(--ink); box-shadow: 0 0 0 4px var(--bg2); }
|
||
.rt-hub i { font-style: normal; font-size: 11.5px; font-weight: 560; margin-top: 6px; padding: 2px 9px; border-radius: 999px; background: var(--ink); color: var(--card); }
|
||
.rt-hub.tick, .rt-src.tick { animation: rttick .4s ease-out; }
|
||
@keyframes rttick { 40% { transform: scale(1.04); } }
|
||
.rt-accts { position: relative; z-index: 1; list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||
.rt-accts li { position: relative; display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; column-gap: 12px; row-gap: 7px; padding: 11px 14px; background: var(--card); border: 1px solid var(--line); border-radius: 12px; font-size: 14px; transition: opacity .3s, border-color .3s; }
|
||
.rt-accts b { font-weight: 560; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.rt-accts b span { font-weight: 450; color: var(--ink3); }
|
||
.rt-accts b img { width: 18px; height: 18px; margin-right: 8px; vertical-align: -3.5px; }
|
||
.rt-accts em { font-style: normal; font-size: 12.5px; color: var(--ink3); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||
.rt-accts .bar { grid-column: 2 / 4; height: 4px; border-radius: 2px; background: var(--bg2); overflow: hidden; }
|
||
.rt-accts .bar i { display: block; height: 100%; width: 0; border-radius: 2px; background: var(--ink3); transition: width .4s; }
|
||
.rt-accts li.nobar .bar { visibility: hidden; }
|
||
.rt-accts.models .bar { display: none; }
|
||
.rt-accts .dot { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--ink3); }
|
||
.rt-accts li.on { border-color: var(--ink); }
|
||
.rt-accts li.on em { color: var(--ink); }
|
||
.rt-accts li.on .bar i { background: var(--ink); }
|
||
.rt-accts li.on .dot { background: var(--ink); border-color: var(--ink); box-shadow: 0 0 0 0 color-mix(in srgb, var(--ink) 40%, transparent); animation: live 1.8s infinite; }
|
||
.rt-accts li.low em { color: var(--ink2); }
|
||
.rt-accts li.rest { border-style: dashed; }
|
||
.rt-accts li.rest > :not(.tag) { opacity: .5; }
|
||
.rt-accts li.hit { animation: rthit .45s; }
|
||
@keyframes rthit { 20% { transform: translateX(-5px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(1px); } }
|
||
.rt-accts .tag { position: absolute; right: 12px; top: -10px; padding: 2px 8px; border-radius: 6px; background: var(--ink); color: var(--card); font: 600 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; opacity: 0; transform: translateY(4px) scale(.9); transition: opacity .25s, transform .25s; pointer-events: none; }
|
||
.rt-accts li.tagged .tag { opacity: 1; transform: none; }
|
||
.rt-foot { display: flex; align-items: center; justify-content: space-between; gap: 14px 24px; flex-wrap: wrap; padding-top: 14px; border-top: 1px solid var(--line); }
|
||
.rt-cap { flex: 1 1 24em; margin: 0; min-height: 3em; display: flex; align-items: center; font-size: 14.5px; color: var(--ink); }
|
||
.rt-cap.in { animation: rtin .4s ease-out; }
|
||
@keyframes rtin { from { opacity: 0; transform: translateY(4px); } }
|
||
.rt-stats { display: flex; gap: 22px; font-size: 12px; color: var(--ink3); }
|
||
.rt-stats span { display: flex; flex-direction: column; }
|
||
.rt-stats b { font-size: 22px; line-height: 1.2; font-weight: 650; letter-spacing: -.02em; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||
.grid.rt-points { grid-template-columns: repeat(4, 1fr); }
|
||
@media (prefers-reduced-motion: reduce) { .rt-accts li.on .dot, .rt-wires path.live, .rt-accts li.hit, .rt-hub.tick, .rt-src.tick, .rt-cap.in { animation: none; } }
|
||
@media (max-width: 860px) {
|
||
.rt-stage { grid-template-columns: 1fr 1fr; column-gap: 40px; row-gap: 30px; }
|
||
.rt-accts { grid-column: 1 / -1; padding-left: 26px; }
|
||
.grid.rt-points { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
@media (max-width: 560px) {
|
||
.rt { padding: 14px 12px 14px; }
|
||
.rt-stage { column-gap: 24px; }
|
||
.rt-accts li { grid-template-columns: 10px minmax(0, 1fr); padding: 10px 12px; }
|
||
.rt-accts em { grid-column: 2; white-space: normal; }
|
||
.rt-accts .bar { grid-column: 2; }
|
||
.grid.rt-points { grid-template-columns: 1fr; }
|
||
}
|
||
.wide img { width: 100%; height: auto; height: auto; display: block; border-radius: 14px; box-shadow: var(--shadow); border: 1px solid var(--line); }
|
||
.subs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||
.subs > div { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 22px; }
|
||
.subs .who { display: flex; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 8px; }
|
||
.subs .who img { width: 20px; height: 20px; }
|
||
.subs p { margin: 0; color: var(--ink2); font-size: 15px; }
|
||
.subs code { font-size: 13px; }
|
||
|
||
@media (max-width: 860px) {
|
||
.hero { grid-template-columns: 1fr; padding-top: 56px; gap: 44px; }
|
||
.split { grid-template-columns: 1fr; }
|
||
.split.rev > :first-child { order: 2; }
|
||
.flow { grid-template-columns: 1fr; }
|
||
.arrow { transform: rotate(90deg); text-align: center; }
|
||
.subs { grid-template-columns: 1fr; }
|
||
.grid { grid-template-columns: 1fr 1fr; }
|
||
/* the links fold into a menu under the bar */
|
||
nav .menu { display: inline-grid; }
|
||
nav .wrap { gap: 10px; }
|
||
nav .wrap > .gh, nav .wrap > .langs { margin-right: 0; }
|
||
nav .links { display: none; position: absolute; top: 100%; left: 0; right: 0; margin: 0; padding: 4px 24px 12px; flex-direction: column; gap: 0; font-size: 16px; color: var(--ink); background: var(--bg); border-bottom: 1px solid var(--line); box-shadow: 0 16px 30px -18px rgba(0,0,0,.25); max-height: calc(100dvh - 60px); overflow-y: auto; }
|
||
nav.open { border-bottom-color: var(--line); }
|
||
nav.open .links { display: flex; }
|
||
nav .links a, nav .links .in-menu { display: block; padding: 13px 0; }
|
||
nav .links a + a { border-top: 1px solid var(--line); }
|
||
.brand { margin-right: auto; }
|
||
}
|
||
@media (max-width: 560px) {
|
||
.wrap { padding: 0 16px; }
|
||
.grid { grid-template-columns: 1fr; }
|
||
.sponsor .card { flex-direction: column; gap: 10px; padding: 16px 20px; text-align: center; }
|
||
.sponsor .card hr, .sponsor .card i { display: none; }
|
||
nav .wrap { gap: 8px; }
|
||
nav .links { padding: 4px 16px 12px; }
|
||
nav .wrap > .gh { display: none; }
|
||
footer .links { margin-left: 0; flex-wrap: wrap; gap: 10px 18px; }
|
||
.rt-tabs { flex-wrap: wrap; }
|
||
pre { font-size: 12px; padding: 16px; }
|
||
.hero { padding-top: 40px; }
|
||
.hero p.lede { font-size: 17.5px; }
|
||
.community { padding-top: 80px; }
|
||
.final { padding: 90px 0 72px; }
|
||
section { padding-top: 64px; }
|
||
.table .path { display: none; }
|
||
}
|
||
@media (max-width: 360px) {
|
||
nav .btn.dl { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav id="nav"><div class="wrap">
|
||
<a class="brand" href="/"><img class="bird" src="/img/magpie-small.svg" alt="">magpie</a>
|
||
<div class="links" id="links">
|
||
<a class="hide" href="#how" data-i18n="nav.how">How it works</a>
|
||
<a class="hide" href="#routing" data-i18n="nav.routing">Routing</a>
|
||
<a class="hide" href="#providers" data-i18n="nav.providers">Providers</a>
|
||
<a href="#cli">CLI</a>
|
||
<a class="hide-sm" href="/docs/start" data-i18n="nav.docs">Docs</a>
|
||
<a class="hide-sm" href="https://github.com/yetone/magpie-releases/releases" data-i18n="nav.releases">Releases</a>
|
||
<a class="in-menu" href="https://discord.gg/vGSnD3ZKQF">Discord</a>
|
||
<a class="in-menu" href="https://github.com/yetone/magpie">GitHub</a>
|
||
</div>
|
||
<div class="langs">
|
||
<button class="theme lang" type="button" aria-label="Language" title="Language" aria-haspopup="true" aria-expanded="false" aria-controls="langs" data-i18n-attr="aria-label:nav.language,title:nav.language"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="6.25"/><path d="M1.75 8h12.5M8 1.75c1.7 1.75 2.55 3.83 2.55 6.25S9.7 12.5 8 14.25M8 1.75C6.3 3.5 5.45 5.58 5.45 8S6.3 12.5 8 14.25"/></svg></button>
|
||
<div class="lang-menu" id="langs" hidden>
|
||
<a href="/" hreflang="en" lang="en">English</a>
|
||
<a href="/zh/" hreflang="zh" lang="zh">中文</a>
|
||
<a href="/ja/" hreflang="ja" lang="ja">日本語</a>
|
||
<a href="/de/" hreflang="de" lang="de">Deutsch</a>
|
||
</div>
|
||
</div>
|
||
<a class="theme gh" href="https://discord.gg/vGSnD3ZKQF" aria-label="magpie on Discord" title="Join us on Discord"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg></a>
|
||
<a class="theme gh" href="https://github.com/yetone/magpie" aria-label="magpie on GitHub" title="Source on GitHub"><svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg></a>
|
||
<button class="theme" type="button" aria-label="Theme" title="Theme: system"><svg class="i-system" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1.75" y="2.75" width="12.5" height="8.5" rx="1.5"/><path d="M5.5 14h5M8 11.25V14"/></svg><svg class="i-light" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="3"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3 3l1 1M12 12l1 1M3 13l1-1M12 4l1-1"/></svg><svg class="i-dark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M13.5 9.5A5.5 5.5 0 0 1 6.5 2.5a5.5 5.5 0 1 0 7 7Z"/></svg></button>
|
||
<a class="btn small dl" href="/download" data-i18n="nav.download">Download</a>
|
||
<button class="theme menu" type="button" aria-label="Menu" aria-expanded="false" aria-controls="links" data-i18n-attr="aria-label:nav.menu"><svg class="i-open" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg><svg class="i-close" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/></svg></button>
|
||
</div></nav>
|
||
|
||
<main class="wrap">
|
||
<header class="hero">
|
||
<div>
|
||
<h1 data-i18n="hero.h1">Every agent’s model. <span>One place.</span></h1>
|
||
<p class="lede" data-i18n="hero.lede">magpie gives every agent every model. Add a provider once — DeepSeek, Kimi, GLM, Qwen, OpenRouter, Ollama, or the Claude and ChatGPT plans you already pay for — and pick it from Codex, Claude Code, Gemini CLI or any other agent, in one click.</p>
|
||
<div class="chips">
|
||
<span class="chip"><img src="/icons/codex-color.svg" alt=""><b>Codex</b><i>→</i><img src="/icons/deepseek-color.svg" alt="">DeepSeek V4 Pro</span>
|
||
<span class="chip"><img src="/icons/claudecode-color.svg" alt=""><b>Claude Code</b><i>→</i><img class="bird" src="/icons/kimi.svg" alt="">Kimi K3</span>
|
||
<span class="chip"><img src="/icons/geminicli-color.svg" alt=""><b>Gemini CLI</b><i>→</i><img src="/icons/zhipu-color.svg" alt="">GLM-5.3</span>
|
||
</div>
|
||
<div class="cta">
|
||
<a class="btn dl" href="/download">
|
||
<svg class="os-mac" viewBox="0 0 16 16" fill="currentColor"><path d="M11.2 8.5c0-1.6 1.3-2.4 1.4-2.4-.8-1.1-2-1.3-2.4-1.3-1-.1-2 .6-2.5.6s-1.3-.6-2.2-.6C4.4 4.8 3.3 5.5 2.7 6.6c-1.2 2.1-.3 5.2.9 6.9.6.8 1.2 1.7 2.1 1.7.8 0 1.2-.5 2.2-.5s1.3.5 2.2.5 1.5-.9 2-1.7c.6-.9.9-1.8.9-1.9 0 0-1.8-.7-1.8-3.1ZM9.6 3.6c.5-.6.8-1.3.7-2.1-.7 0-1.5.5-2 1-.4.5-.8 1.3-.7 2.1.8.1 1.5-.4 2-1Z"/></svg>
|
||
<svg class="os-windows" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.3 6.7 1.5v5.6H1V2.3Zm6.4-.9L15 .3v6.8H7.4V1.4ZM1 7.9h5.7v5.6L1 12.7V7.9Zm6.4 0H15v6.8l-7.6-1.1V7.9Z"/></svg>
|
||
<svg class="os-linux" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8.5M4.5 7 8 10.5 11.5 7M2.5 13.5h11"/></svg>
|
||
<span class="dl-label" data-i18n="dl.mac">Download for Mac</span>
|
||
</a>
|
||
<a class="btn ghost" href="#cli" data-i18n="hero.term">Install from terminal</a>
|
||
</div>
|
||
<div class="meta"><span class="ver" data-i18n="hero.free">Free</span> · <a href="https://github.com/yetone/magpie" data-i18n="hero.oss">Open source</a> · <span class="os-note">macOS 12+ · <a class="alt" href="/download/mac-intel">Intel Mac</a></span> · <a href="#get" data-i18n="hero.other">Windows & Linux</a></div>
|
||
</div>
|
||
<div class="shot">
|
||
<img loading="lazy" class="pick for-light" src="/img/picker-light.png" width="440" height="555" data-i18n-attr="alt:alt.picker" alt="Codex's model picker in magpie, listing DeepSeek, Kimi and GLM models">
|
||
<img loading="lazy" class="pick for-dark" src="/img/picker-dark.png" width="440" height="555" data-i18n-attr="alt:alt.picker" alt="Codex's model picker in magpie, listing DeepSeek, Kimi and GLM models">
|
||
</div>
|
||
</header>
|
||
|
||
<aside class="sponsor" aria-label="Sponsor">
|
||
<p data-i18n="sponsor.by">Sponsored by</p>
|
||
<a class="card" href="https://yylx.io" target="_blank" rel="noopener sponsored">
|
||
<img class="bird" src="/img/sponsor-yylx.png" width="640" height="184" alt="鱼鱼连线 YYLX.IO AI Router">
|
||
<hr>
|
||
<span data-i18n="sponsor.yylx">One API gateway for Claude and GPT, tuned for Claude Code.</span>
|
||
<i aria-hidden="true">→</i>
|
||
</a>
|
||
<br><a class="more" href="mailto:yetoneful@gmail.com?subject=Sponsoring%20magpie" data-i18n="sponsor.become">Become a sponsor</a>
|
||
</aside>
|
||
|
||
<div class="agents">
|
||
<p data-i18n="agents.agents">Every agent</p>
|
||
<ul>
|
||
<li><img src="/icons/claudecode-color.svg" alt="">Claude Code</li>
|
||
<li><img src="/icons/codex-color.svg" alt="">Codex</li>
|
||
<li><img src="/icons/geminicli-color.svg" alt="">Gemini CLI</li>
|
||
<li><img class="bird" src="/icons/opencode.svg" alt="">OpenCode</li>
|
||
<li><img class="bird" src="/icons/mimocode.svg" alt="">MiMo Code</li>
|
||
<li><img class="bird" src="/icons/pi.svg" alt="">Pi</li>
|
||
<li><img class="bird" src="/icons/goose.svg" alt="">Goose</li>
|
||
<li><img class="bird" src="/icons/cursor.svg" alt="">Cursor CLI</li>
|
||
<li><img class="bird" src="/icons/githubcopilot.svg" alt="">Copilot CLI</li>
|
||
<li><img class="bird" src="/icons/vscode.svg" alt="">VS Code</li>
|
||
<li><img src="/icons/crush.png" alt="">Crush</li>
|
||
<li><img src="/icons/deepseek-color.svg" alt="">DeepSeek Harness</li>
|
||
<li><img class="bird" src="/icons/commandcode.svg" alt="">Command Code</li>
|
||
<li><img class="bird" src="/icons/fx.svg" alt="">fx</li>
|
||
<li><img class="bird" src="/icons/omp.svg" alt="">omp</li>
|
||
<li><img class="bird" src="/icons/omo.svg" alt="">OmO</li>
|
||
<li><img class="bird" src="/icons/devin.svg" alt="">Devin</li>
|
||
<li><img class="bird" src="/icons/hermes.svg" alt="">Hermes Agent</li>
|
||
<li><img src="/icons/mistermorph-color.svg" alt="">Mister Morph</li>
|
||
<li><img class="bird" src="/icons/kimi.svg" alt="">Kimi Code</li>
|
||
<li><img src="/icons/meta-color.svg" alt="">Muse Code</li>
|
||
<li><img src="/icons/empryo-color.svg" alt="">Empryo</li>
|
||
<li><img src="/icons/minimax-color.svg" alt="">MiniMax Code</li>
|
||
<li><img class="bird" src="/icons/factory.svg" alt="">Droid</li>
|
||
<li><img class="bird" src="/icons/xai.svg" alt="">Grok Build</li>
|
||
<li><img src="/icons/zcode.png" alt="">ZCode</li>
|
||
<li><img src="/icons/hanako.png" alt="">OpenHanako</li>
|
||
<li><img src="/icons/atomcode.png" alt="">AtomCode</li>
|
||
<li><img src="/icons/alma.png" alt="">Alma</li>
|
||
</ul>
|
||
<p style="margin-top:34px" data-i18n="agents.models">Every model</p>
|
||
<ul>
|
||
<li><img src="/icons/deepseek-color.svg" alt="">DeepSeek</li>
|
||
<li><img class="bird" src="/icons/kimi.svg" alt="">Kimi</li>
|
||
<li><img src="/icons/zhipu-color.svg" alt="">GLM</li>
|
||
<li><img src="/icons/qwen-color.svg" alt="">Qwen</li>
|
||
<li><img src="/icons/minimax-color.svg" alt="">MiniMax</li>
|
||
<li><img src="/icons/claude-color.svg" alt="">Claude</li>
|
||
<li><img class="bird" src="/icons/openai.svg" alt="">OpenAI</li>
|
||
<li><img src="/icons/gemini-color.svg" alt="">Gemini</li>
|
||
<li><img src="/icons/mistral-color.svg" alt="">Mistral</li>
|
||
<li><img class="bird" src="/icons/xai.svg" alt="">xAI</li>
|
||
<li><img class="bird" src="/icons/groq.svg" alt="">Groq</li>
|
||
<li><img class="bird" src="/icons/openrouter.svg" alt="">OpenRouter</li>
|
||
<li><img class="bird" src="/icons/ollama.svg" alt="">Ollama</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<section id="how">
|
||
<p class="eyebrow" data-i18n="how.eyebrow">How it works</p>
|
||
<h2 data-i18n="how.h2">One gateway speaks every API.</h2>
|
||
<p class="sub" data-i18n="how.sub">Codex only speaks OpenAI Responses. Claude Code only speaks Anthropic Messages. magpie runs a gateway on your machine that speaks all of them and translates in both directions — streaming, tool calls and reasoning included — so any agent can use any vendor’s model.</p>
|
||
<div class="flow">
|
||
<div class="col">
|
||
<h4 data-i18n="how.agents">Your agents</h4>
|
||
<ul>
|
||
<li><img src="/icons/codex-color.svg" alt="">Codex<span>Responses</span></li>
|
||
<li><img src="/icons/claudecode-color.svg" alt="">Claude Code<span>Messages</span></li>
|
||
<li><img src="/icons/geminicli-color.svg" alt="">Gemini CLI<span>Gemini</span></li>
|
||
<li><img class="bird" src="/icons/opencode.svg" alt="">OpenCode<span>Chat</span></li>
|
||
<li><img class="bird" src="/icons/goose.svg" alt="">Goose<span>Chat</span></li>
|
||
</ul>
|
||
</div>
|
||
<div class="arrow">→</div>
|
||
<div class="hub">
|
||
<img class="bird" src="/img/magpie.svg" alt="">
|
||
<b data-i18n="how.gateway">magpie gateway</b>
|
||
<code>127.0.0.1:3425</code>
|
||
<ul><li>OpenAI Chat · Responses</li><li>Anthropic Messages</li><li>Google Gemini</li></ul>
|
||
</div>
|
||
<div class="arrow">→</div>
|
||
<div class="col">
|
||
<h4 data-i18n="how.providers">Any provider</h4>
|
||
<ul>
|
||
<li><img src="/icons/deepseek-color.svg" alt="">DeepSeek</li>
|
||
<li><img class="bird" src="/icons/kimi.svg" alt="">Kimi</li>
|
||
<li><img src="/icons/zhipu-color.svg" alt="">GLM</li>
|
||
<li><img src="/icons/claude-color.svg" alt=""><span data-i18n="how.claude">Your Claude plan</span></li>
|
||
<li><img class="bird" src="/icons/openai.svg" alt=""><span data-i18n="how.chatgpt">Your ChatGPT plan</span></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div class="split rev">
|
||
<div>
|
||
<img class="for-light" loading="lazy" src="/img/panel-light.png" width="440" height="470" data-i18n-attr="alt:alt.panel" alt="The magpie menu bar panel: each agent on a different vendor's model">
|
||
<img class="for-dark" loading="lazy" src="/img/panel-dark.png" width="440" height="470" data-i18n-attr="alt:alt.panel" alt="The magpie menu bar panel: each agent on a different vendor's model">
|
||
</div>
|
||
<div>
|
||
<p class="eyebrow" data-i18n="bar.eyebrow">The menu bar</p>
|
||
<h2 data-i18n="bar.h2">Every agent, its own model.</h2>
|
||
<p class="sub" data-i18n="bar.sub">One glance shows what each agent runs. Click a value to change it; magpie rewrites that agent’s config — only the key you changed — and the next session uses it.</p>
|
||
<ul>
|
||
<li data-i18n="bar.l1"><b>Codex</b> gets DeepSeek in its own <code>/model</code> list, with the right reasoning efforts.</li>
|
||
<li data-i18n="bar.l2"><b>Claude Code</b> gets Kimi, GLM or GPT behind <code>opus</code>, <code>sonnet</code> and <code>haiku</code>.</li>
|
||
<li data-i18n="bar.l3"><b>Pick the native model</b> and magpie puts back exactly what was there before.</li>
|
||
<li data-i18n="bar.l4"><b>Profiles</b> switch every agent at once — “Budget”, “Focus”, whatever you save.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="subscriptions">
|
||
<p class="eyebrow" data-i18n="subs.eyebrow">Subscriptions, shared</p>
|
||
<h2 data-i18n="subs.h2">The plans you pay for, in every agent.</h2>
|
||
<p class="sub" data-i18n="subs.sub">Signed in to an agent? That login becomes a provider. Its models show up in every other agent’s picker — nothing copied, no key to paste, tokens refreshed the way the agent does it.</p>
|
||
<div class="subs">
|
||
<div><div class="who"><img src="/icons/claude-color.svg" alt="">Claude Pro / Max</div><p data-i18n="subs.claude">Your Claude Code sign-in powers OpenCode, Pi or Goose: <code>claude/claude-sonnet-5</code>.</p></div>
|
||
<div><div class="who"><img class="bird" src="/icons/openai.svg" alt="">ChatGPT Plus / Pro</div><p data-i18n="subs.codex">Your Codex sign-in puts GPT in Claude Code: <code>codex/gpt-5.5</code>.</p></div>
|
||
<div><div class="who"><img class="bird" src="/icons/githubcopilot.svg" alt="">GitHub Copilot</div><p data-i18n="subs.copilot">Your Copilot seat serves any agent: <code>copilot/claude-sonnet-4.5</code>.</p></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="routing">
|
||
<p class="eyebrow" data-i18n="rt.eyebrow">Smart routing</p>
|
||
<h2 data-i18n="rt.h2">The right model for each turn. It never runs dry.</h2>
|
||
<p class="sub" data-i18n="rt.sub">Say what each model is for — "a quick question", "writing or fixing tests" — and as each turn begins a small, fast model tells magpie which it is: the turn goes to that model, and its tool rounds stay there. <a href="/docs/intent">How it works</a>. And add every key and subscription you have — several Claude, ChatGPT, Copilot or Grok accounts, several keys for one vendor — and turn them all on. For each request magpie picks the account that should answer, and when one can't, the next answers before your agent ever sees an error.</p>
|
||
<div class="rt" id="rt">
|
||
<div class="rt-top">
|
||
<div class="rt-tabs" role="tablist" aria-label="Routing">
|
||
<button role="tab" data-mode="intent" aria-selected="true" data-i18n="rt.intent">By intent</button>
|
||
<button role="tab" data-mode="smart" aria-selected="false" data-i18n="rt.smart">Smart</button>
|
||
<button role="tab" data-mode="order" aria-selected="false" data-i18n="rt.order">In order</button>
|
||
<button role="tab" data-mode="turn" aria-selected="false" data-i18n="rt.turn">In turn</button>
|
||
<button role="tab" data-mode="least" aria-selected="false" data-i18n="rt.least">Least used first</button>
|
||
</div>
|
||
<p class="rt-mode" data-i18n="rt.mode.smart">Smart, the default: the account that resets soonest goes first, so no allowance is left to go to waste; any that can't answer are passed over. (Here each request stands for an hour.)</p>
|
||
</div>
|
||
<div class="rt-stage">
|
||
<svg class="rt-wires" aria-hidden="true"></svg>
|
||
<div class="rt-node rt-src"><img src="/icons/claudecode-color.svg" alt=""><b>Claude Code</b><small data-i18n="rt.agent">your agent</small></div>
|
||
<div class="rt-node rt-hub"><img class="bird" src="/img/magpie-small.svg" alt=""><b>magpie</b><small>localhost:3425</small><i data-i18n="rt.smart">Smart</i></div>
|
||
<ol class="rt-accts">
|
||
<li class="on"><i class="dot"></i><b><img src="/icons/claude-color.svg" alt="">me@work.dev <span>Claude Max</span></b><em>serving</em><div class="bar"><i style="width:72%"></i></div><span class="tag"></span></li>
|
||
<li><i class="dot"></i><b><img src="/icons/claude-color.svg" alt="">me@home.dev <span>Claude Pro</span></b><em>38% used · resets in 2 d</em><div class="bar"><i style="width:38%"></i></div><span class="tag"></span></li>
|
||
<li><i class="dot"></i><b><img class="bird" src="/icons/openai.svg" alt="">me@home.dev <span>ChatGPT Pro</span></b><em>30% used · resets in 4 d</em><div class="bar"><i style="width:30%"></i></div><span class="tag"></span></li>
|
||
<li class="nobar"><i class="dot"></i><b><img class="bird" src="/icons/openrouter.svg" alt="">sk-or-…4f2a <span>OpenRouter</span></b><em>pay as you go</em><div class="bar"><i></i></div><span class="tag"></span></li>
|
||
<li><i class="dot"></i><b><img class="bird" src="/icons/githubcopilot.svg" alt="">octo-dev <span>Copilot Business</span></b><em>24% used · resets in 12 d</em><div class="bar"><i style="width:24%"></i></div><span class="tag"></span></li>
|
||
</ol>
|
||
</div>
|
||
<div class="rt-foot">
|
||
<p class="rt-cap" aria-live="polite" data-i18n="rt.cap">Claude Code sends every request to magpie; magpie decides which account answers it.</p>
|
||
<div class="rt-stats"><span><b>0</b><span data-i18n="rt.s1">requests</span></span><span><b>0</b><span data-i18n="rt.s2">rerouted</span></span><span><b>0</b><span data-i18n="rt.s3">errors your agent saw</span></span></div>
|
||
</div>
|
||
</div>
|
||
<div class="grid rt-points">
|
||
<div><h3 data-i18n="rt.reset">Reset-aware</h3><p data-i18n="rt.reset.p">The account that resets soonest goes first — what it has left would be lost at the reset. It keeps the requests while it has quota to spare, so its prompt cache stays warm.</p></div>
|
||
<div><h3 data-i18n="rt.credit">Out of credit</h3><p data-i18n="rt.credit.p">A key whose balance ran out sits out half an hour; requests go to one that can still pay.</p></div>
|
||
<div><h3 data-i18n="rt.limits">Rate limits & quotas</h3><p data-i18n="rt.limits.p">An account the vendor has limited is passed over for as long as the vendor asks — its Retry-After and reset headers, no guessing, no hammering.</p></div>
|
||
<div><h3 data-i18n="rt.failover">Failover</h3><p data-i18n="rt.failover.p">A failing account steps back a minute, longer each time it fails again; the request is retried on the next before a byte of the reply reaches your agent.</p></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="providers">
|
||
<p class="eyebrow" data-i18n="prov.eyebrow">Providers</p>
|
||
<h2 data-i18n="prov.h2">Paste a key. That’s the setup.</h2>
|
||
<p class="sub" data-i18n="prov.sub">Twenty presets — vendors, relays and local servers. magpie asks each vendor which models it serves, so a model released this morning is in every picker on the next refresh. Keys stay in magpie; agents never see them.</p>
|
||
<div class="wide">
|
||
<img class="for-light" loading="lazy" src="/img/providers-light.png" width="1040" height="507" data-i18n-attr="alt:alt.providers" alt="magpie's Providers tab: DeepSeek, Kimi, GLM, Qwen, MiniMax, OpenRouter, Anthropic and Gemini, each with the agents using it">
|
||
<img class="for-dark" loading="lazy" src="/img/providers-dark.png" width="1040" height="507" data-i18n-attr="alt:alt.providers" alt="magpie's Providers tab: DeepSeek, Kimi, GLM, Qwen, MiniMax, OpenRouter, Anthropic and Gemini, each with the agents using it">
|
||
</div>
|
||
</section>
|
||
|
||
<section id="features">
|
||
<p class="eyebrow" data-i18n="det.eyebrow">Details</p>
|
||
<h2 data-i18n="det.h2">Small, careful, and out of the way.</h2>
|
||
<div class="grid" style="margin-top:32px">
|
||
<div><h3 data-i18n="det.edits">Surgical config edits</h3><p data-i18n="det.edits.p">Comments, ordering and indentation in <code>settings.json</code>, <code>config.toml</code> or <code>config.yaml</code> survive. Writes are atomic.</p></div>
|
||
<div><h3 data-i18n="det.tiny">Tiny, native, open</h3><p data-i18n="det.tiny.p">A 15 MB download on a Mac, on the system webview. No Electron, no runtime. MIT-licensed, <a href="https://github.com/yetone/magpie">on GitHub</a>.</p></div>
|
||
<div><h3 data-i18n="det.env">No keys from your shell</h3><p data-i18n="det.env.p">magpie never reads API keys from environment variables. What you add is what it uses.</p></div>
|
||
<div><h3 data-i18n="det.client">Any gateway client</h3><p data-i18n="det.client.p">Anything with a base-URL setting can use it: <code>OPENAI_BASE_URL</code>, <code>ANTHROPIC_BASE_URL</code>, <code>GOOGLE_GEMINI_BASE_URL</code>.</p></div>
|
||
<div><h3 data-i18n="det.usage">Usage, per agent</h3><p data-i18n="det.usage.p">Tokens, cache hits and cost for each agent and model that went through the gateway.</p></div>
|
||
<div><h3 data-i18n="det.update">Updates itself</h3><p data-i18n="det.update.p">New versions download quietly and install when you restart. Signed and notarised on the Mac.</p></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="cli">
|
||
<p class="eyebrow" data-i18n="cli.eyebrow">Terminal</p>
|
||
<h2 data-i18n="cli.h2">Also a TUI and a CLI.</h2>
|
||
<p class="sub" data-i18n="cli.sub">The same app, from a terminal. The installer puts the <code>magpie</code> command in <code>~/.local/bin</code> and the app where it belongs: <code>/Applications</code> on a Mac, your app menu on Linux.</p>
|
||
<div class="install"><code id="cmd">curl -fsSL https://usemagpie.ai/install.sh | sh</code><button class="copy" data-copy="cmd" data-i18n="copy">Copy</button></div>
|
||
<p class="meta" style="margin:12px 0 28px" data-i18n="cli.win">On Windows, <code>magpie.exe</code> takes the same commands; <code>magpie-cli-windows-amd64.exe</code> from the <a href="https://github.com/yetone/magpie-releases/releases/latest">latest release</a> is the terminal-only build.</p>
|
||
<pre><span class="c" data-i18n="cli.c.add"># add providers</span>
|
||
magpie provider add deepseek sk-… <span class="c" data-i18n="cli.c.preset"># a preset needs only the key</span>
|
||
magpie provider add moonshot sk-…
|
||
magpie provider add ollama <span class="c" data-i18n="cli.c.local"># local servers need none</span>
|
||
|
||
<span class="c" data-i18n="cli.c.give"># give agents third-party models</span>
|
||
magpie codex deepseek/deepseek-v4-pro
|
||
magpie claude moonshot/kimi-k3
|
||
magpie gemini zhipu/glm-5.3
|
||
magpie opencode claude/claude-sonnet-5 <span class="c" data-i18n="cli.c.plan"># your Claude plan, in OpenCode</span>
|
||
|
||
<span class="c" data-i18n="cli.c.group"># one model over several providers</span>
|
||
magpie group add opus models=claude/claude-opus-5-5,copilot/claude-opus-5.5 routing=order
|
||
magpie claude group/opus
|
||
|
||
magpie ls <span class="c" data-i18n="cli.c.ls"># every agent and its model</span>
|
||
magpie save work && magpie use work <span class="c" data-i18n="cli.c.prof"># profiles</span>
|
||
magpie tui <span class="c" data-i18n="cli.c.tui"># the whole app, in the terminal</span></pre>
|
||
</section>
|
||
|
||
<aside class="community" aria-label="Community">
|
||
<a href="https://discord.gg/vGSnD3ZKQF" target="_blank" rel="noopener">
|
||
<span class="glyph"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg></span>
|
||
<div><b data-i18n="comm.h">Join the magpie community</b><span data-i18n="comm.p">Ask questions, share your setup, tell us what to build next — on Discord.</span></div>
|
||
<i aria-hidden="true">→</i>
|
||
</a>
|
||
</aside>
|
||
|
||
<div class="final">
|
||
<img class="bird" src="/img/magpie.svg" alt="">
|
||
<h2 data-i18n="fin.h2">Every agent. Every model.</h2>
|
||
<p class="sub" style="margin:0 auto 32px" data-i18n="fin.sub">Free, open source, native. For macOS, Windows and Linux.</p>
|
||
<div class="cta"><a class="btn dl" href="/download"><span class="dl-label" data-i18n="dl.mac">Download for Mac</span></a><a class="btn ghost" href="https://github.com/yetone/magpie"><svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg> <span data-i18n="fin.gh">View on GitHub</span></a></div>
|
||
<div class="plats" id="get">
|
||
<div><b>macOS</b><span data-i18n="plat.mac">11 or later</span><a href="/download/mac-arm64">Apple Silicon</a><a href="/download/mac-intel">Intel</a></div>
|
||
<div><b>Windows</b><span data-i18n="plat.win">10 or later</span><a href="/download/windows-amd64">x64</a><a href="/download/windows-arm64">ARM</a></div>
|
||
<div><b>Linux</b><span>GTK 3 + WebKitGTK 4.1</span><a href="/download/linux-amd64">x86-64</a><a href="/download/linux-arm64">ARM64</a></div>
|
||
</div>
|
||
<div class="meta"><span class="ver"></span></div>
|
||
</div>
|
||
</main>
|
||
|
||
|
||
<footer><div class="wrap">
|
||
<span>© 2026 magpie · MIT</span>
|
||
<div class="links">
|
||
<a href="/docs/start" data-i18n="foot.start">Get started</a>
|
||
<a href="/docs/import">Add to magpie</a>
|
||
<a href="https://github.com/yetone/magpie">GitHub</a>
|
||
<a href="https://discord.gg/vGSnD3ZKQF">Discord</a>
|
||
<a href="https://github.com/yetone/magpie-releases/releases" data-i18n="nav.releases">Releases</a>
|
||
<a href="/api/latest" data-i18n="foot.feed">Update feed</a>
|
||
<a href="https://x.com/yetone">@yetone</a>
|
||
</div>
|
||
</div></footer>
|
||
|
||
<script>
|
||
(() => {
|
||
const nav = document.getElementById("nav");
|
||
|
||
// The theme button steps system → light → dark.
|
||
const next = { system: "light", light: "dark", dark: "system" };
|
||
document.querySelectorAll("button.theme:not(.menu):not(.lang)").forEach((b) => {
|
||
b.dataset.mode = themeMode();
|
||
b.title = themeTitle(themeMode());
|
||
b.addEventListener("click", () => setTheme(next[themeMode()]));
|
||
});
|
||
// On a narrow screen the links fold into a menu; a link picked, a tap
|
||
// outside or Escape closes it.
|
||
const menu = nav.querySelector(".menu");
|
||
const open = (on) => { nav.classList.toggle("open", on); menu.setAttribute("aria-expanded", String(on)); };
|
||
menu.addEventListener("click", () => open(!nav.classList.contains("open")));
|
||
nav.querySelectorAll(".links a").forEach((a) => a.addEventListener("click", () => open(false)));
|
||
document.addEventListener("click", (e) => { if (!nav.contains(e.target)) open(false); });
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && nav.classList.contains("open")) { open(false); menu.focus(); } });
|
||
|
||
// The language menu. The language picked is remembered, so / stops
|
||
// sending a Chinese or Japanese browser to its page once another was asked
|
||
// for.
|
||
const langBtn = nav.querySelector(".lang"), langs = document.getElementById("langs");
|
||
const langOpen = (on) => { langs.hidden = !on; langBtn.setAttribute("aria-expanded", String(on)); };
|
||
langBtn.addEventListener("click", () => { open(false); langOpen(langs.hidden); });
|
||
langs.querySelectorAll("a").forEach((a) => {
|
||
if (a.hreflang === LANG) a.setAttribute("aria-current", "page");
|
||
a.addEventListener("click", () => { document.cookie = "lang=" + a.hreflang + "; path=/; max-age=31536000; samesite=lax"; });
|
||
});
|
||
document.addEventListener("click", (e) => { if (!langBtn.parentNode.contains(e.target)) langOpen(false); });
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !langs.hidden) { langOpen(false); langBtn.focus(); } });
|
||
menu.addEventListener("click", () => langOpen(false));
|
||
|
||
const onScroll = () => nav.classList.toggle("scrolled", scrollY > 8);
|
||
addEventListener("scroll", onScroll, { passive: true }); onScroll();
|
||
|
||
document.querySelectorAll(".copy").forEach((b) => b.addEventListener("click", async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(document.getElementById(b.dataset.copy).textContent);
|
||
b.textContent = T("Copied", "已复制", "コピーしました", "Kopiert"); setTimeout(() => (b.textContent = T("Copy", "复制", "コピー", "Kopieren")), 1400);
|
||
} catch {}
|
||
}));
|
||
|
||
// Windows and Linux get their own app; arm64 is guessed from the user
|
||
// agent and corrected by the browser when it can tell.
|
||
const ua = navigator.userAgent;
|
||
const os = /Windows/.test(ua) ? "windows" : /Linux|X11|CrOS/.test(ua) && !/Android/.test(ua) ? "linux" : "mac";
|
||
if (os !== "mac") {
|
||
document.documentElement.dataset.os = os;
|
||
const name = os === "windows" ? "Windows" : "Linux";
|
||
const arm = /aarch64|arm64|armv8/i.test(ua);
|
||
const dl = (a64) => "/download/" + os + (a64 ? "-arm64" : "-amd64");
|
||
document.querySelectorAll("a.dl").forEach((a) => (a.href = dl(arm)));
|
||
document.querySelectorAll(".dl-label").forEach((e) => (e.textContent = T("Download for " + name, "下载 " + name + " 版", name + " 版をダウンロード", "Für " + name + " herunterladen")));
|
||
document.querySelectorAll(".os-note").forEach((e) => (e.innerHTML = os === "windows" ? "Windows 10+" : "Needs WebKitGTK 4.1"));
|
||
document.querySelectorAll('.meta a[href="#get"]').forEach((a) => (a.textContent = T("macOS & other builds", "macOS 及其他版本", "macOS とその他のビルド", "macOS und weitere Builds")));
|
||
try { navigator.userAgentData?.getHighEntropyValues(["architecture"]).then((v) => {
|
||
if (v.architecture === "arm") document.querySelectorAll("a.dl").forEach((a) => (a.href = dl(true)));
|
||
}).catch(() => {}); } catch {}
|
||
}
|
||
|
||
// Intel Macs get the Intel build; browsers that can tell say so, the rest
|
||
// get Apple Silicon with the Intel link beside it.
|
||
const setArch = (arch) => {
|
||
if (arch !== "x86" || os !== "mac") return;
|
||
document.querySelectorAll("a.dl").forEach((a) => (a.href = "/download/mac-intel"));
|
||
document.querySelectorAll("a.alt").forEach((a) => { a.href = "/download/mac-arm64"; a.textContent = "Apple Silicon"; });
|
||
};
|
||
try { navigator.userAgentData?.getHighEntropyValues(["architecture"]).then((v) => setArch(v.architecture)).catch(() => {}); } catch {}
|
||
|
||
fetch("/api/latest").then((r) => (r.ok ? r.json() : null)).then((rel) => {
|
||
if (!rel) return;
|
||
document.querySelectorAll(".ver").forEach((e) => (e.textContent = T("Version ", "版本 ", "バージョン ", "Version ") + rel.version));
|
||
}).catch(() => {});
|
||
})();
|
||
</script>
|
||
<script>
|
||
// The routing demo: requests travel from the agent through magpie to the
|
||
// account the chosen strategy picks; one that can't answer sends the request
|
||
// back to magpie, which tries the next.
|
||
(() => {
|
||
const rt = document.getElementById("rt");
|
||
if (!rt) return;
|
||
const $ = (s) => rt.querySelector(s);
|
||
const stage = $(".rt-stage"), svg = $(".rt-wires"), src = $(".rt-src"), hub = $(".rt-hub"), list = $(".rt-accts");
|
||
const cap = $(".rt-cap"), desc = $(".rt-mode"), chip = hub.querySelector("i");
|
||
const stat = [...rt.querySelectorAll(".rt-stats b")], statName = [...rt.querySelectorAll(".rt-stats > span")].map((e) => e.lastChild);
|
||
const who = src.querySelector("small");
|
||
const tabs = [...rt.querySelectorAll(".rt-tabs button")];
|
||
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
const NS = "http://www.w3.org/2000/svg";
|
||
|
||
// Two Claude subscriptions and three other vendors' accounts, each with
|
||
// its vendor's icon; the captions name them by plan.
|
||
const V = {
|
||
claude: ["me@work.dev", "Claude Max", "claude-color", "Claude Max", ""],
|
||
claude2: ["me@home.dev", "Claude Pro", "claude-color", "Claude Pro", ""],
|
||
gpt: ["me@home.dev", "ChatGPT Pro", "openai", "ChatGPT Pro", "bird"],
|
||
or: ["sk-or-…4f2a", "OpenRouter", "openrouter", T("The OpenRouter key", "OpenRouter 密钥", "OpenRouter のキー", "Der OpenRouter-Schlüssel"), "bird"],
|
||
gh: ["octo-dev", "Copilot Business", "githubcopilot", "Copilot", "bird"],
|
||
};
|
||
const A = (v, used, inc, x = {}) => { const [who, plan, icon, name, cls] = V[v]; return { who, plan, icon, name, cls, used, inc, ...x }; };
|
||
// A group's models, each with the rule that sends a turn to it; the first
|
||
// takes what no rule matches.
|
||
const G = (who, plan, icon, cls, rule, short) => ({ who, plan, icon, cls, rule, short, used: 0, inc: 0, key: 1 });
|
||
const MODES = {
|
||
intent: {
|
||
name: T("By intent", "按意图", "インテント別", "Nach Intent"), n: 0, stats: T(["turns", "asked the classifier", "tool rounds kept on their model"], ["轮对话", "次询问分类模型", "个工具轮次留在原模型"], ["ターン", "分類モデルへの問い合わせ", "同じモデルに留まったツール往復"], ["Runden", "Fragen an den Klassifikator", "Tool-Runden beim selben Modell"]),
|
||
desc: T("By intent: as a turn begins, a small fast model (the group's classifier) is asked once which of your rules' intents the message is. The turn goes to that rule's model; its tool rounds stay there, without asking again.",
|
||
"按意图:每轮对话开始时,问一次小而快的模型(路由组的分类模型)这条消息属于你哪条规则的意图。这一轮交给那条规则的模型,之后的工具轮次也留在那里,不再重新询问。",
|
||
"インテント別:ターンの始めに、小さく速いモデル(グループの分類モデル)に、メッセージがどのルールのインテントにあたるかを一度だけ尋ねます。そのターンはそのルールのモデルが担当し、続くツールの往復も問い直さずそこに留まります。",
|
||
"Nach Intent: Zu Beginn einer Runde wird ein kleines, schnelles Modell (der Klassifikator der Gruppe) einmal gefragt, welchem Intent Ihrer Regeln die Nachricht entspricht. Die Runde geht an das Modell dieser Regel; ihre Tool-Runden bleiben dort, ohne erneute Frage."),
|
||
accts: () => [
|
||
G("claude-opus-5-5", "Claude Max", "claude-color", "", T("first · whatever no rule takes", "第一个 · 规则都不匹配时用它", "先頭 · どのルールにも当たらないとき", "erstes · alles, was keine Regel nimmt")),
|
||
G("deepseek-v4-flash", "DeepSeek", "deepseek-color", "", T("intent · a quick question", "意图 · 简单提问", "インテント · ちょっとした質問", "Intent · eine kurze Frage"), T("quick question", "简单提问", "ちょっとした質問", "kurze Frage")),
|
||
G("glm-5.3", T("Zhipu GLM", "智谱 GLM", "Zhipu GLM", "Zhipu GLM"), "zhipu-color", "", T("intent · writing or fixing tests", "意图 · 编写或修复测试", "インテント · テストの作成や修正", "Intent · Tests schreiben oder reparieren"), T("tests", "测试", "テスト", "Tests")),
|
||
G("kimi-k3", "Kimi", "kimi", "bird", T("intent · reviewing a diff", "意图 · 审阅改动", "インテント · 差分のレビュー", "Intent · einen Diff prüfen"), T("review", "审阅", "レビュー", "Review")),
|
||
],
|
||
turns: [
|
||
[T("Add a --dry-run flag to the deploy command", "给 deploy 命令加一个 --dry-run 参数", "deploy コマンドに --dry-run フラグを追加して", "Dem deploy-Befehl ein --dry-run-Flag hinzufügen"), 0, 2],
|
||
[T("What does ^\\d{3}-\\d{4}$ match?", "^\\d{3}-\\d{4}$ 能匹配什么?", "^\\d{3}-\\d{4}$ は何にマッチする?", "Worauf passt ^\\d{3}-\\d{4}$?"), 1, 0],
|
||
[T("TestRouting fails after my change — fix it", "我改完之后 TestRouting 挂了,帮我修好", "変更したら TestRouting が落ちた。直して", "TestRouting schlägt nach meiner Änderung fehl – bitte reparieren"), 2, 2],
|
||
[T("Review my staged changes before I commit", "提交前帮我审一下暂存的改动", "コミット前にステージした変更をレビューして", "Meine gestagten Änderungen vor dem Commit prüfen"), 3, 1],
|
||
[T("Why does useEffect run twice in dev?", "为什么 useEffect 在开发模式下会执行两次?", "開発モードで useEffect が2回実行されるのはなぜ?", "Warum läuft useEffect im Dev-Modus zweimal?"), 1, 0],
|
||
[T("Write table-driven tests for parseTokens", "给 parseTokens 写表驱动测试", "parseTokens のテーブル駆動テストを書いて", "Tabellengetriebene Tests für parseTokens schreiben"), 2, 1],
|
||
[T("Refactor the retry loop into its own function", "把重试循环抽成单独的函数", "リトライのループを別の関数に切り出して", "Die Retry-Schleife in eine eigene Funktion auslagern"), 0, 1],
|
||
],
|
||
},
|
||
smart: {
|
||
name: T("Smart", "智能", "スマート", "Smart"), n: 30,
|
||
desc: T("Smart, the default: the account that resets soonest goes first, so no allowance is left to go to waste; any that can't answer are passed over. (Here each request stands for an hour.)",
|
||
"智能(默认):最先重置的账号先用,额度一点不浪费;答不了的账号直接跳过。(演示里每个请求代表一小时。)",
|
||
"スマート(既定):リセットが最も早いアカウントから使うので、枠を無駄にしません。応答できないアカウントは飛ばします。(デモでは1リクエストを1時間とします。)",
|
||
"Smart, die Voreinstellung: Das Konto, das als Erstes zurückgesetzt wird, kommt zuerst, damit kein Kontingent verfällt; Konten, die nicht antworten können, werden übersprungen. (Hier steht jede Anfrage für eine Stunde.)"),
|
||
first: (a) => T(`${a.name} resets in ${fmt(left(a))} — soonest, so it goes first: what it has left is used, not lost at the reset.`,
|
||
`${a.name} ${fmt(left(a))}后重置,最早,所以先用它:剩下的额度会被用掉,而不是在重置时作废。`,
|
||
`${a.name} は ${fmt(left(a))}後にリセット。最も早いので先に使います。残りの枠はリセットで消えずに使い切られます。`,
|
||
`${a.name} wird in ${fmt(left(a))} zurückgesetzt – am frühesten, also kommt es zuerst dran: Was übrig ist, wird genutzt, statt beim Reset zu verfallen.`),
|
||
accts: () => [
|
||
A("claude", 72, 1.5, { r: 9 }),
|
||
A("claude2", 38, 3, { r: 48, fail: { at: 3, code: 429, rest: 7000 } }),
|
||
A("gpt", 30, 3, { r: 96 }),
|
||
A("or", 0, 0, { key: 1, fail: { at: 0, code: 402 } }),
|
||
A("gh", 24, 2, { r: 288, fail: { at: 4, code: 503, rest: 5000 } }),
|
||
],
|
||
},
|
||
order: {
|
||
name: T("In order", "按顺序", "優先順", "Der Reihe nach"), n: 18,
|
||
desc: T("In order: the first answers everything until it can't; then the next takes over.", "按顺序:第一个账号包揽所有请求,直到它答不了,再由下一个接手。", "優先順:最初のアカウントが応答できなくなるまですべてを担当し、その後は次が引き継ぎます。", "Der Reihe nach: Das erste Konto beantwortet alles, bis es nicht mehr kann; dann übernimmt das nächste."),
|
||
first: (a) => T(`${a.name} answers everything, for as long as it can.`, `${a.name} 只要还能答,就全由它来答。`, `応答できる限り、${a.name} がすべてを担当します。`, `${a.name} beantwortet alles, solange es geht.`),
|
||
accts: () => [A("claude", 76, 3), A("claude2", 44, 3), A("gpt", 30, 3), A("or", 0, 0, { key: 1 }), A("gh", 24, 2)],
|
||
},
|
||
turn: {
|
||
name: T("In turn", "轮流", "持ち回り", "Reihum"), n: 16,
|
||
desc: T("In turn: each request goes to the next account, so the load is spread evenly.", "轮流:每个请求交给下一个账号,负载均匀分摊。", "持ち回り:リクエストごとに次のアカウントへ回し、負荷を均等に分散します。", "Reihum: Jede Anfrage geht an das nächste Konto, so verteilt sich die Last gleichmäßig."),
|
||
first: () => T("Each request goes to the next account in turn.", "每个请求依次交给下一个账号。", "リクエストを順番に次のアカウントへ回します。", "Jede Anfrage geht reihum an das nächste Konto."),
|
||
accts: () => [A("claude", 30, 2), A("claude2", 40, 2), A("gpt", 52, 2), A("or", 0, 0, { key: 1 }), A("gh", 18, 2)],
|
||
},
|
||
least: {
|
||
name: T("Least used", "用量少的优先", "使用量の少ない順", "Geringste Nutzung"), n: 18,
|
||
desc: T("Least used first: each request goes to the account with the most allowance left; a key by the tokens magpie sent it lately.", "用量少的优先:每个请求交给剩余额度最多的账号;密钥按 magpie 最近发给它的 token 数算。", "使用量の少ない順:各リクエストを残り枠が最も多いアカウントへ送ります。キーは magpie が最近送ったトークン数で比べます。", "Geringste Nutzung zuerst: Jede Anfrage geht an das Konto mit dem meisten verbleibenden Kontingent; ein Schlüssel nach den Tokens, die magpie ihm zuletzt geschickt hat."),
|
||
first: () => T("Each request goes to whichever has the most left — watch the bars even out.", "每个请求交给剩得最多的账号——看进度条慢慢拉平。", "各リクエストは残りが最も多いアカウントへ。バーが揃っていく様子をご覧ください。", "Jede Anfrage geht an das Konto mit dem meisten Rest – sehen Sie zu, wie sich die Balken angleichen."),
|
||
accts: () => [A("claude", 64, 6), A("claude2", 30, 6), A("gpt", 22, 6), A("or", 40, 6, { key: 1, bar: 1 }), A("gh", 46, 6)],
|
||
},
|
||
};
|
||
|
||
let mode, M, accts = [], t = 0, gen = 0, next = 0, sent = 0, done = 0, rerouted = 0, errs = 0, ptr = 0;
|
||
let hours = 0; // the demo's clock in smart mode: an hour a request
|
||
const left = (a) => a.r - hours, fmt = (h) => h < 48 ? T(`${h} h`, `${h} 小时`, `${h} 時間`, `${h} Std.`) : T(`${Math.round(h / 24)} d`, `${Math.round(h / 24)} 天`, `${Math.round(h / 24)} 日`, `${Math.round(h / 24)} Tg.`);
|
||
let began = 0, last = null, trips = [], capQ = [], capAt = -1e9, capLo = false, endAt = 0, wSrc, vis = true;
|
||
|
||
const resting = (a) => a.until > t;
|
||
const path = () => { const p = document.createElementNS(NS, "path"); svg.appendChild(p); return p; };
|
||
const tick = (el) => { el.classList.remove("tick"); void el.offsetWidth; el.classList.add("tick"); };
|
||
const travel = (dot, p, rev, dur) => new Promise((res) => trips.push({ dot, p, rev, t0: t, dur: still ? 0 : dur, res, g: gen }));
|
||
const wait = (ms) => travel(null, null, false, ms);
|
||
// A caption about a failure goes ahead of the small ones ("takes over", "is back").
|
||
const say = (s, lo) => {
|
||
if (lo && capQ.length) return;
|
||
if (!lo) capQ = capQ.filter((c) => !c.lo);
|
||
capQ.push({ s, lo });
|
||
if (capQ.length > 2) capQ.shift();
|
||
};
|
||
|
||
function layout() {
|
||
const r = stage.getBoundingClientRect();
|
||
const box = (el) => { const b = el.getBoundingClientRect(); return { l: b.left - r.left, r: b.right - r.left, t: b.top - r.top, b: b.bottom - r.top, cx: (b.left + b.right) / 2 - r.left, cy: (b.top + b.bottom) / 2 - r.top }; };
|
||
svg.setAttribute("viewBox", `0 0 ${r.width} ${r.height}`);
|
||
const s = box(src), h = box(hub);
|
||
wSrc.setAttribute("d", h.l > s.r ? `M${s.r} ${s.cy} L${h.l} ${h.cy}` : `M${s.cx} ${s.b} L${h.cx} ${h.t}`);
|
||
for (const a of accts) {
|
||
const b = box(a.li);
|
||
if (b.l > h.r) {
|
||
const mx = (h.r + b.l) / 2;
|
||
a.wire.setAttribute("d", `M${h.r} ${h.cy} C${mx} ${h.cy} ${mx} ${b.cy} ${b.l} ${b.cy}`);
|
||
} else { // the accounts sit under magpie: a lane down their left
|
||
const x = b.l - 14;
|
||
a.wire.setAttribute("d", `M${h.cx} ${h.b} C${h.cx} ${h.b + 26} ${x} ${h.b + 2} ${x} ${h.b + 28} L${x} ${b.cy - 10} Q${x} ${b.cy} ${b.l} ${b.cy}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function reset(m) {
|
||
gen++; mode = m; M = MODES[m]; accts = M.accts();
|
||
sent = done = rerouted = errs = ptr = began = hours = 0; last = null; endAt = 0; trips = []; capQ = [];
|
||
next = t + 600;
|
||
list.innerHTML = accts.map((a) => `<li><i class="dot"></i><b><img${a.cls ? ` class="${a.cls}"` : ""} src="/icons/${a.icon}.svg" alt="">${a.who} <span>${a.plan}</span></b><em></em><div class="bar"><i></i></div><span class="tag"></span></li>`).join("");
|
||
svg.innerHTML = "";
|
||
wSrc = path();
|
||
accts.forEach((a, i) => {
|
||
a.li = list.children[i]; a.st = a.li.querySelector("em"); a.bi = a.li.querySelector(".bar i"); a.tg = a.li.querySelector(".tag");
|
||
a.li.classList.toggle("nobar", !!a.key && !a.bar);
|
||
a.wire = path(); a.served = 0; a.until = 0;
|
||
});
|
||
layout();
|
||
list.classList.toggle("models", !!M.turns);
|
||
desc.textContent = M.desc; chip.textContent = M.name; who.textContent = T("your agent", "你的 Agent", "あなたのエージェント", "Ihr Agent");
|
||
(M.stats || T(["requests", "rerouted", "errors your agent saw"], ["个请求", "次改道", "个错误被 Agent 看到"], ["リクエスト", "振り替え", "エージェントが見たエラー"], ["Anfragen", "umgeleitet", "Fehler bei Ihrem Agenten"])).forEach((n, i) => (statName[i].textContent = n));
|
||
tabs.forEach((b) => b.setAttribute("aria-selected", String(b.dataset.mode === m)));
|
||
show(M.turns ? T("Claude Code sends every turn to one model name — a magpie group. Watch where each message goes.", "Claude Code 每轮都发给同一个模型名——一个 magpie 路由组。看每条消息去了哪里。", "Claude Code は毎ターン、同じモデル名(magpie のグループ)に送ります。各メッセージの行き先をご覧ください。", "Claude Code schickt jede Runde an denselben Modellnamen – eine Weiterleitungsgruppe von magpie. Sehen Sie, wohin jede Nachricht geht.")
|
||
: T("Claude Code sends every request to magpie; magpie decides which account answers it.", "Claude Code 把每个请求发给 magpie,由 magpie 决定哪个账号来答。", "Claude Code はすべてのリクエストを magpie に送り、どのアカウントが応答するかは magpie が決めます。", "Claude Code schickt jede Anfrage an magpie; magpie entscheidet, welches Konto sie beantwortet."));
|
||
stats(); render();
|
||
}
|
||
|
||
function show(c) { capLo = !!c.lo; const s = c.s || c; cap.textContent = s; cap.classList.remove("in"); void cap.offsetWidth; cap.classList.add("in"); capAt = t; }
|
||
function stats() { stat[0].textContent = done; stat[1].textContent = rerouted; stat[2].textContent = errs; }
|
||
|
||
function pick() {
|
||
const ok = accts.filter((a) => !resting(a));
|
||
if (!ok.length) return null;
|
||
switch (mode) {
|
||
case "smart": return ok.filter((a) => a.used < 90).sort((x, y) => (x.r ?? 1e9) - (y.r ?? 1e9))[0] || ok.slice().sort((x, y) => x.used - y.used)[0];
|
||
case "order": return ok[0];
|
||
case "least": return ok.slice().sort((x, y) => x.used - y.used)[0];
|
||
default:
|
||
for (let i = 0; i < accts.length; i++) {
|
||
const a = accts[(ptr + i) % accts.length];
|
||
if (!resting(a)) { ptr = (accts.indexOf(a) + 1) % accts.length; return a; }
|
||
}
|
||
}
|
||
}
|
||
|
||
function respond(a) {
|
||
if (resting(a)) return a.code;
|
||
if (a.fail && !a.failed && a.served >= a.fail.at) { a.failed = 1; return a.fail.code; }
|
||
if (!a.key && a.used >= 100) return "quota";
|
||
return 200;
|
||
}
|
||
|
||
function serve(a) {
|
||
const was = a.used;
|
||
a.served++; a.used = Math.min(100, a.used + a.inc);
|
||
if (last !== a) {
|
||
if (!began) began = 1, say(M.first(a));
|
||
else if (mode === "smart" && a.used >= 90) say(T(`The others are resting, so ${a.name.replace(/^The /, "the ")} answers — running low beats no answer.`, `其他账号都在休息,所以由 ${a.name} 来答——额度快用完也比没人答强。`, `ほかのアカウントは休止中なので ${a.name} が応答します。枠が残り少なくても、応答なしよりはましです。`, `Die anderen pausieren, also antwortet ${a.name.replace(/^Der /, "der ")} – knappes Kontingent ist besser als keine Antwort.`));
|
||
else if (mode === "smart" && a.r) say(T(`${a.name} takes over — it resets next, in ${fmt(left(a))}.`, `${a.name} 接手——它下一个重置,还有 ${fmt(left(a))}。`, `${a.name} が引き継ぎます。次にリセットされるのはこれで、あと ${fmt(left(a))}。`, `${a.name} übernimmt – sein Reset kommt als Nächstes, in ${fmt(left(a))}.`), true);
|
||
else if (mode === "smart" || mode === "order") say(T(`${a.name} takes over.`, `${a.name} 接手。`, `${a.name} が引き継ぎます。`, `${a.name} übernimmt.`), true);
|
||
last = a;
|
||
}
|
||
if (mode === "smart" && was < 90 && a.used >= 90) say(T(`${a.name} is at ${Math.round(a.used)}% — kept for last. The next with quota to spare takes over.`, `${a.name} 已用 ${Math.round(a.used)}%——留到最后。下一个额度充足的账号接手。`, `${a.name} は ${Math.round(a.used)}% 使用済み。最後に回し、枠に余裕のある次のアカウントが引き継ぎます。`, `${a.name} steht bei ${Math.round(a.used)}% – es kommt zuletzt dran. Das nächste Konto mit genug Kontingent übernimmt.`));
|
||
if (mode !== "smart") return;
|
||
hours++;
|
||
for (const b of accts) if (b.r && left(b) <= 0) { // its week renews: now the last to reset
|
||
const lost = Math.round(100 - b.used);
|
||
b.used = 0; b.r += 168;
|
||
say(T(`${b.name}'s week renewed with only ${lost}% unused — it went first, so little was lost. It resets last now; the next to reset takes over.`, `${b.name} 的周额度已重置,只剩 ${lost}% 没用上——它先用,所以几乎没浪费。现在它最晚重置,下一个要重置的账号接手。`, `${b.name} の週の枠がリセットされました。使い残しは ${lost}% だけ。先に使ったので無駄はほとんどありません。今度はリセットが最も遅いので、次にリセットされるアカウントが引き継ぎます。`, `Das Wochenkontingent von ${b.name} wurde erneuert, nur ${lost}% blieben ungenutzt – es kam zuerst dran, also ging wenig verloren. Jetzt kommt sein Reset als letzter; das Konto mit dem nächsten Reset übernimmt.`));
|
||
if (last === b) last = null;
|
||
}
|
||
}
|
||
|
||
const TAGS = T({ 429: "429 · rate limited", 402: "402 · no credit", 503: "503 · failed", quota: "429 · quota used up" },
|
||
{ 429: "429 · 限流", 402: "402 · 余额不足", 503: "503 · 出错", quota: "429 · 额度用完" },
|
||
{ 429: "429 · レート制限", 402: "402 · クレジット不足", 503: "503 · 失敗", quota: "429 · 枠を使い切り" },
|
||
{ 429: "429 · Rate-Limit", 402: "402 · kein Guthaben", 503: "503 · Fehler", quota: "429 · Kontingent aufgebraucht" });
|
||
function fail(a, code) {
|
||
a.li.classList.remove("hit"); void a.li.offsetWidth; a.li.classList.add("hit", "tagged");
|
||
a.tg.textContent = TAGS[code];
|
||
setTimeout(() => a.li.classList.remove("tagged"), 1500);
|
||
if (resting(a)) return; // it was already out when the request got there
|
||
a.code = code;
|
||
next = Math.max(next, t + 1500); // a moment to read what happened
|
||
if (code === 429) { a.why = "rate"; a.until = t + a.fail.rest; say(T(`${a.name} answered 429 — it rests for as long as the vendor asks, and the request is retried on the next at once.`, `${a.name} 返回 429——按厂商要求的时长休息,请求立刻在下一个账号上重试。`, `${a.name} が 429 を返しました。ベンダーが求める間だけ休ませ、リクエストはすぐ次のアカウントで再試行します。`, `${a.name} antwortete mit 429 – Pause, so lange der Anbieter es verlangt; die Anfrage wird sofort beim nächsten Konto wiederholt.`)); }
|
||
if (code === 402) { a.why = "credit"; a.until = Infinity; say(T(`${a.name} is out of credit — it sits out half an hour, and the request moves on. Claude Code never sees the error.`, `${a.name} 余额不足——歇半小时,请求转给别的账号。Claude Code 完全看不到这个错误。`, `${a.name} はクレジット切れ。30分休ませ、リクエストは別のアカウントへ。Claude Code にこのエラーは見えません。`, `${a.name} hat kein Guthaben mehr – eine halbe Stunde Pause, und die Anfrage zieht weiter. Claude Code sieht den Fehler nie.`)); }
|
||
if (code === 503) { a.why = "fail"; a.until = t + a.fail.rest; say(T(`${a.name} failed — it steps back a while, longer each time it fails again. Another account answers instead.`, `${a.name} 出错了——先退下一会儿,再出错就退得更久。换另一个账号来答。`, `${a.name} が失敗。しばらく外し、失敗が続くほど長く外します。代わりに別のアカウントが応答します。`, `${a.name} ist fehlgeschlagen – eine Weile Pause, bei jedem weiteren Fehler länger. Ein anderes Konto antwortet stattdessen.`)); }
|
||
if (code === "quota") { a.why = "quota"; a.until = Infinity; say(T(`${a.name} is out of quota — passed over until it resets. The request goes to the next.`, `${a.name} 额度用完——重置前都跳过它,请求交给下一个。`, `${a.name} は枠を使い切りました。リセットまで飛ばし、リクエストは次へ。`, `${a.name} hat sein Kontingent aufgebraucht – bis zum Reset übersprungen. Die Anfrage geht an das nächste Konto.`)); }
|
||
if (last === a) last = null;
|
||
}
|
||
|
||
async function request(g) {
|
||
const dot = document.createElementNS(NS, "circle");
|
||
dot.setAttribute("r", 4.5); dot.setAttribute("class", "pkt"); dot.setAttribute("cx", -20);
|
||
svg.appendChild(dot); tick(src);
|
||
await travel(dot, wSrc, false, 380);
|
||
for (let tries = 0; g === gen && tries < 8; tries++) {
|
||
const a = pick();
|
||
if (!a) { await wait(400); continue; }
|
||
tick(hub);
|
||
await travel(dot, a.wire, false, 420);
|
||
if (g !== gen) return;
|
||
const code = respond(a);
|
||
if (code === 200) {
|
||
serve(a);
|
||
dot.classList.add("back"); dot.setAttribute("r", 4);
|
||
await travel(dot, a.wire, true, 360);
|
||
await travel(dot, wSrc, true, 320);
|
||
if (g !== gen) return;
|
||
dot.remove(); done++; stats(); return;
|
||
}
|
||
fail(a, code); rerouted++; stats();
|
||
await travel(dot, a.wire, true, 300);
|
||
}
|
||
if (g !== gen) return;
|
||
dot.remove(); done++; errs++; stats();
|
||
}
|
||
|
||
// By intent: a turn's first request is classified, once; its tool rounds
|
||
// follow it to the same model.
|
||
async function turns(g) {
|
||
const trip = async (a) => {
|
||
const dot = document.createElementNS(NS, "circle");
|
||
dot.setAttribute("r", 4.5); dot.setAttribute("class", "pkt"); dot.setAttribute("cx", -20);
|
||
svg.appendChild(dot); tick(src);
|
||
await travel(dot, wSrc, false, 380);
|
||
if (g !== gen) return dot.remove();
|
||
tick(hub);
|
||
if (!a) return dot;
|
||
await travel(dot, a.wire, false, 420);
|
||
if (g !== gen) return dot.remove();
|
||
last = a; a.served++;
|
||
dot.classList.add("back"); dot.setAttribute("r", 4);
|
||
await travel(dot, a.wire, true, 360);
|
||
await travel(dot, wSrc, true, 320);
|
||
dot.remove();
|
||
};
|
||
for (const [msg, n, rounds] of M.turns) {
|
||
if (g !== gen) return;
|
||
const a = accts[n];
|
||
who.textContent = T("new turn", "新一轮", "新しいターン", "neue Runde"); chip.textContent = T("new turn", "新一轮", "新しいターン", "neue Runde");
|
||
show(T(`A new turn: “${msg}”`, `新一轮对话:“${msg}”`, `新しいターン:「${msg}」`, `Eine neue Runde: „${msg}“`));
|
||
const dot = await trip(null);
|
||
if (g !== gen) return;
|
||
chip.textContent = T("classifying…", "分类中…", "分類中…", "klassifiziere…");
|
||
await wait(900);
|
||
if (g !== gen) return dot?.remove();
|
||
rerouted++; stats();
|
||
chip.textContent = n ? a.short : T("no intent", "无意图", "インテントなし", "kein Intent");
|
||
a.li.classList.remove("hit"); void a.li.offsetWidth; a.li.classList.add("tagged");
|
||
a.tg.textContent = n ? "✓ " + a.short : T("✓ the group's first", "✓ 路由组第一个", "✓ グループの先頭", "✓ erstes der Gruppe");
|
||
setTimeout(() => a.li.classList.remove("tagged"), 1600);
|
||
const why = a.rule.replace(/^[^·]*· /, "");
|
||
show(n ? T(`The classifier says: ${why} — so ${a.who} answers it.`, `分类模型判断:${why}——所以由 ${a.who} 来答。`, `分類モデルの判定:${why}。なので ${a.who} が応答します。`, `Der Klassifikator sagt: ${why} – also antwortet ${a.who}.`)
|
||
: T(`None of the intents — it goes to the group's first model, ${a.who}.`, `哪个意图都不是——交给路由组的第一个模型 ${a.who}。`, `どのインテントでもないので、グループの先頭のモデル ${a.who} へ。`, `Keiner der Intents – die Runde geht an das erste Modell der Gruppe, ${a.who}.`));
|
||
await travel(dot, a.wire, false, 420);
|
||
if (g !== gen) return dot.remove();
|
||
last = a; a.served++;
|
||
dot.classList.add("back"); dot.setAttribute("r", 4);
|
||
await travel(dot, a.wire, true, 360);
|
||
await travel(dot, wSrc, true, 320);
|
||
dot.remove(); done++; stats();
|
||
for (let i = 0; i < rounds; i++) {
|
||
if (g !== gen) return;
|
||
who.textContent = T("tool result", "工具结果", "ツールの結果", "Tool-Ergebnis");
|
||
chip.textContent = T("same turn", "同一轮", "同じターン", "gleiche Runde");
|
||
if (!i) show(T(`Its tool results come back as more requests — no new question: they stay on ${a.who}.`, `工具结果作为后续请求回来——不是新问题,所以留在 ${a.who}。`, `ツールの結果は続きのリクエストとして戻ります。新しい質問ではないので ${a.who} に留まります。`, `Die Tool-Ergebnisse kommen als weitere Anfragen zurück – keine neue Frage, also bleiben sie bei ${a.who}.`));
|
||
await trip(a);
|
||
errs++; stats();
|
||
}
|
||
await wait(rounds ? 700 : 1300);
|
||
}
|
||
if (g !== gen) return;
|
||
who.textContent = T("your agent", "你的 Agent", "あなたのエージェント", "Ihr Agent"); chip.textContent = M.name;
|
||
show(T(`${done} turns, each on the model its rule names — one question to the classifier per turn, ${errs} tool rounds kept on their model.`, `${done} 轮对话,各自交给规则指定的模型——每轮只问一次分类模型,${errs} 个工具轮次留在原模型。`, `${done} ターン、それぞれルールが指すモデルへ。分類モデルへの問い合わせは1ターン1回、${errs} 回のツール往復は同じモデルに留まりました。`, `${done} Runden, jede auf dem Modell, das ihre Regel nennt – eine Frage an den Klassifikator pro Runde, ${errs} Tool-Runden beim selben Modell geblieben.`));
|
||
await wait(4600);
|
||
if (g === gen) reset(mode);
|
||
}
|
||
|
||
function render() {
|
||
if (M.turns) {
|
||
for (const a of accts) {
|
||
if (a.st.textContent !== a.rule) a.st.textContent = a.rule;
|
||
a.li.classList.toggle("on", last === a);
|
||
a.wire.classList.toggle("live", last === a);
|
||
}
|
||
wSrc.classList.toggle("live", !!last);
|
||
return;
|
||
}
|
||
for (const a of accts) {
|
||
const rest = resting(a), n = Math.round(a.used), s = Math.ceil((a.until - t) / 1000);
|
||
const label = rest
|
||
? T({ rate: `rate limited · back in ${s} s`, fail: `failing · back in ${s} s`, credit: "out of credit · back in 30 m", quota: "out of quota · resets 3 PM" },
|
||
{ rate: `限流 · ${s} 秒后恢复`, fail: `出错 · ${s} 秒后恢复`, credit: "余额不足 · 30 分钟后恢复", quota: "额度用完 · 下午 3 点重置" },
|
||
{ rate: `レート制限 · ${s} 秒後に復帰`, fail: `失敗中 · ${s} 秒後に復帰`, credit: "クレジット切れ · 30 分後に復帰", quota: "枠切れ · 午後3時にリセット" },
|
||
{ rate: `Rate-Limit · zurück in ${s} s`, fail: `fehlerhaft · zurück in ${s} s`, credit: "kein Guthaben · zurück in 30 min", quota: "kein Kontingent · Reset um 15 Uhr" })[a.why]
|
||
: last === a ? (mode === "smart" && a.used >= 90 ? T("serving · running low", "服务中 · 额度将尽", "応答中 · 残りわずか", "antwortet · wird knapp") : T("serving", "服务中", "応答中", "antwortet"))
|
||
: a.key ? (a.bar ? T(`${n}% of tokens lately`, `近期 token 占 ${n}%`, `最近のトークンの ${n}%`, `zuletzt ${n}% der Tokens`) : T("pay as you go", "按量付费", "従量課金", "nach Verbrauch"))
|
||
: mode === "smart" && a.used >= 90 ? T(`${n}% used · kept for last`, `已用 ${n}% · 留到最后`, `${n}% 使用 · 最後に回す`, `${n}% genutzt · kommt zuletzt`)
|
||
: mode === "smart" && a.r ? T(`${n}% used · resets in ${fmt(left(a))}`, `已用 ${n}% · ${fmt(left(a))}后重置`, `${n}% 使用 · ${fmt(left(a))}後にリセット`, `${n}% genutzt · Reset in ${fmt(left(a))}`) : T(`${n}% used`, `已用 ${n}%`, `${n}% 使用`, `${n}% genutzt`);
|
||
if (a.st.textContent !== label) a.st.textContent = label;
|
||
a.bi.style.width = a.used + "%";
|
||
a.li.classList.toggle("on", last === a && !rest);
|
||
a.li.classList.toggle("low", mode === "smart" && a.used >= 90);
|
||
a.li.classList.toggle("rest", rest);
|
||
a.wire.classList.toggle("live", last === a && !rest);
|
||
a.wire.classList.toggle("rest", rest);
|
||
}
|
||
wSrc.classList.toggle("live", !!last);
|
||
}
|
||
|
||
function step() {
|
||
if (M.turns && !sent && t >= next) { sent = 1; turns(gen); }
|
||
if (sent < M.n && t >= next) { sent++; request(gen); next = t + (still ? 1400 : 900); }
|
||
const now = trips; trips = [];
|
||
for (const tr of now) {
|
||
if (tr.g !== gen) continue;
|
||
const k = tr.dur ? Math.min(1, (t - tr.t0) / tr.dur) : 1;
|
||
if (tr.dot) {
|
||
const e = k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
|
||
const len = tr.p.getTotalLength(), pt = tr.p.getPointAtLength((tr.rev ? 1 - e : e) * len);
|
||
tr.dot.setAttribute("cx", pt.x); tr.dot.setAttribute("cy", pt.y);
|
||
}
|
||
if (k >= 1) tr.res(); else trips.push(tr);
|
||
}
|
||
for (const a of accts) if (a.until && a.until !== Infinity && t >= a.until) {
|
||
a.until = 0;
|
||
say(T(`${a.name} is back — ${a.why === "rate" ? "its rate limit is over" : "it gets another chance"}.`, `${a.name} 回来了——${a.why === "rate" ? "限流结束" : "再给它一次机会"}。`, `${a.name} が復帰。${a.why === "rate" ? "レート制限が明けました" : "もう一度チャンスを与えます"}。`, `${a.name} ist zurück – ${a.why === "rate" ? "das Rate-Limit ist vorbei" : "es bekommt eine neue Chance"}.`), true);
|
||
}
|
||
if (capQ.length && t - capAt > (capLo && !capQ[0].lo ? 700 : 2200)) show(capQ.shift());
|
||
if (!M.turns && done >= M.n && !endAt) {
|
||
endAt = t + 4200;
|
||
capQ = []; show(T(`${done} requests answered, ${rerouted} rerouted — and not one error reached Claude Code.`, `${done} 个请求全部答完,${rerouted} 次改道——没有一个错误传到 Claude Code。`, `${done} 件のリクエストすべてに応答、振り替え ${rerouted} 回。Claude Code に届いたエラーはゼロ。`, `${done} Anfragen beantwortet, ${rerouted} umgeleitet – und kein einziger Fehler erreichte Claude Code.`));
|
||
}
|
||
if (endAt && t >= endAt) reset(mode);
|
||
render();
|
||
}
|
||
|
||
let prev = 0;
|
||
function frame(now) {
|
||
const dt = Math.min(now - (prev || now), 50); prev = now;
|
||
if (vis && !document.hidden) { t += dt; step(); }
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
tabs.forEach((b) => b.addEventListener("click", () => reset(b.dataset.mode)));
|
||
new IntersectionObserver((es) => { vis = es[0].isIntersecting; }).observe(rt);
|
||
new ResizeObserver(() => layout()).observe(stage);
|
||
reset("intent");
|
||
requestAnimationFrame(frame);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|