site: the home page in Chinese at /zh/, and made for phones (the owner: magpie官网加上多语言支持,另外官网要对移动端友好) — index.html stays the one English source, each string marked data-i18n / data-i18n-attr; the worker serves /zh/ from it with i18n.js's dictionary put in by HTMLRewriter (<html lang>, docs links to /docs/zh/…, canonical and hreflang alternates), and / sends a browser whose Accept-Language prefers Chinese there until a language is picked with the nav's 中文/EN link (the lang cookie; Vary: Accept-Language, Cookie). The routing demo, copy button, download label and theme titles speak Chinese on it; the zh docs' home links go to /zh/. On phones the nav's links fold into a menu (≤860px; Escape, a click outside or on a link closes it, aria-expanded kept), Discord and GitHub move into it under 560px, the footer wraps, the routing tabs wrap, and nothing scrolls sideways at 320, 390, 768 or 1280 in either language (WebKit, light and dark). worker.test.mjs checks every marked key has its Chinese and the dictionary has none unused, and the / redirect by Accept-Language and cookie.

This commit is contained in:
yetone committed 2026-10-03 01:09:21 +08:00
1 parent b683e531d9
commit 8efd7d8027
7 files changed
+493 -181

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
// The home page in other languages. public/index.html is written in
// English, each string to translate marked data-i18n="key" (its inner HTML)
// or data-i18n-attr="attr:key,..." (attributes); worker.js serves it at
// /<lang>/ with these put in. A key a language lacks keeps the English, and
// worker.test.mjs fails on a key the page doesn't have or the language
// misses, so the two stay in step. Links inside a string point at the
// language's own pages.
//
// A language is added here and in LANGS: its dictionary, the name of the
// page's link to the English, and "<html lang>".
const zh = {
"meta.desc": "一个地方给每个 Agent 选模型。Codex 用 DeepSeek,Claude Code 用 Kimi,Gemini CLI 用 GLM——在菜单栏里点一下,经由同一个本地网关。",
"meta.tagline": "每个 Agent 的模型,一处搞定。",
"meta.ogalt": "magpie:每个 Agent 的模型,一处搞定",
"meta.url": "https://usemagpie.ai/zh/",
"alt.picker": "magpie 里 Codex 的模型选择器,列出了 DeepSeek、Kimi 和 GLM 的模型",
"alt.panel": "magpie 菜单栏面板:每个 Agent 各用一家厂商的模型",
"alt.providers": "magpie 的供应商页:DeepSeek、Kimi、GLM、Qwen、MiniMax、OpenRouter、Anthropic 和 Gemini,各自列出在用它的 Agent",
"nav.how": "工作原理",
"nav.routing": "路由",
"nav.providers": "供应商",
"nav.docs": "文档",
"nav.releases": "版本发布",
"nav.lang": "EN",
"nav.lang.href": "/",
"nav.lang.code": "en",
"nav.lang.title": "English",
"nav.menu": "菜单",
"nav.download": "下载",
"hero.h1": "每个 Agent 的模型,<span>一处搞定。</span>",
"hero.lede": "magpie 让每个 Agent 都能用上每一个模型。供应商只需添加一次——DeepSeek、Kimi、GLM、Qwen、OpenRouter、Ollama,或你已经在付费的 Claude 和 ChatGPT 订阅——然后在 Codex、Claude Code、Gemini CLI 或任何 Agent 里一键选用。",
"dl.mac": "下载 Mac 版",
"hero.term": "用终端安装",
"hero.free": "免费",
"hero.oss": "开源",
"hero.other": "Windows 与 Linux",
"sponsor.by": "赞助商",
"sponsor.yylx": "Claude 与 GPT 的一站式 API 网关,为 Claude Code 调优。",
"sponsor.become": "成为赞助商",
"agents.agents": "支持的 Agent",
"agents.models": "支持的模型",
"how.eyebrow": "工作原理",
"how.h2": "一个网关,通吃所有 API。",
"how.sub": "Codex 只认 OpenAI Responses,Claude Code 只认 Anthropic Messages。magpie 在你的电脑上运行一个网关,这些协议它都会说,并在两个方向上互相转换——流式输出、工具调用和推理过程都不落下——于是任何 Agent 都能用任何厂商的模型。",
"how.agents": "你的 Agent",
"how.gateway": "magpie 网关",
"how.providers": "任意供应商",
"how.claude": "你的 Claude 订阅",
"how.chatgpt": "你的 ChatGPT 订阅",
"bar.eyebrow": "菜单栏",
"bar.h2": "每个 Agent,各用各的模型。",
"bar.sub": "每个 Agent 在用什么,一眼就能看清。点一下就能换;magpie 只改写那个 Agent 配置里你改动的那一项,下次会话即生效。",
"bar.l1": "<b>Codex</b> 的 <code>/model</code> 列表里直接出现 DeepSeek,推理强度也对得上。",
"bar.l2": "<b>Claude Code</b> 的 <code>opus</code>、<code>sonnet</code>、<code>haiku</code> 背后换成 Kimi、GLM 或 GPT。",
"bar.l3": "<b>选回原生模型</b>,magpie 会把配置原样恢复。",
"bar.l4": "<b>方案</b>一次切换所有 Agent——“省钱”“专注”,随你保存。",
"subs.eyebrow": "订阅共享",
"subs.h2": "你付费的订阅,每个 Agent 都能用。",
"subs.sub": "在某个 Agent 里登录过?这个登录就成了一个供应商,它的模型会出现在其他所有 Agent 的选择器里——不复制任何东西,不用粘贴密钥,令牌按那个 Agent 自己的方式刷新。",
"subs.claude": "用 Claude Code 的登录驱动 OpenCode、Pi 或 Goose:<code>claude/claude-sonnet-5</code>。",
"subs.codex": "用 Codex 的登录在 Claude Code 里用上 GPT:<code>codex/gpt-5.5</code>。",
"subs.copilot": "你的 Copilot 席位可以服务任意 Agent:<code>copilot/claude-sonnet-4.5</code>。",
"rt.eyebrow": "智能路由",
"rt.h2": "每轮对话用对模型,额度永不断供。",
"rt.sub": "写下每个模型的用途——“简单提问”“编写或修复测试”——每轮对话开始时,一个小而快的模型告诉 magpie 这轮属于哪一类:这一轮就交给对应的模型,它的工具轮次也留在那里。<a href=\"/docs/zh/intent\">原理说明</a>。再把你所有的密钥和订阅都加进来——几个 Claude、ChatGPT、Copilot 或 Grok 账号,同一厂商的多个密钥——全部打开。每个请求由 magpie 挑选该答的账号;某个答不了,下一个会在你的 Agent 看到错误之前接上。",
"rt.intent": "按意图",
"rt.smart": "智能",
"rt.order": "按顺序",
"rt.turn": "轮流",
"rt.least": "用量少的优先",
"rt.mode.smart": "智能(默认):最先重置的账号先用,额度一点不浪费;答不了的账号直接跳过。(演示里每个请求代表一小时。)",
"rt.agent": "你的 Agent",
"rt.cap": "Claude Code 把每个请求发给 magpie,由 magpie 决定哪个账号来答。",
"rt.s1": "个请求",
"rt.s2": "次改道",
"rt.s3": "个错误被 Agent 看到",
"rt.reset": "看重置时间",
"rt.reset.p": "最先重置的账号先用——它剩下的额度到重置时就作废了。只要额度还宽裕,请求就一直交给它,提示词缓存也保持温热。",
"rt.credit": "余额用完",
"rt.credit.p": "余额耗尽的密钥歇半小时,请求转给还付得起的那个。",
"rt.limits": "限流与配额",
"rt.limits.p": "被厂商限流的账号,按厂商要求的时长跳过——依据它的 Retry-After 和重置响应头,不瞎猜,也不硬撞。",
"rt.failover": "故障转移",
"rt.failover.p": "出错的账号先退下一分钟,每再错一次就退得更久;请求会在回复的第一个字节到达你的 Agent 之前,换到下一个账号重试。",
"prov.eyebrow": "供应商",
"prov.h2": "粘贴密钥,就配好了。",
"prov.sub": "二十个预设——模型厂商、中转服务和本地服务器。magpie 会问每家厂商提供哪些模型,所以今早刚发布的模型,下次刷新就出现在每个选择器里。密钥只留在 magpie 里,Agent 永远看不到。",
"det.eyebrow": "细节",
"det.h2": "小巧、细致、不打扰。",
"det.edits": "精准修改配置",
"det.edits.p": "<code>settings.json</code>、<code>config.toml</code>、<code>config.yaml</code> 里的注释、顺序和缩进都原样保留,写入是原子的。",
"det.tiny": "小巧、原生、开源",
"det.tiny.p": "不到 15 MB,基于系统 WebView。没有 Electron,不需要运行时。MIT 许可,<a href=\"https://github.com/yetone/magpie\">源码在 GitHub</a>。",
"det.env": "不读 shell 里的密钥",
"det.env.p": "magpie 从不读取环境变量里的 API 密钥。你添加了什么,它就用什么。",
"det.client": "任何客户端都能接",
"det.client.p": "凡是能设置 base URL 的都能用:<code>OPENAI_BASE_URL</code>、<code>ANTHROPIC_BASE_URL</code>、<code>GOOGLE_GEMINI_BASE_URL</code>。",
"det.usage": "按 Agent 统计用量",
"det.usage.p": "经过网关的每个 Agent、每个模型的 token、缓存命中和费用。",
"det.update": "自动更新",
"det.update.p": "新版本在后台悄悄下载,重启时安装。Mac 版经过签名和公证。",
"cli.eyebrow": "终端",
"cli.h2": "还有 TUI 和 CLI。",
"cli.sub": "同一个应用,在终端里用。安装脚本把 <code>magpie</code> 命令放进 <code>~/.local/bin</code>,把应用放到该放的地方:Mac 上是 <code>/Applications</code>,Linux 上是应用菜单。",
"copy": "复制",
"cli.win": "Windows 上 <code>magpie.exe</code> 接受同样的命令;<a href=\"https://github.com/yetone/magpie-releases/releases/latest\">最新版本</a>里的 <code>magpie-cli-windows-amd64.exe</code> 是只有终端的版本。",
"cli.c.add": "# 添加供应商",
"cli.c.preset": "# 预设只需要密钥",
"cli.c.local": "# 本地服务器不需要",
"cli.c.give": "# 给 Agent 换上第三方模型",
"cli.c.plan": "# 在 OpenCode 里用你的 Claude 订阅",
"cli.c.group": "# 一个模型,多个供应商",
"cli.c.ls": "# 每个 Agent 和它的模型",
"cli.c.prof": "# 方案",
"cli.c.tui": "# 在终端里用完整的应用",
"comm.h": "加入 magpie 社区",
"comm.p": "提问、分享你的配置、告诉我们下一步做什么——都在 Discord。",
"fin.h2": "每个 Agent,每个模型。",
"fin.sub": "免费、开源、原生。支持 macOS、Windows 和 Linux。",
"fin.gh": "在 GitHub 上查看",
"plat.mac": "11 及以上",
"plat.win": "10 及以上",
"foot.start": "快速上手",
"foot.feed": "更新源",
};
export const LANGS = { zh: { dict: zh, html: "zh-CN" } };
+3 -3
View File
@@ -38,9 +38,9 @@
<body>
<nav><div class="wrap">
<a class="brand" href="/"><img class="bird" src="/img/magpie-small.svg" alt="">magpie <small>文档</small></a>
<a class="brand" href="/zh/"><img class="bird" src="/img/magpie-small.svg" alt="">magpie <small>文档</small></a>
<div class="links">
<a href="/">首页</a>
<a href="/zh/">首页</a>
<a href="/docs/zh/start">快速上手</a>
<a href="/docs/zh/intent">意图路由</a>
<a href="/docs/zh/import">Add to magpie</a>
@@ -240,7 +240,7 @@ href = <span class="s">"https://usemagpie.ai/import#"</span> + urlencode({
<footer><div class="wrap">
<span>© 2026 magpie · MIT</span>
<div class="links">
<a href="/">首页</a>
<a href="/zh/">首页</a>
<a href="/docs/zh/start">快速上手</a>
<a href="/docs/zh/intent">意图路由</a>
<a href="https://github.com/yetone/magpie">GitHub</a>
+3 -3
View File
@@ -38,9 +38,9 @@
<body>
<nav><div class="wrap">
<a class="brand" href="/"><img class="bird" src="/img/magpie-small.svg" alt="">magpie <small>文档</small></a>
<a class="brand" href="/zh/"><img class="bird" src="/img/magpie-small.svg" alt="">magpie <small>文档</small></a>
<div class="links">
<a href="/">首页</a>
<a href="/zh/">首页</a>
<a href="/docs/zh/start">快速上手</a>
<a href="/docs/zh/intent">意图路由</a>
<a href="/docs/zh/import">Add to magpie</a>
@@ -302,7 +302,7 @@ The number of its kind (0 for none):</pre>
<footer><div class="wrap">
<span>© 2026 magpie · MIT</span>
<div class="links">
<a href="/">首页</a>
<a href="/zh/">首页</a>
<a href="/docs/zh/intent">意图路由</a>
<a href="/docs/zh/import">Add to magpie</a>
<a href="https://github.com/yetone/magpie">GitHub</a>
+3 -3
View File
@@ -38,9 +38,9 @@
<body>
<nav><div class="wrap">
<a class="brand" href="/"><img class="bird" src="/img/magpie-small.svg" alt="">magpie <small>文档</small></a>
<a class="brand" href="/zh/"><img class="bird" src="/img/magpie-small.svg" alt="">magpie <small>文档</small></a>
<div class="links">
<a href="/">首页</a>
<a href="/zh/">首页</a>
<a href="/docs/zh/start">快速上手</a>
<a href="/docs/zh/intent">意图路由</a>
<a href="/docs/zh/import">Add to magpie</a>
@@ -254,7 +254,7 @@ magpie claude group/opus-anywhere <span class="c"># 使用它</span></pre>
<footer><div class="wrap">
<span>© 2026 magpie · MIT</span>
<div class="links">
<a href="/">首页</a>
<a href="/zh/">首页</a>
<a href="/docs/zh/intent">意图路由</a>
<a href="/docs/zh/import">Add to magpie</a>
<a href="https://github.com/yetone/magpie">GitHub</a>
+232 -172
View File
@@ -4,12 +4,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>magpie</title>
<meta name="description" 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="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 Chinese when the worker served it at /zh/.
window.ZH = /^zh/.test(document.documentElement.lang);
window.themeTitle = (m) => ZH ? "主题:" + { system: "跟随系统", light: "浅色", dark: "深色" }[m] : "Theme: " + m;
(() => {
let mode = "system";
try { mode = localStorage.getItem("theme") || "system"; } catch {}
@@ -18,7 +21,7 @@
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").forEach((b) => { b.dataset.mode = mode; b.title = "Theme: " + mode; });
document.querySelectorAll("button.theme:not(.menu)").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(); };
@@ -29,15 +32,19 @@
<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" content="Every agent’s model. One place.">
<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" content="magpie: every agent’s model, one place">
<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" content="Every agent’s model. One place.">
<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" content="https://usemagpie.ai/">
<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="x-default" href="https://usemagpie.ai/">
<meta name="twitter:card" content="summary_large_image">
<style>
:root {
@@ -90,7 +97,7 @@
.theme:hover { color: var(--ink); }
.theme svg { width: 16px; height: 16px; display: none; }
.theme.gh svg { display: block; }
nav .wrap > .gh { margin-right: -18px; }
nav .wrap > .gh, nav .wrap > .lang { 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%; }
@@ -98,7 +105,7 @@ body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", system-ui, sans-serif;
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; }
@@ -113,6 +120,13 @@ nav .wrap { display: flex; align-items: center; gap: 28px; height: 60px; }
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; }
.theme.lang { width: auto; min-width: 34px; padding: 0 9px; font-size: 13px; font-weight: 560; text-decoration: none; }
.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 { letter-spacing: -.01em; }
:lang(zh) .eyebrow, :lang(zh) .agents p, :lang(zh) .sponsor p { letter-spacing: .04em; }
.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); }
@@ -317,45 +331,67 @@ footer a:hover { color: var(--ink); }
.arrow { transform: rotate(90deg); text-align: center; }
.subs { grid-template-columns: 1fr; }
.grid { grid-template-columns: 1fr 1fr; }
nav .links a.hide { display: none; }
/* the links fold into a menu under the bar */
nav .menu { display: inline-grid; }
nav .wrap { gap: 10px; }
nav .wrap > .gh, nav .wrap > .lang { 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 .links { gap: 16px; }
nav .wrap { gap: 14px; }
nav .links a.hide-sm { display: none; }
nav .wrap > .gh { margin-right: -6px; }
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">
<a class="hide" href="#how">How it works</a>
<a class="hide" href="#routing">Routing</a>
<a class="hide" href="#providers">Providers</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">Docs</a>
<a class="hide-sm" href="https://github.com/yetone/magpie-releases/releases">Releases</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>
<a class="theme lang" href="/zh/" hreflang="zh" lang="zh" title="中文" data-i18n="nav.lang" data-i18n-attr="href:nav.lang.href,hreflang:nav.lang.code,lang:nav.lang.code,title:nav.lang.title">中文</a>
<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">Download</a>
<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>Every agent’s model. <span>One place.</span></h1>
<p class="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>
<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>
@@ -366,31 +402,31 @@ footer a:hover { color: var(--ink); }
<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">Download for Mac</span>
<span class="dl-label" data-i18n="dl.mac">Download for Mac</span>
</a>
<a class="btn ghost" href="#cli">Install from terminal</a>
<a class="btn ghost" href="#cli" data-i18n="hero.term">Install from terminal</a>
</div>
<div class="meta"><span class="ver">Free</span> · <a href="https://github.com/yetone/magpie">Open source</a> · <span class="os-note">macOS 12+ · <a class="alt" href="/download/mac-intel">Intel Mac</a></span> · <a href="#get">Windows &amp; Linux</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 &amp; Linux</a></div>
</div>
<div class="shot">
<img loading="lazy" class="pick for-light" src="/img/picker-light.png" width="440" height="555" 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" alt="Codex's model picker in magpie, listing DeepSeek, Kimi and GLM models">
<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>Sponsored by</p>
<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>One API gateway for Claude and GPT, tuned for Claude Code.</span>
<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">Become a sponsor</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>Every agent</p>
<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>
@@ -418,7 +454,7 @@ footer a:hover { color: var(--ink); }
<li><img src="/icons/hanako.png" alt="">OpenHanako</li>
<li><img src="/icons/alma.png" alt="">Alma</li>
</ul>
<p style="margin-top:34px">Every model</p>
<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>
@@ -437,12 +473,12 @@ footer a:hover { color: var(--ink); }
</div>
<section id="how">
<p class="eyebrow">How it works</p>
<h2>One gateway speaks every API.</h2>
<p class="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>
<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>Your agents</h4>
<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>
@@ -454,19 +490,19 @@ footer a:hover { color: var(--ink); }
<div class="arrow">→</div>
<div class="hub">
<img class="bird" src="/img/magpie.svg" alt="">
<b>magpie gateway</b>
<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>Any provider</h4>
<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="">Your Claude plan</li>
<li><img class="bird" src="/icons/openai.svg" alt="">Your ChatGPT plan</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>
@@ -475,53 +511,53 @@ footer a:hover { color: var(--ink); }
<section>
<div class="split rev">
<div>
<img class="for-light" loading="lazy" src="/img/panel-light.png" width="440" height="470" 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" alt="The magpie menu bar panel: each agent on a different vendor's model">
<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">The menu bar</p>
<h2>Every agent, its own model.</h2>
<p class="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>
<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><b>Codex</b> gets DeepSeek in its own <code>/model</code> list, with the right reasoning efforts.</li>
<li><b>Claude Code</b> gets Kimi, GLM or GPT behind <code>opus</code>, <code>sonnet</code> and <code>haiku</code>.</li>
<li><b>Pick the native model</b> and magpie puts back exactly what was there before.</li>
<li><b>Profiles</b> switch every agent at once — “Budget”, “Focus”, whatever you save.</li>
<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">Subscriptions, shared</p>
<h2>The plans you pay for, in every agent.</h2>
<p class="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>
<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>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>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>Your Copilot seat serves any agent: <code>copilot/claude-sonnet-4.5</code>.</p></div>
<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">Smart routing</p>
<h2>The right model for each turn. It never runs dry.</h2>
<p class="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>
<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">By intent</button>
<button role="tab" data-mode="smart" aria-selected="false">Smart</button>
<button role="tab" data-mode="order" aria-selected="false">In order</button>
<button role="tab" data-mode="turn" aria-selected="false">In turn</button>
<button role="tab" data-mode="least" aria-selected="false">Least used first</button>
<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">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>
<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>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>Smart</i></div>
<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>
@@ -531,83 +567,83 @@ footer a:hover { color: var(--ink); }
</ol>
</div>
<div class="rt-foot">
<p class="rt-cap" aria-live="polite">Claude Code sends every request to magpie; magpie decides which account answers it.</p>
<div class="rt-stats"><span><b>0</b>requests</span><span><b>0</b>rerouted</span><span><b>0</b>errors your agent saw</span></div>
<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>Reset-aware</h3><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>Out of credit</h3><p>A key whose balance ran out sits out half an hour; requests go to one that can still pay.</p></div>
<div><h3>Rate limits &amp; quotas</h3><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>Failover</h3><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><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 &amp; 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">Providers</p>
<h2>Paste a key. That’s the setup.</h2>
<p class="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>
<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" 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" alt="magpie's Providers tab: DeepSeek, Kimi, GLM, Qwen, MiniMax, OpenRouter, Anthropic and Gemini, each with the agents using it">
<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">Details</p>
<h2>Small, careful, and out of the way.</h2>
<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>Surgical config edits</h3><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>Tiny, native, open</h3><p>Under 15 MB, on the system webview. No Electron, no runtime. MIT-licensed, <a href="https://github.com/yetone/magpie">on GitHub</a>.</p></div>
<div><h3>No keys from your shell</h3><p>magpie never reads API keys from environment variables. What you add is what it uses.</p></div>
<div><h3>Any gateway client</h3><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>Usage, per agent</h3><p>Tokens, cache hits and cost for each agent and model that went through the gateway.</p></div>
<div><h3>Updates itself</h3><p>New versions download quietly and install when you restart. Signed and notarised on the Mac.</p></div>
<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">Under 15 MB, 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">Terminal</p>
<h2>Also a TUI and a CLI.</h2>
<p class="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">Copy</button></div>
<p class="meta" style="margin:12px 0 28px">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"># add providers</span>
magpie provider add deepseek sk-… <span class="c"># a preset needs only the key</span>
<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"># local servers need none</span>
magpie provider add ollama <span class="c" data-i18n="cli.c.local"># local servers need none</span>
<span class="c"># give agents third-party models</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"># your Claude plan, in OpenCode</span>
magpie opencode claude/claude-sonnet-5 <span class="c" data-i18n="cli.c.plan"># your Claude plan, in OpenCode</span>
<span class="c"># one model over several providers</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"># every agent and its model</span>
magpie save work &amp;&amp; magpie use work <span class="c"># profiles</span>
magpie tui <span class="c"># the whole app, in the terminal</span></pre>
magpie ls <span class="c" data-i18n="cli.c.ls"># every agent and its model</span>
magpie save work &amp;&amp; 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>Join the magpie community</b><span>Ask questions, share your setup, tell us what to build next — on Discord.</span></div>
<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>Every agent. Every model.</h2>
<p class="sub" style="margin:0 auto 32px">Free, open source, native. For macOS, Windows and Linux.</p>
<div class="cta"><a class="btn dl" href="/download"><span class="dl-label">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> View on GitHub</a></div>
<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>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>10 or later</span><a href="/download/windows-amd64">x64</a><a href="/download/windows-arm64">ARM</a></div>
<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>
@@ -618,12 +654,12 @@ magpie tui <span class="c"># the whole app, in the t
<footer><div class="wrap">
<span>© 2026 magpie · MIT</span>
<div class="links">
<a href="/docs/start">Get started</a>
<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">Releases</a>
<a href="/api/latest">Update feed</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>
@@ -634,18 +670,33 @@ magpie tui <span class="c"># the whole app, in the t
// The theme button steps system → light → dark.
const next = { system: "light", light: "dark", dark: "system" };
document.querySelectorAll("button.theme").forEach((b) => {
document.querySelectorAll("button.theme:not(.menu)").forEach((b) => {
b.dataset.mode = themeMode();
b.title = "Theme: " + 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 picked is remembered, so / stops sending a Chinese
// browser to /zh/ once English was asked for, and the other way round.
nav.querySelectorAll("a.lang").forEach((a) => a.addEventListener("click", () => {
document.cookie = "lang=" + (a.hreflang || "en") + "; path=/; max-age=31536000; samesite=lax";
}));
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 = "Copied"; setTimeout(() => (b.textContent = "Copy"), 1400);
b.textContent = ZH ? "已复制" : "Copied"; setTimeout(() => (b.textContent = ZH ? "复制" : "Copy"), 1400);
} catch {}
}));
@@ -659,9 +710,9 @@ magpie tui <span class="c"># the whole app, in the t
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 = "Download for " + name));
document.querySelectorAll(".dl-label").forEach((e) => (e.textContent = ZH ? "下载 " + name + " 版" : "Download for " + name));
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 = "macOS & other builds"));
document.querySelectorAll('.meta a[href="#get"]').forEach((a) => (a.textContent = ZH ? "macOS 及其他版本" : "macOS & other builds"));
try { navigator.userAgentData?.getHighEntropyValues(["architecture"]).then((v) => {
if (v.architecture === "arm") document.querySelectorAll("a.dl").forEach((a) => (a.href = dl(true)));
}).catch(() => {}); } catch {}
@@ -678,7 +729,7 @@ magpie tui <span class="c"># the whole app, in the t
fetch("/api/latest").then((r) => (r.ok ? r.json() : null)).then((rel) => {
if (!rel) return;
document.querySelectorAll(".ver").forEach((e) => (e.textContent = "Version " + rel.version));
document.querySelectorAll(".ver").forEach((e) => (e.textContent = (ZH ? "版本 " : "Version ") + rel.version));
}).catch(() => {});
})();
</script>
@@ -697,6 +748,7 @@ magpie tui <span class="c"># the whole app, in the t
const tabs = [...rt.querySelectorAll(".rt-tabs button")];
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
const NS = "http://www.w3.org/2000/svg";
const T = (en, zh) => (ZH ? zh : en); // the demo's words, in the page's language
// Two Claude subscriptions and three other vendors' accounts, each with
// its vendor's icon; the captions name them by plan.
@@ -704,7 +756,7 @@ magpie tui <span class="c"># the whole app, in the t
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", "The OpenRouter key", "bird"],
or: ["sk-or-…4f2a", "OpenRouter", "openrouter", T("The OpenRouter key", "OpenRouter 密钥"), "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 }; };
@@ -713,28 +765,31 @@ magpie tui <span class="c"># the whole app, in the t
const G = (who, plan, icon, cls, rule, short) => ({ who, plan, icon, cls, rule, short, used: 0, inc: 0, key: 1 });
const MODES = {
intent: {
name: "By intent", n: 0, stats: ["turns", "asked the classifier", "tool rounds kept on their model"],
desc: "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.",
name: T("By intent", "按意图"), n: 0, stats: T(["turns", "asked the classifier", "tool rounds kept on their model"], ["轮对话", "次询问分类模型", "个工具轮次留在原模型"]),
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.",
"按意图:每轮对话开始时,问一次小而快的模型(路由组的分类模型)这条消息属于你哪条规则的意图。这一轮交给那条规则的模型,之后的工具轮次也留在那里,不再重新询问。"),
accts: () => [
G("claude-opus-5-5", "Claude Max", "claude-color", "", "first · whatever no rule takes"),
G("deepseek-v4-flash", "DeepSeek", "deepseek-color", "", "intent · a quick question", "quick question"),
G("glm-5.3", "Zhipu GLM", "zhipu-color", "", "intent · writing or fixing tests", "tests"),
G("kimi-k3", "Kimi", "kimi", "bird", "intent · reviewing a diff", "review"),
G("claude-opus-5-5", "Claude Max", "claude-color", "", T("first · whatever no rule takes", "第一个 · 规则都不匹配时用它")),
G("deepseek-v4-flash", "DeepSeek", "deepseek-color", "", T("intent · a quick question", "意图 · 简单提问"), T("quick question", "简单提问")),
G("glm-5.3", T("Zhipu GLM", "智谱 GLM"), "zhipu-color", "", T("intent · writing or fixing tests", "意图 · 编写或修复测试"), T("tests", "测试")),
G("kimi-k3", "Kimi", "kimi", "bird", T("intent · reviewing a diff", "意图 · 审阅改动"), T("review", "审阅")),
],
turns: [
["Add a --dry-run flag to the deploy command", 0, 2],
["What does ^\\d{3}-\\d{4}$ match?", 1, 0],
["TestRouting fails after my change — fix it", 2, 2],
["Review my staged changes before I commit", 3, 1],
["Why does useEffect run twice in dev?", 1, 0],
["Write table-driven tests for parseTokens", 2, 1],
["Refactor the retry loop into its own function", 0, 1],
[T("Add a --dry-run flag to the deploy command", "给 deploy 命令加一个 --dry-run 参数"), 0, 2],
[T("What does ^\\d{3}-\\d{4}$ match?", "^\\d{3}-\\d{4}$ 能匹配什么?"), 1, 0],
[T("TestRouting fails after my change — fix it", "我改完之后 TestRouting 挂了,帮我修好"), 2, 2],
[T("Review my staged changes before I commit", "提交前帮我审一下暂存的改动"), 3, 1],
[T("Why does useEffect run twice in dev?", "为什么 useEffect 在开发模式下会执行两次?"), 1, 0],
[T("Write table-driven tests for parseTokens", "给 parseTokens 写表驱动测试"), 2, 1],
[T("Refactor the retry loop into its own function", "把重试循环抽成单独的函数"), 0, 1],
],
},
smart: {
name: "Smart", n: 30,
desc: "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.)",
first: (a) => `${a.name} resets in ${fmt(left(a))} — soonest, so it goes first: what it has left is used, not lost at the reset.`,
name: T("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.)",
"智能(默认):最先重置的账号先用,额度一点不浪费;答不了的账号直接跳过。(演示里每个请求代表一小时。)"),
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))}后重置,最早,所以先用它:剩下的额度会被用掉,而不是在重置时作废。`),
accts: () => [
A("claude", 72, 1.5, { r: 9 }),
A("claude2", 38, 3, { r: 48, fail: { at: 3, code: 429, rest: 7000 } }),
@@ -744,28 +799,28 @@ magpie tui <span class="c"># the whole app, in the t
],
},
order: {
name: "In order", n: 18,
desc: "In order: the first answers everything until it can't; then the next takes over.",
first: (a) => `${a.name} answers everything, for as long as it can.`,
name: T("In order", "按顺序"), n: 18,
desc: T("In order: the first answers everything until it can't; then the next takes over.", "按顺序:第一个账号包揽所有请求,直到它答不了,再由下一个接手。"),
first: (a) => T(`${a.name} answers everything, for as long as it can.`, `${a.name} 只要还能答,就全由它来答。`),
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: "In turn", n: 16,
desc: "In turn: each request goes to the next account, so the load is spread evenly.",
first: () => "Each request goes to the next account in turn.",
name: T("In turn", "轮流"), n: 16,
desc: T("In turn: each request goes to the next account, so the load is spread evenly.", "轮流:每个请求交给下一个账号,负载均匀分摊。"),
first: () => T("Each request goes to the next account in turn.", "每个请求依次交给下一个账号。"),
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: "Least used", n: 18,
desc: "Least used first: each request goes to the account with the most allowance left; a key by the tokens magpie sent it lately.",
first: () => "Each request goes to whichever has the most left — watch the bars even out.",
name: T("Least used", "用量少的优先"), 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 数算。"),
first: () => T("Each request goes to whichever has the most left — watch the bars even out.", "每个请求交给剩得最多的账号——看进度条慢慢拉平。"),
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 ? `${h} h` : `${Math.round(h / 24)} d`;
const left = (a) => a.r - hours, fmt = (h) => h < 48 ? T(`${h} h`, `${h} 小时`) : T(`${Math.round(h / 24)} d`, `${Math.round(h / 24)} 天`);
let began = 0, last = null, trips = [], capQ = [], capAt = -1e9, capLo = false, endAt = 0, wSrc, vis = true;
const resting = (a) => a.until > t;
@@ -813,10 +868,11 @@ magpie tui <span class="c"># the whole app, in the t
});
layout();
list.classList.toggle("models", !!M.turns);
desc.textContent = M.desc; chip.textContent = M.name; who.textContent = "your agent";
(M.stats || ["requests", "rerouted", "errors your agent saw"]).forEach((n, i) => (statName[i].textContent = n));
desc.textContent = M.desc; chip.textContent = M.name; who.textContent = T("your agent", "你的 Agent");
(M.stats || T(["requests", "rerouted", "errors your agent saw"], ["个请求", "次改道", "个错误被 Agent 看到"])).forEach((n, i) => (statName[i].textContent = n));
tabs.forEach((b) => b.setAttribute("aria-selected", String(b.dataset.mode === m)));
show(M.turns ? "Claude Code sends every turn to one model name — a magpie group. Watch where each message goes." : "Claude Code sends every request to magpie; magpie decides which account answers it.");
show(M.turns ? T("Claude Code sends every turn to one model name — a magpie group. Watch where each message goes.", "Claude Code 每轮都发给同一个模型名——一个 magpie 路由组。看每条消息去了哪里。")
: T("Claude Code sends every request to magpie; magpie decides which account answers it.", "Claude Code 把每个请求发给 magpie,由 magpie 决定哪个账号来答。"));
stats(); render();
}
@@ -850,23 +906,24 @@ magpie tui <span class="c"># the whole app, in the t
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(`The others are resting, so ${a.name.replace(/^The /, "the ")} answers — running low beats no answer.`);
else if (mode === "smart" && a.r) say(`${a.name} takes over — it resets next, in ${fmt(left(a))}.`, true);
else if (mode === "smart" || mode === "order") say(`${a.name} takes over.`, true);
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} 来答——额度快用完也比没人答强。`));
else if (mode === "smart" && a.r) say(T(`${a.name} takes over — it resets next, in ${fmt(left(a))}.`, `${a.name} 接手——它下一个重置,还有 ${fmt(left(a))}。`), true);
else if (mode === "smart" || mode === "order") say(T(`${a.name} takes over.`, `${a.name} 接手。`), true);
last = a;
}
if (mode === "smart" && was < 90 && a.used >= 90) say(`${a.name} is at ${Math.round(a.used)}% — kept for last. The next with quota to spare takes over.`);
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)}%——留到最后。下一个额度充足的账号接手。`));
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(`${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.`);
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}% 没用上——它先用,所以几乎没浪费。现在它最晚重置,下一个要重置的账号接手。`));
if (last === b) last = null;
}
}
const TAGS = { 429: "429 · rate limited", 402: "402 · no credit", 503: "503 · failed", quota: "429 · quota used up" };
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 · 额度用完" });
function fail(a, code) {
a.li.classList.remove("hit"); void a.li.offsetWidth; a.li.classList.add("hit", "tagged");
a.tg.textContent = TAGS[code];
@@ -874,10 +931,10 @@ magpie tui <span class="c"># the whole app, in the t
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(`${a.name} answered 429 — it rests for as long as the vendor asks, and the request is retried on the next at once.`); }
if (code === 402) { a.why = "credit"; a.until = Infinity; say(`${a.name} is out of credit — it sits out half an hour, and the request moves on. Claude Code never sees the error.`); }
if (code === 503) { a.why = "fail"; a.until = t + a.fail.rest; say(`${a.name} failed — it steps back a while, longer each time it fails again. Another account answers instead.`); }
if (code === "quota") { a.why = "quota"; a.until = Infinity; say(`${a.name} is out of quota — passed over until it resets. The request goes to the next.`); }
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——按厂商要求的时长休息,请求立刻在下一个账号上重试。`)); }
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 完全看不到这个错误。`)); }
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} 出错了——先退下一会儿,再出错就退得更久。换另一个账号来答。`)); }
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} 额度用完——重置前都跳过它,请求交给下一个。`)); }
if (last === a) last = null;
}
@@ -930,19 +987,21 @@ magpie tui <span class="c"># the whole app, in the t
for (const [msg, n, rounds] of M.turns) {
if (g !== gen) return;
const a = accts[n];
who.textContent = "new turn"; chip.textContent = "new turn";
show(`A new turn: “${msg}”`);
who.textContent = T("new turn", "新一轮"); chip.textContent = T("new turn", "新一轮");
show(T(`A new turn: “${msg}”`, `新一轮对话:“${msg}”`));
const dot = await trip(null);
if (g !== gen) return;
chip.textContent = "classifying…";
chip.textContent = T("classifying…", "分类中…");
await wait(900);
if (g !== gen) return dot?.remove();
rerouted++; stats();
chip.textContent = n ? a.short : "no intent";
chip.textContent = n ? a.short : T("no intent", "无意图");
a.li.classList.remove("hit"); void a.li.offsetWidth; a.li.classList.add("tagged");
a.tg.textContent = n ? "✓ " + a.short : "✓ the group's first";
a.tg.textContent = n ? "✓ " + a.short : T("✓ the group's first", "✓ 路由组第一个");
setTimeout(() => a.li.classList.remove("tagged"), 1600);
show(n ? `The classifier says: ${a.rule.replace("intent · ", "")} — so ${a.who} answers it.` : `None of the intents — it goes to the group's first model, ${a.who}.`);
const why = a.rule.replace(/^[^·]*· /, "");
show(n ? T(`The classifier says: ${why} — so ${a.who} answers it.`, `分类模型判断:${why}——所以由 ${a.who} 来答。`)
: T(`None of the intents — it goes to the group's first model, ${a.who}.`, `哪个意图都不是——交给路由组的第一个模型 ${a.who}。`));
await travel(dot, a.wire, false, 420);
if (g !== gen) return dot.remove();
last = a; a.served++;
@@ -952,17 +1011,17 @@ magpie tui <span class="c"># the whole app, in the t
dot.remove(); done++; stats();
for (let i = 0; i < rounds; i++) {
if (g !== gen) return;
who.textContent = "tool result";
chip.textContent = "same turn";
if (!i) show(`Its tool results come back as more requests — no new question: they stay on ${a.who}.`);
who.textContent = T("tool result", "工具结果");
chip.textContent = T("same turn", "同一轮");
if (!i) show(T(`Its tool results come back as more requests — no new question: they stay on ${a.who}.`, `工具结果作为后续请求回来——不是新问题,所以留在 ${a.who}。`));
await trip(a);
errs++; stats();
}
await wait(rounds ? 700 : 1300);
}
if (g !== gen) return;
who.textContent = "your agent"; chip.textContent = M.name;
show(`${done} turns, each on the model its rule names — one question to the classifier per turn, ${errs} tool rounds kept on their model.`);
who.textContent = T("your agent", "你的 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} 个工具轮次留在原模型。`));
await wait(4600);
if (g === gen) reset(mode);
}
@@ -980,11 +1039,12 @@ magpie tui <span class="c"># the whole app, in the t
for (const a of accts) {
const rest = resting(a), n = Math.round(a.used), s = Math.ceil((a.until - t) / 1000);
const label = rest
? { 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" }[a.why]
: last === a ? (mode === "smart" && a.used >= 90 ? "serving · running low" : "serving")
: a.key ? (a.bar ? `${n}% of tokens lately` : "pay as you go")
: mode === "smart" && a.used >= 90 ? `${n}% used · kept for last`
: mode === "smart" && a.r ? `${n}% used · resets in ${fmt(left(a))}` : `${n}% used`;
? 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 点重置" })[a.why]
: last === a ? (mode === "smart" && a.used >= 90 ? T("serving · running low", "服务中 · 额度将尽") : T("serving", "服务中"))
: a.key ? (a.bar ? T(`${n}% of tokens lately`, `近期 token 占 ${n}%`) : T("pay as you go", "按量付费"))
: mode === "smart" && a.used >= 90 ? T(`${n}% used · kept for last`, `已用 ${n}% · 留到最后`)
: mode === "smart" && a.r ? T(`${n}% used · resets in ${fmt(left(a))}`, `已用 ${n}% · ${fmt(left(a))}后重置`) : T(`${n}% used`, `已用 ${n}%`);
if (a.st.textContent !== label) a.st.textContent = label;
a.bi.style.width = a.used + "%";
a.li.classList.toggle("on", last === a && !rest);
@@ -1012,12 +1072,12 @@ magpie tui <span class="c"># the whole app, in the t
}
for (const a of accts) if (a.until && a.until !== Infinity && t >= a.until) {
a.until = 0;
say(`${a.name} is back — ${a.why === "rate" ? "its rate limit is over" : "it gets another chance"}.`, true);
say(T(`${a.name} is back — ${a.why === "rate" ? "its rate limit is over" : "it gets another chance"}.`, `${a.name} 回来了——${a.why === "rate" ? "限流结束" : "再给它一次机会"}。`), 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(`${done} requests answered, ${rerouted} rerouted — and not one error reached Claude Code.`);
capQ = []; show(T(`${done} requests answered, ${rerouted} rerouted — and not one error reached Claude Code.`, `${done} 个请求全部答完,${rerouted} 次改道——没有一个错误传到 Claude Code。`));
}
if (endAt && t >= endAt) reset(mode);
render();
+77
View File
@@ -14,9 +14,14 @@
// /download/linux the Linux app (x86-64); /download/linux-arm64
// /download/<file> any file of the newest release, by name
// /docs, /docs/zh the getting-started guide: /docs/start, /docs/zh/start
// /zh/ the home page in Chinese (i18n.js); / sends a browser
// that prefers Chinese there, until a language is
// picked on the page (the lang cookie)
//
// Everything else is the static site in public/.
import { LANGS } from "./i18n.js";
const REPO = "yetone/magpie-releases";
const TTL = 300; // seconds the newest release is remembered
@@ -67,10 +72,82 @@ export default {
}
const guide = DOCS[url.pathname.replace(/\/+$/, "")];
if (guide) return Response.redirect(new URL(guide, url).toString(), 302);
const home = url.pathname.match(/^\/([a-z]{2})(\/(index\.html)?)?$/);
if (home && LANGS[home[1]]) {
if (!home[2]) return Response.redirect(new URL(`/${home[1]}/`, url).toString(), 301);
// the English page, fetched afresh: its ETag would also stand for an
// older dictionary
const res = await env.ASSETS.fetch(new Request(new URL("/", url), { method: req.method }));
return beacon(translate(res, home[1]));
}
if (url.pathname === "/" && (req.method === "GET" || req.method === "HEAD")) {
const lang = preferred(req);
const vary = { Vary: "Accept-Language, Cookie", "Cache-Control": "no-cache" };
if (lang !== "en") return new Response(null, { status: 302, headers: { Location: `/${lang}/`, ...vary } });
const res = beacon(await env.ASSETS.fetch(req));
const out = new Response(res.body, res);
out.headers.append("Vary", "Accept-Language, Cookie");
return out;
}
return beacon(await env.ASSETS.fetch(req));
},
};
// preferred is the home page's language for this browser: the one picked on
// the page (the lang cookie), else the first of its Accept-Language that the
// site has, else English.
export function preferred(req) {
const picked = (req.headers.get("Cookie") || "").match(/(?:^|;\s*)lang=([a-z]{2})/);
if (picked) return LANGS[picked[1]] ? picked[1] : "en";
const wants = (req.headers.get("Accept-Language") || "")
.split(",")
.map((p, i) => {
const [tag, ...rest] = p.trim().toLowerCase().split(";");
const q = rest.map((x) => x.trim()).find((x) => x.startsWith("q="));
return { lang: tag.split("-")[0], q: q ? parseFloat(q.slice(2)) || 0 : 1, i };
})
.filter((w) => w.lang && w.q > 0)
.sort((a, b) => b.q - a.q || a.i - b.i);
for (const w of wants) {
if (w.lang === "en") return "en";
if (LANGS[w.lang]) return w.lang;
}
return "en";
}
// translate puts a language's strings into the English home page: the
// inner HTML of each data-i18n element, the attributes data-i18n-attr names,
// <html lang>, and links to the docs and home made the language's own.
function translate(res, lang) {
const { dict, html } = LANGS[lang];
const out = new Response(res.body, res);
out.headers.delete("ETag");
return new HTMLRewriter()
.on("html", { element: (el) => el.setAttribute("lang", html) })
.on("[data-i18n]", {
element: (el) => {
const v = dict[el.getAttribute("data-i18n")];
if (v != null) el.setInnerContent(v, { html: true });
},
})
.on("[data-i18n-attr]", {
element: (el) => {
for (const pair of el.getAttribute("data-i18n-attr").split(",")) {
const [attr, key] = pair.split(":");
if (dict[key] != null) el.setAttribute(attr, dict[key]);
}
},
})
.on('a[href^="/docs/"]', {
element: (el) => {
const href = el.getAttribute("href");
if (!href.startsWith(`/docs/${lang}/`)) el.setAttribute("href", `/docs/${lang}/` + href.slice(6));
},
})
.on("a.brand", { element: (el) => el.setAttribute("href", `/${lang}/`) })
.transform(out);
}
// beacon adds Cloudflare Web Analytics to a page. The dashboard's automatic
// injection skips whatever a worker returns, and every page passes through
// this one.
+35
View File
@@ -57,3 +57,38 @@ test("notes: each release in the language, English where it has no Chinese", asy
const again = (await get("/api/notes?after=0.1.1&upto=0.1.3&lang=zh")).body.releases;
assert.equal(again[0].notes, ZH);
});
test("i18n: every string the home page marks has its Chinese, and no other", async () => {
const { readFile } = await import("node:fs/promises");
const { LANGS } = await import("./i18n.js");
const html = await readFile(new URL("./public/index.html", import.meta.url), "utf8");
const keys = new Set();
for (const [, k] of html.matchAll(/data-i18n="([^"]+)"/g)) keys.add(k);
for (const [, pairs] of html.matchAll(/data-i18n-attr="([^"]+)"/g))
for (const p of pairs.split(",")) keys.add(p.split(":")[1]);
for (const [lang, { dict }] of Object.entries(LANGS)) {
assert.deepEqual([...keys].filter((k) => !(k in dict)), [], `${lang} lacks`);
assert.deepEqual(Object.keys(dict).filter((k) => !keys.has(k)), [], `${lang} has unused`);
}
});
test("home: / sends a browser that prefers Chinese to /zh/, until a language is picked", async () => {
const env = { ASSETS: { fetch: async () => new Response("<html></html>", { headers: { "Content-Type": "text/plain" } }) } };
const home = (headers) => worker.fetch(new Request("https://usemagpie.ai/", { headers }), env, ctx);
const to = async (headers) => {
const res = await home(headers);
assert.match(res.headers.get("Vary") || "", /Accept-Language/);
return res.status === 302 ? res.headers.get("Location") : null;
};
assert.equal(await to({}), null);
assert.equal(await to({ "Accept-Language": "en-US,en;q=0.9,zh;q=0.8" }), null);
assert.equal(await to({ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" }), "/zh/");
assert.equal(await to({ "Accept-Language": "zh-TW" }), "/zh/");
assert.equal(await to({ "Accept-Language": "fr-FR,zh;q=0.5,en;q=0.4" }), "/zh/");
assert.equal(await to({ "Accept-Language": "en;q=0.2,zh;q=0.8" }), "/zh/");
assert.equal(await to({ "Accept-Language": "zh-CN", Cookie: "a=b; lang=en" }), null);
assert.equal(await to({ Cookie: "lang=zh" }), "/zh/");
const res = await worker.fetch(new Request("https://usemagpie.ai/zh"), env, ctx);
assert.equal(res.status, 301);
assert.equal(new URL(res.headers.get("Location")).pathname, "/zh/");
});