providers, stats: count how often each partner is shown, opened, has its key page opened and is added, per day and capped, and send the days that have ended with the daily stats as "magpie partner" events; off with the stats or with what magpie is used with (yetone: 能否统计每个合作伙伴的展示数和点击数)

Ran: go test ./internal/provider ./internal/stats ./internal/gui ./internal/tui .;
partners.test.cjs (chromium + webkit), privacy-usage-stats, i18n-keys,
gui-ja, gui-de; GOOS=linux -tags nogui and GOOS=windows builds.
This commit is contained in:
yetone committed 2026-10-09 01:40:02 +08:00
1 parent af798bd1bf
commit 7d663a2769
13 files changed
+436 -10

No files matched your search

+4
View File
@@ -1859,6 +1859,10 @@ and models to look after first, by magpie's own ids only:
fine-tune) is only `<provider>/other`; one on a provider of your own is
only `custom`, a routing group only `group`;
- how many of each, and how many routing groups you have.
- for each partner (a sponsor listed first in the add sheet), by its id, how
many times a day the add sheet showed it, its row was opened, its key
page was opened and a provider was added from it (at most 20 of each,
5 adds), sent the day after as `magpie partner` events.
No names, base URLs, accounts, keys, prompts or usage go. Turn that part off
in Settings → Privacy → Share the agents, providers and models I use; the
+2 -2
View File
@@ -30,7 +30,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi
| Allowance | `Quotas` asks every vendor at once: subscription windows (`SubscriptionUsage`), key-bought plans (`PlanQuotas`) and key balances (`KeyBalances`). The Usage page, `magpie quota --json` and `GET /v1/magpie/quotas` read it. A single card can be read again on its own (#840). A remote magpie's cards come from what it has kept (`CachedCards`, `GET /v1/magpie/quotas/cards`) and are never read there for this computer except by a card's refresh (`POST /v1/magpie/quotas/refresh`, throttled per card). What it has kept is the Usage page's cards with the readings made behind the requests since laid over them, a later one over an earlier by `readSeq` (`cards_kept.go`): each account's `LoginUsage` reading (the used-up switch, the Codex warm-up, routing's `Allowances`), and each key's `KeyAllowance` windows on its plan or balance card. An account or key read that way and never on the page gets a card from that reading, and after a restart, before either is read again, the cards come from `quotas.json`, dated (`AsOf`). A magpie serving only other magpies has nobody on its page (#1313). Cursor, Grok and a Kiro key keep the page's cards only. | [`cards_kept.go`](../../internal/provider/cards_kept.go), [`quotas.go`](../../internal/provider/quotas.go), [`subscription_usage.go`](../../internal/provider/subscription_usage.go), [`planquota.go`](../../internal/provider/planquota.go), [`balance.go`](../../internal/provider/balance.go), [`quota_refresh.go`](../../internal/provider/quota_refresh.go), [`remote_quotas.go`](../../internal/provider/remote_quotas.go) |
| A key's own windows | A sub2api key given a spending limit per 5 hours, day or 7 days answers its `/v1/usage` with `rate_limits` and no `remaining` (`readSub2APIKeyLimits`). With the Balance URL set to it, the key's card shows them as the windows *5 hours*, *1 day*, *7 days*, in USD, beside a balance field the user wrote; *Check balance* and `magpie provider show` give what its fullest window has left (`Balance`). `KeyAllowance` gives routing the same windows: never waited for, read in the background at most once a minute, a failed read keeping the last, and seeded from the card's last reading (`quotas.json`) after a restart. A key bought as a plan (Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code: `planQuotaSourceOf`) gives routing its card's windows the same way (`planKeyWindows`, Zhipu's team quota behind a key with none of its own); the Usage page's read of its card is taken at once (`noteKeyAllowance`), and a key there that turns out pay-as-you-go, with no windows, is asked again only every 30 minutes (`keyNoWindowsAge`). StepFun's Step Plan is read through a sign-in, not the key, and isn't one of them. | [`sub2api_usage.go`](../../internal/provider/sub2api_usage.go), [`key_allowance.go`](../../internal/provider/key_allowance.go) |
| Last served | `served.json` records when each account, plan or key last answered, shown as `lastServedAt` (#570). | [`served.go`](../../internal/provider/served.go) |
| Partners | Services that pay to be listed first in the add sheet, under a heading of their own ("Partners · magpie's sponsors"). The list is `site/partners.js`, served at `usemagpie.ai/api/partners`: a change reaches users with a deploy, not a release. `Partners` never waits: it returns what was fetched last and fetches again behind the call every 6 hours (10 minutes after a failure), keeping the list in `partners.json` in the cache folder. Each entry is checked again on every read (`validPartners`): its own id, never a built-in preset's, a name, an endpoint, https only, at most 12; what only magpie's presets may set (a decision API, no key, a user endpoint) is cleared. Its logo is fetched through `FetchIcon` and kept as an uploaded icon. A partner is a preset to the rest of magpie (`Preset`, `FromPreset`, the editor), and one no longer listed still is to the providers added from it (`Seen`), without the sponsored mark. The window lists a partner in the languages it names (`langs`) only and shows its tagline in the page's language (`notes`, else `en`). `MAGPIE_PARTNERS` points it elsewhere, `off` lists none; tests list none unless they name a list. | [`partners.go`](../../internal/provider/partners.go), [`site/partners.js`](../../site/partners.js) |
| Partners | Services that pay to be listed first in the add sheet, under a heading of their own ("Partners · magpie's sponsors"). The list is `site/partners.js`, served at `usemagpie.ai/api/partners`: a change reaches users with a deploy, not a release. `Partners` never waits: it returns what was fetched last and fetches again behind the call every 6 hours (10 minutes after a failure), keeping the list in `partners.json` in the cache folder. Each entry is checked again on every read (`validPartners`): its own id, never a built-in preset's, a name, an endpoint, https only, at most 12; what only magpie's presets may set (a decision API, no key, a user endpoint) is cleared. Its logo is fetched through `FetchIcon` and kept as an uploaded icon. A partner is a preset to the rest of magpie (`Preset`, `FromPreset`, the editor), and one no longer listed still is to the providers added from it (`Seen`), without the sponsored mark. The window lists a partner in the languages it names (`langs`) only and shows its tagline in the page's language (`notes`, else `en`). `MAGPIE_PARTNERS` points it elsewhere, `off` lists none; tests list none unless they name a list. How often each listed partner was shown, opened (its row, its key page) and added is counted per UTC day (`CountPartner`, capped at 20 of each and 5 adds, kept in `partner-counts.json` in the cache folder) and sent with the next day's stats event as `magpie partner` events (`internal/stats`); nothing is counted with the stats or their usage part off. | [`partners.go`](../../internal/provider/partners.go), [`partner_events.go`](../../internal/provider/partner_events.go), [`site/partners.js`](../../site/partners.js) |
| Protocol detection | Works out which of Chat, Responses and Anthropic Messages a base URL speaks when a provider is added; a Gemini URL is not detected, and the editor hides Detect while Gemini is the Base URL's API. Detection, endpoint tests and model tests share `probe`, which adds `anthropic-version` only for Anthropic Messages; OpenAI probes omit that default so a relay that refuses it can answer. User-supplied headers still apply through `Sign`. | [`detect.go`](../../internal/provider/detect.go), [`test.go`](../../internal/provider/test.go) |
## Runtime path
@@ -63,7 +63,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi
go test -tags nogui ./internal/provider -run 'TestAccount|TestCodex|TestGroup|TestQuota|TestLogin|TestSignIn|TestSeveralKeys|TestKeyProtocol|TestSetKeyWeight|TestReadSub2APIKeyLimits|TestSub2APIKeyLimitsOnItsCard|TestKeyAllowance|TestPlanKeyAllowance|TestPayAsYouGoKeyAskedSeldom|TestClaudeAccountToldRenewed|TestPluginAccountToldRenewed'
go test -tags nogui ./internal/provider -run 'TestVolcengine|TestQianfan|TestBedrock'
go test -tags nogui ./internal/provider -run '^TestProviderProbesUseProtocolHeaders$'
go test -tags nogui ./internal/provider -run '^TestPartners'
go test -tags nogui ./internal/provider -run '^Test(Partners|CountPartner)'
go test -tags nogui ./internal/provider -run 'TestGeminiBase|TestCustomGeminiProvider|TestFactoryGeminiUnchanged'
node --test internal/gui/tests/partners.test.cjs site/worker.test.mjs
go test -tags nogui ./internal/provider
+27 -2
View File
@@ -37,6 +37,9 @@ let editing = null; // provider id being edited; { preset } or { custom: true }
let draft = null; // the editor's working copy
let naming = null; // the provider whose models' names and levels are open in its editor
let adding = false; // the preset sheet is open
// the partners counted as shown since the sheet opened: once an opening,
// however often it is drawn (countPartner)
let partnersCounted = null;
let importing = null; // a magpie://import link waiting for a yes: { provider, error, replaces }
let importingApps = null; // the Import from other apps dialog: { sources, picks }
let providerDiscovery = []; // only opaque fingerprints and app names, never credentials
@@ -6439,6 +6442,7 @@ function renderAdd() {
setRoom(view, 0);
}
sheet.hidden = !adding;
if (!adding) partnersCounted = null;
// Duplicate and Add another open a new provider's editor with no sheet
if (!adding) return editing && typeof editing === "object" ? renderEditor(null, editing.preset) : null;
const head = el("div", "row-head");
@@ -6497,6 +6501,10 @@ function renderAdd() {
any = true;
const grid = section("Partners", "magpie's sponsors");
for (const pr of partners) grid.append(partnerTile(pr));
partnersCounted ??= new Set();
const fresh = partners.filter((p) => !partnersCounted.has(p.id));
for (const p of fresh) partnersCounted.add(p.id);
countPartner("shown", fresh.map((p) => p.id));
}
if (subs.length || inPlugins) {
any = true;
@@ -6726,9 +6734,19 @@ function partnerNote(pr) {
return n.en || "";
}
// countPartner counts what for partners (provider.CountPartner): magpie
// counts them only when the stats are on, and nothing waits on it
function countPartner(what, ids) {
if (ids.length) api("partner", { what, ids }).catch(() => {});
}
// a partner's row: its own section says it is sponsored, so no badge
function partnerTile(pr) {
const b = tile({ ...pr, sponsored: false, note: "" });
if (!pr.added) {
const open = b.onclick;
b.onclick = (e) => { countPartner("opened", [pr.id]); open(e); };
}
if (!pr.added) b.title = pr.name + (partnerNote(pr) ? " · " + partnerNote(pr) : "") + "\n" + hostOf(pr.chat || pr.responses || pr.anthropic);
return b;
}
@@ -8007,7 +8025,14 @@ function drawEditor(p, presetID) {
const keysUrl = p?.keysUrl || pr?.keysUrl;
// the link follows the plan picked: a region's keysUrl goes with its
// endpoints, and one without falls back to the preset's own page
if (keysUrl) { const b = el("button", "link", t("Get a key ↗")); b.onclick = () => api("open", { url: draft?.keysUrl || pr?.keysUrl || p?.keysUrl }); side.append(b); }
if (keysUrl) {
const b = el("button", "link", t("Get a key ↗"));
b.onclick = () => {
if (pr?.kind === "partner") countPartner("keys", [pr.id]);
api("open", { url: draft?.keysUrl || pr?.keysUrl || p?.keysUrl });
};
side.append(b);
}
const keyWrap = el("div", "pair");
keyWrap.append(key, side);
if (p?.keyList?.length) ed.append(...field(t("Accounts"), renderKeyAccounts(p), p.routing ? t("Tick every key to use; Routing says how requests spread over them.") : t("Tick every key to use. Requests go to the first; when it runs out of quota or hits a rate limit, the next ticked key takes over.")));
@@ -19354,7 +19379,7 @@ function renderRedact(s, keep) {
row(t("Count me as a user"), t("Once a day, a random id for this computer with magpie's version and system"),
onOff(!s.noStats, (on) => savePrefs({ ...keep, noStats: !on })));
// rides on that event: nothing goes without it
if (!s.noStats) row(t("Share the agents, providers and models I use"), t("Sent with that event, by magpie's own ids, with how many of each; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage"),
if (!s.noStats) row(t("Share the agents, providers and models I use"), t("Sent with that event, by magpie's own ids, with how many of each, and how often each partner was shown, opened and added; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage"),
onOff(!s.noUsageStats, (on) => savePrefs({ ...keep, noUsageStats: !on })));
}
+4 -4
View File
@@ -648,7 +648,7 @@ const I18N = {
"Count me as a user": "计入用户数",
"Once a day, a random id for this computer with magpie's version and system": "每天一次,发送本机随机 ID、magpie 版本和系统",
"Share the agents, providers and models I use": "分享我用的 agent、provider 和模型",
"Sent with that event, by magpie's own ids, with how many of each; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage": "随上面那条一起发送:只用 magpie 内置的 ID 和各自的数量,你自己添加的 provider 只记作“custom”。不含名称、地址、账号、密钥和用量",
"Sent with that event, by magpie's own ids, with how many of each, and how often each partner was shown, opened and added; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage": "随上面那条一起发送:只用 magpie 内置的 ID 和各自的数量,以及每个合作伙伴被展示、打开和添加的次数;你自己添加的 provider 只记作“custom”。不含名称、地址、账号、密钥和用量",
"Warm-up and check-in": "预热与签到",
"Warm up on reset": "窗口重置时预热",
"Daily warm-up": "每日定时预热",
@@ -4436,7 +4436,7 @@ const I18N = {
"Count me as a user": "計入使用者數",
"Once a day, a random id for this computer with magpie's version and system": "每天一次,傳送本機隨機 ID、magpie 版本和系統",
"Share the agents, providers and models I use": "分享我用的 agent、provider 和模型",
"Sent with that event, by magpie's own ids, with how many of each; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage": "隨上面那則一起傳送:只用 magpie 內建的 ID 和各自的數量,你自己新增的 provider 只記作「custom」。不含名稱、位址、帳號、金鑰和用量",
"Sent with that event, by magpie's own ids, with how many of each, and how often each partner was shown, opened and added; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage": "隨上面那則一起傳送:只用 magpie 內建的 ID 和各自的數量,以及每個合作夥伴被展示、開啟和新增的次數;你自己新增的 provider 只記作「custom」。不含名稱、位址、帳號、金鑰和用量",
"Warm-up and check-in": "預熱與簽到",
"Warm up on reset": "時段重置時預熱",
"Daily warm-up": "每日定時預熱",
@@ -8195,7 +8195,7 @@ const I18N = {
"Count me as a user": "ユーザー数に含める",
"Once a day, a random id for this computer with magpie's version and system": "1 日 1 回、このコンピュータのランダムな ID と magpie のバージョン、システムを送信します",
"Share the agents, providers and models I use": "使っているエージェント、プロバイダー、モデルを共有",
"Sent with that event, by magpie's own ids, with how many of each; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage": "上の送信に含めます。magpie 組み込みの ID とそれぞれの数だけで、自分で追加したプロバイダーは「custom」とだけ記録します。名前、アドレス、アカウント、キー、使用量は含みません",
"Sent with that event, by magpie's own ids, with how many of each, and how often each partner was shown, opened and added; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage": "上の送信に含めます。magpie 組み込みの ID とそれぞれの数、各パートナーが表示・選択・追加された回数だけで、自分で追加したプロバイダーは「custom」とだけ記録します。名前、アドレス、アカウント、キー、使用量は含みません",
"Warm-up and check-in": "ウォームアップとチェックイン",
"Warm up on reset": "リセット時にウォームアップ",
"Daily warm-up": "毎日のウォームアップ",
@@ -11969,7 +11969,7 @@ const I18N = {
"Count me as a user": "Mich als Nutzer zählen",
"Once a day, a random id for this computer with magpie's version and system": "Einmal täglich eine zufällige ID dieses Computers mit magpie-Version und System",
"Share the agents, providers and models I use": "Meine Agents, Anbieter und Modelle teilen",
"Sent with that event, by magpie's own ids, with how many of each; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage": "Mit diesem Ereignis, nur mit magpies eigenen IDs und ihrer Anzahl; ein selbst hinzugefügter Anbieter heißt nur „custom“. Keine Namen, Adressen, Konten, Schlüssel oder Nutzung",
"Sent with that event, by magpie's own ids, with how many of each, and how often each partner was shown, opened and added; a provider you added yourself is only “custom”. No names, addresses, accounts, keys or usage": "Mit diesem Ereignis, nur mit magpies eigenen IDs und ihrer Anzahl sowie wie oft jeder Partner gezeigt, geöffnet und hinzugefügt wurde; ein selbst hinzugefügter Anbieter heißt nur „custom“. Keine Namen, Adressen, Konten, Schlüssel oder Nutzung",
"Warm-up and check-in": "Vorwärmen und Check-in",
"Warm up on reset": "Beim Reset vorwärmen",
"Daily warm-up": "Tägliches Vorwärmen",
+13
View File
@@ -1729,6 +1729,19 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
}
rw.WriteHeader(http.StatusNoContent)
})
// the add sheet counts what it showed of the partners and what was
// opened of them (provider.CountPartner); only listed partners count
mux.HandleFunc("POST /api/partner", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
What string
IDs []string
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&in)
if len(in.IDs) <= provider.MaxPartners {
provider.CountPartner(in.What, in.IDs...)
}
rw.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /api/open", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ URL string }
_ = json.NewDecoder(r.Body).Decode(&in)
+25 -1
View File
@@ -28,10 +28,11 @@ const presets = [
];
const providers = [{ id: "taken", name: "Taken", icon: "generic", preset: "taken", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }];
function server(lang, list) {
function server(lang, list, counted) {
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/api/partner") { counted?.push(route.request().postDataJSON()); return route.fulfill({ status: 204, body: "" }); }
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
@@ -112,6 +113,29 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.context().close();
});
}
// how often each partner is shown, opened and its key page opened is
// counted (provider.CountPartner): shown once per opening of the sheet,
// not again as it is redrawn or searched
await t.test("counted", async () => {
const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage();
const counted = [];
const list = partners.map((p) => (p.id === "acme" ? { ...p, keysUrl: "https://acme.example/keys" } : p));
await page.route("**/*", server("en", list, counted));
const sheet = await openSheet(page);
const input = page.locator("#addSheet input").first();
await input.fill("acme");
await sheet.locator(".kind").first().waitFor();
await input.fill("");
await sheet.locator(".tile", { hasText: "Taken" }).waitFor();
await page.waitForTimeout(100);
assert.deepEqual(counted, [{ what: "shown", ids: ["acme", "taken"] }]);
await sheet.locator(".tile", { hasText: "Acme AI" }).click();
await page.locator("#modal .editor").getByRole("button", { name: "Get a key ↗" }).click();
await page.waitForTimeout(100);
assert.deepEqual(counted.slice(1), [{ what: "opened", ids: ["acme"] }, { what: "keys", ids: ["acme"] }]);
await page.context().close();
});
await t.test("no partners, no heading", async () => {
const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage();
await page.route("**/*", server("en", []));
+190
View File
@@ -0,0 +1,190 @@
package provider
import (
"cmp"
"encoding/json"
"os"
"path/filepath"
"slices"
"sync"
"time"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/settings"
)
// A partner pays to be listed, so how often it is seen and picked is
// counted for it: per day (UTC), per partner, how many times the add sheet
// showed it, its row was opened, its key page was opened, and a provider
// was added from it. Nothing else is kept, and the counts go with the
// day's stats event (internal/stats), only for days that have ended, under
// the same switches: off with the stats, or with what magpie is used with.
// What is counted for a partner.
const (
PartnerShown = "shown" // the add sheet listed it
PartnerOpened = "opened" // its row was opened
PartnerKeys = "keys" // its key page was opened
PartnerAdded = "added" // a provider was added from it
)
// partnerCaps is the most counted of each a day: a count, not a log of
// what one user did, and a page drawn in a loop can't swell it.
var partnerCaps = map[string]int{PartnerShown: 20, PartnerOpened: 20, PartnerKeys: 20, PartnerAdded: 5}
// partnerDays are the days kept unsent at most.
const partnerDays = 14
// PartnerCount is one day's count of one kind for one partner.
type PartnerCount struct {
Day string `json:"day"` // 2006-01-02, UTC
ID string `json:"id"`
What string `json:"what"`
Count int `json:"count"`
}
type partnerCounts struct {
// Days is day → partner → what → count.
Days map[string]map[string]map[string]int `json:"days"`
}
var partnerCountMu sync.Mutex
func partnerCountFile() string { return filepath.Join(appdir.Cache(), "partner-counts.json") }
// partnerCounting reports whether the user lets it be counted: the stats
// on, and what magpie is used with sent with them. internal/stats reads
// the same switches.
func partnerCounting() bool {
for _, k := range []string{"DO_NOT_TRACK", "MAGPIE_NO_STATS"} {
if v := os.Getenv(k); v != "" && v != "0" && v != "false" {
return false
}
}
s := settings.Load()
return !s.NoStats && !s.NoUsageStats
}
// livePartner reports whether id is a partner listed now: only those are
// counted, never a provider of the user's own.
func livePartner(id string) bool {
for _, p := range Partners() {
if p.ID == id {
return true
}
}
return false
}
// CountPartner counts what for each of the partners ids, today.
func CountPartner(what string, ids ...string) {
limit := partnerCaps[what]
if limit == 0 || !partnerCounting() {
return
}
var live []string
for _, id := range ids {
if livePartner(id) && !slices.Contains(live, id) {
live = append(live, id)
}
}
if len(live) == 0 {
return
}
partnerCountMu.Lock()
defer partnerCountMu.Unlock()
c := readPartnerCounts()
now := time.Now().UTC()
day := now.Format("2006-01-02")
for d := range c.Days {
if t, err := time.Parse("2006-01-02", d); err != nil || now.Sub(t) > partnerDays*24*time.Hour {
delete(c.Days, d)
}
}
if c.Days[day] == nil {
c.Days[day] = map[string]map[string]int{}
}
changed := false
for _, id := range live {
if c.Days[day][id] == nil {
c.Days[day][id] = map[string]int{}
}
if c.Days[day][id][what] < limit {
c.Days[day][id][what]++
changed = true
}
}
if changed {
writePartnerCounts(c)
}
}
// PartnerCounts are the counts of the days before today, to be sent.
func PartnerCounts(now time.Time) []PartnerCount {
partnerCountMu.Lock()
defer partnerCountMu.Unlock()
c := readPartnerCounts()
today := now.UTC().Format("2006-01-02")
var out []PartnerCount
for day, ids := range c.Days {
if day >= today {
continue
}
for id, whats := range ids {
for what, n := range whats {
out = append(out, PartnerCount{Day: day, ID: id, What: what, Count: n})
}
}
}
slices.SortFunc(out, func(a, b PartnerCount) int {
if a.Day != b.Day {
return cmp.Compare(a.Day, b.Day)
}
if a.ID != b.ID {
return cmp.Compare(a.ID, b.ID)
}
return cmp.Compare(a.What, b.What)
})
return out
}
// PartnerCountsSent forgets the days that were sent: those before now's.
func PartnerCountsSent(now time.Time) {
partnerCountMu.Lock()
defer partnerCountMu.Unlock()
c := readPartnerCounts()
today := now.UTC().Format("2006-01-02")
sent := false
for d := range c.Days {
if d < today {
delete(c.Days, d)
sent = true
}
}
if sent {
writePartnerCounts(c)
}
}
func readPartnerCounts() partnerCounts {
var c partnerCounts
if b, err := os.ReadFile(partnerCountFile()); err == nil {
_ = json.Unmarshal(b, &c)
}
if c.Days == nil {
c.Days = map[string]map[string]map[string]int{}
}
return c
}
func writePartnerCounts(c partnerCounts) {
b, err := json.Marshal(c)
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(partnerCountFile()), 0o755)
tmp := partnerCountFile() + ".tmp"
if os.WriteFile(tmp, b, 0o644) == nil {
_ = os.Rename(tmp, partnerCountFile())
}
}
+84
View File
@@ -0,0 +1,84 @@
package provider
import (
"fmt"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/yetone/magpie/internal/settings"
)
func countsLine(cs []PartnerCount) string {
var out []string
for _, c := range cs {
out = append(out, fmt.Sprintf("%s %s %d", c.ID, c.What, c.Count))
}
return strings.Join(out, ",")
}
// A partner listed now is counted by the day, each kind up to its cap; a
// provider of the user's own, or one no longer listed, never is. The days
// ended are what is sent, and once sent they are gone.
func TestCountPartner(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("DO_NOT_TRACK", "")
t.Setenv("MAGPIE_NO_STATS", "")
resetPartners()
t.Cleanup(resetPartners)
var body atomic.Value
body.Store(`{"partners":[{"id":"acme","name":"Acme","chat":"https://a.example/v1"}]}`)
t.Setenv("MAGPIE_PARTNERS", partnerFeedServer(t, &body).URL)
fetchPartnersNow()
for range 25 {
CountPartner(PartnerShown, "acme", "acme", "deepseek", "mine")
}
CountPartner(PartnerOpened, "acme")
CountPartner("clicked", "acme")
if _, err := Add(Provider{Name: "Acme", Preset: "acme", Chat: "https://a.example/v1", Key: "sk-acme"}); err != nil {
t.Fatal(err)
}
if _, err := Add(Provider{Name: "Mine", Chat: "https://mine.example/v1", Key: "sk-mine"}); err != nil {
t.Fatal(err)
}
now := time.Now()
if got := PartnerCounts(now); len(got) != 0 {
t.Fatalf("today's counts are sent before the day ends: %v", got)
}
tomorrow := now.Add(24 * time.Hour)
if got, want := countsLine(PartnerCounts(tomorrow)), "acme added 1,acme opened 1,acme shown 20"; got != want {
t.Fatalf("counts = %q, want %q", got, want)
}
if d := PartnerCounts(tomorrow)[0].Day; d != now.UTC().Format("2006-01-02") {
t.Fatalf("day %q", d)
}
PartnerCountsSent(tomorrow)
if got := PartnerCounts(tomorrow); len(got) != 0 {
t.Fatalf("sent days kept: %v", got)
}
// what magpie is used with turned off, or all stats: nothing counted
settings.Save(settings.Settings{NoUsageStats: true})
CountPartner(PartnerShown, "acme")
settings.Save(settings.Settings{NoStats: true})
CountPartner(PartnerShown, "acme")
settings.Save(settings.Settings{})
t.Setenv("DO_NOT_TRACK", "1")
CountPartner(PartnerShown, "acme")
t.Setenv("DO_NOT_TRACK", "")
// a partner that left the list
body.Store(`{"partners":[]}`)
fetchPartnersNow()
CountPartner(PartnerShown, "acme")
if got := PartnerCounts(tomorrow); len(got) != 0 {
t.Fatalf("counted while off or unlisted: %v", got)
}
}
+8 -1
View File
@@ -632,7 +632,14 @@ func add(p Provider, once bool) (string, error) {
}
}
p.ID, p.Name = freeID(p.ID), freeName(p.Name)
return p.ID, Save(p)
if err := Save(p); err != nil {
return p.ID, err
}
if p.Preset != "" {
// a provider added from a partner counts for it (partner_events.go)
CountPartner(PartnerAdded, p.Preset)
}
return p.ID, nil
}
// AddCopy adds p, a copy the user made of the provider from (#268), beside
+10
View File
@@ -22,6 +22,7 @@ import (
"strings"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/update"
)
@@ -108,6 +109,12 @@ func Send(ctx context.Context, version, what string, now time.Time) error {
uses = append(uses, event("magpie uses", map[string]any{"kind": l.kind, "id": x}))
}
}
// a partner's counts of the days ended, each at its own day
for _, c := range provider.PartnerCounts(now) {
e := event("magpie partner", map[string]any{"id": c.ID, "what": c.What, "count": c.Count, "day": c.Day})
e["timestamp"] = c.Day + "T12:00:00Z"
uses = append(uses, e)
}
}
body, _ := json.Marshal(map[string]any{
"api_key": Key,
@@ -128,6 +135,9 @@ func Send(ctx context.Context, version, what string, now time.Time) error {
if res.StatusCode/100 != 2 {
return fmt.Errorf("stats: %s", res.Status)
}
if !settings.Load().NoUsageStats {
provider.PartnerCountsSent(now)
}
return os.WriteFile(stamp, []byte(day+"\n"), 0o644)
}
+63
View File
@@ -3,6 +3,7 @@ package stats
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -21,6 +22,7 @@ import (
// the user turns it off.
func TestSend(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("DO_NOT_TRACK", "")
t.Setenv("MAGPIE_NO_STATS", "")
usage = func() Usage {
@@ -155,3 +157,64 @@ func TestModelLabelKeepsTheUsersOwnModelIDs(t *testing.T) {
}
}
}
// The partners' counts of the days ended go with the day's event, each as
// one "magpie partner" at its own day, and are sent once; with what magpie
// is used with turned off they don't go.
func TestSendPartnerCounts(t *testing.T) {
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("DO_NOT_TRACK", "")
t.Setenv("MAGPIE_NO_STATS", "")
usage = func() Usage { return Usage{} }
defer func() { usage = readUsage }()
counts := `{"days":{"2026-09-27":{"acme":{"shown":12,"opened":2}},"2026-09-28":{"acme":{"shown":3}}}}`
write := func() {
f := filepath.Join(home, ".cache", "magpie", "partner-counts.json")
if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f, []byte(counts), 0o644); err != nil {
t.Fatal(err)
}
}
write()
var got [][]map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b struct {
Batch []map[string]any `json:"batch"`
}
json.NewDecoder(r.Body).Decode(&b)
got = append(got, b.Batch)
}))
defer srv.Close()
t.Setenv("MAGPIE_STATS_HOST", srv.URL)
day := time.Date(2026, 9, 28, 9, 0, 0, 0, time.UTC)
partners := func(batch []map[string]any) []string {
var out []string
for _, e := range batch {
if e["event"] != "magpie partner" {
continue
}
p := e["properties"].(map[string]any)
out = append(out, fmt.Sprintf("%s %s %s %v %v", e["timestamp"], p["id"], p["what"], p["count"], p["$process_person_profile"]))
}
return out
}
Send(context.Background(), "0.1.300", "app", day)
if want := "2026-09-27T12:00:00Z acme opened 2 false,2026-09-27T12:00:00Z acme shown 12 false"; strings.Join(partners(got[0]), ",") != want {
t.Fatalf("partner events %q, want %q", partners(got[0]), want)
}
Send(context.Background(), "0.1.300", "app", day.Add(24*time.Hour))
if want := "2026-09-28T12:00:00Z acme shown 3 false"; strings.Join(partners(got[1]), ",") != want {
t.Fatalf("the next day sent %q, want %q", partners(got[1]), want)
}
write()
settings.Save(settings.Settings{NoUsageStats: true})
Send(context.Background(), "0.1.300", "app", day.Add(48*time.Hour))
if len(got) != 3 || len(partners(got[2])) != 0 {
t.Fatalf("sent with usage off: %v", got[2:])
}
}
+3
View File
@@ -402,9 +402,12 @@ func (m *model) openProviderModels(id string) {
// openPresets picks a vendor magpie knows, then asks for its key.
func (m *model) openPresets() {
var items []agent.Option
var shown []string
for _, d := range provider.Partners() {
items = append(items, agent.Option{Value: d.ID, Note: d.Name + " · partner (sponsor)"})
shown = append(shown, d.ID)
}
go provider.CountPartner(provider.PartnerShown, shown...)
for _, d := range provider.Presets() {
items = append(items, agent.Option{Value: d.ID, Note: d.Name + " · " + string(d.Kind)})
}
+3
View File
@@ -192,9 +192,12 @@ func presets() error {
kind := provider.Kind("")
// partners first, as the app lists them: their heading says they pay
all := []provider.PresetDef{}
var shown []string
for _, pa := range provider.PartnersNow(3 * time.Second) {
all = append(all, pa.PresetDef)
shown = append(shown, pa.ID)
}
provider.CountPartner(provider.PartnerShown, shown...)
for _, pr := range append(all, provider.Presets()...) {
if pr.Kind != kind {
kind = pr.Kind