feat(admin): 计价配置(按模型·分输入/输出单价)—— 计费比率配置落地

计费需 token↔真钱比率,配置归管理端。本次落地"按模型·分输入/输出"粒度:

后端(gateway):
- store.Pricing 模型(BaseModel + model_id 唯一 + input_per_1k/output_per_1k + currency),
  AutoMigrate 建 sundynix_pricing;ListPricing/UpsertPricing(OnConflict model_id 覆盖)。
- admin handler:GET /admin/pricing 列表、PUT /admin/pricing 设置(校验非负,币种默认 CNY),
  挂在 RequireAdmin 组下。

前端(admin):
- api:listPricing/savePricing(带 Bearer)。
- PricingPage:列出所有已登记模型(chat+embedding),每行可编辑 输入/输出每1K单价 + 币种,逐行保存。
- routes 新增「计价」页(配置组)。

实测:PUT→ok;GET 返回正确行;重复 PUT 同 model_id 仍 1 行且值更新(upsert 生效);表自动迁移。
前端 tsc 干净。下一步可做用量计量 × 单价折算(真正计费)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-19 11:25:24 +08:00
parent 030dcda9b4
commit 597665f3c8
8 changed files with 254 additions and 2 deletions
+22
View File
@@ -120,6 +120,28 @@ export async function testModel(m: ModelInput): Promise<{ ok: boolean; message:
return (await res.json()) as { ok: boolean; message: string };
}
// ---- 计价(token↔真钱,按模型分输入/输出)----
export interface Pricing {
model_id: string;
input_per_1k: number;
output_per_1k: number;
currency: string;
}
export async function listPricing(): Promise<Pricing[]> {
const res = guard(await fetch(`${ADMIN}/pricing`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list pricing failed: ${res.status}`);
return ((await res.json()) as { pricing: Pricing[] }).pricing ?? [];
}
export async function savePricing(p: Pricing): Promise<void> {
const res = guard(await fetch(`${ADMIN}/pricing`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `save pricing failed: ${res.status}`);
}
}
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
export async function gatewayOnline(): Promise<boolean> {
try {
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import { listModels, listPricing, savePricing, type Model, type Pricing } from "../api";
// 每个模型一行的本地编辑态。
interface Row {
model: Model;
inPer1k: string;
outPer1k: string;
currency: string;
dirty: boolean;
saving: boolean;
msg: string;
}
// 计价配置:为每个已登记模型设「输入 / 输出 每 1K token 单价」+ 币种(token↔真钱)。供计费折算。
export function PricingPage() {
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const load = async () => {
setLoading(true);
setErr("");
try {
const [chat, emb, pricing] = await Promise.all([listModels("chat"), listModels("embedding"), listPricing()]);
const byID = new Map<string, Pricing>(pricing.map((p) => [p.model_id, p]));
const mk = (m: Model): Row => {
const p = byID.get(m.id);
return {
model: m,
inPer1k: p ? String(p.input_per_1k) : "",
outPer1k: p ? String(p.output_per_1k) : "",
currency: p?.currency || "CNY",
dirty: false,
saving: false,
msg: "",
};
};
setRows([...chat.map(mk), ...emb.map(mk)]);
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const patch = (id: string, p: Partial<Row>) => setRows((rs) => rs.map((r) => (r.model.id === id ? { ...r, ...p, dirty: true, msg: "" } : r)));
const save = async (r: Row) => {
setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: true, msg: "" } : x)));
try {
await savePricing({
model_id: r.model.id,
input_per_1k: Number(r.inPer1k) || 0,
output_per_1k: Number(r.outPer1k) || 0,
currency: r.currency || "CNY",
});
setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, dirty: false, msg: "✓ 已保存" } : x)));
} catch (e) {
setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, msg: (e as Error).message } : x)));
}
};
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-600">{err}</div>;
return (
<div>
<p className="mb-4 text-sm text-gray-500"> token 1K token × </p>
{rows.length === 0 ? (
<div className="text-sm text-gray-400"></div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-400">
<th className="py-2"></th>
<th></th>
<th> / 1K</th>
<th> / 1K</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.model.id} className="border-t">
<td className="py-2">
<div className="font-medium text-gray-800">{r.model.model}</div>
<div className="text-[11px] text-gray-400">{r.model.provider}</div>
</td>
<td className="text-gray-500">{r.model.kind}</td>
<td>
<input
className="w-24 rounded border px-2 py-1 focus:border-violet-500 focus:outline-none"
type="number"
step="0.0001"
min="0"
value={r.inPer1k}
onChange={(e) => patch(r.model.id, { inPer1k: e.target.value })}
placeholder="0"
/>
</td>
<td>
<input
className="w-24 rounded border px-2 py-1 focus:border-violet-500 focus:outline-none"
type="number"
step="0.0001"
min="0"
value={r.outPer1k}
onChange={(e) => patch(r.model.id, { outPer1k: e.target.value })}
placeholder="0"
/>
</td>
<td>
<select
className="rounded border px-2 py-1 focus:border-violet-500 focus:outline-none"
value={r.currency}
onChange={(e) => patch(r.model.id, { currency: e.target.value })}
>
<option value="CNY">CNY</option>
<option value="USD">USD</option>
</select>
</td>
<td className="text-right">
<button
className="rounded bg-violet-600 px-3 py-1 text-xs text-white hover:bg-violet-700 disabled:opacity-40"
disabled={!r.dirty || r.saving}
onClick={() => save(r)}
>
{r.saving ? "保存中…" : "保存"}
</button>
{r.msg && <span className={`ml-2 text-[11px] ${r.msg.startsWith("✓") ? "text-emerald-600" : "text-rose-600"}`}>{r.msg}</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+8
View File
@@ -5,6 +5,7 @@ import { Soon } from "./components/Soon";
// 新增页面 = 在此加一条;real 页面用 lazy 懒加载(代码分割)。
const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
const PricingPage = lazy(() => import("./pages/PricingPage").then((m) => ({ default: m.PricingPage })));
export interface RouteDef {
path: string;
@@ -29,6 +30,13 @@ export const routes: RouteDef[] = [
ready: true,
element: <DatasourcesPage />,
},
{
path: "/pricing",
label: "计价",
group: "配置",
ready: true,
element: <PricingPage />,
},
{
path: "/tenants",
label: "租户",