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:
1 parent
3f5cd1f3cc
commit
80441c2c29
7 files changed
+305
-2
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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": "上へ",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user