providers: a model's reasoning levels, name and images in the editor's Names & levels are made with the provider's Save, not at each tick (ARNO on Discord: setting one model's reasoning effort applies the config at each click rather than at the provider's OK/Save, so picking levels lags) — each tick of a level (and each name typed, each Accepts images, each Restore default) was its own POST /api/provider/efforts (name, images), which saved settings and ran catalog.Changed, agent.SyncCatalog, rewriting every agent's files, then fetched the providers and the state and redrew the providers page, the editor's model list with it, before the next tick took: picking levels lagged a click behind, and Cancel had nothing to drop. Names & levels now stage in the editor's draft (draft.modelPrefs, by model id: name, efforts, images or ownImages for the vendor's answer), nothing sent and nothing redrawn on a tick; each row changed says "unsaved" (未保存), a tick back to what is saved stages nothing, and Restore default stages its own name, every level and the vendor's answer for images (the provider list's model now carries ownImages so the tick shows it). Both editors' Save (a key's provider and a signed-in account's) send modelPrefs with the rest, and Cancel drops them. The save action makes them after the provider is saved (and renamed) with the new provider.SetModelPrefs, which makes each as SetModelName, SetModelEfforts and SetModelImage do (their bodies now setModel*, telling nothing, the three Set* telling the agents when they changed something) and tells the agents once for all of them; a refused one fails the Save saying why. The per-model endpoints stay for the CLI and anything else that calls them. provider-levels-save.test.cjs (WebKit and Chromium, en and zh: ticks, a name, Accepts images and Restore default send nothing, redraw nothing, scroll nothing and say unsaved; Cancel drops them; one Save carries exactly the changed models' levels) fails without the change ("nothing is sent before the Save") and passes with it; provider-levels-scroll.test.cjs now checks that a tick leaves the list in place with nothing sent. TestSetModelPrefs (all set, agents told once, nothing changed told nothing, Restore default, a refused level) and TestProviderSaveModelPrefs (a Save's modelPrefs made; a refused level fails it) are new. click-scroll (one flake of "picking request 7", then 3/3), model-pick, models-free-only, model-test-one, panel-effort, claude-effort, editor-short-window pass in WebKit. 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 (TestFXInState failed once on its TempDir cleanup, passes alone and in a second full run).

This commit is contained in:
yetone committed 2026-10-02 17:02:42 +08:00
1 parent d113d7613c
commit b0dc82c47c
8 files changed
+377 -59

No files matched your search

+71 -31
View File
@@ -4351,6 +4351,13 @@ function cancelEdit(keep) {
editing = null; draft = null; importing = null; importingApps = null; renderProviders();
}
// modelPrefsOfDraft: the names, levels and images staged in the editor's
// Names & levels, for its Save, or nothing when none changed.
function modelPrefsOfDraft() {
const x = draft?.modelPrefs;
return x && Object.keys(x).length ? x : undefined;
}
// proxyPicker: the proxy one provider's requests go through (#237) — the
// one in Settings, none, or its own — so Codex can go through a proxy
// while a vendor at home goes direct. The draft keeps the choice as
@@ -5072,7 +5079,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 }, 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, proxy, accountProxies: own.map, maxConcurrency, modelPrefs: modelPrefsOfDraft() }, t("{name} saved", { name: p.name })); };
bar.append(cancel, saveBtn);
ed.append(bar);
return ed;
@@ -5361,7 +5368,7 @@ function drawEditor(p, presetID) {
if (isNew && draft.copyOf) body.copyOf = draft.copyOf;
if (decides) body.decide = (draft.decide || "").trim();
if (custom) { body.icon = draft.icon || "generic"; body.balanceURL = (draft.balanceURL || "").trim(); body.balancePath = (draft.balancePath || "").trim(); body.modelsURL = (draft.modelsURL || "").trim(); }
if (p) { body.fallback = draft.fallback; body.unlisted = draft.unlisted; }
if (p) { body.fallback = draft.fallback; body.unlisted = draft.unlisted; body.modelPrefs = modelPrefsOfDraft(); }
body.searches = !!draft.searches && searchable();
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");
@@ -5902,67 +5909,100 @@ function renderModels(p) {
lost.append(b);
}
};
// the names and reasoning levels of the models agents see: saved at once,
// apart from the editor's Save, as they change nothing but what is shown
// the names and reasoning levels of the models agents see, and whether
// they see images: staged in draft.modelPrefs and made with the editor's
// Save, as the rest of it is, Cancel dropping them. Each tick used to be
// saved on its own, every one rewriting the agents' files, so picking a
// model's levels lagged a click behind (ARNO on Discord).
const drawNames = () => {
names.replaceChildren();
names.hidden = naming !== p.id;
if (names.hidden) return;
const ids = draft.chosen.length ? draft.chosen : p.models.filter((m) => m.on).map((m) => m.id);
if (!ids.length) { names.append(el("span", "hint", t("Pick a model first."))); return; }
const prefs = draft.modelPrefs = draft.modelPrefs || {};
for (const id of ids) {
const m = p.models.find((x) => x.id === id) || { id, name: id };
const own = m.default || m.name || m.id;
// what is staged for it, and what it is saved as
const pref = () => prefs[id] = prefs[id] || {};
const savedName = m.default ? m.name : "";
const levels = m.efforts || [];
const savedKept = m.kept?.length || m.given ? m.kept || [] : levels;
const nameNow = () => prefs[id]?.name ?? savedName;
const keptNow = () => prefs[id]?.efforts ? (prefs[id].efforts.length || m.given ? prefs[id].efforts : levels) : savedKept;
const imagesNow = () => prefs[id]?.ownImages ? !!m.ownImages : prefs[id]?.images ?? !!m.images;
const row = el("div", "mname");
const name = input(m.default ? m.name : "", own);
const name = input(nameNow(), own);
name.title = t("The name agents and magpie show for {id}; empty for its own", { id: m.id });
const save = () => {
name.onchange = () => {
const v = name.value.trim();
if (v === (m.default ? m.name : "")) return;
accountAction("provider/name", { id: p.id, model: m.id, modelName: v }, v ? t("{id} is called {name}", { id: m.id, name: v }) : t("{id} has its own name again", { id: m.id }));
if (v === savedName) delete pref().name; else pref().name = v;
drawReset();
};
name.onchange = save;
name.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") name.blur(); else if (e.key === "Escape") { name.value = m.default ? m.name : ""; name.blur(); } };
name.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") name.blur(); else if (e.key === "Escape") { name.value = nameNow(); name.blur(); } };
const who = el("div", "mwho");
who.append(name, el("code", "", m.id));
row.append(who);
const [img, imgCb] = tick(t("Accepts images"), !!m.images);
const [img, imgCb] = tick(t("Accepts images"), imagesNow());
img.title = t("Whether agents are told {id} can see images", { id: m.id });
imgCb.onchange = () => accountAction("provider/images", { id: p.id, model: m.id, images: imgCb.checked },
imgCb.checked ? t("{id} accepts images", { id: m.id }) : t("{id} does not accept images", { id: m.id }));
imgCb.onchange = () => {
const x = pref();
delete x.ownImages;
if (imgCb.checked === !!m.images) delete x.images;
else x.images = imgCb.checked;
drawReset();
};
row.append(img);
const levels = m.efforts || [];
// a model whose levels aren't known (m.given) can be given any
// of them, and none again
const boxes = [];
if (levels.length > 1) {
const lv = el("div", "mlevels");
lv.title = t(m.given ? "Its reasoning levels aren't known: tick the ones it takes" : "Reasoning levels agents are offered");
const kept = m.kept?.length || m.given ? m.kept || [] : levels;
for (const l of levels) {
const [tk, cb] = tick(t(l), kept.includes(l));
const [tk, cb] = tick(t(l), keptNow().includes(l));
cb.onchange = () => {
const kept = keptNow();
const next = levels.filter((x) => x === l ? cb.checked : kept.includes(x));
if (!next.length && !m.given) { cb.checked = true; status(t("Keep at least one level"), "err"); return; }
accountAction("provider/efforts", { id: p.id, model: m.id, efforts: next.length === levels.length && !m.given ? [] : next },
next.length ? t("{id}: {levels}", { id: m.id, levels: next.map((x) => t(x)).join(", ") }) : t("{id} is as its provider has it again", { id: m.id }));
const all = next.length === levels.length && !m.given;
if (all ? !m.kept?.length : next.join() === savedKept.join()) delete pref().efforts;
else pref().efforts = all ? [] : next;
drawReset();
};
boxes.push([l, cb]);
lv.append(tk);
}
row.append(lv);
}
if (m.default || m.kept?.length || m.imageSet) {
const reset = el("button", "text action", t("Restore default"));
reset.title = t("Its own name, every reasoning level it has, and whether it sees images");
reset.onclick = async () => {
reset.classList.add("busy");
try {
if (m.default) await api("provider/name", { id: p.id, model: m.id, modelName: "" });
if (m.imageSet) await api("provider/images", { id: p.id, model: m.id, images: null });
} catch (e) { status(e.message, "err"); reset.classList.remove("busy"); return; }
accountAction("provider/efforts", { id: p.id, model: m.id, efforts: [] }, t("{id} is as its provider has it again", { id: m.id }));
};
row.append(reset);
}
// what the Save will make of it differs from what it is: said beside
// it, so a change not yet saved isn't taken for one made
const unsaved = el("span", "hint munsaved", t("unsaved"));
unsaved.title = t("Made when the provider is saved; Cancel drops it");
const reset = el("button", "text action", t("Restore default"));
reset.title = t("Its own name, every reasoning level it has, and whether it sees images");
reset.onclick = () => {
prefs[id] = {};
if (m.default) prefs[id].name = "";
if (m.kept?.length) prefs[id].efforts = [];
if (m.imageSet) prefs[id].ownImages = true;
name.value = nameNow();
imgCb.checked = imagesNow();
for (const [l, cb] of boxes) cb.checked = keptNow().includes(l);
drawReset();
};
const drawReset = () => {
const x = prefs[id];
if (x && !Object.keys(x).length) delete prefs[id];
unsaved.hidden = !prefs[id];
// staged back to its own already, there is nothing to restore
const images = prefs[id]?.ownImages ? false : prefs[id]?.images !== undefined ? prefs[id].images !== !!m.ownImages : !!m.imageSet;
const custom = nameNow() !== "" || (prefs[id]?.efforts ? prefs[id].efforts.length > 0 : !!m.kept?.length) || images;
reset.hidden = !custom;
};
row.append(unsaved, reset);
drawReset();
names.append(row);
}
};
+2
View File
@@ -869,6 +869,8 @@ const I18N = {
"Restore default": "恢复默认",
"Its own name, every reasoning level it has, and whether it sees images": "恢复默认名称、全部推理档位,以及是否支持图片",
"{id} is as its provider has it again": "{id} 已恢复为供应商的默认设置",
"unsaved": "未保存",
"Made when the provider is saved; Cancel drops it": "保存供应商时生效;取消则放弃",
// gateway
"running": "运行中",
+13 -1
View File
@@ -32,6 +32,7 @@ type modelJSON struct {
Given bool `json:"given,omitempty"` // its levels aren't known: Efforts are those it can be given, Kept those it was
Images bool `json:"images"` // agents are told it can see images
ImageSet bool `json:"imageSet,omitempty"` // the user said so, rather than its vendor
Own bool `json:"ownImages,omitempty"` // its vendor's answer, which a staged Restore default shows
On bool `json:"on"` // exposed to agents
Context int `json:"context,omitempty"`
Max int `json:"max,omitempty"` // the most its context may be set to, above Context
@@ -377,9 +378,10 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
if m.ImageInput != nil {
images = *m.ImageInput
}
own := images
images, _ = provider.ApplyImage(p.ID, m.ID, images, m.ImageInput)
_, imageSet := provider.ImageOverride(p.ID, m.ID)
j := modelJSON{ID: m.ID, Name: m.Name, Efforts: provider.EffortsOf(m), On: on, Context: m.Context, Max: m.MaxContext, Free: m.Free, Images: images, ImageSet: imageSet}
j := modelJSON{ID: m.ID, Name: m.Name, Efforts: provider.EffortsOf(m), On: on, Context: m.Context, Max: m.MaxContext, Free: m.Free, Images: images, ImageSet: imageSet, Own: own}
if i := slices.IndexFunc(most, func(c catalog.Model) bool { return c.ID == m.ID }); j.Max == 0 && i >= 0 {
j.Max = most[i].MaxContext
}
@@ -632,6 +634,10 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
// Images, for images: whether the model takes images. Nil
// gives the vendor's answer back.
Images *bool `json:"images"`
// ModelPrefs, for save: the names, levels and images the
// 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"`
// Test, for test: models to send a request each, in place of
// one per endpoint
Test []string `json:"test"`
@@ -797,6 +803,12 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
in.ID = to
}
}
if len(req.ModelPrefs) > 0 {
if err := provider.SetModelPrefs(in.ID, req.ModelPrefs); err != nil {
fail(rw, err)
return
}
}
provider.ForgetBalances()
// a new key means a new vendor list is worth a try; keep it short
if p, err := provider.Find(in.ID); err == nil && p.Ready() && (old == nil || old.Key != p.Key) {
+45
View File
@@ -0,0 +1,45 @@
package gui
import (
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/settings"
)
// A provider's Save carries what its editor's Names & levels changed
// (modelPrefs), and makes it with the rest (ARNO on Discord: each level
// ticked was applied on its own, before the Save).
func TestProviderSaveModelPrefs(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)
}
if w := post(`{"id":"relay","from":"relay","name":"Relay","chat":"http://127.0.0.1:1/v1","models":["sol"],
"modelPrefs":{"sol":{"name":"My Sol","efforts":["low","high"],"images":true}}}`); w.Code != 200 {
t.Fatalf("%d %s", w.Code, w.Body)
}
s := settings.Load()
if s.ModelNames["relay/sol"] != "My Sol" || !slices.Equal(s.ModelEfforts["relay/sol"], []string{"low", "high"}) || !s.ModelImages["relay/sol"] {
t.Fatalf("names %v, efforts %v, images %v", s.ModelNames, s.ModelEfforts, s.ModelImages)
}
// one it refuses fails the Save, saying why
if w := post(`{"id":"relay","from":"relay","name":"Relay","chat":"http://127.0.0.1:1/v1","models":["sol"],"modelPrefs":{"sol":{"efforts":["loud"]}}}`); w.Code == 200 || !strings.Contains(w.Body.String(), "loud") {
t.Fatalf("%d %s", w.Code, w.Body)
}
}
@@ -0,0 +1,94 @@
// A model's reasoning levels, name and images, picked in the provider
// editor's Names & levels, are staged and made with the editor's Save, as
// the rest of the editor is; Cancel drops them. They used to be posted, and
// the agents' files rewritten, at each tick, so picking levels lagged (ARNO
// on Discord).
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 levels = ["none", "low", "medium", "high"];
function serve(lang, posts) {
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
const models = Array.from({ length: 3 }, (_, i) => ({ id: `model-${i + 1}`, name: `Model ${i + 1}`, on: true, efforts: levels, images: false }));
models[2] = { ...models[2], kept: ["low", "high"] };
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 };
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 model's levels are made with the provider's Save, not at each tick`, 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";
const L = { names: zh ? "名称与推理档位" : "Names & levels", images: zh ? "支持图片输入" : "Accepts images", low: zh ? "低" : "low", high: zh ? "高" : "high", unsaved: zh ? "未保存" : "unsaved", save: zh ? "保存" : "Save", cancel: zh ? "取消" : "Cancel", reset: zh ? "恢复默认" : "Restore default" };
const open = async () => {
await page.locator(".row.provider").click();
// it stays open from one opening of the editor to the next
if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: L.names, exact: true }).click();
};
const row = (id) => page.locator(".mname", { has: page.locator("code", { hasText: id }) });
await open();
const list = await page.locator(".mnames:not([hidden])").elementHandle();
const y = await page.evaluate(() => scrollY);
// ticks: nothing sent, nothing redrawn, the boxes as ticked
await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).uncheck();
await row("model-1").getByRole("checkbox", { name: L.high, exact: true }).uncheck();
await row("model-1").getByRole("checkbox", { name: L.images, exact: true }).check();
await row("model-2").locator(".mwho input").first().fill("Two");
await row("model-2").locator(".mwho input").first().press("Enter");
await row("model-3").getByRole("button", { name: L.reset }).click();
await page.waitForTimeout(200);
assert.deepEqual(posts, [], "nothing is sent before the Save");
assert(await list.evaluate((e) => e.isConnected), "the list is not redrawn");
assert.equal(await page.evaluate(() => scrollY), y, "a tick never scrolls the page");
assert.equal(await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).isChecked(), false);
assert.equal(await row("model-1").getByRole("checkbox", { name: L.high, exact: true }).isChecked(), false);
for (const id of ["model-1", "model-2", "model-3"]) assert(await row(id).getByText(L.unsaved, { exact: true }).isVisible(), id + " says it is unsaved");
assert.equal(await row("model-3").getByRole("checkbox", { name: zh ? "中" : "medium", exact: true }).isChecked(), true, "Restore default ticks every level");
// Cancel drops them; opened again, the editor is as saved
await page.getByRole("button", { name: L.cancel, exact: true }).click();
await open();
assert.equal(await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).isChecked(), true);
assert.equal(await row("model-1").getByText(L.unsaved, { exact: true }).isVisible(), false);
assert.deepEqual(posts, []);
// picked again and saved: one Save carries them all
await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).uncheck();
await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).check(); // back as it was: nothing for it
await row("model-1").getByRole("checkbox", { name: L.high, exact: true }).uncheck();
await row("model-3").getByRole("checkbox", { name: zh ? "中" : "medium", exact: true }).check();
await page.getByRole("button", { name: L.save, exact: true }).click();
await page.waitForFunction(() => !document.querySelector(".mnames"));
assert.deepEqual(posts.map((p) => p.path), ["/api/provider/save"]);
assert.deepEqual(posts[0].body.modelPrefs, { "model-1": { efforts: ["none", "low", "medium"] }, "model-3": { efforts: ["low", "medium", "high"] } });
assert.deepEqual(errors, []);
});
}
}
@@ -1,5 +1,6 @@
// A saved reasoning level redraws the provider editor without moving its
// scrollable model list back to the first model.
// A reasoning level ticked leaves the provider editor's scrollable model
// list where it is: it is staged for the editor's Save, with nothing sent
// and nothing redrawn (provider-levels-save.test.cjs).
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -55,15 +56,13 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const fifth = page.locator(".mname", { has: page.locator("code", { hasText: "model-5" }) });
const old = await list.elementHandle();
await fifth.getByRole("checkbox", { name: "low" }).uncheck();
await page.waitForFunction((e) => !e.isConnected, old);
assert.deepEqual(posts.at(-1), { id: "relay", model: "model-5", efforts: ["none", "medium", "high"] });
assert(await old.evaluate((e) => e.isConnected));
assert.equal(await fifth.getByRole("checkbox", { name: "low" }).isChecked(), false);
assert.equal(await list.evaluate((e) => e.scrollTop), before);
const next = page.locator(".mname", { has: page.locator("code", { hasText: "model-6" }) });
const current = await list.elementHandle();
await next.getByRole("checkbox", { name: "high" }).uncheck();
await page.waitForFunction((e) => !e.isConnected, current);
assert.deepEqual(posts.at(-1), { id: "relay", model: "model-6", efforts: ["none", "low", "medium"] });
assert.equal(await next.getByRole("checkbox", { name: "high" }).isChecked(), false);
assert.deepEqual(posts, []);
assert.equal(await list.evaluate((e) => e.scrollTop), before);
assert.deepEqual(errors, []);
});
+87 -20
View File
@@ -3,6 +3,7 @@ package provider
import (
"errors"
"fmt"
"maps"
"slices"
"strings"
@@ -62,21 +63,33 @@ func splitRef(ref string) (*Provider, string, error) {
// their own are told (catalog.Touched), as for any other change of the
// catalog.
func SetModelName(ref, name string) error {
return touchedIf(setModelName(ref, name))
}
// touchedIf tells the agents of a change to the catalog that was made.
func touchedIf(changed bool, err error) error {
if changed {
catalog.Touched()
}
return err
}
func setModelName(ref, name string) (bool, error) {
p, model, err := splitRef(ref)
if err != nil {
return err
return false, err
}
name = strings.Join(strings.Fields(name), " ")
if len([]rune(name)) > 80 {
return errors.New("a model's name is at most 80 characters")
return false, errors.New("a model's name is at most 80 characters")
}
if name != "" && !p.serves(model) {
return fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID)
return false, fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID)
}
s := settings.Load()
key := p.ID + "/" + model
if s.ModelNames[key] == name {
return nil
return false, nil
}
if name == "" {
delete(s.ModelNames, key)
@@ -87,10 +100,9 @@ func SetModelName(ref, name string) error {
s.ModelNames[key] = name
}
if err := settings.Save(s); err != nil {
return err
return false, err
}
catalog.Touched()
return nil
return true, nil
}
// SetModelPrice is what a provider's model costs the user, in USD per million
@@ -229,9 +241,13 @@ var Levels = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"
// levels aren't known, any of Levels, which it is given; none takes them
// away.
func SetModelEfforts(ref string, efforts []string) error {
return touchedIf(setModelEfforts(ref, efforts))
}
func setModelEfforts(ref string, efforts []string) (bool, error) {
p, model, err := splitRef(ref)
if err != nil {
return err
return false, err
}
all := p.Known(model)
var keep []string
@@ -241,10 +257,10 @@ func SetModelEfforts(ref string, efforts []string) error {
continue
}
if len(all) == 0 && !slices.Contains(Levels, e) {
return fmt.Errorf("%q is not a reasoning level (they are %s)", e, strings.Join(Levels, ", "))
return false, fmt.Errorf("%q is not a reasoning level (they are %s)", e, strings.Join(Levels, ", "))
}
if len(all) > 0 && !slices.Contains(all, e) {
return fmt.Errorf("%s/%s has no reasoning level %q (it has %s)", p.ID, model, e, strings.Join(all, ", "))
return false, fmt.Errorf("%s/%s has no reasoning level %q (it has %s)", p.ID, model, e, strings.Join(all, ", "))
}
if !slices.Contains(keep, e) {
keep = append(keep, e)
@@ -257,7 +273,7 @@ func SetModelEfforts(ref string, efforts []string) error {
s := settings.Load()
key := p.ID + "/" + model
if slices.Equal(s.ModelEfforts[key], keep) {
return nil
return false, nil
}
if len(keep) == 0 {
delete(s.ModelEfforts, key)
@@ -268,10 +284,9 @@ func SetModelEfforts(ref string, efforts []string) error {
s.ModelEfforts[key] = keep
}
if err := settings.Save(s); err != nil {
return err
return false, err
}
catalog.Touched()
return nil
return true, nil
}
// ModelEfforts are the reasoning levels the user kept of the provider's
@@ -307,12 +322,16 @@ func effortsKept(all, kept []string) []string {
// SetModelImage says whether a provider's model takes images, in place
// of what its vendor's list says. nil gives that answer back.
func SetModelImage(ref string, images *bool) error {
return touchedIf(setModelImage(ref, images))
}
func setModelImage(ref string, images *bool) (bool, error) {
p, model, err := splitRef(ref)
if err != nil {
return err
return false, err
}
if images != nil && !p.serves(model) {
return fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID)
return false, fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID)
}
if images != nil {
if vendor, known := vendorSees(p, model); known && *images == vendor {
@@ -323,12 +342,12 @@ func SetModelImage(ref string, images *bool) error {
key := p.ID + "/" + model
if images == nil {
if _, ok := s.ModelImages[key]; !ok {
return nil
return false, nil
}
delete(s.ModelImages, key)
} else {
if cur, ok := s.ModelImages[key]; ok && cur == *images {
return nil
return false, nil
}
if s.ModelImages == nil {
s.ModelImages = map[string]bool{}
@@ -336,10 +355,58 @@ func SetModelImage(ref string, images *bool) error {
s.ModelImages[key] = *images
}
if err := settings.Save(s); err != nil {
return false, err
}
return true, nil
}
// ModelPref is what the provider editor's Names & levels changed of one
// model, sent with its Save: each part left nil is as it was. Name "" gives
// the model its own name back, Efforts [] all its levels, and OwnImages the
// vendor's answer for whether it sees images.
type ModelPref struct {
Name *string `json:"name,omitempty"`
Efforts *[]string `json:"efforts,omitempty"`
Images *bool `json:"images,omitempty"`
OwnImages bool `json:"ownImages,omitempty"`
}
// SetModelPrefs makes the changes to a provider's models, by model id, as
// SetModelName, SetModelEfforts and SetModelImage do, and tells the agents
// once, after them all, rather than once a change. It stops at the first
// that fails, telling the agents of those made before it.
func SetModelPrefs(pid string, prefs map[string]ModelPref) error {
changed := false
set := func(c bool, err error) error {
changed = changed || c
return err
}
catalog.Touched()
return nil
err := func() error {
for _, model := range slices.Sorted(maps.Keys(prefs)) {
m, ref := prefs[model], pid+"/"+model
if m.Name != nil {
if err := set(setModelName(ref, *m.Name)); err != nil {
return err
}
}
if m.Efforts != nil {
if err := set(setModelEfforts(ref, *m.Efforts)); err != nil {
return err
}
}
if m.Images != nil || m.OwnImages {
images := m.Images
if m.OwnImages {
images = nil
}
if err := set(setModelImage(ref, images)); err != nil {
return err
}
}
}
return nil
}()
return touchedIf(changed, err)
}
// ImageOverride is the user's answer for whether pid's model takes images.
+59
View File
@@ -0,0 +1,59 @@
package provider
import (
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/settings"
)
// The provider editor's Names & levels are made with its Save, all at
// once: each is made as SetModelName, SetModelEfforts and SetModelImage
// make it, and the agents' files are rewritten once, not once a change
// (ARNO on Discord: every reasoning level ticked was applied on its own,
// so picking them lagged).
func TestSetModelPrefs(t *testing.T) {
prefsHome(t)
touched := 0
catalog.Changed = func() { touched++ }
t.Cleanup(func() { catalog.Changed = nil })
name, yes := "My Sol", true
efforts := []string{"low", "high"}
if err := SetModelPrefs("a", map[string]ModelPref{"sol": {Name: &name, Efforts: &efforts, Images: &yes}}); err != nil {
t.Fatal(err)
}
if touched != 1 {
t.Fatalf("agents told %d times, want once", touched)
}
s := settings.Load()
if s.ModelNames["a/sol"] != "My Sol" || !slices.Equal(s.ModelEfforts["a/sol"], efforts) || !s.ModelImages["a/sol"] {
t.Fatalf("names %v, efforts %v, images %v", s.ModelNames, s.ModelEfforts, s.ModelImages)
}
if _, ok := s.ModelNames["b/sol"]; ok {
t.Fatal("b's sol was named too")
}
// nothing changed: nothing to tell
if err := SetModelPrefs("a", map[string]ModelPref{"sol": {Name: &name}}); err != nil || touched != 1 {
t.Fatalf("%v, told %d times", err, touched)
}
// Restore default: its own name, every level, the vendor's answer
own, all := "", []string{}
if err := SetModelPrefs("a", map[string]ModelPref{"sol": {Name: &own, Efforts: &all, OwnImages: true}}); err != nil {
t.Fatal(err)
}
s = settings.Load()
if len(s.ModelNames)+len(s.ModelEfforts)+len(s.ModelImages) != 0 || touched != 2 {
t.Fatalf("names %v, efforts %v, images %v, told %d times", s.ModelNames, s.ModelEfforts, s.ModelImages, touched)
}
// a level it hasn't is refused, the change before it still told
bad := []string{"xhigh"}
err := SetModelPrefs("a", map[string]ModelPref{"sol": {Name: &name, Efforts: &bad}})
if err == nil || !strings.Contains(err.Error(), "xhigh") {
t.Fatalf("got %v", err)
}
if settings.Load().ModelNames["a/sol"] != "My Sol" || touched != 3 {
t.Fatalf("names %v, told %d times", settings.Load().ModelNames, touched)
}
}