diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 7f211e0d..93198a14 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -6185,6 +6185,18 @@ function cancelEdit(keep) { editing = null; draft = null; importing = null; importingApps = null; renderProviders(); } +// priceTypedError: a model with no list price given only part of its +// price in Names & levels (its input and output both needed) holds the +// Save, its empty box focused, rather than the part typed being dropped. +function priceTypedError(ed) { + const id = Object.keys(draft?.priceTyped || {}).find((x) => !draft.chosen?.length || draft.chosen.includes(x)); + if (id === undefined) return false; + const row = [...ed.querySelectorAll(".mname")].find((r) => r.querySelector("code")?.textContent === id); + [...(row?.querySelectorAll(".mprice input") || [])].find((i) => i.value.trim() === "" && !i.placeholder)?.focus({ preventScroll: true }); + editorError(t("{id} has no list price: give its input and output prices", { id }), "warn"); + return true; +} + // modelPrefsOfDraft: the names, levels and images staged in the editor's // Names & levels, for its Save, or nothing when none changed. function modelPrefsOfDraft() { @@ -7070,6 +7082,7 @@ function drawEditor(p, presetID) { if (maxConcurrency === undefined) return concurrencyError(ed); const priceRate = priceRateOfDraft(); if (priceRate === undefined) return priceRateError(ed); + if (priceTypedError(ed)) return; saveBtn.classList.add("busy"); providerAction("save", { id: p.id, models: chosenIds(), unlisted: draft.unlisted, fallback: draft.fallback, contexts: cx.map, outputs: ox.map, compacts: cpx.map, proxy, accountProxies: own.map, maxConcurrency, priceRate, modelPrefs: modelPrefsOfDraft(), pinUpstream: !!draft.pinUpstream, ...routingOfDraft(p) }, t("{name} saved", { name: p.name })); }; bar.append(cancel, saveBtn); ed.append(bar); @@ -7410,6 +7423,7 @@ function drawEditor(p, presetID) { if (body.maxConcurrency === undefined) return concurrencyError(ed); body.priceRate = priceRateOfDraft(); if (body.priceRate === undefined) return priceRateError(ed); + if (p && priceTypedError(ed)) return; if (draft.balanceToken) body.balanceToken = draft.balanceToken; else if (draft.clearBalanceToken) body.clearBalanceToken = true; if (team) { @@ -8436,7 +8450,8 @@ function renderModels(p) { priceBox.append(el("span", "", t("Price, $ / 1M tokens"))); const cells = parts.map(([k, l]) => { const box = el("label", "mpart"); - const i = input(priceNow() ? shown(priceNow()[k]) : "", m.list ? shown(m.list[k]) : "", "number"); + const typed = draft.priceTyped?.[id]; + const i = input(typed ? typed[parts.findIndex(([x]) => x === k)] : priceNow() ? shown(priceNow()[k]) : "", m.list ? shown(m.list[k]) : "", "number"); i.inputMode = "decimal"; i.min = "0"; i.step = "any"; @@ -8453,6 +8468,7 @@ function renderModels(p) { const v = cells.map((c) => c.value.trim()); const x = pref(); delete x.price; delete x.ownPrice; + if (draft.priceTyped) delete draft.priceTyped[id]; if (v.every((s) => s === "")) { if (m.price) x.ownPrice = true; drawReset(); @@ -8461,6 +8477,16 @@ function renderModels(p) { const price = {}; for (const [i, [k]] of parts.entries()) { const n = v[i] === "" ? m.list?.[k] ?? (k.startsWith("cache") ? 0 : NaN) : Number(v[i]); + // a model with no list price is given its input and output one + // box at a time: what is typed stays, waiting for the other, and + // the Save asks for it (PAMI on Discord: each box typed was + // emptied again, so a price could never be set) + if (Number.isNaN(n) && v[i] === "" && v.every((s) => s === "" || (Number.isFinite(Number(s)) && Number(s) >= 0))) { + (draft.priceTyped = draft.priceTyped || {})[id] = v; + status(t("{id} has no list price: give its input and output prices", { id: m.id }), "warn"); + drawReset(); + return; + } if (!Number.isFinite(n) || n < 0) { status(Number.isNaN(n) && v[i] === "" ? t("{id} has no list price: give its input and output prices", { id: m.id }) : t("A price is a number of dollars, 0 or more"), "err"); showPrice(); @@ -8531,6 +8557,7 @@ function renderModels(p) { reset.title = t("Its own name, every reasoning level it has, whether it sees images, the API it is asked on, the model it is the same as, and its list price"); reset.onclick = () => { prefs[id] = {}; + if (draft.priceTyped) delete draft.priceTyped[id]; if (m.default) prefs[id].name = ""; if (m.kept?.length) prefs[id].efforts = []; if (m.imageSet) prefs[id].ownImages = true; @@ -8548,10 +8575,10 @@ function renderModels(p) { const drawReset = () => { const x = prefs[id]; if (x && !Object.keys(x).length) delete prefs[id]; - unsaved.hidden = !prefs[id]; + unsaved.hidden = !prefs[id] && !draft.priceTyped?.[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 || apiNow() !== "" || sameNow() !== "" || priceNow() !== null; + const custom = nameNow() !== "" || (prefs[id]?.efforts ? prefs[id].efforts.length > 0 : !!m.kept?.length) || images || apiNow() !== "" || sameNow() !== "" || priceNow() !== null || !!draft.priceTyped?.[id]; reset.hidden = !custom; }; row.append(unsaved, reset); diff --git a/internal/gui/tests/model-price-edit.test.cjs b/internal/gui/tests/model-price-edit.test.cjs index f93e8b80..31881d0a 100644 --- a/internal/gui/tests/model-price-edit.test.cjs +++ b/internal/gui/tests/model-price-edit.test.cjs @@ -81,11 +81,21 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(await boxes("model-1").evaluateAll((is) => is.map((i) => i.value)), ["1.25", "8", "0.125", "0"]); await row("model-2").getByRole("button", { name: L.reset }).click(); assert.deepEqual(await boxes("model-2").evaluateAll((is) => is.map((i) => i.value)), ["", "", "", ""]); - // no list price: input and output must be given + // no list price: input and output must be given, one box at a time — + // the part typed stays (PAMI on Discord: it was emptied, so no price + // could be set), and a Save before the other part asks for it await row("model-3").getByRole("spinbutton", { name: L.input, exact: true }).fill("3"); await row("model-3").getByRole("spinbutton", { name: L.input, exact: true }).press("Enter"); await page.locator("#status", { hasText: L.nolist }).waitFor(); - assert.equal(await row("model-3").getByText(L.unsaved, { exact: true }).isVisible(), false); + assert.deepEqual(await boxes("model-3").evaluateAll((is) => is.map((i) => i.value)), ["3", "", "", ""]); + assert(await row("model-3").getByText(L.unsaved, { exact: true }).isVisible()); + await page.getByRole("button", { name: L.save, exact: true }).click(); + await page.locator(".editor-error", { hasText: L.nolist }).waitFor(); + assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("aria-label")), L.output); + assert.deepEqual(posts, [], "a price half given isn't sent"); + await row("model-3").getByRole("spinbutton", { name: L.output, exact: true }).fill("15"); + await row("model-3").getByRole("spinbutton", { name: L.output, exact: true }).press("Enter"); + assert.deepEqual(await boxes("model-3").evaluateAll((is) => is.map((i) => i.value)), ["3", "15", "0", "0"]); assert.equal(await page.evaluate(() => scrollY), y, "nothing scrolled the page"); assert.deepEqual(posts, [], "nothing is sent before the Save"); if (process.env.ARTIFACT_DIR) await page.locator(".mnames").screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-model-price-rows.png`) }); @@ -96,6 +106,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(posts[0].body.modelPrefs, { "model-1": { price: { input: 1.25, output: 8, cache_read: 0.125, cache_write: 0 } }, "model-2": { ownPrice: true }, + "model-3": { price: { input: 3, output: 15, cache_read: 0, cache_write: 0 } }, }); const missing = await page.evaluate(() => ["Price, $ / 1M tokens", "{id} has no list price: give its input and output prices", "A price is a number of dollars, 0 or more"] .filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k]));