From 150bd9363655deff2134570209cf6968c7f3d171 Mon Sep 17 00:00:00 2001 From: Mika Krul <167889775+MikaKrul@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:21:54 +0200 Subject: [PATCH 1/2] fix(web): accept comma as decimal in usage price entry Typing a price with a comma (e.g. 2,5) was blocked while typing and rejected on save with 'Use non-negative numbers for prices.' Price cells now accept either '.' or ',' as the decimal point, and the parser reads the comma as a dot. Multiple separators are still rejected. Model: claude-haiku-5-5 (logfare) via OpenCode --- .../components/usage/usagePriceForm.test.ts | 22 +++++++++++++++++-- .../src/components/usage/usagePriceForm.ts | 15 ++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/usage/usagePriceForm.test.ts b/apps/web/src/components/usage/usagePriceForm.test.ts index 742308b426697..5e13ace967290 100644 --- a/apps/web/src/components/usage/usagePriceForm.test.ts +++ b/apps/web/src/components/usage/usagePriceForm.test.ts @@ -72,13 +72,31 @@ describe("model price entry", () => { }); it("lets price cells take only numbers while typing", () => { - for (const price of ["0.075", ".5", "15", "1e-7", " 2.5 "]) + for (const price of ["0.075", "0,075", ".5", ",5", "15", "1e-7", " 2.5 "]) for (let end = 1; end <= price.length; end++) expect(isUsagePriceEdit(price.slice(0, end - 1), price.slice(0, end))).toBe(true); - for (const next of ["a", "3a", "$3", "-1", "1.2.3", "e", "1,000"]) + for (const next of ["a", "3a", "$3", "-1", "1.2.3", "1,2,3", "1.2,3", "e"]) expect(isUsagePriceEdit("", next)).toBe(false); }); + it("reads a comma as the decimal point", () => { + const form = usagePriceForm("example-model", { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + }); + expect( + parseUsagePriceForm({ + ...form, + inputCostPerMillionTokens: "2,5", + outputCostPerMillionTokens: "0,075", + }), + ).toEqual({ + model: "example-model", + price: { inputCostPerMillionTokens: 2.5, outputCostPerMillionTokens: 0.075 }, + }); + expect(parseUsagePriceForm({ ...form, inputCostPerMillionTokens: "1,2,3" })).toBeNull(); + }); + it("never blocks deleting part of a price, but blocks replacing it with a non-number", () => { expect(isUsagePriceEdit("1e-7", "e-7")).toBe(true); expect(isUsagePriceEdit("1e-7", "1-7")).toBe(true); diff --git a/apps/web/src/components/usage/usagePriceForm.ts b/apps/web/src/components/usage/usagePriceForm.ts index c120284acf267..24eb46bf632f9 100644 --- a/apps/web/src/components/usage/usagePriceForm.ts +++ b/apps/web/src/components/usage/usagePriceForm.ts @@ -24,19 +24,22 @@ export function usagePriceForm(model = "", price?: UsageModelPriceOverride): Usa /** * Whether a price cell may change from `previous` to `next` while typing: - * digits with an optional decimal point, plus the exponent form saved rates can - * stringify to. Deleting a run of characters is always allowed so a mid-value - * delete such as `1e-7` to `e-7` is not stuck; saving still validates the result. + * digits with an optional decimal point (`.` or `,`), plus the exponent form saved + * rates can stringify to. Deleting a run of characters is always allowed so a + * mid-value delete such as `1e-7` to `e-7` is not stuck; saving still validates the result. */ export function isUsagePriceEdit(previous: string, next: string): boolean { - if (/^\s*(?:\d*\.?\d*|(?:\d+\.?\d*|\.\d+)e[+-]?\d*)\s*$/i.test(next)) return true; + if (/^\s*(?:\d*[.,]?\d*|(?:\d+[.,]?\d*|[.,]\d+)e[+-]?\d*)\s*$/i.test(next)) return true; if (next.length >= previous.length) return false; let start = 0; while (start < next.length && next[start] === previous[start]) start++; return next.slice(start) === previous.slice(previous.length - (next.length - start)); } -/** Blank cache prices use the input rate; explicit zero means free. */ +/** + * Blank cache prices use the input rate; explicit zero means free. + * A comma is accepted as the decimal point. + */ export function parseUsagePriceForm( form: UsagePriceForm, ): { model: string; price: UsageModelPriceOverride } | null { @@ -44,7 +47,7 @@ export function parseUsagePriceForm( if (model.length === 0) return null; const rates: Partial> = {}; for (const field of USAGE_PRICE_FIELDS) { - const raw = form[field.key].trim(); + const raw = form[field.key].trim().replace(",", "."); if (raw === "") { if (field.optional) continue; return null; From 89cbbf6331771b9ab045255a2b65865e92ca2ed7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 03:55:50 +0000 Subject: [PATCH 2/2] test(web): verify comma decimal price entry --- apps/web/src/components/usage/usagePriceForm.test.ts | 8 +++++++- apps/web/src/components/usage/usagePriceForm.ts | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/usage/usagePriceForm.test.ts b/apps/web/src/components/usage/usagePriceForm.test.ts index 0565e3787e0cc..b66f48f844681 100644 --- a/apps/web/src/components/usage/usagePriceForm.test.ts +++ b/apps/web/src/components/usage/usagePriceForm.test.ts @@ -32,7 +32,7 @@ describe("model price entry", () => { }); }); - it.each(["", " ", "-1", "Infinity", "NaN", "1e999", "$2", "0x10"])( + it.each(["", " ", "-1", "Infinity", "NaN", "1e999", "$2", "0x10", "1,2,3", "1.2,3", "1,2.3"])( "rejects invalid required prices: %j", (inputCostPerMillionTokens) => { expect( @@ -66,6 +66,12 @@ describe("model price entry", () => { expect(isUsagePriceEdit("", "2,5")).toBe(true); expect(isUsagePriceEdit("", ",5")).toBe(true); expect(isUsagePriceEdit("", "1,2,3")).toBe(false); + expect(isUsagePriceEdit("1,2", "1,2.3")).toBe(false); + for (const value of ["2,5", ",5", "0,075", "1,5e-7"]) { + for (let end = 1; end <= value.length; end++) { + expect(isUsagePriceEdit(value.slice(0, end - 1), value.slice(0, end))).toBe(true); + } + } }); it("never blocks deleting part of a price, but blocks replacing it with a non-number", () => { diff --git a/apps/web/src/components/usage/usagePriceForm.ts b/apps/web/src/components/usage/usagePriceForm.ts index a125a13df5c61..500eda424edfb 100644 --- a/apps/web/src/components/usage/usagePriceForm.ts +++ b/apps/web/src/components/usage/usagePriceForm.ts @@ -32,8 +32,7 @@ export function isUsagePriceEdit(previous: string, next: string): boolean { return next.slice(start) === previous.slice(previous.length - (next.length - start)); } -/** Blank cache prices use the input rate; explicit zero means free. - * A comma is accepted as the decimal point. */ +/** Blank cache prices use the input rate; explicit zero means free. */ export function parseUsagePriceForm( form: UsagePriceForm, ): { model: string; price: UsageModelPriceOverride } | null {