diff --git a/docs/reference.md b/docs/reference.md index c8ee8f83..5244c14a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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 diff --git a/internal/agent/inuse.go b/internal/agent/inuse.go new file mode 100644 index 00000000..6eeffd8a --- /dev/null +++ b/internal/agent/inuse.go @@ -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/), 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 "" +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 9bcfe6b5..c7bf4404 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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", diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 9333966c..f410c631 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/tests/privacy-usage-stats.test.cjs b/internal/gui/tests/privacy-usage-stats.test.cjs new file mode 100644 index 00000000..b7d0a789 --- /dev/null +++ b/internal/gui/tests/privacy-usage-stats.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index d708bc79..4d46cf5a 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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 diff --git a/internal/stats/stats.go b/internal/stats/stats.go index f9483544..106bd46b 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -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 } diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index b5ecd246..47d47cae 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -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) + } + } +} diff --git a/internal/stats/usage.go b/internal/stats/usage.go new file mode 100644 index 00000000..bcd3e317 --- /dev/null +++ b/internal/stats/usage.go @@ -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 +}