Skip to content

Commit 71d70c1

Browse files
hoangsonwwclaude
andcommitted
feat(pricing): make introductory rates editable in Settings
Users could edit a model's standard rates but not its time-limited introductory (promo) rates — those were display-only, so the Sonnet 5 intro pricing could only be changed by editing seed code. Since intro promos are not a one-off (any future model launch may ship one), make the whole mechanism editable and generic. Backend - New setIntroPricing prepared statement updates ONLY the intro_* columns, kept separate from upsertPricing so a standard-rate edit never touches the promo and vice versa. - PUT /api/pricing now accepts optional intro_input/output/cache_* and intro_until. It only writes intro columns when the caller sends at least one intro field, so older clients that PUT just the standard rates preserve any existing promo (backward compatible). intro_until is validated as YYYY-MM-DD; an empty value clears the promo and zeroes the intro rates (so a re-added date can't resurrect stale values). Both writes run in one transaction. Frontend (Settings → Model Pricing) - The edit/add form gains a second row: an "Introductory rates" box with a cutoff-date field plus intro input/output/cache-read/cache-write(5m/1h) inputs. Empty date = no promo. Client mirrors the YYYY-MM-DD validation. - The display row now shows the intro subline on every rate column (was input/output only) so all editable intro values are visible. Nothing here is Sonnet-5-specific: the date-driven promo window works for any model pattern, so a future launch promo needs no code change — just an edit in the UI. Adds a server route test covering persist / preserve / clear / reject-malformed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 48fb3ec commit 71d70c1

7 files changed

Lines changed: 372 additions & 16 deletions

File tree

client/src/i18n/locales/en/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"title": "Model Pricing",
1515
"description": "Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.",
1616
"introNote": "Intro ${{input}}/${{output}} through {{until}}",
17+
"introRatesTitle": "Introductory rates",
18+
"introRatesHint": "Leave the date empty for no promo",
19+
"introUntil": "Through (date)",
20+
"introUntilInvalid": "Introductory cutoff must be a YYYY-MM-DD date",
1721
"resetConfirm": "Confirm Reset",
1822
"resetDefaults": "Reset Defaults",
1923
"resetResult": "Reset to {{count}} default rules",

client/src/i18n/locales/vi/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"title": "Định giá mô hình",
1515
"description": "Đơn giá theo một triệu token (USD). Dùng % làm ký tự đại diện trong mẫu. Một số mô hình có mức giá giới thiệu có thời hạn (hiển thị màu tím); usage vào hoặc trước ngày kết thúc tính theo mức đó, sau đó tính theo mức chuẩn.",
1616
"introNote": "Ưu đãi ${{input}}/${{output}} đến {{until}}",
17+
"introRatesTitle": "Giá giới thiệu",
18+
"introRatesHint": "Để trống ngày nếu không có ưu đãi",
19+
"introUntil": "Đến (ngày)",
20+
"introUntilInvalid": "Ngày kết thúc ưu đãi phải theo định dạng YYYY-MM-DD",
1721
"resetConfirm": "Xác nhận đặt lại",
1822
"resetDefaults": "Khôi phục mặc định",
1923
"resetResult": "Đã đặt lại về {{count}} quy tắc mặc định",

client/src/i18n/locales/zh/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"title": "模型定价",
1515
"description": "每百万 Token 的费率(美元)。在模式中使用 % 作为通配符。部分模型有限时优惠费率(以紫色显示);在截止日期当天及之前的用量按该费率计费,之后按标准费率计费。",
1616
"introNote": "优惠 ${{input}}/${{output}},截止 {{until}}",
17+
"introRatesTitle": "优惠费率",
18+
"introRatesHint": "留空日期表示无优惠",
19+
"introUntil": "截止(日期)",
20+
"introUntilInvalid": "优惠截止日期必须为 YYYY-MM-DD 格式",
1721
"resetConfirm": "确认重置",
1822
"resetDefaults": "恢复默认",
1923
"resetResult": "已重置为 {{count}} 条默认规则",

client/src/pages/Settings.tsx

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* @author Son Nguyen <hoangson091104@gmail.com>
55
*/
66

7-
import { useEffect, useState, useCallback, useRef, useSyncExternalStore } from "react";
7+
import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragment } from "react";
88
import { useTranslation } from "react-i18next";
99
import {
1010
DollarSign,
@@ -127,6 +127,14 @@ interface EditRow {
127127
cache_write_1h_per_mtok: string;
128128
fast_input_per_mtok: string;
129129
fast_output_per_mtok: string;
130+
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
131+
// values are ignored). Generic: any model can carry a promo window.
132+
intro_until: string;
133+
intro_input_per_mtok: string;
134+
intro_output_per_mtok: string;
135+
intro_cache_read_per_mtok: string;
136+
intro_cache_write_per_mtok: string;
137+
intro_cache_write_1h_per_mtok: string;
130138
}
131139

132140
const emptyRow: EditRow = {
@@ -139,6 +147,12 @@ const emptyRow: EditRow = {
139147
cache_write_1h_per_mtok: "0",
140148
fast_input_per_mtok: "0",
141149
fast_output_per_mtok: "0",
150+
intro_until: "",
151+
intro_input_per_mtok: "0",
152+
intro_output_per_mtok: "0",
153+
intro_cache_read_per_mtok: "0",
154+
intro_cache_write_per_mtok: "0",
155+
intro_cache_write_1h_per_mtok: "0",
142156
};
143157

144158
interface SystemInfo {
@@ -526,6 +540,12 @@ export function Settings() {
526540
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
527541
fast_input_per_mtok: String(rule.fast_input_per_mtok),
528542
fast_output_per_mtok: String(rule.fast_output_per_mtok),
543+
intro_until: rule.intro_until ?? "",
544+
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
545+
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
546+
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
547+
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
548+
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
529549
});
530550
};
531551

@@ -546,6 +566,11 @@ export function Settings() {
546566
setError(t("pricing.validationRequired"));
547567
return;
548568
}
569+
const introUntil = editRow.intro_until.trim();
570+
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
571+
setError(t("pricing.introUntilInvalid"));
572+
return;
573+
}
549574
setSaving(true);
550575
setError(null);
551576
try {
@@ -559,6 +584,14 @@ export function Settings() {
559584
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
560585
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
561586
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
587+
// Always send the intro block so the UI is authoritative for it: an
588+
// empty date clears the promo, a valid date persists the intro rates.
589+
intro_until: introUntil || null,
590+
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
591+
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
592+
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
593+
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
594+
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
562595
});
563596
setEditingPattern(null);
564597
setAdding(false);
@@ -775,6 +808,47 @@ export function Settings() {
775808
</>
776809
);
777810

811+
// Second edit row: the time-limited introductory-rate block. Rendered under
812+
// the standard-rate cells whenever a row is being edited/added. Leaving the
813+
// date empty means "no promo" — the rate inputs are then ignored. This is the
814+
// ONLY place intro rates are entered, and it works for any model pattern (not
815+
// just Sonnet 5), so a future model with a launch promo needs no code change.
816+
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
817+
<label className="flex flex-col gap-1">
818+
<span className="text-[10px] uppercase tracking-wider text-violet-300/70">{t(labelKey)}</span>
819+
<input
820+
type={opts.date ? "text" : "number"}
821+
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
822+
value={editRow[key]}
823+
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
824+
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
825+
/>
826+
</label>
827+
);
828+
829+
const renderIntroEditRow = () => (
830+
<tr className="bg-surface-3">
831+
<td colSpan={10} className="px-4 pb-3 pt-2">
832+
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
833+
<div className="flex items-center gap-1.5 mb-2">
834+
<span className="text-[11px] font-semibold text-violet-300 uppercase tracking-wider">
835+
{t("pricing.introRatesTitle")}
836+
</span>
837+
<span className="text-[11px] text-gray-500">{t("pricing.introRatesHint")}</span>
838+
</div>
839+
<div className="flex flex-wrap items-end gap-3">
840+
{introField("intro_until", "pricing.introUntil", { date: true })}
841+
{introField("intro_input_per_mtok", "common:token.input")}
842+
{introField("intro_output_per_mtok", "common:token.output")}
843+
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
844+
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
845+
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
846+
</div>
847+
</div>
848+
</td>
849+
</tr>
850+
);
851+
778852
const actionBanner = (keys: string[]) => {
779853
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
780854
if (!match) return null;
@@ -1031,9 +1105,10 @@ export function Settings() {
10311105
<tbody className="divide-y divide-border">
10321106
{pricing.map((rule) =>
10331107
editingPattern === rule.model_pattern ? (
1034-
<tr key={rule.model_pattern} className="bg-surface-3">
1035-
{renderEditCells()}
1036-
</tr>
1108+
<Fragment key={rule.model_pattern}>
1109+
<tr className="bg-surface-3">{renderEditCells()}</tr>
1110+
{renderIntroEditRow()}
1111+
</Fragment>
10371112
) : (
10381113
<tr
10391114
key={rule.model_pattern}
@@ -1072,12 +1147,27 @@ export function Settings() {
10721147
</td>
10731148
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono">
10741149
${rule.cache_read_per_mtok}
1150+
{rule.intro_until && (
1151+
<span className="block text-[11px] text-violet-400/80">
1152+
${rule.intro_cache_read_per_mtok}
1153+
</span>
1154+
)}
10751155
</td>
10761156
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono">
10771157
${rule.cache_write_per_mtok}
1158+
{rule.intro_until && (
1159+
<span className="block text-[11px] text-violet-400/80">
1160+
${rule.intro_cache_write_per_mtok}
1161+
</span>
1162+
)}
10781163
</td>
10791164
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono">
10801165
${rule.cache_write_1h_per_mtok}
1166+
{rule.intro_until && (
1167+
<span className="block text-[11px] text-violet-400/80">
1168+
${rule.intro_cache_write_1h_per_mtok}
1169+
</span>
1170+
)}
10811171
</td>
10821172
<td className="px-4 py-3 text-sm text-gray-400 text-right font-mono">
10831173
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
@@ -1108,7 +1198,12 @@ export function Settings() {
11081198
</tr>
11091199
)
11101200
)}
1111-
{adding && <tr className="bg-surface-3">{renderEditCells()}</tr>}
1201+
{adding && (
1202+
<>
1203+
<tr className="bg-surface-3">{renderEditCells()}</tr>
1204+
{renderIntroEditRow()}
1205+
</>
1206+
)}
11121207
</tbody>
11131208
</table>
11141209
</div>
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* @file Tests for editing time-limited introductory pricing via PUT /api/pricing.
3+
*
4+
* The Settings page lets users edit a model's introductory (promo) rates, not
5+
* just its standard rates. This suite verifies the route contract that backs
6+
* that UI, and that the mechanism is generic (works for any model pattern, not
7+
* just the seeded Sonnet 5 promo):
8+
*
9+
* 1. PUT with intro fields + a valid intro_until persists the intro rates.
10+
* 2. PUT that omits every intro field preserves an existing promo (backward
11+
* compatible with older clients that only send standard rates).
12+
* 3. PUT with an empty intro_until clears the promo AND zeroes the intro
13+
* rates so a stale value can't resurface later.
14+
* 4. A malformed intro_until is rejected with 400 and writes nothing.
15+
* 5. Editing standard rates never disturbs the intro block.
16+
*
17+
* @author Son Nguyen <hoangson091104@gmail.com>
18+
*/
19+
20+
const { describe, it, before, after } = require("node:test");
21+
const assert = require("node:assert/strict");
22+
const path = require("path");
23+
const fs = require("fs");
24+
const os = require("os");
25+
const http = require("http");
26+
27+
const TEST_DB = path.join(os.tmpdir(), `dashboard-intro-edit-${Date.now()}-${process.pid}.db`);
28+
process.env.DASHBOARD_DB_PATH = TEST_DB;
29+
30+
const { createApp, startServer } = require("../index");
31+
const { db, stmts } = require("../db");
32+
33+
let server;
34+
let BASE;
35+
36+
function fetch(urlPath, options = {}) {
37+
return new Promise((resolve, reject) => {
38+
const url = new URL(urlPath, BASE);
39+
const opts = {
40+
hostname: url.hostname,
41+
port: url.port,
42+
path: url.pathname + url.search,
43+
method: options.method || "GET",
44+
headers: { "Content-Type": "application/json", ...options.headers },
45+
};
46+
const req = http.request(opts, (res) => {
47+
let body = "";
48+
res.on("data", (c) => (body += c));
49+
res.on("end", () => {
50+
let parsed;
51+
try {
52+
parsed = JSON.parse(body);
53+
} catch {
54+
parsed = body;
55+
}
56+
resolve({ status: res.statusCode, body: parsed });
57+
});
58+
});
59+
req.on("error", reject);
60+
if (options.body) req.write(JSON.stringify(options.body));
61+
req.end();
62+
});
63+
}
64+
65+
const PATTERN = "test-promo-model%";
66+
67+
before(async () => {
68+
const app = createApp();
69+
server = await startServer(app, 0);
70+
BASE = `http://127.0.0.1:${server.address().port}`;
71+
});
72+
73+
after(() => {
74+
if (server) server.close();
75+
if (db) db.close();
76+
for (const suffix of ["", "-wal", "-shm"]) {
77+
try {
78+
fs.unlinkSync(TEST_DB + suffix);
79+
} catch {
80+
/* ignore */
81+
}
82+
}
83+
});
84+
85+
describe("PUT /api/pricing — introductory rate editing", () => {
86+
it("persists intro rates when a valid intro_until is supplied", async () => {
87+
const res = await fetch("/api/pricing", {
88+
method: "PUT",
89+
body: {
90+
model_pattern: PATTERN,
91+
display_name: "Test Promo Model",
92+
input_per_mtok: 3,
93+
output_per_mtok: 15,
94+
intro_until: "2026-08-31",
95+
intro_input_per_mtok: 2,
96+
intro_output_per_mtok: 10,
97+
intro_cache_read_per_mtok: 0.2,
98+
intro_cache_write_per_mtok: 2.5,
99+
intro_cache_write_1h_per_mtok: 4,
100+
},
101+
});
102+
assert.equal(res.status, 200);
103+
const row = stmts.getPricing.get(PATTERN);
104+
assert.equal(row.intro_until, "2026-08-31");
105+
assert.equal(row.intro_input_per_mtok, 2);
106+
assert.equal(row.intro_output_per_mtok, 10);
107+
assert.equal(row.intro_cache_read_per_mtok, 0.2);
108+
assert.equal(row.intro_cache_write_per_mtok, 2.5);
109+
assert.equal(row.intro_cache_write_1h_per_mtok, 4);
110+
// Standard rates persisted too.
111+
assert.equal(row.input_per_mtok, 3);
112+
assert.equal(row.output_per_mtok, 15);
113+
});
114+
115+
it("preserves an existing promo when the PUT omits all intro fields", async () => {
116+
// Older client shape: standard rates only, no intro_* keys at all.
117+
const res = await fetch("/api/pricing", {
118+
method: "PUT",
119+
body: {
120+
model_pattern: PATTERN,
121+
display_name: "Test Promo Model (renamed)",
122+
input_per_mtok: 4,
123+
output_per_mtok: 16,
124+
},
125+
});
126+
assert.equal(res.status, 200);
127+
const row = stmts.getPricing.get(PATTERN);
128+
// Standard rates updated…
129+
assert.equal(row.input_per_mtok, 4);
130+
assert.equal(row.display_name, "Test Promo Model (renamed)");
131+
// …but the promo is untouched.
132+
assert.equal(row.intro_until, "2026-08-31");
133+
assert.equal(row.intro_input_per_mtok, 2);
134+
assert.equal(row.intro_output_per_mtok, 10);
135+
});
136+
137+
it("rejects a malformed intro_until without mutating the row", async () => {
138+
const before = stmts.getPricing.get(PATTERN);
139+
const res = await fetch("/api/pricing", {
140+
method: "PUT",
141+
body: {
142+
model_pattern: PATTERN,
143+
display_name: "Test Promo Model",
144+
input_per_mtok: 4,
145+
output_per_mtok: 16,
146+
intro_until: "August 31",
147+
intro_input_per_mtok: 1,
148+
},
149+
});
150+
assert.equal(res.status, 400);
151+
const after = stmts.getPricing.get(PATTERN);
152+
assert.deepEqual(after, before);
153+
});
154+
155+
it("clears the promo and zeroes intro rates when intro_until is emptied", async () => {
156+
const res = await fetch("/api/pricing", {
157+
method: "PUT",
158+
body: {
159+
model_pattern: PATTERN,
160+
display_name: "Test Promo Model",
161+
input_per_mtok: 4,
162+
output_per_mtok: 16,
163+
intro_until: "",
164+
// Even though rates are still sent, an empty date clears them so a
165+
// re-added date later can't silently resurrect stale values.
166+
intro_input_per_mtok: 2,
167+
intro_output_per_mtok: 10,
168+
},
169+
});
170+
assert.equal(res.status, 200);
171+
const row = stmts.getPricing.get(PATTERN);
172+
assert.equal(row.intro_until, null);
173+
assert.equal(row.intro_input_per_mtok, 0);
174+
assert.equal(row.intro_output_per_mtok, 0);
175+
});
176+
});

0 commit comments

Comments
 (0)