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:
+2
-1
@@ -34,7 +34,8 @@
|
||||
- [x] 可观测性:Prometheus /metrics(请求数/耗时/在途,路由模板低基数)· 结构化 JSON 访问日志 + X-Request-ID · /healthz(存活) + /readyz(就绪) 探针
|
||||
- [x] Harness **输入**护栏(拦提示词注入 + 超大体,纯逻辑 `internal/guardrail` + 单测 + 实跑验证)
|
||||
- [x] Harness **输出**护栏(dispatcher 发射层逐片脱敏疑似密钥/令牌 sk-/AKIA/JWT/Bearer + 轨迹标记 + 单测)
|
||||
- [ ] 🟡 商业化与计费模块(占位,仅统计任务数)
|
||||
- [x] 计价配置(按模型·分输入/输出每1K单价+币种;admin 计价页 + /admin/pricing 端点 + sundynix_pricing 表)
|
||||
- [ ] 🟡 计费/商业化:用量计量×单价折算 + 配额(计价配置已完成,计量待做)
|
||||
|
||||
## 第 3 层 · MESSAGE BUS(NATS 零拷贝骨干网)
|
||||
|
||||
|
||||
@@ -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: "租户",
|
||||
|
||||
@@ -42,6 +42,48 @@ func (h *Handler) ListModels(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"models": out})
|
||||
}
|
||||
|
||||
// ListPricing: GET /api/v1/admin/pricing —— 列出各模型的计价配置(token↔真钱)。
|
||||
func (h *Handler) ListPricing(c *gin.Context) {
|
||||
rows, err := h.db.ListPricing(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
out = append(out, gin.H{
|
||||
"model_id": p.ModelID, "input_per_1k": p.InputPer1K, "output_per_1k": p.OutputPer1K, "currency": p.Currency,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"pricing": out})
|
||||
}
|
||||
|
||||
// SavePricing: PUT /api/v1/admin/pricing —— 设置某模型的输入/输出单价(每 1K token)。
|
||||
func (h *Handler) SavePricing(c *gin.Context) {
|
||||
var b struct {
|
||||
ModelID string `json:"model_id"`
|
||||
InputPer1K float64 `json:"input_per_1k"`
|
||||
OutputPer1K float64 `json:"output_per_1k"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&b); err != nil || b.ModelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "model_id required"})
|
||||
return
|
||||
}
|
||||
if b.InputPer1K < 0 || b.OutputPer1K < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "单价不能为负"})
|
||||
return
|
||||
}
|
||||
if b.Currency == "" {
|
||||
b.Currency = "CNY"
|
||||
}
|
||||
if err := h.db.UpsertPricing(c.Request.Context(), b.ModelID, b.InputPer1K, b.OutputPer1K, b.Currency); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// SaveModel: POST /api/v1/admin/models —— 新增/更新一条模型配置。
|
||||
func (h *Handler) SaveModel(c *gin.Context) {
|
||||
var b modelBody
|
||||
|
||||
@@ -75,6 +75,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
admin.POST("/models/:id/active", h.SetActiveModel)
|
||||
admin.DELETE("/models/:id", h.DeleteModel)
|
||||
admin.POST("/models/test", h.TestModel)
|
||||
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱)
|
||||
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
|
||||
}
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -216,6 +216,39 @@ func (p *Postgres) ListLinks(ctx context.Context, owner, kb string) ([]DocLink,
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Pricing 是某模型的计价配置(token↔真钱):按模型分输入/输出单价(每 1K token)。
|
||||
// 表名 sundynix_pricing。ModelID 关联 sundynix_model.id,唯一。
|
||||
type Pricing struct {
|
||||
BaseModel
|
||||
ModelID string `gorm:"size:24;uniqueIndex"` // 关联 sundynix_model.id
|
||||
InputPer1K float64 `gorm:"column:input_per_1k"` // 每 1K 输入 token 单价
|
||||
OutputPer1K float64 `gorm:"column:output_per_1k"` // 每 1K 输出 token 单价
|
||||
Currency string `gorm:"size:8"` // 币种(CNY / USD…)
|
||||
}
|
||||
|
||||
func (Pricing) TableName() string { return "sundynix_pricing" }
|
||||
|
||||
// ListPricing 列出全部计价配置。
|
||||
func (p *Postgres) ListPricing(ctx context.Context) ([]Pricing, error) {
|
||||
if p.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []Pricing
|
||||
err := p.db.WithContext(ctx).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// UpsertPricing 写入/更新某模型的计价(model_id 唯一,重复即覆盖单价/币种)。
|
||||
func (p *Postgres) UpsertPricing(ctx context.Context, modelID string, inPer1K, outPer1K float64, currency string) error {
|
||||
if p.db == nil {
|
||||
return errStoreDisabled
|
||||
}
|
||||
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "model_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{"input_per_1k": inPer1K, "output_per_1k": outPer1K, "currency": currency, "updated_at": time.Now()}),
|
||||
}).Create(&Pricing{ModelID: modelID, InputPer1K: inPer1K, OutputPer1K: outPer1K, Currency: currency}).Error
|
||||
}
|
||||
|
||||
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
|
||||
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
|
||||
type LLMModel struct {
|
||||
|
||||
@@ -39,7 +39,7 @@ func OpenPostgres(dsn string) *Postgres {
|
||||
migrateLegacyIntIDs(db)
|
||||
migrateDocLinkToID(db)
|
||||
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}); err != nil {
|
||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user