stats: the day's event says which agents, providers and models magpie is used with, by magpie's own ids (a provider of the user's own, and its models, only "custom"), with how many of each and of routing groups: one "magpie uses" event per id in the same PostHog batch, so unique users break down by agent, provider and model. Settings → Privacy → Share the agents, providers and models I use turns it off (on by default; gone while Count me as a user is off); no names, URLs, accounts, keys or usage. go test ./internal/stats (payload, off switch, labels), the Privacy switch in Chromium and WebKit (en, zh), go test -tags nogui ./... in a temp HOME, go vet, GOOS=linux/windows builds pass; read against the real config: 9 agents, 24 providers, 25 groups in 0.75 s

This commit is contained in:
yetone committed 2026-10-08 19:26:13 +08:00
1 parent 78d90571cf
commit 169c23c18e
9 files changed
+383 -42

No files matched your search

+20 -4
View File
@@ -1835,10 +1835,26 @@ MAGPIE_GITHUB_MIRROR=https://gh.example magpie serve
Once a day, a running magpie (the app, or `magpie serve`) sends one event to
PostHog so we know how many people use it: a random id made up on your
computer (`~/.config/magpie/install-id`), magpie's version, and your system
and architecture. Nothing else goes: no accounts, keys, providers, models,
prompts or usage. Turn it off in 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).
and architecture.
With it goes what magpie is used with, so we know which agents, providers
and models to look after first, by magpie's own ids only:
- the agents connected to magpie (`claude`, `codex`);
- the providers that are on: a preset's id (`deepseek`), a subscription's
(`codex`, `copilot`), a community plugin's (`plugin:kiro`); a provider you
added yourself is only `custom`, another plugin only `plugin`;
- the models the connected agents are set to, as the provider's id and the
vendor's model id (`deepseek/deepseek-v4`); 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.
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
day's event then has the id, version and system only. Turn it all off in
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).
## Community
+61
View File
@@ -0,0 +1,61 @@
package agent
import (
"maps"
"slices"
"strings"
"github.com/yetone/magpie/internal/provider"
)
// Models are magpie's models the agent is set to, as catalog ids
// (provider/model, group/<id>), each once: the agent's own models aside.
// Usage stats count them (internal/stats).
func (a *Agent) Models() []string {
var out []string
add := func(r string) {
if r != "" && !slices.Contains(out, r) {
out = append(out, r)
}
}
if a.Native != nil {
st := a.Native.Read()
if st.Provider != "connected" {
return nil
}
for _, k := range slices.Sorted(maps.Keys(st.Fields)) {
add(modelRef(st.Fields[k].Value))
}
return out
}
vals := a.Values()
for _, f := range a.Fields {
v := vals[f.Key]
if !magpieValue(a, f, v, vals) {
continue
}
m, _, _ := a.split(v)
r := modelRef(m)
if r == "" && f.Options != nil {
for _, o := range f.Options(vals) {
if o.Value == m {
r = o.Ref
break
}
}
}
add(r)
}
return out
}
// modelRef is v as a catalog id when it names one of magpie's models or
// groups, as an agent spells it (magpie/…, Claude Code's [1m]); "" else.
func modelRef(v string) string {
v = strings.TrimSuffix(strings.TrimSpace(v), "[1m]")
v = strings.TrimPrefix(v, magpieID+"/")
if strings.HasPrefix(v, provider.GroupPrefix) || isMagpie(v) {
return v
}
return ""
}
+5 -2
View File
@@ -19227,8 +19227,11 @@ function renderRedact(s, keep) {
i.onblur = save;
row(t("Masked words"), t("Your own words to keep from vendors, separated by commas"), i);
renderRedactRules(s, row);
row(t("Count me as a user"), t("Once a day, a random id for this computer with magpie's version and system — nothing you use magpie for"),
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"),
onOff(!s.noUsageStats, (on) => savePrefs({ ...keep, noUsageStats: !on })));
}
function renderOTel(s, keep) {
@@ -19756,7 +19759,7 @@ function prefsKeep(s) {
trayUsages: s.trayUsages || [],
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "",
claudeWarmup: s.claudeWarmup || "", codexWarmAt: s.codexWarmAt || "", claudeWarmAt: s.claudeWarmAt || "",
codexWarmAts: warmTimes(s.codexWarmAts, s.codexWarmAt), claudeWarmAts: warmTimes(s.claudeWarmAts, s.claudeWarmAt), workbuddyCheckin: !!s.workbuddyCheckin, traeCheckin: !!s.traeCheckin, minimaxCheckin: !!s.minimaxCheckin, qoderCheckin: !!s.qoderCheckin, noStats: !!s.noStats,
codexWarmAts: warmTimes(s.codexWarmAts, s.codexWarmAt), claudeWarmAts: warmTimes(s.claudeWarmAts, s.claudeWarmAt), workbuddyCheckin: !!s.workbuddyCheckin, traeCheckin: !!s.traeCheckin, minimaxCheckin: !!s.minimaxCheckin, qoderCheckin: !!s.qoderCheckin, noStats: !!s.noStats, noUsageStats: !!s.noUsageStats,
memberModel: !!s.memberModel,
noUpdatePill: !!s.noUpdatePill, noAutoUpdate: !!s.noAutoUpdate, updateEvery: s.updateEvery || 360,
trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, trayNoLogos: !!s.trayNoLogos, trayNoBird: !!s.trayNoBird, vision: s.vision || "", imageGen: s.imageGen || "", searcher: s.searcher || "", searchFirst: s.searchFirst || "", currency: s.currency || "usd",
+12 -4
View File
@@ -643,7 +643,9 @@ const I18N = {
"API keys, private keys, tokens and passwords go to vendors as placeholders, and come back as they were": "API Key、私钥、Token 和密码以占位符发给模型厂商,回复里再还原",
"Mask personal data": "脱敏个人信息",
"Count me as a user": "计入用户数",
"Once a day, a random id for this computer with magpie's version and system — nothing you use magpie for": "每天一次,仅发送本机随机 ID、magpie 版本和系统,不含任何使用内容",
"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”。不含名称、地址、账号、密钥和用量",
"Warm-up and check-in": "预热与签到",
"Warm up on reset": "窗口重置时预热",
"Daily warm-up": "每日定时预热",
@@ -4399,7 +4401,9 @@ const I18N = {
"API keys, private keys, tokens and passwords go to vendors as placeholders, and come back as they were": "API Key、私鑰、Token 和密碼以佔位符發給模型廠商,回覆裡再還原",
"Mask personal data": "脫敏個人資訊",
"Count me as a user": "計入使用者數",
"Once a day, a random id for this computer with magpie's version and system — nothing you use magpie for": "每天一次,僅傳送本機隨機 ID、magpie 版本和系統,不含任何使用內容",
"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」。不含名稱、位址、帳號、金鑰和用量",
"Warm-up and check-in": "預熱與簽到",
"Warm up on reset": "時段重置時預熱",
"Daily warm-up": "每日定時預熱",
@@ -8126,7 +8130,9 @@ const I18N = {
"API keys, private keys, tokens and passwords go to vendors as placeholders, and come back as they were": "APIキー、秘密鍵、トークン、パスワードはプレースホルダーとしてベンダーに送られ、元どおりに戻されます",
"Mask personal data": "個人情報をマスク",
"Count me as a user": "ユーザー数に含める",
"Once a day, a random id for this computer with magpie's version and system — nothing you use magpie for": "1 日 1 回、このコンピュータのランダムな ID と magpie のバージョン、システムを送信します。magpie で何をしているかは含みません",
"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」とだけ記録します。名前、アドレス、アカウント、キー、使用量は含みません",
"Warm-up and check-in": "ウォームアップとチェックイン",
"Warm up on reset": "リセット時にウォームアップ",
"Daily warm-up": "毎日のウォームアップ",
@@ -11868,7 +11874,9 @@ const I18N = {
"API keys, private keys, tokens and passwords go to vendors as placeholders, and come back as they were": "API-Schlüssel, private Schlüssel, Token und Passwörter gehen als Platzhalter an Anbieter und kommen unverändert zurück",
"Mask personal data": "Personenbezogene Daten maskieren",
"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 — nothing you use magpie for": "Einmal täglich eine zufällige ID dieses Computers mit magpie-Version und System — nichts darüber, wofür Sie magpie nutzen",
"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",
"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",
@@ -0,0 +1,83 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Privacy has a switch for the agents, providers and models the daily
// event names, on until turned off, below Count me as a user, and gone
// while that is off: it rides on that event. Turning it off saves
// noUsageStats, and a save of another setting keeps it.
// English and Chinese; no backend, the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
function serve(lang, settings, saved) {
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings });
if (url.pathname === "/api/settings") {
if (req.method() === "POST") { const b = req.postDataJSON(); saved.push(b); Object.assign(settings, b); }
return json(settings);
}
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const words = {
en: { count: "Count me as a user", share: "Share the agents, providers and models I use", personal: "Mask personal data", on: "On", off: "Off" },
zh: { count: "计入用户数", share: "分享我用的 agent、provider 和模型", personal: "脱敏个人信息", on: "开启", off: "关闭" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: Privacy switches off sharing what magpie is used with`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const errors = [];
const settings = { theme: "light", lang, tray: "panel", currency: "usd" }, saved = [];
const context = await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" });
const page = await context.newPage();
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, settings, saved));
await page.goto("http://magpie.test/?view=settings&tab=privacy");
const row = (name) => page.locator("#redactList .row", { has: page.locator(".name", { hasText: new RegExp("^" + name + "$") }) });
const isOn = (name) => page.waitForFunction(([name, on]) => [...document.querySelectorAll("#redactList .row")].some((r) => r.querySelector(".name")?.textContent === name && r.querySelector(".opt.on")?.textContent === on), [name, w.on]);
const isOff = (name) => page.waitForFunction(([name, off]) => [...document.querySelectorAll("#redactList .row")].some((r) => r.querySelector(".name")?.textContent === name && r.querySelector(".opt.on")?.textContent === off), [name, w.off]);
// on by default, right below Count me as a user
await isOn(w.share);
const names = await page.$$eval("#redactList .row .name", (ns) => ns.map((n) => n.textContent));
assert.equal(names.indexOf(w.share), names.indexOf(w.count) + 1, names.join(" | "));
await row(w.share).getByRole("button", { name: w.off, exact: true }).click();
await isOff(w.share);
assert.equal(saved.at(-1).noUsageStats, true, JSON.stringify(saved));
assert.ok(!saved.at(-1).noStats);
// another setting saved keeps it off
await row(w.personal).getByRole("button", { name: w.on, exact: true }).click();
await isOn(w.personal);
assert.equal(saved.at(-1).noUsageStats, true, JSON.stringify(saved.at(-1)));
// not counted at all: nothing to share, no switch
await row(w.count).getByRole("button", { name: w.off, exact: true }).click();
await isOff(w.count);
await row(w.share).waitFor({ state: "detached" });
assert.equal(saved.at(-1).noStats, true);
await context.close();
assert.deepEqual(errors, []);
});
}
}
+3
View File
@@ -175,6 +175,9 @@ type Settings struct {
// NoStats stops the one event a day that counts magpie's users (see
// internal/stats).
NoStats bool `json:"noStats,omitempty"`
// NoUsageStats keeps which agents, providers and models magpie is
// used with out of that event, which then counts the user only.
NoUsageStats bool `json:"noUsageStats,omitempty"`
// NoUpdatePill keeps the header's Update pill away when a newer magpie
// is out; UpdateSkip is the one version it was hidden for, and a newer
// one brings it back. Either way magpie still downloads the version and
+40 -16
View File
@@ -1,8 +1,11 @@
// Package stats counts magpie's users: once a day a running magpie tells
// PostHog it is in use, under an id made up on this computer, with its
// version and system. Nothing else goes: no names, accounts, keys, models,
// prompts or usage. Settings → Privacy turns it off, and so do
// DO_NOT_TRACK=1 and MAGPIE_NO_STATS=1; a build from source never sends it.
// version and system, and, unless the user turned that part off, which
// agents, providers and models it is used with, by their built-in ids
// (Usage): a provider of the user's own is only "custom". No names, URLs,
// accounts, keys, prompts or usage go. Settings → Privacy turns it off,
// and so do DO_NOT_TRACK=1 and MAGPIE_NO_STATS=1; a build from source
// never sends it.
package stats
import (
@@ -75,23 +78,44 @@ func Send(ctx context.Context, version, what string, now time.Time) error {
if err != nil {
return err
}
at := now.UTC().Format(time.RFC3339)
event := func(name string, props map[string]any) map[string]any {
// a count, not a person: PostHog keeps no profile for the id
props["$process_person_profile"] = false
// PostHog's batch takes the id in either place: both, to be sure
props["distinct_id"] = id
return map[string]any{"event": name, "distinct_id": id, "timestamp": at, "properties": props}
}
active := map[string]any{
"version": version,
"kind": what,
"$os": osName(),
"arch": runtime.GOARCH,
}
var uses []map[string]any
if !settings.Load().NoUsageStats {
u := usage()
active["agents"] = len(u.Agents)
active["providers"] = len(u.Providers)
active["models"] = len(u.Models)
active["groups"] = u.Groups
// one event for each, so PostHog counts the users of each id
for _, l := range []struct {
kind string
ids []string
}{{"agent", u.Agents}, {"provider", u.Providers}, {"model", u.Models}} {
for _, x := range l.ids {
uses = append(uses, event("magpie uses", map[string]any{"kind": l.kind, "id": x}))
}
}
}
body, _ := json.Marshal(map[string]any{
"api_key": Key,
"event": "magpie active",
"distinct_id": id,
"timestamp": now.UTC().Format(time.RFC3339),
"properties": map[string]any{
"version": version,
"kind": what,
"$os": osName(),
"arch": runtime.GOARCH,
// a count, not a person: PostHog keeps no profile for the id
"$process_person_profile": false,
},
"api_key": Key,
"batch": append([]map[string]any{event("magpie active", active)}, uses...),
})
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", Host()+"/i/v0/e/", bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", Host()+"/batch/", bytes.NewReader(body))
if err != nil {
return err
}
+65 -16
View File
@@ -5,26 +5,36 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// One event a day, under the same id, with nothing but the id, the version
// and the system; none once the user turns it off.
// and the system, and what magpie is used with by built-in ids; none once
// the user turns it off.
func TestSend(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("DO_NOT_TRACK", "")
t.Setenv("MAGPIE_NO_STATS", "")
var got []map[string]any
usage = func() Usage {
return Usage{Agents: []string{"claude", "codex"}, Providers: []string{"deepseek", "custom"}, Models: []string{"deepseek/deepseek-v4"}, Groups: 2}
}
defer func() { usage = readUsage }()
var got [][]map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var e map[string]any
json.NewDecoder(r.Body).Decode(&e)
if r.URL.Path != "/i/v0/e/" || e["api_key"] != Key {
t.Errorf("sent %s %v", r.URL.Path, e)
var b struct {
Key string `json:"api_key"`
Batch []map[string]any `json:"batch"`
}
got = append(got, e)
json.NewDecoder(r.Body).Decode(&b)
if r.URL.Path != "/batch/" || b.Key != Key {
t.Errorf("sent %s %v", r.URL.Path, b)
}
got = append(got, b.Batch)
}))
defer srv.Close()
t.Setenv("MAGPIE_STATS_HOST", srv.URL)
@@ -35,21 +45,60 @@ func TestSend(t *testing.T) {
}
}
if len(got) != 2 {
t.Fatalf("%d events for two days", len(got))
t.Fatalf("%d sends for two days", len(got))
}
if got[0]["distinct_id"] != got[1]["distinct_id"] || len(got[0]["distinct_id"].(string)) != 32 {
t.Fatalf("ids: %v %v", got[0]["distinct_id"], got[1]["distinct_id"])
if got[0][0]["distinct_id"] != got[1][0]["distinct_id"] || len(got[0][0]["distinct_id"].(string)) != 32 {
t.Fatalf("ids: %v %v", got[0][0]["distinct_id"], got[1][0]["distinct_id"])
}
p := got[0]["properties"].(map[string]any)
if p["version"] != "0.1.300" || p["kind"] != "app" || len(p) != 5 {
t.Fatalf("properties: %v", p)
a := got[0][0]
p := a["properties"].(map[string]any)
if a["event"] != "magpie active" || p["version"] != "0.1.300" || p["kind"] != "app" || p["agents"] != 2.0 || p["providers"] != 2.0 || p["models"] != 1.0 || p["groups"] != 2.0 || len(p) != 10 {
t.Fatalf("active: %v", a)
}
settings.Save(settings.Settings{NoStats: true})
var uses []string
for _, e := range got[0][1:] {
p := e["properties"].(map[string]any)
if e["event"] != "magpie uses" || e["distinct_id"] != a["distinct_id"] || p["$process_person_profile"] != false || len(p) != 4 {
t.Fatalf("use: %v", e)
}
uses = append(uses, p["kind"].(string)+" "+p["id"].(string))
}
if want := "agent claude,agent codex,provider deepseek,provider custom,model deepseek/deepseek-v4"; strings.Join(uses, ",") != want {
t.Fatalf("uses %q, want %q", uses, want)
}
// what it is used with turned off: the count of the user only
settings.Save(settings.Settings{NoUsageStats: true})
Send(context.Background(), "0.1.300", "app", day.Add(48*time.Hour))
if len(got) != 3 || len(got[2]) != 1 || len(got[2][0]["properties"].(map[string]any)) != 6 {
t.Fatalf("sent with usage off: %v", got[2:])
}
settings.Save(settings.Settings{NoStats: true})
Send(context.Background(), "0.1.300", "app", day.Add(72*time.Hour))
t.Setenv("MAGPIE_NO_STATS", "1")
settings.Save(settings.Settings{})
Send(context.Background(), "0.1.300", "app", day.Add(72*time.Hour))
if len(got) != 2 {
Send(context.Background(), "0.1.300", "app", day.Add(96*time.Hour))
if len(got) != 3 {
t.Fatalf("sent while off: %d", len(got))
}
}
// A provider is named by what magpie built in, never by what the user
// typed: a preset's id, a subscription's agent, a known plugin's id;
// anything else is "custom" or "plugin".
func TestProviderLabel(t *testing.T) {
for _, c := range []struct {
p provider.Provider
want string
}{
{provider.Provider{ID: "my-deepseek", Name: "My DeepSeek", Preset: "deepseek", Chat: "https://api.deepseek.com/v1"}, "deepseek"},
{provider.Provider{ID: "secret-relay", Name: "Secret Relay", Chat: "https://relay.example/v1"}, "custom"},
{provider.Provider{ID: "odd", Name: "Odd", Preset: "no-such-preset"}, "custom"},
{provider.Provider{ID: "codex", Account: &provider.Account{Agent: "codex", User: "someone@example.com"}}, "codex"},
} {
if got := providerLabel(c.p); got != c.want {
t.Errorf("%s: %q, want %q", c.p.ID, got, c.want)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package stats
import (
"slices"
"strings"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/provider"
)
// Usage is what the day's event says magpie is used with, when the user
// shares it: built-in ids only. A provider of the user's own is "custom",
// and so is a model on one; a plugin no one knows is "plugin".
type Usage struct {
Agents []string // the agents connected to magpie: claude, codex
Providers []string // the providers on: deepseek, codex (a subscription), plugin:kiro
Models []string // the models the connected agents are set to: deepseek/deepseek-v4, custom, group
Groups int // routing groups shown
}
// most of each list sent: far more than anyone has
const most = 50
// usage reads Usage; tests put their own.
var usage = readUsage
func readUsage() Usage {
defer provider.Hold()()
var u Usage
for _, a := range agent.All() {
if !a.Wired() {
continue
}
id, _, _ := strings.Cut(a.ID, "@wsl:")
u.Agents = with(u.Agents, id)
for _, ref := range a.Models() {
u.Models = with(u.Models, modelLabel(ref))
}
}
for _, p := range provider.All() {
if p.On() && !p.Hidden {
u.Providers = with(u.Providers, providerLabel(p))
}
}
for _, g := range provider.Groups() {
if !g.Hidden && !g.Disabled {
u.Groups++
}
}
return u
}
func with(xs []string, x string) []string {
if x == "" || slices.Contains(xs, x) || len(xs) >= most {
return xs
}
return append(xs, x)
}
// providerLabel is p as stats name it: nothing the user typed.
func providerLabel(p provider.Provider) string {
switch {
case p.IsPlugin():
if id := p.PluginProvider(); provider.Movable(id) || provider.Preset(id) != nil {
return "plugin:" + id
}
return "plugin"
case p.Account != nil:
return p.Account.Agent
case p.Preset != "":
if d := provider.Preset(p.Preset); d != nil {
return d.ID
}
}
return "custom"
}
// modelLabel is an agent's model as stats name it: the provider's label
// and the vendor's model id, "group" for a routing group, and the label
// alone for a provider of the user's own; "" for one magpie can't find.
func modelLabel(ref string) string {
if strings.HasPrefix(ref, provider.GroupPrefix) {
return "group"
}
p, m, ok := provider.Resolve(ref)
if !ok {
return ""
}
l := providerLabel(p)
if l == "custom" || l == "plugin" {
return l
}
return l + "/" + m
}