agent, gui: Codex left on model_provider = "magpie" with no [model_providers.magpie] loads its config again, and connecting with nothing in magpie says what to do, in the reader's language (Tystem on Discord) — the Codex app said "无法加载 config.toml … Model provider magpie not found", magpie showed Codex as not connected on "deepseek-flash", and its switch answered only "Codex can't be connected to magpie", in English. Codex looks model_provider up in [model_providers.*] as it loads config.toml (codex-rs: "Model provider {id} not found"), so the key without the table keeps it from loading any config, a new thread too, not just an old one. magpie never writes one without the other: it takes model_provider out whenever it steps out as the provider (disconnect, Unwire, a model of Codex's own, Join) and keeps the table so the threads started on magpie still open. That is unchanged, and TestCodexSteppingOutLeavesNoHalfState now covers it. Another tool or a hand edit had taken the table away. Sync, at magpie's start and whenever the catalog changes, now writes the table back whenever the config names magpie as its provider, at the top or in a profile, and has no table, whether Codex is connected or not. Check reports that state too. A config magpie can't parse is still only reported, never written to. A Connect that finds none of magpie's models because no provider or subscription has been added answers agent.NoModelsError, "Add a provider or subscription in magpie first, then connect Codex". /api answers it with code no_models and the agent's name, which app.js says through t() (en/zh/ja/de). docs/reference.md no longer says the table goes with a native model. TestCodexProviderWithoutTable (top and profile: Check says so, Sync writes the table) and TestConnectWithNoModels in internal/agent, TestAgentConnectNoModels in internal/gui, and agent-connect-no-models.test.cjs (WebKit, en/zh/ja/de: the toast in the reader's language, the switch back off, no scroll, no native dialog) are new and fail without the change; the latter's en case passes either way, as the server's message is English. Chromium wouldn't launch on this machine for this test or the existing agent-connect.test.cjs. TestCodexSyncAddsBaseURLToProvider now expects the table added to a hand-made provider that had none. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. In the first full run, internal/gui's TestProvidersAnswerWhileListsComeIn and internal/proc's TestUserPath failed under load; both passed alone and in the second full run.

This commit is contained in:
yetone committed 2026-10-05 21:09:13 +08:00
1 parent 9debc58397
commit 528d6c12dc
10 files changed
+269 -4

No files matched your search

+6 -1
View File
@@ -756,7 +756,12 @@ model (`opus`, `sonnet`…) removes them and restores whatever was there.
**Codex** gets a `[model_providers.magpie]` table, `model_catalog_json`
pointing at `~/.codex/magpie-models.json` (written from the catalog, so the
models show in Codex's own list) and a valid `model`/`effort`; picking a
native model removes all of that. Your ChatGPT sign-in is never touched.
native model or disconnecting removes `model_provider` and the catalog. The
`[model_providers.magpie]` table stays, so a thread started on magpie can
still be opened. Codex won't load its config at all when `model_provider =
"magpie"` has no table ("Model provider `magpie` not found"). If another
tool leaves that state behind, magpie writes the table back the next time
it syncs. Your ChatGPT sign-in is never touched.
Codex reads its model list at start-up, so restart it after a switch.
**OpenCode, Pi, Crush** get a `magpie` provider entry and `magpie/provider/model`.
+14
View File
@@ -392,6 +392,15 @@ func (a *Agent) Pick(key, v string) error {
// the provider and the whole catalog in, so the agent's own model list has
// every one of magpie's models. An agent that can keep the model it is on
// (Join) keeps it. An agent already connected is left as it is.
// NoModelsError is Connect's answer while magpie has no models to give
// the agent: no provider or subscription has been added yet. The GUI says
// it in the reader's language by its code (no_models).
type NoModelsError struct{ Agent string }
func (e *NoModelsError) Error() string {
return "Add a provider or subscription in magpie first, then connect " + e.Agent
}
func (a *Agent) Connect() error {
_, err := a.ConnectHow()
return err
@@ -474,6 +483,11 @@ func (a *Agent) connect() (Connection, error) {
}
}
if f == nil {
// nothing to connect it to yet: no provider or subscription added,
// which said only that it can't be connected (Tystem on Discord)
if shown, hidden := provider.CatalogFor(a.ID); len(shown)+len(hidden) == 0 {
return Connection{}, &NoModelsError{Agent: a.Name}
}
return Connection{}, fmt.Errorf("%s can't be connected to magpie", a.Name)
}
cur := connectWas(vals[f.Key], opts)
+44 -1
View File
@@ -48,7 +48,13 @@ import (
// The [model_providers.magpie] table stays once written. A thread keeps the
// provider it was started on, and one started on magpie can't be opened
// again without the table ("Model provider `magpie` not found"), whichever
// way Codex is routed now.
// way Codex is routed now. Codex looks model_provider up in the tables as
// it loads config.toml (codex-rs config: "Model provider `{id}` not
// found"), so `model_provider = "magpie"` without the table keeps it from
// loading its config at all, a new thread too: magpie never writes the one
// without the other, takes model_provider out whenever it steps out as the
// provider (disconnected, unwired, joined beside the sign-in), and Sync
// writes the table back when something else took it.
// codexStandIn is the model Codex is set to use, when magpie is its
// provider, for a model it names that magpie doesn't serve. Codex asks for
@@ -163,6 +169,31 @@ func codexIn(at place) *Agent {
t, err := edit.GetTOMLTable(path, "model_providers."+magpieID)
return err == nil && t != nil
}
// noProvider: the config was read and has no such table; one magpie
// can't read is told by Check as it is, and written to by nothing
noProvider := func() bool {
t, err := edit.GetTOMLTable(path, "model_providers."+magpieID)
return err == nil && t == nil
}
// namesMagpie: the config has Codex on magpie as its provider, at its
// top or in a profile. Codex then reads [model_providers.magpie] as it
// loads the config, before any thread: without the table it loads no
// config at all ("Model provider `magpie` not found"), the app and the
// CLI both, whatever the model.
namesMagpie := func() bool {
if asProvider() {
return true
}
names, _ := edit.TOMLTables(path)
for _, n := range names {
if strings.HasPrefix(n, "profiles.") {
if t, _ := edit.GetTOMLTable(path, n); t["model_provider"] == magpieID {
return true
}
}
}
return false
}
// dropProvider takes magpie out as the provider Codex is on; the table
// stays for the threads started on it
dropProvider := func() error {
@@ -678,6 +709,15 @@ func codexIn(at place) *Agent {
Joined: joined,
Routed: routed,
Sync: func() error {
// model_provider = "magpie" left with its table gone (taken by
// another tool, or a hand edit), which keeps Codex from loading
// its config at all, connected to magpie or not (Tystem on
// Discord): the table is written back, as it stays once written
if namesMagpie() && noProvider() {
if err := putProvider(); err != nil {
return err
}
}
if err := failover(); err != nil {
return err
}
@@ -750,6 +790,9 @@ func codexIn(at place) *Agent {
return nil
},
Check: func() string {
if namesMagpie() && noProvider() {
return "Codex's config names magpie as its model_provider but has no [model_providers.magpie], so Codex can't load its config (\"Model provider `magpie` not found\"): magpie writes it back when it next syncs, or connect Codex again"
}
if !wired() {
return ""
}
+85
View File
@@ -0,0 +1,85 @@
package agent
import (
"errors"
"strings"
"testing"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
)
// Tystem on Discord: Codex wouldn't load its config ("Model provider
// `magpie` not found") with model_provider = "magpie" and model =
// "deepseek-flash" left in config.toml and no [model_providers.magpie],
// and magpie showed Codex as not connected. Codex refuses the whole config
// so, not just an old thread: Check says so, and Sync writes the table
// back, at the top and for a profile alike.
func TestCodexProviderWithoutTable(t *testing.T) {
for _, tc := range []struct{ name, config string }{
{"top", "model_provider = \"magpie\"\nmodel = \"deepseek-flash\"\n"},
{"profile", "profile = \"ds\"\n\n[profiles.ds]\nmodel_provider = \"magpie\"\nmodel = \"deepseek-flash\"\n"},
} {
t.Run(tc.name, func(t *testing.T) {
home, read := codexHome(t, "", tc.config)
cx := codex(home)
if msg := cx.Check(); !strings.Contains(msg, "[model_providers.magpie]") {
t.Fatalf("check: %q", msg)
}
if err := cx.Sync(); err != nil {
t.Fatal(err)
}
tb, err := edit.GetTOMLTable(cx.Path, "model_providers.magpie")
if err != nil || tb["base_url"] != here(home).v1() || tb["wire_api"] != "responses" || tb["experimental_bearer_token"] == "" {
t.Fatalf("table %v %v:\n%s", tb, err, read())
}
if msg := cx.Check(); strings.Contains(msg, "[model_providers.magpie]") {
t.Fatalf("check after sync: %q", msg)
}
})
}
}
// Every way magpie steps out as Codex's provider takes model_provider out
// with it and leaves the table: never the half state above.
func TestCodexSteppingOutLeavesNoHalfState(t *testing.T) {
for _, tc := range []struct {
name string
out func(a *Agent) error
}{
{"disconnect", func(a *Agent) error { return a.Fields[0].Set("") }},
{"unwire", func(a *Agent) error { return a.Unwire() }},
{"own model", func(a *Agent) error { return a.Fields[0].Set("gpt-5.5") }},
} {
t.Run(tc.name, func(t *testing.T) {
home, read := codexHome(t, "", "model = \"gpt-5.5\"\n")
cx := codex(home)
if err := cx.Fields[0].Set("fake/m1"); err != nil {
t.Fatal(err)
}
if err := tc.out(cx); err != nil {
t.Fatal(err)
}
if p, _ := edit.GetTOMLTop(cx.Path, "model_provider"); p == magpieID {
t.Fatalf("model_provider left:\n%s", read())
}
if tb, _ := edit.GetTOMLTable(cx.Path, "model_providers.magpie"); tb == nil {
t.Fatalf("table gone, for the threads started on magpie:\n%s", read())
}
})
}
}
// Connecting with no provider or subscription in magpie says what to do,
// with a code the GUI says it by in the reader's language.
func TestConnectWithNoModels(t *testing.T) {
home, _ := codexHome(t, "", "model = \"gpt-5.5\"\n")
if err := provider.Delete("fake"); err != nil {
t.Fatal(err)
}
_, err := codex(home).ConnectHow()
var nm *NoModelsError
if !errors.As(err, &nm) || nm.Agent != "Codex" || !strings.Contains(err.Error(), "Add a provider or subscription in magpie first, then connect Codex") {
t.Fatalf("%v", err)
}
}
+3 -2
View File
@@ -84,7 +84,8 @@ func TestCodexProviderKeepsOwnBaseURL(t *testing.T) {
// A Codex that an older magpie made its provider, with no base URL, gets
// magpie's at the next sync; a provider set up by hand with a catalog of
// the user's own is not touched.
// the user's own keeps its keys, and only the table it names comes back
// (Codex loads no config while it is missing).
func TestCodexSyncAddsBaseURLToProvider(t *testing.T) {
home, read := codexHome(t, "", "")
cx := codex(home)
@@ -116,7 +117,7 @@ func TestCodexSyncAddsBaseURLToProvider(t *testing.T) {
if err := codex(home).Sync(); err != nil {
t.Fatal(err)
}
if cfg = read(); cfg != mine {
if cfg = read(); !strings.HasPrefix(cfg, mine) || strings.Contains(cfg, "openai_base_url") || !strings.Contains(cfg, "[model_providers.magpie]") {
t.Fatalf("hand-made provider changed:\n%s", cfg)
}
}
+32
View File
@@ -0,0 +1,32 @@
package gui
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// Tystem on Discord: connecting Codex with no provider or subscription in
// magpie answered "Codex can't be connected to magpie", untranslated. The
// answer carries code no_models and the agent's name, which the GUI says
// in the reader's language.
func TestAgentConnectNoModels(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("CODEX_HOME", filepath.Join(home, ".codex"))
os.MkdirAll(filepath.Join(home, ".codex"), 0o755)
os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("model = \"gpt-5.5\"\n"), 0o600)
rec := httptest.NewRecorder()
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/agents/connect/codex", strings.NewReader("{}")))
var out map[string]string
json.Unmarshal(rec.Body.Bytes(), &out)
if rec.Code != 400 || out["code"] != "no_models" || out["agent"] != "Codex" || !strings.Contains(out["error"], "Add a provider or subscription") {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
}
+5
View File
@@ -1548,6 +1548,11 @@ func fail(rw http.ResponseWriter, err error) {
out["code"] = "runtime_unavailable"
out["offline"] = string(unavailable.Offline)
}
var noModels *agent.NoModelsError
if errors.As(err, &noModels) {
out["code"] = "no_models"
out["agent"] = noModels.Agent
}
_ = json.NewEncoder(rw).Encode(out)
}
+3
View File
@@ -104,6 +104,9 @@ async function api(path, body) {
err.code = data.code;
if (data.offline === "stage" || data.offline === "disconnect") err.offline = data.offline;
}
// magpie has no models to connect an agent to yet: said in the
// reader's language
if (data?.code === "no_models") err.message = t("Add a provider or subscription in magpie first, then connect {agent}", { agent: data.agent });
if (data?.why) err.why = data.why; // a failed move's reason, said in the reader's language
throw err;
}
+3
View File
@@ -285,6 +285,7 @@ const I18N = {
"Connected · pick magpie's models with {cmd} in {agent}": "已接入 · 在 {agent} 里用 {cmd} 选 magpie 的模型",
"Connected · magpie's models are in {agent}'s model list": "已接入 · magpie 的模型在 {agent} 的模型列表里",
"Connect {agent} to magpie": "将 {agent} 接入 magpie",
"Add a provider or subscription in magpie first, then connect {agent}": "请先在 magpie 中添加一个供应商或订阅,再接入 {agent}",
"Connected · switch off to put back what {agent} had before magpie": "已接入 · 关掉即还原 {agent} 接入 magpie 之前的设置",
"Switch on and {agent}'s model list gets magpie's models": "打开后,{agent} 的模型列表里就有 magpie 的模型",
"{agent} is connected to magpie": "{agent} 已接入 magpie",
@@ -3622,6 +3623,7 @@ const I18N = {
"Connected · pick magpie's models with {cmd} in {agent}": "接続済み · {agent} で {cmd} を使って magpie のモデルを選択",
"Connected · magpie's models are in {agent}'s model list": "接続済み · magpie のモデルは {agent} のモデル一覧にあります",
"Connect {agent} to magpie": "{agent} を magpie に接続",
"Add a provider or subscription in magpie first, then connect {agent}": "先に magpie でプロバイダかサブスクリプションを追加してから、{agent} を接続してください",
"Connected · switch off to put back what {agent} had before magpie": "接続済み · オフにすると {agent} を magpie 接続前の設定に戻します",
"Switch on and {agent}'s model list gets magpie's models": "オンにすると {agent} のモデル一覧に magpie のモデルが加わります",
"{agent} is connected to magpie": "{agent} は magpie に接続されています",
@@ -6940,6 +6942,7 @@ const I18N = {
"Connected · pick magpie's models with {cmd} in {agent}": "Verbunden · magpie-Modelle mit {cmd} in {agent} wählen",
"Connected · magpie's models are in {agent}'s model list": "Verbunden · magpie-Modelle stehen in der Modellliste von {agent}",
"Connect {agent} to magpie": "{agent} mit magpie verbinden",
"Add a provider or subscription in magpie first, then connect {agent}": "Fügen Sie zuerst in magpie einen Anbieter oder ein Abo hinzu und verbinden Sie dann {agent}",
"Connected · switch off to put back what {agent} had before magpie": "Verbunden · ausschalten, um den Stand von {agent} vor magpie wiederherzustellen",
"Switch on and {agent}'s model list gets magpie's models": "Einschalten, dann erhält die Modellliste von {agent} die magpie-Modelle",
"{agent} is connected to magpie": "{agent} ist mit magpie verbunden",
@@ -0,0 +1,74 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Tystem on Discord: switching Codex on with no provider or subscription in
// magpie said only "Codex can't be connected to magpie", in English whatever
// the language. The server answers that case with code no_models, and the
// toast says what to do in the reader's language; the switch goes back off.
// No click moves the page. 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");
const options = [{ value: "gpt-5.4", label: "GPT-5.4" }, { value: "relay/m1", label: "m1", ref: "relay/m1", note: "Relay · via magpie" }];
const state = { agents: [{ id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", fields: [{ key: "model", label: "model", value: "gpt-5.4", options }] }], profiles: [] };
function server(lang, posts) {
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data, status = 200) => route.fulfill({ status, 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({ ...state, settings: { lang, theme: "light" } });
if (url.pathname === "/api/agents/connect/codex") {
posts.push(url.pathname);
return json({ error: "Add a provider or subscription in magpie first, then connect Codex", code: "no_models", agent: "Codex" }, 400);
}
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
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)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const said = {
en: "Add a provider or subscription in magpie first, then connect Codex",
zh: "请先在 magpie 中添加一个供应商或订阅,再接入 Codex",
ja: "先に magpie でプロバイダかサブスクリプションを追加してから、Codex を接続してください",
de: "Fügen Sie zuerst in magpie einen Anbieter oder ein Abo hinzu und verbinden Sie dann Codex",
};
const sw = '.row.agent[data-id="codex"] .ag-conn';
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of Object.keys(said)) {
test(`${engine} ${lang}: connecting with no models says to add one first`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width: 980, height: 520 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [], posts = [], dialogs = [];
page.on("pageerror", (e) => errors.push(e.message));
page.on("dialog", (d) => { dialogs.push(d.message()); d.dismiss(); });
await page.route("**/*", server(lang, posts));
await page.goto("http://magpie.test/?view=agents");
await page.locator(sw).waitFor();
const tops = () => page.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#view-agents")?.scrollTop]);
const top = await tops();
await page.locator(sw).click();
await page.waitForFunction((s) => document.querySelector("#status")?.textContent.includes(s), said[lang]);
assert.deepEqual(posts, ["/api/agents/connect/codex"]);
assert.equal(await page.locator(sw).getAttribute("aria-checked"), "false", "the switch goes back off");
assert.ok(!(await page.locator(sw).isDisabled()), "and can be tried again");
assert.deepEqual(await tops(), top, "no click moved the page");
assert.deepEqual(dialogs, [], "no native dialog");
assert.deepEqual(errors, []);
});
}
}