* usage: add opt-in OTLP HTTP metadata export
Export gateway traces and optional duration/token histograms asynchronously with a bounded queue, retries, proxy support and a bounded shutdown. Add bilingual Settings controls and MAGPIE_OTEL_* overrides. Keep prompts, replies, sessions and provider credentials local; use the same OTLP path for Langfuse.
Validated: full Go tests (nogui), usage race tests, focused go vet, and Chromium/WebKit settings tests in English and Chinese.
Refs #213
* otel: protect collector credentials in backups and settings
Remove all OTLP headers from keyless backups and restrict both new and existing settings files to 0600 before saving. Clarify SIGTERM export behavior. Addresses the two blocking review findings in #412.
* otel: preserve local credentials and read-only settings on restore
---------
Co-authored-by: raper <raper@raperdeMacBook-Air.local>
* feat(gui): show and filter usage for each API key
* feat(gateway): issue per-user caller keys and attribute usage
* feat(gui): manage gateway users and filter caller-key usage
* test: verify caller-key failover and document user management
* refactor(gateway): use named caller keys without user accounts
* feat(gui): manage named caller keys with existing account styles
* fix: remove user breakdown from CLI and leftover gateway styling
* fix(usage): drop stale user field from request response
* feat(gateway): authenticate LAN calls with named caller keys
* fix(gateway): preserve LAN key rotation in the named caller store
* fix(gui): retain native LAN key copy and rotation controls
* fix(gui): inherit the global theme for API key inputs
* fix(usage): show client API keys using the existing terminology
* fix(gateway): rotate named keys without changing usage identity
* fix(gui): unify gateway keys and selectable connection examples
Keep sharing controls in Settings and manage credentials only in Gateway. Refresh the named-key list on navigation, ignore stale reads after key actions, and generate all API examples from the selected address and enabled key.
* fix(gui): keep caller key names literal in connection menus
* fix(access): use Magpie as default key and drop user migration
* refactor(gui): name caller key source and tests consistently
* refactor(usage): split provider-key accounting out of caller-key PR
* fix(gateway): preserve LAN compatibility and add headless key management
* fix(gui): clarify gateway keys and confirm destructive actions
* test(gateway): guard caller attribution under 120 concurrent requests
* fix(gateway): keep open access, tolerate migration errors and revoke legacy keys
* fix(backup): preserve named gateway keys and honor no-keys restores
* fix(gui): keep local API-key semantics and disambiguate shared credentials
* docs(gateway): keep review scope focused and append browser test documentation
* fix(backup): retain existing default keys when old backups lack their store
* fix(gateway): attribute video creation usage to the caller key
* fix(access): explain default key revocation blocked by read-only settings
* test(usage): preserve provider and caller identities after rebase
* test(gui): reconcile caller key fixtures with merged main
* docs(usage): clarify caller coverage and independent provider accounting
* fix(gui): initialize fast members for model-shortcut groups
Latest main introduced fast-member arrays but omitted the model shortcut's draft. The existing unlisted-models browser regression fails unchanged on main. Initialize fast to an empty list and assert a standard-speed group is saved, without relaxing existing assertions.
* fix(access): retain legacy LAN access when the key store cannot be created
Allow only the configured legacy credential before either migration marker exists. Refuse revoked mirrors, disabled matches and unreadable stores; keep the fallback's caller identity stable when migration later succeeds. Cover read-only configuration directories, remote credential transports and revocation.
* fix(backup): save settings before replacing gateway credentials
Leave the key store untouched when settings validation or writes fail. Restore the previous settings association when the key store refuses its write, and report a rollback failure separately. Cover invalid settings and read-only settings/store failures.
* fix(gui): reset missing caller filters and focus key controls without scrolling
* test(provider): synchronize Zhipu quota fixture observations
* feat(routing): group requests by session with names and estimated costs
* fix(routing): address session view review feedback
Preserve Luna Reserve and other header fallbacks for ordinary Codex turn metadata; reuse metadata and the kind across trace and native accounting. Default to By request and persist the grouping choice. Store only token accounting fields in route history while retaining legacy JSON compatibility.
Add regressions for helper precedence, native kind accounting, history compatibility and grouping preferences.
* feat(usage): attribute gateway usage to individual API keys
* refactor(usage): keep provider accounting focused on CLI and CSV
* fix(usage): attribute System One calls and name upstream key fields explicitly
* fix(usage): leave keyless System One requests unattributed
A provider may serve a model under an id of its own: namespaced with a
prefix, dated, a `-latest` that is not what models.dev calls it, or simply
a different spelling. magpie sends the name it knows the model by, and the
provider answers that it has no such model.
magpie model wire relay-b/model-2 vendor-c/model-2-preview
magpie model wire 'relay-b/*' 'vendor-c/*'
magpie model wire relay-b/model-2 --reset
A `*` in the **value** stands for the model, so `relay-b/*` set to
`vendor-c/*` sends `vendor-c/model-3` for model-3. A value with no `*` sends
every model of that provider under one name, which is the case where a relay
really does serve them alike. The CLI shows the pattern the way it was
given, with a concrete line for one of the models, so what is in force is
not something you have to imagine. An exact key wins over the provider's.
The canonical id stays canonical. `Entry.Model` is not rewritten: it is the
key automatic routing groups are built from, what `Resolve` returns as the
model's identity, what `GET /models` lists, what an agent asks for, and what
a call is recorded and priced as. The alias goes into the request's model
field and nowhere else — which is the part that was wrong at first.
Handing it to the serializer gave the provider's name to the code that
decides what a request should look like: `buildChat` checks the model for
Gemini compatibility, `buildAnthropic` matches it for adaptive-only and
effort handling, `buildCodeAssist` maps by model. A provider serving a
model under a namespaced id got a request shaped as if it were that other
model. So the request is shaped from the model magpie knows and the field is
rewritten once at the end, with `rewriteModel` — which already existed to
put one provider's own model name in a body and keeps every other byte as
it was.
It has to reach every path. A request the provider understands as it came
is relayed by `passthrough` without being rebuilt, and the Anthropic token
count is asked of the provider directly; both rewrite the field too, so on
a provider speaking the client's own protocol — the common case for a relay
fronting one vendor — the alias still applies.
"Swapped" compared the canonical id with what the vendor's reply named, so
an alias that is not a prefix or a dated variant marked every call swapped.
It compares the name that was actually sent, in the gateway and in the
ledger alike; the ledger reads the map once for the lot, since its rows are
what a reader sets beside a bill. The Codex backend path is left alone: it
sends magpie's own pick and never an alias.
`provider.Test` asked with the canonical id, so a provider that only accepts
the alias reported a failure while its real requests worked. It asks with
the alias now and still reports the canonical id, since that is the one the
user picked.
A blank name was a hole: the setter trimmed what it was given, so a value of
only whitespace was removed on the way in, while the read path would have
used it had anything else written it — a hand-edited file, or a build from
before the trim. Both ends treat a blank as no name now. The key is
checked where it is written rather than in Save, so one hand-edited entry
cannot block every save, and #288's walk moves it on a provider rename
without a line here.
Co-authored-by: yetone <yetoneful@gmail.com>
A provider that serves a model the catalogue does not list, or lists at the
wrong size, has a window and a reply limit magpie cannot know: both fall
back to whatever the catalogue says about the model's maker, which for such
a provider is nothing at all.
magpie model context relay-a/gpt-5.5 262144
magpie model output relay-a/gpt-5.5 131072
magpie model context 'relay-a/*' 200000
magpie model output relay-a/gpt-5.5 --reset
Both are looked for in the same order as each other: this model, then
`<provider id>/*`, then the provider's own list, then the catalogue. A
model's own value wins over its provider's. The reply limit is new; the
window is not — `Provider.Contexts` has been there, with a `"*"` of its own,
settable through `magpie provider set <id> context.<model>=`, and this
reuses it rather than shadowing it.
That reuse is the point rather than a detail. A context is a routing input
and not only a number agents are shown: at 95% of a held member's window a
request moves to a member that takes more, so two sources of truth would
mean the gateway routing on one number while agents are told another.
`magpie model context` is a second way to set the same field, not a second
field.
Both reach the agents. Pi keeps a model's window and reply limit in its own
models.json, OpenCode keeps its limit, and Crush, Droid, Cline, Qoder and
Zcode keep their own; an agent reads those files when it starts, so a limit
set here that never reaches them is a limit that does not take effect.
`magpie provider set` has always told the agents, and this behaves the same
way rather than introducing a second, quieter path. The test asserts each
setter reaches them exactly once — the two paths differ (one saves the
provider file, the other settings and then says so) and that difference is
worth pinning. An end-to-end pass confirms it: with a Pi models.json in
place, `magpie model context relay-a/sol 999999` writes `contextWindow:
999999` into `providers.magpie.models`.
A `0` is refused rather than read as a reset, naming `--reset`; a mistyped
model id is refused the way `magpie model name` refuses the same id; a reset
says what is actually in force instead of claiming to be back to the
vendor's list while a provider-wide value still applies; and
`magpie model context <provider>/*` reports that value rather than saying
the provider serves no such model. Validation happens where the entry is
written, so one hand-edited bad key cannot stop a theme or a window size
from saving, and #288's walk moves these on a provider rename without a
line here.
Magpie reports models.dev's list price for every call. That is the right
number for a vendor's own key and the wrong one for a provider that
resells or exposes a model at a different tariff, and it leaves a model the
catalogue does not list with no cost at all — a provider's own /models
gives ids, not economics.
magpie model price relay-a/gpt-5.5 0.12,1.20,0.01,0.15
magpie model price relay-a/* 0.12,1.20,0.01,0.15
magpie model price relay-a/gpt-5.5 --reset
magpie model prices
Absolute USD per million tokens. All four parts are required: a price
missing one would understate the rest of every call, and a hand-edited file
that left a part out would bill it at zero, so each part is a pointer and
the setter refuses a price that is negative, NaN or infinite, naming the
part at fault. Zero stays distinct throughout — a model served at no cost
has a price, not the absence of one. A `<provider>/*` entry covers every
model of that provider and an exact entry wins over it; --reset removes
only the exact entry and says so when a provider-wide one is still in
force.
The order a price is looked for in is the price for this model, then
`<provider>/*`, then the provider's own catalogue, then models.dev for the
maker. A price is a provider's tariff, not the model's: the same model
through two providers has two prices, and each keeps its own. A provider is
resolved by its id, or by an id it was renamed from, and never by its
display name — Find answers those too, and a record naming one was counted
at another provider's price.
provider.EffectivePrice is the one lookup every cost caller shares, so the
usage ledger and a session's totals both go through it, and a session
naming a model bare is still priced at its maker's list price, which is a
different question from what one provider charges. catalog.PriceOf and
PricedBy keep returning the models.dev price for anything wanting an
unbiased number. The background-task pickers (draw, search, vision) still
rank by catalogue price: they choose which model answers, not what a call
is reported to cost.
Unlike every other per-model preference this does not call
catalog.Touched. What a call costs is not what an agent picks a model by,
and the model lists magpie keeps in the agents' own files are not its to
rewrite over a number in a cost report. A rename moves the prices with it
through the walk #288 adds, so nothing here has to say so, and an id
recorded before a rename still resolves.
Validation happens where the entry is written, not in Save: one hand-edited
bad price in the file must not make saving a theme or a window size fail. A
price for a model the provider does not serve is refused the way
`magpie model name` refuses the same id, since it is a price that never
applies and nothing reports it as dead; a removal is exempt, so an entry
left for a model that has since gone can still be taken away.
* docker: a server image and a multi-arch build workflow
The image is the terminal-only binary built with -tags nogui on
distroless static, run as nonroot with magpie's files on a /config
volume, created and chowned at build time so a fresh named volume
is writable by uid 65532.
The workflow builds amd64 and arm64 on native runners, smoke-tests
each image before anything is pushed (ready probe, unknown-model
4xx JSON, /config write), pushes by digest, merges a multi-arch
manifest (semver trio plus latest on v* tags, edge otherwise) and
attests build provenance. PR runs build and smoke-test only.
The README documents running the image, the web UI mode, and why
subscription sign-ins must start where a browser shares loopback
with magpie.
Co-Authored-By: glm-5.3-flash <hakase@ooooo.space>
Generated-By: omp 18.4.3
* docker: README publishes the ports on the host's loopback and says how to require a key — until the gateway is shared from Settings it takes any key from anyone who reaches it, and Docker's -p 3425:3425 puts it on every interface of the host past its firewall. Checked in the built image: with settings lan+lanKey a request from the host through the published port without the key gets 401 and with it 200; without them any key gets 200.
---------
Co-authored-by: glm-5.3-flash <hakase@ooooo.space>
Co-authored-by: yetone <yetoneful@gmail.com>
* feat(gui): choose macOS session terminal
* session terminal: build the darwin CLI without cgo, drop the dead macOS 11 path, and open Terminal when the .command default is not a terminal
- terminal_apps_darwin.go imports "C" but had no cgo tag, and terminal_apps_other.go was !darwin only, so `CGO_ENABLED=0 GOOS=darwin go build -tags nogui .` (make release-cli's darwin targets) failed with undefined discoverTerminals. The cgo file and its test are now darwin && cgo; the stub is !darwin || !cgo.
- magpie needs macOS 12 (LSMinimumSystemVersion, -mmacosx-version-min=12.0), so the macOS 11 branch (temp .command file, deprecated LSCopyApplicationURLsForURL, CFRelease) never ran; it is gone along with the CoreServices link.
- With no saved choice, a default .command handler that is not a listed terminal, or a failed lookup, now opens Terminal as before the PR instead of failing; Settings labels the system option "System default (Terminal)" then, and the unused "System default (unavailable)" string is dropped.
- session-terminal.test.cjs covers an editor as the default in Chromium and WebKit, en and zh, and is on the README's node --test line.
---------
Co-authored-by: yetone <yetoneful@gmail.com>
* feat: add DimAgent subscription support
Add OAuth PKCE login with automatic and pasted callbacks, account management, model discovery, quota reporting and gateway integration. Safely rotate credentials during concurrent requests and preserve fresh tokens when updating model caches. Cover protocol, provider, GUI and callback behavior with regression tests.
* dimagent: a refresh hiccup inside the day's lead keeps the token that still runs
The fallback checked !dimagentNearExpiry, which is false whenever a refresh
runs at all, so a 5xx/timeout on /oauth/token failed every request for the
last 24h of a still-valid token. A 401/403 still lapses the account.
* dimagent: review tests (sign forwarding, parallel refresh once, hiccup in lead, LoginUsage)
* dimagent: signing in again takes the callback port from the sign-in it replaces
StartSignIn begins the new flow before it cancels the old one, so the new
flow's listen on 54321 failed (ignored) and the old listener then shut
down a second later: the browser's callback was refused and only pasting
could finish. The older DimAgent flow is now ended and its server closed
first, and the listen retried briefly while the port is let go.
* dimagent: the fake-upstream tests get a name of their own
---------
Co-authored-by: yetone <yetoneful@gmail.com>
* providers: qianfan personal, team and pay-as-you-go endpoints as its preset regions
* providers: qianfan review fixes — pay-as-you-go fetches /v2/models, enterprise naming, the plans' union models, a zh note
* providers: qianfan plans list nothing, pay as you go does — NoList scoped by region
* providers: qianfan review round two — old id maps to the new preset, the union models, IAM keys and a README section
* providers: qianfan key link follows the region — normalize takes it from the endpoints, the button from the plan picked
* providers: ParseImport takes a region key page
* subscription: Qoder is a subscription you sign in to and route through — Qoder lists no public API, so magpie speaks the endpoint its own desktop client uses: api3.qoder.sh's agent_chat_generation SSE, wrapped in the COSY envelope the client signs with (an RSA-encrypted key and a signed request line), the body through the client's own base64 codec, and the account reached by Qoder's OAuth device flow (qoder.com -> a dt- device token -> a jt- job token that serves models and rotates from its refresh token). The protocol lives in internal/qoder (endpoints, codec, cosy, oauth, models, a credential store, and /api/v1/userinfo, asked with the device token at sign-in so the account is labelled by its email and name rather than a bare uid); provider/qoder.go is who is signed in, its models, and the sign-in; gateway/qoder.go turns a request into a Qoder body and reads the SSE back, folding tool calls and results the way the client does. It is offered as a single, risky subscription in the Providers page: Qoder has no public API for this, and may treat a request that isn't its own client's as third-party use — the tile says so and points at an account you can afford to lose. Tested: the codec, the COSY signing, the model list, the request body and a served round-trip against stubs, and live — a signed-in account answered a Pi request for qfmodel end to end. Not tested: the userinfo email/name path against a fresh real sign-in (the device token is short-lived), and multi-turn tool use against the real client's exact framing.
* subscription: Qoder uses the chosen model and keeps rotating tokens consistent
Qoder used qfmodel regardless of the caller's model, refreshed the same rotating token concurrently, and could return upstream failures as empty successful replies. Requests now use the selected account's enabled model key and complete model_config; unknown or disabled models are refused. Token checking, rotation and atomic persistence are serialized, each caller receives a separate credential, and a rotated pair is retained for a persistence retry if its disk write fails.
Accounts are kept in logins.json with multiple accounts, selection, secondary-account toggles, usage and removal, including the last account. The old qoder.json is migrated after a successful write. A sign-in generates one machine ID that the account keeps for subsequent requests; the OS and architecture follow the running platform. Login polling expires after 15 minutes.
Qoder's response path maps non-200 SSE envelope statuses to HTTP errors before output and terminal error events after streaming begins, including requests using magpie's search rounds. Tool-result headings stay beside their own results; normal EOF flushes pending text and emits a stop event even after tool calls. Thinking, effort and context settings follow the selected model (low/medium/high is the fallback when no effort levels are declared). Corrected the Chinese risk wording and account plan label, removed unused protocol fields and the unsupported China-client claim, and included ufec/CLIProxyAPI's MIT copyright and license. Codec and signature tests now have fixed expected answers instead of relying only on round trips or an absolute Windows capture path.
The shared relay in claude_subscription.go is unchanged. Its error-status propagation and handling of failures after partial non-streaming output are existing upstream issues outside this change; Qoder handles these failures in its own relayQoder response path.
Tested: Qoder, codec, COSY and shared stream-error regressions passed 20 consecutive runs, including eight concurrent requests refreshing once, persistence failure recovery, account migration and removal, sign-in and usage against local HTTP stubs, and error handling across Chat, Responses, Anthropic and Gemini. go vet ./..., the production build, JavaScript syntax checks and git diff --check pass. The complete Windows test run has the same 72 failing tests/subtests as HEAD, with no new failures; the existing Claude shell-client test still times out. Not tested against a live Qoder account. The race detector could not build because gcc is unavailable.
* subscription: Qoder keeps its rotating tokens, speaks tools in its own
API's way, and thinks at the effort each model declares
An aborted request could sign the account out. A refresh ran on the
request's own context, so one cancelled after Qoder had rotated the pair
but before its reply arrived dropped the new pair and left the spent one
saved: every later call failed with "qoder job token refresh: status 401"
until the account was signed in again. The failure wasn't recorded
either, so the account looked healthy in the app while every request
failed. Refreshing now runs on context.WithoutCancel with its own 20s
timeout, in provider/qoder.go and the device-token refresh in
qoder_usage.go, and a refresh Qoder refuses (401 or 403, either token)
marks the account lapsed: Qoder's job-token refresh returns a
JobTokenRefreshHTTPError with its status, as the device one did. Side
logins don't carry Lapsed out, so qoderLogins passes it on.
A refresh blocked every other subscription. Both held qoderMu and the
global loginsMu across the HTTP call, up to 20s — with a 2s refresh in
flight, Logins("workbuddy") blocked 1.9s. qoderMu still serializes read,
rotate and write-back so one pair is rotated once; loginsMu is taken only
to read (qoderLookup) and to write back (qoderPersist), and the write-back
re-reads logins.json, so a change made meanwhile is kept. migrateQoder
takes no lock when there's no legacy file to move, or listing Qoder's own
accounts would wait behind a refresh too.
Tool calls were carried as XML in the text with values magpie HTML-
escaped, so an argument reached the model as "x", the model took
to imitating the escaping, and a value it meant literally — "< b &&"
— was unescaped on the way back and corrupted. A call had no id while
its result said "Function result for call A". Qoder's endpoint serves
OpenAI's own tool calls: asked with a tools array it answers with
tool_calls carrying ids, and accepts assistant tool_calls plus a role:tool
turn named by tool_call_id. So they are sent and read that way now: no
XML contract in the prompt, no escaping on either side, and a result
paired with its call by the id Qoder sent. The XML splitter stays as a
fallback for a model that still writes a call in its text.
Effort was chosen from reasoning_efforts, a field no model lists, so
every model got the low/medium/high fallback: gfmodel, which offers only
high and max, was asked for medium and refused with "该模型始终思考,
不支持关闭思考", and xhigh was clamped to high. The levels and the
default come from thinking_config now — asked for nothing, or for a
level Qoder doesn't name, a model thinks at its own default; a model
that can't stop thinking thinks at its lowest when asked for none; one
that declares no levels is sent none. A model whose only thinking_config
is enabled now thinks, as it does in Qoder's own client. Upstream
failures carry the vendor's reason from details, which had been dropped.
Tested: go test ./... and go vet on the changed packages, and the darwin
and windows builds. New regressions cover a refresh outliving an aborted
request, a refused refresh lapsing the account, logins and a concurrent
write-back during a refresh in flight, tool turns paired by id, native
tools and streamed tool_calls, and effort defaults. Live, against a
signed-in account: two parallel calls and a follow-up call each got
their own result; a file written with "< b &&" kept them; gfmodel,
asked for nothing, answered at max; qfmodel's xhigh reached Qoder as
xhigh. Not tested: a refresh the upstream refuses for real, and models
the account isn't offered.
* agent: MiMo Code — a terminal coding assistant that is Xiaomi's fork of OpenCode, whose config keeps OpenCode's shape: its own mimocode.json(c) under ~/.config/mimocode, its auth beside it, and a magpie provider block with npm/@ai-sdk/openai-compatible, model and small_model fields. opencode() becomes openCodeLike() for the two to share it, and magpieProviderJSON splits shape from the catalog id (magpieProviderJSONFor), so a visibility narrowed on mimocode (magpie visible mimocode …) narrows its list and leaves OpenCode's alone. The library gives it the same instructions, MCP servers (its mcp key) and skills as OpenCode, except the shared skills go into its own skills/ folder: it reads Claude Code's skills only behind a flag, though it reads its CLAUDE.md like OpenCode, and the page says so. Its requests are told by the mimocode/ User-Agent. Checked against XiaomiMiMo/MiMo-Code
* icon: MiMo Code's own favicon, from packages/ui/src/assets/favicon — its frame with the bottom bar, in the monochrome the app masks to the text colour