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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "租户",
|
||||
|
||||
Reference in New Issue
Block a user