8 Commits
Author SHA1 Message Date
tobitege 21702d01ff Add German translation; harden paths against unset env vars (#770)
* Add German translation and harden paths against unset env vars

Add a German locale to the GUI, tray menu, usage alerts and website home page, and refuse to start when the home folder is unset or relative so nothing is written under the working folder; the pi plugin no longer adopts the user's PI_CODING_AGENT_DIR, and an inherited APPIMAGE no longer selects portable mode. Scripts refuse to run from the wrong folder or empty a shared out folder, and a new internal/testenv sandboxes every test package from the user's real files, which also fixes the Claude picker and Termux installer tests on Windows.

* Makefile: know magpie's folder by its go.mod, so paths with spaces work

The check compared $(CURDIR) with $(dir $(abspath …)), which make splits on spaces, so a checkout under 'My Projects' or 'John Doe' refused every target. It now reads go.mod relative to the working folder and wants magpie's module there: make -C and a symlinked Makefile work, make -f from another folder still stops before any recipe runs.

* i18n: German for the strings main added since, with a parity test

75 strings main added (group order, patterns, slow-start wait, usage caps, RTK savings …) get their German, and two that main dropped go. gui-de.test.cjs checks, as gui-ja does, that de has every zh string with its placeholders and tags, and that the window reads in German when picked or on a German system.

* appdir: ignore relative folder variables when reading, keep them for children

CheckEnv no longer unsets anything. magpie reads the XDG folders, APPDATA/LOCALAPPDATA and every agent's folder variable through appdir.Getenv/LookupEnv, which pass over a relative value; the environment stays as it is, so the agents magpie starts get the variable as the user set it. That covers what proc.UserPath lends from the login shell too, which is set after main's check and reached the desktop app unchecked. On Windows a path rooted on the current drive (\Users\x, Git Bash's /c/x) counts as usable, and magpie version and help answer without a home, before run makes any folder.

* Sandbox the tests main added since

internal/wslrun gets testenv's TestMain, and four tests main added that set HOME alone set their home with testenv.SetHome, so on Windows they don't read the runner's profile through USERPROFILE.

* agentenv: PI_CONFIG_DIR and OPENCODE_DB take relative names on purpose

omp puts $PI_CONFIG_DIR under the home and OpenCode a relative $OPENCODE_DB in its data folder, so neither depends on the working folder: appdir no longer passes them over.

* i18n: German for the Library's multi-select and System One's decision API

The 19 strings main added since (#791's skill multi-select, the decision API snippets) get their German; the one main replaced (four APIs → five APIs) goes.

* appdir: tidy the import into magpie's group; say what Go makes of /c/x

The appdir import joins the group of magpie's own imports where a file has one. rooted's comment now says that Go takes a Git Bash path like /c/x as \c\x on the current drive, not C:\x.
2026-10-04 23:30:56 +08:00
yetone 33acb6bca0 providers: each model's credit rate shows by it in the provider's model list and an agent's model picker, Qoder's 0.5× and WorkBuddy's 0.03×, a discount's price before it struck through (01huadalang on Discord: qcoder 或者 workbuddy 能不能实时抓取或者按一段时间抓取能展示列表模型的积分消耗倍率,这样不用打开软件查看,能选择到便宜的模型) — magpie read each model's price only to tell free from not: Qoder's price_factor (06584e15) and WorkBuddy's "credits" ("x0.03", wbFreeCredits), so the lists said FREE or nothing, and picking a cheap model meant opening Qoder or WorkBuddy. catalog.Model now keeps the price, Rate (the credits a request costs, as a multiple; 0 is not listed, or Free) and RateWas (the price before a discount running now), and carries both through provider.Entry, agent.Option and the providers page's models. Qoder (and Qoder CN): Rate is the price_factor; RateWas an active promotion's before_promotion_price_factor, else an original_price_factor above the price (Qwen3.8-Flash: free, 0.1× struck through), and a promotion's 0 that isn't free is its price before times its discount_factor. WorkBuddy and WorkBuddy AI (the CodeBuddy plan; both builds read the same /v3/config): Rate is the credits (wbCredits; x0.00 stays FREE, an unreadable or infinite rate is none). A plugin's model gives it as it gives free: rate and rateWas on the model its provider.models hook lists, a number (0.5) or as the vendor writes it ("x0.03", "0.5×"), which host.js reads (rateOf) into plugin.Model.Rate/RateWas and pluginCatalog keeps; the moved Qoder and WorkBuddy users get it once their plugins send it. The badge is small and grey (the context badge's colours, no uppercase), beside FREE where that shows, its title saying what a request costs (每次请求消耗 0.5× 积分;折扣前 / 限时免费,原价). Rates are as of the provider's last list: a built-in's is fetched at start-up when it has none, on Refresh and when its key changes, a plugin's when its plugins are asked again (start-up, a sign-in, a plugin change); nothing asks on a timer. Kiro, Cursor, ZCode and Devin list no per-model rate magpie reads, so they show none. TestParseModelsRate (qoder) and TestWBCreditsRate (provider) are new and don't build without the change; TestFakePlugin now checks a plugin's rate given as a number and as "x0.03" (0 for both with host.js not reading them). model-rate.test.cjs (WebKit, en and zh: the chips' badges and titles, struck-through prices, grey not green, no stripe, a click picks and scrolls nothing, the picker's badges) fails without the change and passes with it; click-scroll, models-free-only, model-pick and favorite-star pass in WebKit. No request was made to Qoder, WorkBuddy or any vendor. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME, go test ./internal/gui pass. 2026-10-02 22:48:45 +08:00
yetone 06584e15fc qoder: a model is free only at a price of 0, as Qoder's own client shows it (01huadalang on Discord: Qoder shows Qwen3.8-Max at 0.5× credits with its off-peak 40% discount, magpie called it free) — Qoder's listing has is_free true on Qwen3.8-Max at a price_factor of 0.5, and the built-in took either is_free or a price of 0 for free. Now, as the plugin's freeOf does (magpie-community/plugins 6fd444c): a listed price_factor (priceFactor) says it, free only at 0; a price of 0 only while an active promotion lasts, with a price before it (before_promotion_price_factor > 0), is a discount, not free; a limited-time 0× (Qwen3.8-Flash, its 0.1× struck through) is free while it is; is_free counts only on a listing with no price. TestParseModelsFreeIsThePrice, with the listing's real entries (Qwen3.8-Max's off-peak promotion, Qwen3.8-Flash's original_price_factor), fails without the change (Qwen3.8-Max and the promotion-window models read free) and passes with it; TestParseModelsFree's is_free-at-price-1 now reads paid. No request was made to Qoder. Moved Qoder users get it from opencode-qoder-auth 0.2.1 (the mover's min stays 0.2.0 until that is on npm). 2026-10-02 22:00:34 +08:00
yetone f099d07e92 qoder: Qoder CN is moved onto its plugin too, @magpie-community/opencode-qoder-auth 0.2.0, which serves it as "qoder-cn" (the user: 为啥 Qoder CN 没有插件啊) — the plugin had Qoder alone when the move was made (09-30), Qoder CN coming to magpie a day later, so Qoder CN stayed a built-in that wasn't deprecated. 0.2.0 serves both sites (Qoder CN signing in on qoder.cn with its own client id and no redirect_uri, chatting on gateway.qoder.com.cn, and on its device token when qoder.cn refuses the job token), so movers now has "qoder-cn" beside "qoder", both from qoderMover(id): an account's pairs, machine id and, for one chatting on its device token, deviceChat go to the plugin and come back. Both movers' min is 0.2.0. The PLUGIN-SERVED notices in internal/qoder, internal/provider/qoder*.go and internal/gateway/qoder.go and AGENTS.md's table now name both ids rather than saying Qoder CN isn't moved. TestQoderCNMover (a device-chat account and a job-token one, round trip) fails with deviceChat not brought back. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass. 2026-10-02 19:59:36 +08:00
yetone fa9daa6945 docs: every built-in subscription a plugin serves once moved says so in its code, and AGENTS.md says where a fix for it goes (user: 对内置的订阅 provider 在代码层面和文档层面加上注释,防止又有别的 agent 在改内置订阅 provider 的代码而不知道生效的其实是对应的插件代码) — a moved Cursor, Devin, Grok, Command Code plan, Kiro, Zed, Qoder, Factory, Xiaomi MiMo, ZCode or WorkBuddy (provider.Moved; since v0.1.642 also the default for a new sign-in, provider.Adopt) is signed in, listed, requested and metered by its @magpie-community plugin, and none of the built-in's code in internal/provider/<name>*.go, internal/gateway/<name>.go, internal/zed or internal/qoder serves it; a fix made there alone never reaches those users. Each of those files now opens with a PLUGIN-SERVED notice naming the subscription's id, its plugin package, where the plugin lives (magpie-community/plugins, packages/<name>) and the mover whose min to raise so moved users get the fixed plugin (keepMovedCurrent); Qoder's adds that Qoder CN isn't moved and still runs there. AGENTS.md (CLAUDE.md imports it) lists the moved built-ins with their packages and movers, how to tell whether a user's is moved (move.state "plugin", onPlugins), the order a fix goes in (the plugin, publish, raise min, the built-in only for those not moved), the magpie code that does run for a moved one (internal/plugin, plugins.go, plugin_usage.go, pluginsignin.go, migrate*.go, app.js's subOf/pluginSubs/startPluginSignIn), and the parity tests. movers in migrate.go and SUBS in app.js point there too. TestMovedBuiltinsSayTheirPlugin fails for a mover whose code carries no PLUGIN-SERVED notice naming its id and package (it failed while a wrapped package name was split). Comments and docs only, no behaviour changed. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut failed once on their timing under load, pass alone; untouched). 2026-10-02 19:37:02 +08:00
yetone 4f9acf72a4 subscriptions: Qoder CN, for accounts on qoder.cn (#349) — Qoder's China site (qoder.cn, Alibaba Cloud or phone sign-in) has its own accounts, sign-in page, account API (openapi.qoder.com.cn) and gateway (gateway.qoder.com.cn), so a qoder.cn account couldn't sign in through magpie's Qoder, which only knows qoder.com. A new Qoder CN subscription signs in there with the client id Qoder CN's own CLI sends, and everything after — polling, job token and refresh, user info, models, usage and chat — follows the site saved with the account; if qoder.cn refuses the job token, the account chats with its device token and renews it, as that CLI does. Qoder's tile is now Qoder (international), each tile saying which accounts it is for. Accounts saved before keep working on qoder.com unchanged. Not tried with a real qoder.cn account. TestSites, TestCNDeviceChatRefresh, TestQoderCNSignIn, TestQoderCNDeviceChat, TestQoderGlobalUnchanged and TestQoderCNGateway fail without the change; qoder-cn-tile.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass but for TestCursorPluginContext, which fails on main (1ce05f0) as well; click-scroll, old-webkit pass. 2026-10-01 01:42:12 +08:00
yetone 947521194f qoder: a model Qoder's listing marks free is shown free (lml on Discord asked which Qoder models cost no credits) — a model with is_free (isFree) true or a price_factor (priceFactor) of 0, as Qoder's own client reads its listing, gets catalog.Model.Free, so the model picker's free badge shows on it; a saved account's models, re-read from its stored listing, carry it too. TestParseModelsFree and TestQoderSavedModelsFree fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut, a load flake, passes alone). 2026-09-30 17:29:55 +08:00
Ethan Xu 81736b2fb2 subscription: Qoder is a subscription you sign in to and route throug… (#162)
* 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 &#34;x&#34;, 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.
2026-09-29 16:40:20 +08:00