gui: a provider's editor has a Max output beside its Context window, so a model whose reply limit magpie has wrong can be set right there (ARNO on Discord: 上下文长度有问题还可以手动覆盖,maxtoken错了如何解决呢) — the reply limits the user sets (ModelOutputs, "provider/model" or "provider/*") could only be set with magpie model output; the editor now shows them in a Max output box written as the Context window box is (32k for all its models, or gpt-6=128k, comma separated), and a Save sends them: the limits not in the box are dropped, a model the provider doesn't list is refused, and the catalog is touched so /v1/models, the agents' max tokens and the gateway's per-member clamp take the new limit. zh and ja strings added. TestModelOutputsSetFromTheEditor, TestProviderSaveOutputs and provider-max-output.test.cjs (WebKit, en and zh) fail on the old code. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestClientGoneRecordedAsCanceled flaked once under load, passes alone).

This commit is contained in:
yetone committed 2026-10-04 23:37:35 +08:00
1 parent 3f5cd1f3cc
commit 80441c2c29
7 files changed
+305 -2

No files matched your search

+27 -2
View File
@@ -6389,7 +6389,7 @@ function pickedOf(p) {
// draftOf is a saved provider as its editor's form holds it.
function draftOf(p) {
return { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, decide: p.decide || "", key: "", api: p.chat ? "openai" : p.anthropic ? "anthropic" : p.responses ? "responses" : p.decide ? "decide" : "openai", chosen: pickedOf(p), extra: [], headers: headerRows(p.headers), icon: p.icon || "", fallback: [...(p.fallback || [])], unlisted: !!p.unlisted, searches: !!p.searches, balanceURL: p.balanceURL || "", balancePath: p.balancePath || "", modelsURL: p.modelsURL || "", contexts: contextsText(p.contexts), keysUrl: p.keysUrl || "", ...proxyDraft(p), ...concurrencyDraft(p) };
return { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, decide: p.decide || "", key: "", api: p.chat ? "openai" : p.anthropic ? "anthropic" : p.responses ? "responses" : p.decide ? "decide" : "openai", chosen: pickedOf(p), extra: [], headers: headerRows(p.headers), icon: p.icon || "", fallback: [...(p.fallback || [])], unlisted: !!p.unlisted, searches: !!p.searches, balanceURL: p.balanceURL || "", balancePath: p.balancePath || "", modelsURL: p.modelsURL || "", contexts: contextsText(p.contexts), outputs: contextsText(p.outputs), keysUrl: p.keysUrl || "", ...proxyDraft(p), ...concurrencyDraft(p) };
}
// duplicateProvider opens the Add form on a copy of p (#268): its URLs,
@@ -6601,6 +6601,7 @@ function drawEditor(p, presetID) {
// that take 872K (#120)
const cx = input(draft.contexts || "", t("e.g. 128k · or gpt-6=1m, comma separated"));
ed.append(...field(t("Context window"), contextPicks(p, cx), t("How long a request the models take, told to the agents; empty leaves it to the vendor and models.dev")));
ed.append(...outputField());
ed.append(...field(t("Fallback"), renderFallback(p), fallbackHint(p)));
const proxies = el("div", "stack");
proxies.append(proxyPicker());
@@ -6624,6 +6625,8 @@ function drawEditor(p, presetID) {
saveBtn.onclick = () => {
const cx = parseContexts(draft.contexts || "");
if (cx.error) return editorError(t("Context window: {v} is not a length like 128k or 1m", { v: cx.error }), "warn");
const ox = parseContexts(draft.outputs || "");
if (ox.error) return outputError(ed, ox.error);
const proxy = proxyOfDraft();
if (proxy === null) { ed.querySelector(".proxy-url")?.focus(); return editorError(t("Proxy: type its address, like http://127.0.0.1:7890"), "warn"); }
const own = accountProxiesOfDraft();
@@ -6634,7 +6637,7 @@ function drawEditor(p, presetID) {
}
const maxConcurrency = concurrencyOfDraft();
if (maxConcurrency === undefined) return concurrencyError(ed);
saveBtn.classList.add("busy"); providerAction("save", { id: p.id, models: chosenIds(), unlisted: draft.unlisted, fallback: draft.fallback, contexts: cx.map, proxy, accountProxies: own.map, maxConcurrency, modelPrefs: modelPrefsOfDraft(), ...routingOfDraft(p) }, t("{name} saved", { name: p.name })); };
saveBtn.classList.add("busy"); providerAction("save", { id: p.id, models: chosenIds(), unlisted: draft.unlisted, fallback: draft.fallback, contexts: cx.map, outputs: ox.map, proxy, accountProxies: own.map, maxConcurrency, modelPrefs: modelPrefsOfDraft(), ...routingOfDraft(p) }, t("{name} saved", { name: p.name })); };
bar.append(cancel, saveBtn);
ed.append(bar);
return ed;
@@ -6806,6 +6809,7 @@ function drawEditor(p, presetID) {
// models.dev says: one for all of them, and model=size for one
const cx = input(draft.contexts || "", t("e.g. 128k · or gpt-6=1m, comma separated"));
if (!decideOnly(p || pr)) ed.append(...field(t("Context window"), contextPicks(p, cx), t("How long a request the models take, told to the agents; empty leaves it to the vendor and models.dev")));
if (!decideOnly(p || pr)) ed.append(...outputField());
}
if (p && !decideOnly(p)) ed.append(...field(t("Fallback"), renderFallback(p), fallbackHint(p)));
else if (custom) {
@@ -6954,6 +6958,11 @@ function drawEditor(p, presetID) {
const cx = parseContexts(draft.contexts || "");
if (cx.error) return editorError(t("Context window: {v} is not a length like 128k or 1m", { v: cx.error }), "warn");
body.contexts = cx.map;
if (!decideOnly(p || pr)) {
const ox = parseContexts(draft.outputs || "");
if (ox.error) return outputError(ed, ox.error);
body.outputs = ox.map;
}
body.proxy = proxyOfDraft();
if (body.proxy === null) { ed.querySelector(".proxy-url")?.focus(); return editorError(t("Proxy: type its address, like http://127.0.0.1:7890"), "warn"); }
body.maxConcurrency = concurrencyOfDraft();
@@ -7037,6 +7046,22 @@ function contextPicks(p, cx) {
return wrap;
}
// outputField is the editor's Max output: the most a reply of its models
// may hold, over what the vendor or models.dev says, one for all of them
// and model=size for one, as the context window is set (ARNO on Discord: a
// model's maxTokens was wrong, and only its window could be set here).
function outputField() {
const ox = input(draft.outputs || "", t("e.g. 32k · or gpt-6=128k, comma separated"));
ox.classList.add("outputs");
ox.oninput = () => { draft.outputs = ox.value; };
return field(t("Max output"), ox, t("The most a reply may hold, told to the agents as their max tokens; empty leaves it to the vendor and models.dev"));
}
function outputError(ed, v) {
ed.querySelector("input.outputs")?.focus({ preventScroll: true });
return editorError(t("Max output: {v} is not a number of tokens like 32k", { v }), "warn");
}
// parseContexts reads "128k, gpt-6=1m" back: sizes by model id, "*" for
// all; error is the first part that isn't a size.
function parseContexts(text) {
+8
View File
@@ -695,6 +695,10 @@ const I18N = {
"Each model's most · {n}": "各模型最大 · {n}",
"Each model's most": "各模型最大",
"Context window: {v} is not a length like 128k or 1m": "上下文窗口:{v} 不是 128k、1m 这样的长度",
"Max output": "最大输出",
"e.g. 32k · or gpt-6=128k, comma separated": "如 32k · 或 gpt-6=128k,逗号分隔",
"The most a reply may hold, told to the agents as their max tokens; empty leaves it to the vendor and models.dev": "一次回复最多的 token 数,会作为 max tokens 告诉各个 Agent;留空则按厂商和 models.dev 的",
"Max output: {v} is not a number of tokens like 32k": "最大输出:{v} 不是 32k 这样的 token 数",
"add a model: filter, or type provider/model…": "添加模型:筛选,或输入 provider/model…",
"No provider serves {id} now; it is skipped": "现在没有供应商提供 {id},会跳过它",
"Up": "上移",
@@ -3662,6 +3666,10 @@ const I18N = {
"Each model's most · {n}": "各モデルの最大 · {n}",
"Each model's most": "各モデルの最大",
"Context window: {v} is not a length like 128k or 1m": "コンテキストウィンドウ:{v} は 128k や 1m のような長さではありません",
"Max output": "最大出力",
"e.g. 32k · or gpt-6=128k, comma separated": "例:32k · または gpt-6=128k(カンマ区切り)",
"The most a reply may hold, told to the agents as their max tokens; empty leaves it to the vendor and models.dev": "1 回の返答の最大トークン数で、max tokens としてエージェントに伝えられます。空欄の場合はベンダーと models.dev に従います",
"Max output: {v} is not a number of tokens like 32k": "最大出力:{v} は 32k のようなトークン数ではありません",
"add a model: filter, or type provider/model…": "モデルを追加:絞り込み、または provider/model を入力…",
"No provider serves {id} now; it is skipped": "現在 {id} を提供しているプロバイダはないため、スキップされます",
"Up": "上へ",
+13
View File
@@ -134,6 +134,7 @@ type providerJSON struct {
Groups map[string][]string `json:"groups,omitempty"`
Off bool `json:"off"` // switched off: kept, but agents get none of its models
Contexts map[string]int `json:"contexts,omitempty"` // the windows the user set, "*" for all its models
Outputs map[string]int `json:"outputs,omitempty"` // the reply limits the user set (provider.OutputsOf)
Fetched *time.Time `json:"fetched,omitempty"` // when the list came from the vendor; the page says how long ago in its language
Agents []providerAgent `json:"agents"` // detected agents, current ones flagged
Sponsored bool `json:"sponsored"`
@@ -365,6 +366,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{},
Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts,
MaxConcurrency: p.MaxConcurrency, PluginConcurrency: p.PluginConcurrency(),
Outputs: provider.OutputsOf(p.ID),
}
if out.Fallback == nil {
out.Fallback = []string{}
@@ -732,6 +734,10 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
// editor's Names & levels changed, by model id, made with the
// rest of the Save and not a click at a time
ModelPrefs map[string]provider.ModelPref `json:"modelPrefs"`
// Outputs, for save: the reply limits the editor's Max output
// says, by model id, "*" for all (provider.SetModelOutputs); a
// save that leaves it out keeps them
Outputs map[string]int `json:"outputs"`
// Routing and Affinity, for route, affinity and save: how
// requests spread over its keys or accounts, and how long a
// conversation stays with the one that answered it. The
@@ -969,6 +975,13 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
p.Fetch(ctx)
cancel()
}
// after the list is fetched: a limit has to name a model it has
if req.Outputs != nil {
if err := provider.SetModelOutputs(in.ID, req.Outputs); err != nil {
fail(rw, err)
return
}
}
case "key":
// the saved key, for the editor's Show button; it never
// leaves this machine (the panel is served on loopback)
+59
View File
@@ -0,0 +1,59 @@
package gui
import (
"maps"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// A provider's Save carries its editor's Max output, as it does its
// context window (ARNO on Discord: a model's maxTokens was wrong and only
// the window could be set there); a save that doesn't say keeps them, and
// the editor is given them back to show.
func TestProviderSaveOutputs(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", os.Getenv("HOME"))
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
mux := http.NewServeMux()
providerRoutes(mux, nil)
post := func(body string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/provider/save", strings.NewReader(body)))
return w
}
if w := post(`{"id":"relay","name":"Relay","key":"k","chat":"http://127.0.0.1:1/v1","models":["sol"],"new":true}`); w.Code != 200 {
t.Fatalf("%d %s", w.Code, w.Body)
}
const save = `{"id":"relay","from":"relay","name":"Relay","chat":"http://127.0.0.1:1/v1","models":["sol"]`
if w := post(save + `,"outputs":{"*":32000,"sol":128000}}`); w.Code != 200 {
t.Fatalf("%d %s", w.Code, w.Body)
}
want := map[string]int{"relay/*": 32000, "relay/sol": 128000}
if got := settings.Load().ModelOutputs; !maps.Equal(got, want) {
t.Fatalf("outputs %v", got)
}
p, err := provider.Find("relay")
if err != nil {
t.Fatal(err)
}
if got := providerInfo(*p, nil).Outputs; !maps.Equal(got, map[string]int{"*": 32000, "sol": 128000}) {
t.Errorf("the editor is given %v", got)
}
if w := post(save + `}`); w.Code != 200 || !maps.Equal(settings.Load().ModelOutputs, want) {
t.Fatalf("a save that doesn't say: %d %v", w.Code, settings.Load().ModelOutputs)
}
if w := post(save + `,"outputs":{"typo":5}}`); w.Code == 200 || !strings.Contains(w.Body.String(), "typo") {
t.Fatalf("a model it doesn't serve: %d %s", w.Code, w.Body)
}
if w := post(save + `,"outputs":{}}`); w.Code != 200 || len(settings.Load().ModelOutputs) != 0 {
t.Fatalf("emptied: %d %v", w.Code, settings.Load().ModelOutputs)
}
}
@@ -0,0 +1,67 @@
// A provider's editor sets the most a reply of its models may hold, as it
// sets their context window (ARNO on Discord: 有些模型的 maxtokens 还是错误的
// …上下文长度可以手动覆盖,maxtoken 错了如何解决): one for all its models and
// model=size for one, shown as set, sent with the Save, and a value that
// isn't a number of tokens is refused before anything is sent.
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, posts) {
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
const models = [1, 2].map((i) => ({ id: `model-${i}`, name: `Model ${i}`, on: true, output: 8000 }));
const provider = { id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example/v1", responses: "", anthropic: "", models, agents: [], key: { set: true, masked: "sk-…1234" }, ready: true, outputs: { "*": 64000, "model-2": 8000 } };
const providers = { providers: [provider], presets: [], excluded: [], gateway: { running: true, window: true } };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:${JSON.stringify(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);
if (url.pathname === "/api/providers") return json(providers);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname.startsWith("/api/provider/")) {
posts.push({ path: url.pathname, body: route.request().postDataJSON() });
return json(providers);
}
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
await route.fulfill({ body: await fs.readFile(file), contentType: { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)] });
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: a provider's max output is set in its editor`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width: 900, height: 720 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [], posts = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=providers");
const zh = lang === "zh";
await page.locator(".row.provider").click();
const label = page.getByText(zh ? "最大输出" : "Max output", { exact: true });
assert(await label.isVisible(), "the field is there");
const box = page.locator("input.outputs");
assert.equal(await box.inputValue(), "64k, model-2=8k", "what is set is shown");
// not a number of tokens: refused, nothing sent
await box.fill("lots");
await page.getByRole("button", { name: zh ? "保存" : "Save", exact: true }).click();
await page.getByText(zh ? "最大输出:lots 不是 32k 这样的 token 数" : "Max output: lots is not a number of tokens like 32k").waitFor();
assert.deepEqual(posts, []);
await box.fill("32k, model-1=128k");
await page.getByRole("button", { name: zh ? "保存" : "Save", exact: true }).click();
await page.waitForFunction(() => !document.querySelector("input.outputs"));
assert.deepEqual(posts.map((p) => p.path), ["/api/provider/save"]);
assert.deepEqual(posts[0].body.outputs, { "*": 32000, "model-1": 128000 });
assert.deepEqual(errors, []);
});
}
}
+69
View File
@@ -0,0 +1,69 @@
package provider
import (
"maps"
"testing"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/settings"
)
// A provider's editor sets its reply limits as a whole, as it sets its
// windows (ARNO on Discord: a model's maxTokens was wrong and only the
// window could be set there): those it leaves out come off, another
// provider's stay, a model it doesn't serve is refused with nothing kept,
// and a save that changes nothing tells the agents nothing.
func TestModelOutputsSetFromTheEditor(t *testing.T) {
limitsHome(t)
for _, id := range []string{"relay", "other"} {
if err := Save(Provider{ID: id, Name: id, Chat: "https://relay.example/v1", Key: "k", Models: []string{"sol", "luna"}}); err != nil {
t.Fatal(err)
}
}
if err := SetModelOutput("relay/luna", 1000); err != nil {
t.Fatal(err)
}
if err := SetModelOutput("other/sol", 2000); err != nil {
t.Fatal(err)
}
if got := OutputsOf("relay"); !maps.Equal(got, map[string]int{"luna": 1000}) {
t.Fatalf("OutputsOf: %v", got)
}
told := 0
catalog.Changed = func() { told++ }
t.Cleanup(func() { catalog.Changed = nil })
if err := SetModelOutputs("relay", map[string]int{"*": 32000, "sol": 128000}); err != nil {
t.Fatal(err)
}
if told != 1 {
t.Errorf("told the agents %d times; want once", told)
}
want := map[string]int{"relay/*": 32000, "relay/sol": 128000, "other/sol": 2000}
if got := settings.Load().ModelOutputs; !maps.Equal(got, want) {
t.Errorf("outputs %v; want %v", got, want)
}
if e, _ := EntryOf("relay/sol"); e.Output != 128000 {
t.Errorf("relay/sol output %d", e.Output)
}
if e, _ := EntryOf("relay/luna"); e.Output != 32000 {
t.Errorf("relay/luna output %d; want the provider's 32000", e.Output)
}
told = 0
if err := SetModelOutputs("relay", map[string]int{"*": 32000, "sol": 128000}); err != nil || told != 0 {
t.Errorf("the same again: %v, told %d", err, told)
}
if err := SetModelOutputs("relay", map[string]int{"typo": 5}); err == nil {
t.Error("a model relay doesn't serve was accepted")
}
if got := settings.Load().ModelOutputs; !maps.Equal(got, want) {
t.Errorf("a refused save changed them: %v", got)
}
if err := SetModelOutputs("relay", map[string]int{}); err != nil {
t.Fatal(err)
}
if got := settings.Load().ModelOutputs; !maps.Equal(got, map[string]int{"other/sol": 2000}) {
t.Errorf("emptied: %v", got)
}
}
+62
View File
@@ -1336,6 +1336,68 @@ func SetModelOutput(id string, n int) error {
return nil
}
// OutputsOf is the reply limits the user set on a provider's models, by
// model id, "*" for all of them: what its editor's Max output shows.
func OutputsOf(providerID string) map[string]int {
out := map[string]int{}
for k, n := range settings.Load().ModelOutputs {
if model, ok := strings.CutPrefix(k, providerID+"/"); ok && model != "" && n > 0 {
out[model] = n
}
}
return out
}
// SetModelOutputs makes a provider's reply limits outs, by model id, "*"
// for all of them, as its editor's Max output says (ARNO on Discord: a
// model's maxTokens was wrong, and only the context window could be set in
// the editor): those it leaves out are taken away, and every one set has
// to be a model the provider serves, as for SetModelOutput. Nothing is
// saved, nor the agents told, when nothing changed.
func SetModelOutputs(providerID string, outs map[string]int) error {
p, err := Find(providerID)
if err != nil {
return err
}
for model, n := range outs {
if n < 0 {
return errorf("an output limit is a number of tokens, not %d", n)
}
if err := settings.CheckModelKey("an output limit", p.ID+"/"+model); err != nil {
return err
}
if model != "*" && n > 0 && !p.serves(model) {
return errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID)
}
}
s := settings.Load()
changed := false
for k := range s.ModelOutputs {
if model, ok := strings.CutPrefix(k, p.ID+"/"); ok && outs[model] <= 0 {
delete(s.ModelOutputs, k)
changed = true
}
}
for model, n := range outs {
if n <= 0 || s.ModelOutputs[p.ID+"/"+model] == n {
continue
}
if s.ModelOutputs == nil {
s.ModelOutputs = map[string]int{}
}
s.ModelOutputs[p.ID+"/"+model] = n
changed = true
}
if !changed {
return nil
}
if err := settings.Save(s); err != nil {
return err
}
catalog.Touched()
return nil
}
// DropModelOutput takes a user's reply limit away under the key it is
// stored at, and hands that key back, so a caller left without a provider
// to name it by can still say which entry it cleared. It does not ask