diff --git a/README.md b/README.md
index 7f6ffbc1..f9b6eec2 100644
--- a/README.md
+++ b/README.md
@@ -283,7 +283,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle
## Privacy
-Your prompts, replies, keys and accounts go only to the providers you use. Once a day a released magpie tells us it is in use: a random id, its version and system, and which agents, providers and models it is used with, by magpie's own ids (a provider you added yourself is only `custom`), and for each partner listed first in the add sheet, how many times a day it was shown, opened and added (counts only). No names, URLs, accounts, keys, prompts or usage. Turn part or all of it off in **Settings → Privacy**, or with `DO_NOT_TRACK=1`. [What is sent, exactly](docs/reference.md#counting-users).
+Your prompts, replies, keys and accounts go only to the providers you use. Once a day a released magpie tells us it is in use: a random id, its version and system, and which agents, providers and models it is used with, by magpie's own ids (a provider you added yourself is only `custom`), and for each partner listed first in the add sheet, how many times a day it was shown, opened and added (counts only). No names, URLs, accounts, keys, prompts or usage. Turn part or all of it off in **Settings → Privacy**, or with `DO_NOT_TRACK=1`. A partner's website and key links go through `usemagpie.ai/go/…`, which counts the click, and usemagpie.ai counts fetches of the partner list; the server sees these whatever the settings, and keeps no id or address. [What is sent, exactly](docs/reference.md#counting-users).
## Community
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 3718a5fd..644f0bae 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -283,7 +283,7 @@ magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}
## 隐私
-你的提示词、回复、密钥和账号只发给你用的供应商。正式版 magpie 每天告诉我们一次它在用:一个随机 ID、版本和系统,以及用了哪些 agent、供应商和模型,只用 magpie 自己的 ID(你自己添加的供应商只记作 `custom`),以及添加供应商页里排在最前的合作伙伴每天被展示、打开和添加的次数(只有次数)。不发送名称、URL、账号、密钥、提示词和用量。可以在 **设置 → 隐私** 里关掉一部分或全部,也可以设 `DO_NOT_TRACK=1`。[具体发送哪些内容](docs/reference.md#counting-users)。
+你的提示词、回复、密钥和账号只发给你用的供应商。正式版 magpie 每天告诉我们一次它在用:一个随机 ID、版本和系统,以及用了哪些 agent、供应商和模型,只用 magpie 自己的 ID(你自己添加的供应商只记作 `custom`),以及添加供应商页里排在最前的合作伙伴每天被展示、打开和添加的次数(只有次数)。不发送名称、URL、账号、密钥、提示词和用量。可以在 **设置 → 隐私** 里关掉一部分或全部,也可以设 `DO_NOT_TRACK=1`。合作伙伴的官网和获取 Key 链接经 `usemagpie.ai/go/…` 跳转并计一次点击,usemagpie.ai 也统计合作伙伴列表的拉取次数;这是服务器本身看到的,不受设置影响,不记录 ID 和 IP。[具体发送哪些内容](docs/reference.md#counting-users)。
## 社区
diff --git a/docs/reference.md b/docs/reference.md
index dc9db3a0..0503d5d4 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -1959,6 +1959,17 @@ Settings → Privacy → Count me as a user, or with `DO_NOT_TRACK=1` or
`MAGPIE_NO_STATS=1`. Builds from source never send it. The code is
[internal/stats](../internal/stats/stats.go).
+Partners are also counted where usemagpie.ai sees them, whatever these
+settings say, as any website sees its visits. The partner list
+(`/api/partners`) gives each partner's website and key page as a
+`https://usemagpie.ai/go//site|keys[/]` link, which sends a
+`magpie partner go` event (the partner, which link, the region, and the
+country Cloudflare gives) before sending the browser on; and one in ten
+fetches of the list sends a `magpie partners fetch` event with weight 10
+and the country, while a partner is listed. Each event has an id of its
+own: no install id, address or account goes with it. The code is
+[site/worker.js](../site/worker.js).
+
## Community
Questions, setups worth sharing, ideas, bugs: come talk to us and other
diff --git a/site/partners.js b/site/partners.js
index e539605b..9f8415a4 100644
--- a/site/partners.js
+++ b/site/partners.js
@@ -2,8 +2,8 @@
// magpie's "Add provider" sheet, under a heading that says they sponsor
// magpie. The app fetches this list from /api/partners every six hours and
// keeps it (internal/provider/partners.go), so a change here reaches users
-// with `npx wrangler deploy`, no release. At most 12 are listed, in this
-// order.
+// with `npx wrangler deploy`, no release. At most 12 whose term hasn't
+// ended are listed, in this order.
//
// An entry is a preset (internal/provider/presets.go) and a few fields more:
//
@@ -26,4 +26,10 @@
// from, until RFC 3339 times bounding when it is listed (the paid term)
//
// Every URL is https. An entry that breaks a rule is left out by the app.
+//
+// The app gets keysUrl and website (a region's too) as usemagpie.ai/go/…
+// links, which count the click and send the browser on (worker.js). The
+// providers users add keep those links, so an entry stays here after its
+// term ends: /api/partners leaves it out, /go still finds it. Remove one
+// only when nobody can still be using it.
export const PARTNERS = [];
diff --git a/site/public/docs/ja/start.html b/site/public/docs/ja/start.html
index 1cd3d10d..a54e9773 100644
--- a/site/public/docs/ja/start.html
+++ b/site/public/docs/ja/start.html
@@ -266,7 +266,7 @@ magpie claude group/opus-anywhere # これを使う
インストーラと magpie update にプロキシを渡します:curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890、または magpie update --proxy socks5://127.0.0.1:1080(http・https・socks5・socks5h に対応。HTTPS_PROXY と ALL_PROXY も使われます)。--mirror <prefix> を付けると、指定した GitHub ダウンロードミラーから <prefix>https://github.com/… としてリリースファイルを取得します。magpie update mirror <prefix> でアプリ自身の更新を含め毎回使い、magpie update mirror off で解除します。指定しない限りミラーは使われず、ファイルの SHA-256 はミラーではなく常に usemagpie.ai から取得するので、ミラーが書き換えたファイルはインストールされません。アプリ自身の更新は Settings → Proxy を通ります。
magpie は何を送信しますか?
- プロンプト、返答、キー、アカウントは、使っているプロバイダーにだけ送られます。間に magpie 自身のサーバーはありません。リリース版の magpie は 1 日 1 回、使われていることを知らせます。何人が使っていて、何を優先して面倒を見るべきかを知るためです。送るのは、このコンピュータで作ったランダムな ID、magpie のバージョンとシステム、そして使っているエージェント、プロバイダー、モデルを magpie 自身の ID(claude、deepseek、deepseek/deepseek-v4)で、それぞれの数とともに。自分で追加したプロバイダーとそのモデルは custom とだけ記録します。プロバイダー追加シートの先頭に載るパートナーについては、1 日に表示された回数、開かれた回数、追加された回数も送ります(回数のみ)。名前、Base URL、アカウント、キー、プロンプト、使用量は送りません。設定 → プライバシー → 使っているエージェント、プロバイダー、モデルを共有 でエージェント、プロバイダー、モデルとパートナーの回数を外せます。ユーザー数に含める、DO_NOT_TRACK=1、MAGPIE_NO_STATS=1 のどれかですべてオフになります。ソースからのビルドは送信しません。詳しくはリファレンスを。
+ プロンプト、返答、キー、アカウントは、使っているプロバイダーにだけ送られます。間に magpie 自身のサーバーはありません。リリース版の magpie は 1 日 1 回、使われていることを知らせます。何人が使っていて、何を優先して面倒を見るべきかを知るためです。送るのは、このコンピュータで作ったランダムな ID、magpie のバージョンとシステム、そして使っているエージェント、プロバイダー、モデルを magpie 自身の ID(claude、deepseek、deepseek/deepseek-v4)で、それぞれの数とともに。自分で追加したプロバイダーとそのモデルは custom とだけ記録します。プロバイダー追加シートの先頭に載るパートナーについては、1 日に表示された回数、開かれた回数、追加された回数も送ります(回数のみ)。名前、Base URL、アカウント、キー、プロンプト、使用量は送りません。設定 → プライバシー → 使っているエージェント、プロバイダー、モデルを共有 でエージェント、プロバイダー、モデルとパートナーの回数を外せます。ユーザー数に含める、DO_NOT_TRACK=1、MAGPIE_NO_STATS=1 のどれかですべてオフになります。ソースからのビルドは送信しません。パートナーの公式サイトと「キーを取得」のリンクは usemagpie.ai/go/… を経由し、移動の前にクリックを 1 回数えます(どのパートナーか、どのリンクか、国)。usemagpie.ai はパートナー一覧が取得された回数も数えます。これはサーバー自身が見るもので、どの Web サイトのアクセス集計とも同じく、上の設定には左右されません。ID、IP アドレス、アカウントは記録しません。詳しくはリファレンスを。
エージェントが 502 を返す、または接続できず、magpie の Routing タブに何も出ない?
プロキシアプリ(Surge、Clash、mihomo など。システムプロキシ、TUN、拡張モードのいずれかが有効)が、エージェントから http://127.0.0.1:3425 へのリクエストを横取りして別の場所へ送っているため、magpie に届いていません。除外リスト(skip-proxy、バイパス)に 127.0.0.1 を書くだけでは足りないことがあります。Codex など一部のエージェントは、それでもプロキシを通します。ローカルアドレスを直結にするルールを、他のルールより前に追加してください:
diff --git a/site/public/docs/start.html b/site/public/docs/start.html
index dcae3bc4..62cd5c2d 100644
--- a/site/public/docs/start.html
+++ b/site/public/docs/start.html
@@ -266,7 +266,7 @@ magpie claude group/opus-anywhere # use it
Give the installer and magpie update a proxy: curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890, or magpie update --proxy socks5://127.0.0.1:1080 (http, https, socks5 and socks5h; HTTPS_PROXY and ALL_PROXY are honoured too). --mirror <prefix> fetches the release file from a GitHub download mirror you choose, as <prefix>https://github.com/…; magpie update mirror <prefix> keeps it for every update, the app’s too, and magpie update mirror off removes it. No mirror is used unless you give one, and the file’s SHA-256 still comes from usemagpie.ai, never from the mirror: a file the mirror changed is refused. The app’s own updates go through Settings → Proxy.
What does magpie send about me?
- Your prompts, replies, keys and accounts go only to the providers you use; magpie has no server of its own in the path. Once a day a released magpie tells us it is in use, so we know how many people use it and what to look after first: a random id made up on your computer, magpie’s version and your system, and which agents, providers and models it is used with, by magpie’s own ids (claude, deepseek, deepseek/deepseek-v4) with how many of each. A provider you added yourself, and a model on it, is only custom. For each partner listed first in the add sheet, how many times a day it was shown, opened and added from goes with them, as counts only. No names, base URLs, accounts, keys, prompts or usage are sent. Settings → Privacy → Share the agents, providers and models I use leaves out the agents, providers and models, and the partner counts; Count me as a user, DO_NOT_TRACK=1 or MAGPIE_NO_STATS=1 turns it all off. Builds from source never send it. The details are in the reference.
+ Your prompts, replies, keys and accounts go only to the providers you use; magpie has no server of its own in the path. Once a day a released magpie tells us it is in use, so we know how many people use it and what to look after first: a random id made up on your computer, magpie’s version and your system, and which agents, providers and models it is used with, by magpie’s own ids (claude, deepseek, deepseek/deepseek-v4) with how many of each. A provider you added yourself, and a model on it, is only custom. For each partner listed first in the add sheet, how many times a day it was shown, opened and added from goes with them, as counts only. No names, base URLs, accounts, keys, prompts or usage are sent. Settings → Privacy → Share the agents, providers and models I use leaves out the agents, providers and models, and the partner counts; Count me as a user, DO_NOT_TRACK=1 or MAGPIE_NO_STATS=1 turns it all off. Builds from source never send it. A partner's website and key-page links go through usemagpie.ai/go/…, which counts the click (the partner, which link, and a country) before sending you on, and usemagpie.ai counts how many times the partner list is fetched. The server sees these whatever the settings say, as any website sees its visits; no id, address or account is kept with them. The details are in the reference.
The agent gets 502 or can’t connect, and nothing shows in magpie’s Routing tab?
A proxy app (Surge, Clash, mihomo and the like, with system proxy, TUN or enhanced mode on) has taken the agent’s request to http://127.0.0.1:3425 and sent it somewhere else, so it never reaches magpie. The bypass list (skip-proxy, “bypass”) isn’t always enough: some agents, Codex among them, send it through the proxy anyway. Add rules that send the local addresses direct, ahead of your other rules:
diff --git a/site/public/docs/zh/start.html b/site/public/docs/zh/start.html
index aec36b86..410a7b6e 100644
--- a/site/public/docs/zh/start.html
+++ b/site/public/docs/zh/start.html
@@ -266,7 +266,7 @@ magpie claude group/opus-anywhere # 使用它
给安装脚本和 magpie update 加代理:curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890,或 magpie update --proxy socks5://127.0.0.1:1080(支持 http、https、socks5、socks5h;环境变量 HTTPS_PROXY、ALL_PROXY 同样生效)。--mirror <前缀> 从你指定的 GitHub 下载镜像取发布文件,即 <前缀>https://github.com/…;magpie update mirror <前缀> 让之后每次更新(包括应用自己的更新)都用它,magpie update mirror off 取消。不指定就不用任何镜像;文件的 SHA-256 始终来自 usemagpie.ai,而不是镜像,镜像改过的文件会被拒绝安装。应用自己的更新走 设置 → 代理。
magpie 会发送我的哪些信息?
- 你的提示词、回复、密钥和账号只发给你用的供应商,中间没有 magpie 自己的服务器。正式发布的 magpie 每天告诉我们一次它在用,好让我们知道有多少人在用、该先照顾哪些:本机随机生成的 ID、magpie 版本和系统,以及用了哪些 agent、供应商和模型,只用 magpie 自己的 ID(claude、deepseek、deepseek/deepseek-v4)和各自的数量。你自己添加的供应商及其上的模型只记作 custom。添加供应商页里排在最前的合作伙伴,每天被展示、打开和添加的次数也一并发送,只有次数。不发送名称、Base URL、账号、密钥、提示词和用量。设置 → 隐私 → 分享我用的 agent、provider 和模型 可以去掉 agent、供应商、模型和合作伙伴次数这部分;计入用户数、DO_NOT_TRACK=1 或 MAGPIE_NO_STATS=1 全部关闭。从源码构建的版本从不发送。详见参考文档。
+ 你的提示词、回复、密钥和账号只发给你用的供应商,中间没有 magpie 自己的服务器。正式发布的 magpie 每天告诉我们一次它在用,好让我们知道有多少人在用、该先照顾哪些:本机随机生成的 ID、magpie 版本和系统,以及用了哪些 agent、供应商和模型,只用 magpie 自己的 ID(claude、deepseek、deepseek/deepseek-v4)和各自的数量。你自己添加的供应商及其上的模型只记作 custom。添加供应商页里排在最前的合作伙伴,每天被展示、打开和添加的次数也一并发送,只有次数。不发送名称、Base URL、账号、密钥、提示词和用量。设置 → 隐私 → 分享我用的 agent、provider 和模型 可以去掉 agent、供应商、模型和合作伙伴次数这部分;计入用户数、DO_NOT_TRACK=1 或 MAGPIE_NO_STATS=1 全部关闭。从源码构建的版本从不发送。合作伙伴的官网和「获取 Key」链接经 usemagpie.ai/go/… 跳转,跳转前记一次点击(哪个合作伙伴、哪个链接、哪个国家),usemagpie.ai 也会统计合作伙伴列表被拉取的次数。这部分是服务器本身看到的,和任何网站统计访问一样,不受上面的设置影响;不记录 ID、IP 和账号。详见参考文档。
Agent 报 502 或连不上,magpie 的路由标签页里却什么都没有?
代理软件(Surge、Clash、mihomo 等,开了系统代理、TUN 或增强模式)把 Agent 发往 http://127.0.0.1:3425 的请求接走、转去了别处,请求根本没到 magpie。只在跳过列表(skip-proxy、「绕过」)里写 127.0.0.1 不一定够:有些 Agent(比如 Codex)照样会走代理。在代理软件里加两条本地地址直连的规则,放在其他规则前面:
diff --git a/site/worker.js b/site/worker.js
index 91bb67ab..81afe8de 100644
--- a/site/worker.js
+++ b/site/worker.js
@@ -8,7 +8,10 @@
// Both take ?lang=zh for the notes in Chinese, where a
// release has them (below its marker);
// any other lang, or none, is the English alone.
-// /api/partners {partners: [...]}: the add sheet's Partners (partners.js)
+// /api/partners {partners: [...]}: the add sheet's Partners (partners.js),
+// their website and key links through /go
+// /go//keys[/], /go//site[/]
+// a partner's key page or website, counted
// /download the Apple Silicon dmg
// /download/mac-arm64 the same; /download/mac-intel the Intel dmg
// /download/windows the Windows app (x64); /download/windows-arm64
@@ -59,7 +62,20 @@ export default {
return json({ ...got.rel, notes: inLang(got.rel.notes, lang) }, 200, cacheFor(got));
}
if (url.pathname === "/api/partners") {
- return json({ partners: PARTNERS }, 200, { "Cache-Control": "public, max-age=600" });
+ const now = Date.now();
+ if (PARTNERS.some((p) => live(p, now)) && Math.random() < 1 / FETCH_SAMPLE)
+ ctx.waitUntil(capture("magpie partners fetch", { weight: FETCH_SAMPLE, country: country(req) }));
+ return json({ partners: served(PARTNERS, now) }, 200, { "Cache-Control": "public, max-age=600" });
+ }
+ if (url.pathname.startsWith("/go/")) {
+ let id, what, region, to;
+ try {
+ [, , id, what, region] = url.pathname.split("/").map(decodeURIComponent);
+ to = goTarget(PARTNERS, id, what, region);
+ } catch {}
+ if (!to) return new Response("not found\n", { status: 404 });
+ if (req.method === "GET") ctx.waitUntil(capture("magpie partner go", { id, what, region: region || "", country: country(req) }));
+ return new Response(null, { status: 302, headers: { Location: to, "Cache-Control": "no-store" } });
}
if (url.pathname === "/api/notes") {
const got = await releases(ctx, env);
@@ -117,6 +133,66 @@ export default {
},
};
+// A partner's reach and clicks are counted here, not in the app: the app's
+// own counts (provider.CountPartner) go only from users who let the stats
+// go, and these are what the server sees anyway. /api/partners hands out
+// each partner's website and key page as a /go link, which counts the
+// click (event "magpie partner go") and sends the browser on. Each app
+// fetches the list every six hours, so the fetches tell how many apps a
+// partner can be seen in: one in FETCH_SAMPLE is sent, as "magpie partners
+// fetch" with weight FETCH_SAMPLE (sum it), and only while someone is
+// listed. No id, address or account goes: a country, at most.
+const FETCH_SAMPLE = 10;
+const GO = "https://usemagpie.ai/go/";
+const POSTHOG = "https://us.i.posthog.com/i/v0/e/";
+// the Magpie project's PostHog key, as the app's (internal/stats): it can
+// send events and read nothing
+const POSTHOG_KEY = "phc_nyDyYyrUTRGvNq5U7FBesCyM2j4GdKeqGp8ko7DBerTC";
+
+// live reports whether a partner's term includes now.
+function live(p, now) {
+ return (!p.from || Date.parse(p.from) <= now) && (!p.until || now < Date.parse(p.until));
+}
+
+// served is the list the app gets: those whose term hasn't ended (an
+// ended one stays in partners.js, so the /go links of providers added from
+// it keep working), with their website and key page as /go links.
+export function served(list, now) {
+ const go = (p, what, r) => GO + [p.id, what, r && r.id].filter(Boolean).map(encodeURIComponent).join("/");
+ const linked = (o, p, r) => ({ ...o, ...(o.website && { website: go(p, "site", r) }), ...(o.keysUrl && { keysUrl: go(p, "keys", r) }) });
+ return list
+ .filter((p) => !p.until || now < Date.parse(p.until))
+ .map((p) => ({ ...linked(p, p), ...(p.regions && { regions: p.regions.map((r) => linked(r, p, r)) }) }));
+}
+
+// goTarget is where a /go link sends the browser: the partner's own key
+// page or website, the region's where it has one. Only partners.js's own
+// addresses, never one the link names.
+export function goTarget(list, id, what, region) {
+ const p = list.find((x) => x.id === id);
+ const field = what === "keys" ? "keysUrl" : what === "site" ? "website" : "";
+ if (!p || !field) return "";
+ if (!region) return p[field] || "";
+ const r = (p.regions || []).find((x) => x.id === region);
+ return r ? r[field] || p[field] || "" : "";
+}
+
+function country(req) {
+ return (req.cf && req.cf.country) || "";
+}
+
+// capture sends one event to PostHog, under an id of its own: a count, not
+// a person.
+async function capture(event, props) {
+ try {
+ await fetch(POSTHOG, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ api_key: POSTHOG_KEY, event, distinct_id: crypto.randomUUID(), properties: { ...props, $process_person_profile: false } }),
+ });
+ } catch {}
+}
+
// 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.
diff --git a/site/worker.test.mjs b/site/worker.test.mjs
index f51281ac..944afdb5 100644
--- a/site/worker.test.mjs
+++ b/site/worker.test.mjs
@@ -2,7 +2,7 @@
// (freecss on Discord). GitHub and the edge cache are stood in for.
import { test } from "node:test";
import assert from "node:assert/strict";
-import worker from "./worker.js";
+import worker, { served, goTarget } from "./worker.js";
const EN = "### Features\n\n- One thing (#1)\n\n### Install\n\nDownload it.";
const ZH = "### 新功能\n\n- 一件事 (#1)";
@@ -52,8 +52,14 @@ const FEED = `
</ul>
`;
+// what the worker sent PostHog
+const captured = [];
globalThis.fetch = async (u, init = {}) => {
u = String(u);
+ if (u === "https://us.i.posthog.com/i/v0/e/") {
+ captured.push(JSON.parse(init.body));
+ return new Response("{}");
+ }
if (u.startsWith("https://api.github.com/")) {
seen.push(new Headers(init.headers));
if (apiDown) return new Response('{"message":"API rate limit exceeded"}', { status: 403 });
@@ -279,8 +285,9 @@ test("partners", async () => {
const res = await ask("/api/partners");
assert.equal(res.status, 200);
assert.equal(res.headers.get("Cache-Control"), "public, max-age=600");
- assert.deepEqual(await res.json(), { partners: PARTNERS });
- assert(PARTNERS.length <= 12);
+ const { partners } = await res.json();
+ assert.deepEqual(partners, served(PARTNERS, Date.now()));
+ assert(partners.length <= 12);
const ids = new Set();
for (const p of PARTNERS) {
assert.match(p.id, /^[a-z0-9][a-z0-9-]{1,40}$/, p.id);
@@ -293,3 +300,96 @@ test("partners", async () => {
for (const t of [p.from, p.until].filter(Boolean)) assert(!isNaN(Date.parse(t)), p.id + ": " + t);
}
});
+
+// The website and key links the app gets go through /go, which counts the
+// click and sends the browser on; one whose term ended leaves the list but
+// its links, kept in the providers added from it, still go.
+const LIST = [
+ { id: "acme", name: "Acme", chat: "https://api.acme.example/v1", website: "https://acme.example/", keysUrl: "https://acme.example/keys", iconUrl: "https://acme.example/i.png",
+ regions: [{ id: "cn", name: "China", chat: "https://cn.acme.example/v1", keysUrl: "https://cn.acme.example/keys" }, { id: "us east", name: "US", chat: "https://us.acme.example/v1" }] },
+ { id: "plain", name: "Plain", chat: "https://api.plain.example/v1" },
+ { id: "gone", name: "Gone", chat: "https://api.gone.example/v1", keysUrl: "https://gone.example/keys", until: "2026-01-01T00:00:00Z" },
+];
+const NOW = Date.parse("2026-10-10T00:00:00Z");
+
+test("partners: links go through /go", () => {
+ const out = served(LIST, NOW);
+ assert.deepEqual(out.map((p) => p.id), ["acme", "plain"]);
+ const [acme, plain] = out;
+ assert.equal(acme.website, "https://usemagpie.ai/go/acme/site");
+ assert.equal(acme.keysUrl, "https://usemagpie.ai/go/acme/keys");
+ assert.equal(acme.iconUrl, LIST[0].iconUrl);
+ assert.equal(acme.chat, LIST[0].chat);
+ assert.equal(acme.regions[0].keysUrl, "https://usemagpie.ai/go/acme/keys/cn");
+ assert.equal(acme.regions[0].website, undefined);
+ assert.equal(acme.regions[1].keysUrl, undefined);
+ assert.deepEqual(plain, LIST[1]);
+ // partners.js itself is left as it is
+ assert.equal(LIST[0].keysUrl, "https://acme.example/keys");
+ assert.equal(served([{ ...LIST[0], until: "2026-10-10T00:00:01Z" }], NOW).length, 1);
+});
+
+test("partners: /go sends the browser on", () => {
+ assert.equal(goTarget(LIST, "acme", "keys"), "https://acme.example/keys");
+ assert.equal(goTarget(LIST, "acme", "site"), "https://acme.example/");
+ assert.equal(goTarget(LIST, "acme", "keys", "cn"), "https://cn.acme.example/keys");
+ // a region without its own page has the partner's
+ assert.equal(goTarget(LIST, "acme", "keys", "us east"), "https://acme.example/keys");
+ assert.equal(goTarget(LIST, "acme", "site", "cn"), "https://acme.example/");
+ // ended, still goes
+ assert.equal(goTarget(LIST, "gone", "keys"), "https://gone.example/keys");
+ for (const [id, what, region] of [["nobody", "keys"], ["acme", "chat"], ["acme", "keys", "mars"], ["plain", "keys"], ["acme", "constructor"]])
+ assert.equal(goTarget(LIST, id, what, region), "", [id, what, region].join(" "));
+});
+
+test("partners: /go counts the click", async () => {
+ const { PARTNERS } = await import("./partners.js");
+ const p = PARTNERS.find((x) => x.keysUrl || x.website);
+ if (!p) {
+ // nobody listed: every /go is not found, and nothing is sent
+ captured.length = 0;
+ for (const path of ["/go/acme/keys", "/go/%E0/keys", "/go/"]) assert.equal((await ask(path)).status, 404, path);
+ assert.deepEqual(captured, []);
+ return;
+ }
+ const what = p.keysUrl ? "keys" : "site";
+ captured.length = 0;
+ const res = await worker.fetch(new Request("https://usemagpie.ai/go/" + p.id + "/" + what), {}, ctx);
+ assert.equal(res.status, 302);
+ assert.equal(res.headers.get("Location"), p.keysUrl || p.website);
+ await Promise.resolve();
+ assert.equal(captured.length, 1);
+ assert.equal(captured[0].event, "magpie partner go");
+ assert.equal(captured[0].properties.id, p.id);
+ assert.equal(captured[0].properties.what, what);
+ assert.equal(captured[0].properties.$process_person_profile, false);
+ // a HEAD goes on but isn't counted
+ captured.length = 0;
+ const head = await worker.fetch(new Request("https://usemagpie.ai/go/" + p.id + "/" + what, { method: "HEAD" }), {}, ctx);
+ assert.equal(head.status, 302);
+ assert.deepEqual(captured, []);
+ assert.equal((await ask("/go/nobody-at-all/keys")).status, 404);
+ assert.equal((await ask("/go/%E0/keys")).status, 404);
+});
+
+test("partners: the list's fetches are counted, one in ten", async () => {
+ const { PARTNERS } = await import("./partners.js");
+ const anyLive = PARTNERS.some((p) => (!p.from || Date.parse(p.from) <= Date.now()) && (!p.until || Date.now() < Date.parse(p.until)));
+ const random = Math.random;
+ try {
+ for (const [r, sent] of [[0.05, anyLive], [0.5, false]]) {
+ Math.random = () => r;
+ captured.length = 0;
+ const res = await ask("/api/partners");
+ assert.equal(res.status, 200);
+ await new Promise((ok) => setTimeout(ok, 0));
+ assert.equal(captured.length, sent ? 1 : 0, "random " + r);
+ if (sent) {
+ assert.equal(captured[0].event, "magpie partners fetch");
+ assert.equal(captured[0].properties.weight, 10);
+ }
+ }
+ } finally {
+ Math.random = random;
+ }
+});