feat: add ai cost tracking foundation

This commit is contained in:
2026-07-31 16:24:35 +08:00
parent ec9f8a015a
commit a84650bb9e
14 changed files with 327 additions and 2 deletions

View File

@@ -136,6 +136,14 @@ const modelForm = reactive({
extraParams: "", extraParams: "",
remark: "", remark: "",
timeoutSecond: 30, timeoutSecond: 30,
inputPricePer1k: null as number | null,
outputPricePer1k: null as number | null,
currency: "CNY",
usageScenarios: "",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
}); });
const quickModelForm = reactive({ const quickModelForm = reactive({
@@ -212,6 +220,9 @@ const nullableModelFields = [
"responseFormat", "responseFormat",
"extraParams", "extraParams",
"remark", "remark",
"inputPricePer1k",
"outputPricePer1k",
"usageScenarios",
] as const; ] as const;
const systemSettingValues = reactive<Record<string, SystemSettingValue>>({}); const systemSettingValues = reactive<Record<string, SystemSettingValue>>({});
@@ -289,6 +300,7 @@ async function loadDashboard() {
async function refreshStorage() { storageLoading.value = true; try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); } finally { storageLoading.value = false; } } async function refreshStorage() { storageLoading.value = true; try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); } finally { storageLoading.value = false; } }
function formatBytes(value: unknown) { if (typeof value !== "number") return "无法统计"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value, i = 0; while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; } return `${size.toFixed(i ? 2 : 0)} ${units[i]}`; } function formatBytes(value: unknown) { if (typeof value !== "number") return "无法统计"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value, i = 0; while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; } return `${size.toFixed(i ? 2 : 0)} ${units[i]}`; }
function formatMoney(value?: number | null, currency = "CNY") { if (typeof value !== "number") return "-"; return `${currency || "CNY"} ${value.toFixed(6)}`; }
async function cleanupRetrievalLogs() { if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期"); const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value); await ElMessageBox.confirm(`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`, "确认清理", { type: "warning" }); const result = await api.cleanupRetrievalLogs(cleanupBefore.value); ElMessage.success(`已清理 ${result.deleted} 条日志`); await loadCurrentMenu(); } async function cleanupRetrievalLogs() { if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期"); const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value); await ElMessageBox.confirm(`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`, "确认清理", { type: "warning" }); const result = await api.cleanupRetrievalLogs(cleanupBefore.value); ElMessage.success(`已清理 ${result.deleted} 条日志`); await loadCurrentMenu(); }
async function saveRetention() { await api.saveRetrievalRetention(retentionDays.value); ElMessage.success(retentionDays.value == null ? "已设为永久保留" : `已设为保留 ${retentionDays.value}`); await loadCurrentMenu(); } async function saveRetention() { await api.saveRetrievalRetention(retentionDays.value); ElMessage.success(retentionDays.value == null ? "已设为永久保留" : `已设为保留 ${retentionDays.value}`); await loadCurrentMenu(); }
@@ -629,6 +641,14 @@ async function quickAddModel() {
extraParams: null, extraParams: null,
remark: "快速添加", remark: "快速添加",
timeoutSecond: 30, timeoutSecond: 30,
inputPricePer1k: null,
outputPricePer1k: null,
currency: "CNY",
usageScenarios: "正式对话",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
}); });
ElMessage.success(`${provider.label} 模型已新增`); ElMessage.success(`${provider.label} 模型已新增`);
quickModelForm.apiKey = ""; quickModelForm.apiKey = "";
@@ -715,6 +735,14 @@ function editModel(row: ModelItem) {
extraParams: row.extraParams ?? "", extraParams: row.extraParams ?? "",
remark: row.remark ?? "", remark: row.remark ?? "",
timeoutSecond: row.timeoutSecond, timeoutSecond: row.timeoutSecond,
inputPricePer1k: row.inputPricePer1k ?? null,
outputPricePer1k: row.outputPricePer1k ?? null,
currency: row.currency || "CNY",
usageScenarios: row.usageScenarios ?? "",
allowSummary: row.allowSummary ?? 1,
allowReport: row.allowReport ?? 1,
allowFixedInfo: row.allowFixedInfo ?? 1,
allowDeepChat: row.allowDeepChat ?? 1,
}); });
} }
@@ -742,6 +770,14 @@ function resetModelForm() {
extraParams: "", extraParams: "",
remark: "", remark: "",
timeoutSecond: 30, timeoutSecond: 30,
inputPricePer1k: null,
outputPricePer1k: null,
currency: "CNY",
usageScenarios: "",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
}); });
} }
@@ -1001,6 +1037,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div class="stat"><span>输入 Token</span><strong>{{ stats?.inputToken ?? 0 }}</strong></div> <div class="stat"><span>输入 Token</span><strong>{{ stats?.inputToken ?? 0 }}</strong></div>
<div class="stat"><span>输出 Token</span><strong>{{ stats?.outputToken ?? 0 }}</strong></div> <div class="stat"><span>输出 Token</span><strong>{{ stats?.outputToken ?? 0 }}</strong></div>
<div class="stat"><span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong></div> <div class="stat"><span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong></div>
<div class="stat"><span>估算成本</span><strong>{{ formatMoney(stats?.estimatedCost, stats?.costCurrency || 'CNY') }}</strong></div>
</div> </div>
<section class="storage-panel" v-loading="storageLoading"><div class="storage-panel-head"><div><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div><div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div><small>最后统计{{ storageStats?.createdAt || '尚未统计' }}</small></section> <section class="storage-panel" v-loading="storageLoading"><div class="storage-panel-head"><div><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div><div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div><small>最后统计{{ storageStats?.createdAt || '尚未统计' }}</small></section>
</template> </template>
@@ -1292,7 +1329,27 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-form-item label="超时秒数"> <el-form-item label="超时秒数">
<el-input-number v-model="modelForm.timeoutSecond" :min="1" :max="300" /> <el-input-number v-model="modelForm.timeoutSecond" :min="1" :max="300" />
</el-form-item> </el-form-item>
<el-form-item label="输入单价/千Token">
<el-input-number v-model="modelForm.inputPricePer1k" :min="0" :precision="6" :step="0.001" placeholder="不填则不估算成本" />
</el-form-item>
<el-form-item label="输出单价/千Token">
<el-input-number v-model="modelForm.outputPricePer1k" :min="0" :precision="6" :step="0.001" placeholder="不填则不估算成本" />
</el-form-item>
<el-form-item label="币种">
<el-input v-model="modelForm.currency" placeholder="CNY / USD" maxlength="10" />
</el-form-item>
</div> </div>
<el-form-item label="适用场景">
<el-input v-model="modelForm.usageScenarios" placeholder="例如:正式对话、摘要、固定信息、报告批处理" />
</el-form-item>
<el-form-item label="可用能力">
<div class="entitlement-capabilities">
<el-checkbox v-model="modelForm.allowFixedInfo" :true-value="1" :false-value="0">固定信息</el-checkbox>
<el-checkbox v-model="modelForm.allowDeepChat" :true-value="1" :false-value="0">深度对话</el-checkbox>
<el-checkbox v-model="modelForm.allowSummary" :true-value="1" :false-value="0">摘要沉淀</el-checkbox>
<el-checkbox v-model="modelForm.allowReport" :true-value="1" :false-value="0">周期报告</el-checkbox>
</div>
</el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="modelForm.remark" placeholder="用途、额度、注意事项等" /> <el-input v-model="modelForm.remark" placeholder="用途、额度、注意事项等" />
</el-form-item> </el-form-item>
@@ -1306,6 +1363,10 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-table-column prop="provider" label="Provider" /> <el-table-column prop="provider" label="Provider" />
<el-table-column prop="apiType" label="协议" width="170" /> <el-table-column prop="apiType" label="协议" width="170" />
<el-table-column prop="modelName" label="模型" /> <el-table-column prop="modelName" label="模型" />
<el-table-column label="估算单价" width="180">
<template #default="{ row }">{{ row.currency || 'CNY' }} {{ row.inputPricePer1k ?? '-' }}/{{ row.outputPricePer1k ?? '-' }}</template>
</el-table-column>
<el-table-column prop="usageScenarios" label="适用场景" min-width="160" show-overflow-tooltip />
<el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip /> <el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="鉴权" width="110" /> <el-table-column prop="authType" label="鉴权" width="110" />
<el-table-column prop="enabled" label="启用" width="90" /> <el-table-column prop="enabled" label="启用" width="90" />
@@ -1521,6 +1582,9 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="totalToken" label="Token" width="90" /> <el-table-column prop="totalToken" label="Token" width="90" />
<el-table-column label="估算成本" width="130">
<template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || 'CNY') }}</template>
</el-table-column>
<el-table-column prop="costMs" label="耗时(ms)" width="100" /> <el-table-column prop="costMs" label="耗时(ms)" width="100" />
<el-table-column prop="status" label="状态" width="100" /> <el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="errorMessage" label="错误" min-width="220" show-overflow-tooltip /> <el-table-column prop="errorMessage" label="错误" min-width="220" show-overflow-tooltip />
@@ -1726,6 +1790,10 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<span>输入 Token{{ selectedAiLog.inputToken || '-' }}</span> <span>输入 Token{{ selectedAiLog.inputToken || '-' }}</span>
<span>输出 Token{{ selectedAiLog.outputToken || '-' }}</span> <span>输出 Token{{ selectedAiLog.outputToken || '-' }}</span>
<span> Token{{ selectedAiLog.totalToken || '-' }}</span> <span> Token{{ selectedAiLog.totalToken || '-' }}</span>
<span>估算成本{{ formatMoney(selectedAiLog.estimatedCost, selectedAiLog.currency || 'CNY') }}</span>
<span>问题类型{{ selectedAiLog.questionType || '-' }}</span>
<span>知识命中{{ selectedAiLog.knowledgeHit ? '是' : '否' }}</span>
<span>路由原因{{ selectedAiLog.routeReason || '-' }}</span>
<span>耗时{{ selectedAiLog.costMs || '-' }}ms</span> <span>耗时{{ selectedAiLog.costMs || '-' }}ms</span>
<span>时间{{ selectedAiLog.createdAt }}</span> <span>时间{{ selectedAiLog.createdAt }}</span>
</section> </section>

View File

@@ -27,6 +27,8 @@ export interface DashboardStats {
inputToken: number; inputToken: number;
outputToken: number; outputToken: number;
totalToken: number; totalToken: number;
estimatedCost?: number | null;
costCurrency?: string | null;
} }
export interface PromptDetail { export interface PromptDetail {
@@ -164,6 +166,14 @@ export interface ModelItem {
remark?: string | null; remark?: string | null;
timeoutSecond: number; timeoutSecond: number;
enabled: number; enabled: number;
inputPricePer1k?: number | null;
outputPricePer1k?: number | null;
currency: string;
usageScenarios?: string | null;
allowSummary: number;
allowReport: number;
allowFixedInfo: number;
allowDeepChat: number;
} }
export interface AgentGenerationConfig { export interface AgentGenerationConfig {
@@ -376,6 +386,11 @@ export interface AiLogRecord {
outputToken?: number | null; outputToken?: number | null;
totalToken?: number | null; totalToken?: number | null;
costMs?: number | null; costMs?: number | null;
estimatedCost?: number | null;
currency?: string | null;
routeReason?: string | null;
questionType?: string | null;
knowledgeHit?: boolean;
status: string; status: string;
errorMessage?: string | null; errorMessage?: string | null;
prompt?: string | null; prompt?: string | null;

View File

@@ -0,0 +1,66 @@
"""add ai cost tracking fields
Revision ID: 0018_ai_cost_tracking
Revises: 0017_share_drafts
"""
from alembic import op
import sqlalchemy as sa
revision = "0018_ai_cost_tracking"
down_revision = "0017_share_drafts"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
model_columns = {column["name"] for column in inspector.get_columns("sys_model")}
_add_column_if_missing(model_columns, "sys_model", sa.Column("input_price_per_1k", sa.Numeric(12, 6), nullable=True))
_add_column_if_missing(model_columns, "sys_model", sa.Column("output_price_per_1k", sa.Numeric(12, 6), nullable=True))
_add_column_if_missing(model_columns, "sys_model", sa.Column("currency", sa.String(10), nullable=False, server_default="CNY"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("usage_scenarios", sa.String(255), nullable=True))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_summary", sa.Integer(), nullable=False, server_default="1"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_report", sa.Integer(), nullable=False, server_default="1"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_fixed_info", sa.Integer(), nullable=False, server_default="1"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_deep_chat", sa.Integer(), nullable=False, server_default="1"))
log_columns = {column["name"] for column in inspector.get_columns("sys_ai_request_log")}
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("model_id", sa.BigInteger(), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("estimated_cost", sa.Numeric(18, 6), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("currency", sa.String(10), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("route_reason", sa.String(255), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("question_type", sa.String(50), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("knowledge_hit", sa.Integer(), nullable=False, server_default="0"))
if "model_id" not in log_columns:
op.create_index("ix_sys_ai_request_log_model_id", "sys_ai_request_log", ["model_id"])
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
log_columns = {column["name"] for column in inspector.get_columns("sys_ai_request_log")}
if "model_id" in log_columns:
op.drop_index("ix_sys_ai_request_log_model_id", table_name="sys_ai_request_log")
for column in ["knowledge_hit", "question_type", "route_reason", "currency", "estimated_cost", "model_id"]:
if column in log_columns:
op.drop_column("sys_ai_request_log", column)
model_columns = {column["name"] for column in inspector.get_columns("sys_model")}
for column in [
"allow_deep_chat",
"allow_fixed_info",
"allow_report",
"allow_summary",
"usage_scenarios",
"currency",
"output_price_per_1k",
"input_price_per_1k",
]:
if column in model_columns:
op.drop_column("sys_model", column)
def _add_column_if_missing(columns: set[str], table: str, column: sa.Column) -> None:
if column.name not in columns:
op.add_column(table, column)

View File

@@ -231,7 +231,9 @@ def ai_logs(
AiRequestLog.id, AiRequestLog.session_id, AiRequestLog.message_id, AiRequestLog.user_id, AiRequestLog.id, AiRequestLog.session_id, AiRequestLog.message_id, AiRequestLog.user_id,
AiRequestLog.model_name, AiRequestLog.knowledge_ids, AiRequestLog.retrieve_count, AiRequestLog.model_name, AiRequestLog.knowledge_ids, AiRequestLog.retrieve_count,
AiRequestLog.input_token, AiRequestLog.output_token, AiRequestLog.total_token, AiRequestLog.input_token, AiRequestLog.output_token, AiRequestLog.total_token,
AiRequestLog.cost_ms, AiRequestLog.status, AiRequestLog.error_message, AiRequestLog.created_at, AiRequestLog.cost_ms, AiRequestLog.estimated_cost, AiRequestLog.currency,
AiRequestLog.route_reason, AiRequestLog.question_type, AiRequestLog.knowledge_hit,
AiRequestLog.status, AiRequestLog.error_message, AiRequestLog.created_at,
)).order_by(AiRequestLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize) )).order_by(AiRequestLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
).all() ).all()
return api_success(page_result([_ai_log_dict(item, include_chunks=False) for item in logs], total=total, page=page, page_size=pageSize)) return api_success(page_result([_ai_log_dict(item, include_chunks=False) for item in logs], total=total, page=page, page_size=pageSize))
@@ -373,6 +375,11 @@ def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chu
"outputToken": log.output_token, "outputToken": log.output_token,
"totalToken": log.total_token, "totalToken": log.total_token,
"costMs": log.cost_ms, "costMs": log.cost_ms,
"estimatedCost": float(log.estimated_cost) if log.estimated_cost is not None else None,
"currency": log.currency,
"routeReason": log.route_reason,
"questionType": log.question_type,
"knowledgeHit": bool(log.knowledge_hit),
"status": log.status, "status": log.status,
"errorMessage": log.error_message, "errorMessage": log.error_message,
"createdAt": log.created_at, "createdAt": log.created_at,

View File

@@ -280,6 +280,14 @@ def create_model(
extra_params=payload.extraParams, extra_params=payload.extraParams,
remark=payload.remark, remark=payload.remark,
timeout_second=payload.timeoutSecond, timeout_second=payload.timeoutSecond,
input_price_per_1k=payload.inputPricePer1k,
output_price_per_1k=payload.outputPricePer1k,
currency=payload.currency or "CNY",
usage_scenarios=payload.usageScenarios,
allow_summary=payload.allowSummary,
allow_report=payload.allowReport,
allow_fixed_info=payload.allowFixedInfo,
allow_deep_chat=payload.allowDeepChat,
enabled=0, enabled=0,
) )
db.add(model) db.add(model)
@@ -323,6 +331,14 @@ def update_model(
model.extra_params = payload.extraParams model.extra_params = payload.extraParams
model.remark = payload.remark model.remark = payload.remark
model.timeout_second = payload.timeoutSecond model.timeout_second = payload.timeoutSecond
model.input_price_per_1k = payload.inputPricePer1k
model.output_price_per_1k = payload.outputPricePer1k
model.currency = payload.currency or "CNY"
model.usage_scenarios = payload.usageScenarios
model.allow_summary = payload.allowSummary
model.allow_report = payload.allowReport
model.allow_fixed_info = payload.allowFixedInfo
model.allow_deep_chat = payload.allowDeepChat
db.add(model) db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id) OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id)
db.commit() db.commit()
@@ -433,6 +449,14 @@ def _model_dict(model: ModelConfig) -> dict:
"remark": model.remark, "remark": model.remark,
"timeoutSecond": model.timeout_second, "timeoutSecond": model.timeout_second,
"enabled": model.enabled, "enabled": model.enabled,
"inputPricePer1k": float(model.input_price_per_1k) if model.input_price_per_1k is not None else None,
"outputPricePer1k": float(model.output_price_per_1k) if model.output_price_per_1k is not None else None,
"currency": model.currency,
"usageScenarios": model.usage_scenarios,
"allowSummary": model.allow_summary,
"allowReport": model.allow_report,
"allowFixedInfo": model.allow_fixed_info,
"allowDeepChat": model.allow_deep_chat,
} }

View File

@@ -49,6 +49,14 @@ class ModelConfig(Base):
remark: Mapped[str | None] = mapped_column(String(255), nullable=True) remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False) timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
enabled: Mapped[int] = mapped_column(default=0, nullable=False) enabled: Mapped[int] = mapped_column(default=0, nullable=False)
input_price_per_1k: Mapped[Decimal | None] = mapped_column(Numeric(12, 6), nullable=True)
output_price_per_1k: Mapped[Decimal | None] = mapped_column(Numeric(12, 6), nullable=True)
currency: Mapped[str] = mapped_column(String(10), default="CNY", nullable=False)
usage_scenarios: Mapped[str | None] = mapped_column(String(255), nullable=True)
allow_summary: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_report: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_fixed_info: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_deep_chat: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
class SystemConfig(Base): class SystemConfig(Base):

View File

@@ -2,7 +2,9 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func from decimal import Decimal
from sqlalchemy import BigInteger, DateTime, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -17,6 +19,7 @@ class AiRequestLog(Base):
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
model_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True) model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
prompt: Mapped[str | None] = mapped_column(Text, nullable=True) prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True) knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
@@ -26,6 +29,11 @@ class AiRequestLog(Base):
output_token: Mapped[int | None] = mapped_column(Integer, nullable=True) output_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_token: Mapped[int | None] = mapped_column(Integer, nullable=True) total_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
estimated_cost: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True)
currency: Mapped[str | None] = mapped_column(String(10), nullable=True)
route_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
question_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
knowledge_hit: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -35,6 +35,8 @@ class DashboardStats(BaseModel):
inputToken: int inputToken: int
outputToken: int outputToken: int
totalToken: int totalToken: int
estimatedCost: float | None = None
costCurrency: str | None = None
class AdminUserUpdateRequest(BaseModel): class AdminUserUpdateRequest(BaseModel):
@@ -161,6 +163,14 @@ class ModelSaveRequest(BaseModel):
extraParams: str | None = None extraParams: str | None = None
remark: str | None = Field(default=None, max_length=255) remark: str | None = Field(default=None, max_length=255)
timeoutSecond: int = Field(default=30, ge=1, le=300) timeoutSecond: int = Field(default=30, ge=1, le=300)
inputPricePer1k: float | None = Field(default=None, ge=0, le=1000000)
outputPricePer1k: float | None = Field(default=None, ge=0, le=1000000)
currency: str = Field(default="CNY", max_length=10)
usageScenarios: str | None = Field(default=None, max_length=255)
allowSummary: int = Field(default=1, ge=0, le=1)
allowReport: int = Field(default=1, ge=0, le=1)
allowFixedInfo: int = Field(default=1, ge=0, le=1)
allowDeepChat: int = Field(default=1, ge=0, le=1)
class EnableModelRequest(BaseModel): class EnableModelRequest(BaseModel):

View File

@@ -133,6 +133,12 @@ class AdminDashboardService:
msg_filter.append(ChatMessage.created_at <= end) msg_filter.append(ChatMessage.created_at <= end)
ai_filter.append(AiRequestLog.created_at <= end) ai_filter.append(AiRequestLog.created_at <= end)
cost_currency = db.scalar(
select(AiRequestLog.currency)
.where(and_(*ai_filter), AiRequestLog.currency.is_not(None))
.order_by(AiRequestLog.id.desc())
.limit(1)
)
return { return {
"userCount": db.scalar(select(func.count(User.id)).where(and_(*user_filter))) or 0, "userCount": db.scalar(select(func.count(User.id)).where(and_(*user_filter))) or 0,
"sessionCount": db.scalar(select(func.count(ChatSession.id)).where(and_(*session_filter))) or 0, "sessionCount": db.scalar(select(func.count(ChatSession.id)).where(and_(*session_filter))) or 0,
@@ -142,6 +148,8 @@ class AdminDashboardService:
"inputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.input_token), 0)).where(and_(*ai_filter))) or 0, "inputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.input_token), 0)).where(and_(*ai_filter))) or 0,
"outputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.output_token), 0)).where(and_(*ai_filter))) or 0, "outputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.output_token), 0)).where(and_(*ai_filter))) or 0,
"totalToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.total_token), 0)).where(and_(*ai_filter))) or 0, "totalToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.total_token), 0)).where(and_(*ai_filter))) or 0,
"estimatedCost": float(db.scalar(select(func.coalesce(func.sum(AiRequestLog.estimated_cost), 0)).where(and_(*ai_filter))) or 0),
"costCurrency": cost_currency,
} }

View File

@@ -2,9 +2,11 @@ from __future__ import annotations
import json import json
from collections.abc import Sequence from collections.abc import Sequence
from decimal import Decimal, ROUND_HALF_UP
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.ai_config import ModelConfig
from app.models.logs import AiRequestLog from app.models.logs import AiRequestLog
from app.services.rag_service import RetrievedChunk from app.services.rag_service import RetrievedChunk
@@ -25,12 +27,18 @@ class AiRequestLogService:
output_token: int, output_token: int,
cost_ms: int, cost_ms: int,
retrieved_chunks: Sequence[RetrievedChunk] | None = None, retrieved_chunks: Sequence[RetrievedChunk] | None = None,
model_id: int | None = None,
route_reason: str | None = None,
question_type: str | None = None,
) -> None: ) -> None:
model = db.get(ModelConfig, model_id) if model_id else None
estimated_cost, currency = _estimate_cost(model, input_token, output_token)
db.add( db.add(
AiRequestLog( AiRequestLog(
session_id=session_id, session_id=session_id,
message_id=message_id, message_id=message_id,
user_id=user_id, user_id=user_id,
model_id=model_id,
model_name=model_name, model_name=model_name,
prompt=prompt, prompt=prompt,
knowledge_ids=knowledge_ids, knowledge_ids=knowledge_ids,
@@ -40,6 +48,11 @@ class AiRequestLogService:
output_token=output_token, output_token=output_token,
total_token=input_token + output_token, total_token=input_token + output_token,
cost_ms=cost_ms, cost_ms=cost_ms,
estimated_cost=estimated_cost,
currency=currency,
route_reason=route_reason or _default_route_reason(retrieve_count),
question_type=question_type or _question_type(retrieve_count),
knowledge_hit=1 if retrieve_count > 0 else 0,
status="SUCCESS", status="SUCCESS",
) )
) )
@@ -58,18 +71,25 @@ class AiRequestLogService:
cost_ms: int, cost_ms: int,
error_message: str, error_message: str,
retrieved_chunks: Sequence[RetrievedChunk] | None = None, retrieved_chunks: Sequence[RetrievedChunk] | None = None,
model_id: int | None = None,
route_reason: str | None = None,
question_type: str | None = None,
) -> None: ) -> None:
db.add( db.add(
AiRequestLog( AiRequestLog(
session_id=session_id, session_id=session_id,
message_id=message_id, message_id=message_id,
user_id=user_id, user_id=user_id,
model_id=model_id,
model_name=model_name, model_name=model_name,
prompt=prompt, prompt=prompt,
knowledge_ids=knowledge_ids, knowledge_ids=knowledge_ids,
retrieve_count=retrieve_count, retrieve_count=retrieve_count,
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks), retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
cost_ms=cost_ms, cost_ms=cost_ms,
route_reason=route_reason or _default_route_reason(retrieve_count),
question_type=question_type or _question_type(retrieve_count),
knowledge_hit=1 if retrieve_count > 0 else 0,
status="FAILED", status="FAILED",
error_message=error_message, error_message=error_message,
) )
@@ -91,3 +111,22 @@ def _dump_retrieved_chunks(chunks: Sequence[RetrievedChunk] | None) -> str | Non
for index, chunk in enumerate(chunks, start=1) for index, chunk in enumerate(chunks, start=1)
] ]
return json.dumps(payload, ensure_ascii=False) return json.dumps(payload, ensure_ascii=False)
def _estimate_cost(model: ModelConfig | None, input_token: int, output_token: int) -> tuple[Decimal | None, str | None]:
if model is None:
return None, None
input_price = Decimal(model.input_price_per_1k or 0)
output_price = Decimal(model.output_price_per_1k or 0)
if input_price <= 0 and output_price <= 0:
return None, model.currency
value = (Decimal(input_token) / Decimal(1000) * input_price) + (Decimal(output_token) / Decimal(1000) * output_price)
return value.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP), model.currency
def _default_route_reason(retrieve_count: int) -> str:
return "当前默认正式模型;本轮命中知识库" if retrieve_count > 0 else "当前默认正式模型;本轮未命中知识库"
def _question_type(retrieve_count: int) -> str:
return "knowledge_grounded" if retrieve_count > 0 else "general_chat"

View File

@@ -154,6 +154,7 @@ class ChatService:
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=None,
) )
db.commit() db.commit()
raise HTTPException( raise HTTPException(
@@ -204,6 +205,7 @@ class ChatService:
output_token=completion.output_token, output_token=completion.output_token,
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks, retrieved_chunks=rag_result.chunks,
model_id=completion.model_id,
) )
db.commit() db.commit()
return completion.answer return completion.answer

View File

@@ -117,6 +117,7 @@ class ChatStreamService:
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None,
) )
_mark_retrieval_failed(db, rag_result, str(exc), cost_ms) _mark_retrieval_failed(db, rag_result, str(exc), cost_ms)
db.commit() db.commit()
@@ -137,6 +138,7 @@ class ChatStreamService:
cost_ms=cost_ms, cost_ms=cost_ms,
error_message="模型未返回有效内容", error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None,
) )
_mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms) _mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms)
db.commit() db.commit()
@@ -185,6 +187,7 @@ class ChatStreamService:
output_token=_rough_token_count(answer), output_token=_rough_token_count(answer),
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None,
) )
db.commit() db.commit()
@@ -279,6 +282,7 @@ class ChatStreamService:
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None,
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -298,6 +302,7 @@ class ChatStreamService:
cost_ms=cost_ms, cost_ms=cost_ms,
error_message="模型未返回有效内容", error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None,
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
@@ -381,6 +386,7 @@ def _write_success(
output_token=_rough_token_count(answer), output_token=_rough_token_count(answer),
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None,
) )
if rag_result is not None and rag_result.retrieval_log_id: if rag_result is not None and rag_result.retrieval_log_id:
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id) retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.models import Base
from app.models.ai_config import ModelConfig
from app.models.logs import AiRequestLog
from app.services.ai_request_log_service import AiRequestLogService
def _db() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def test_ai_request_log_estimates_cost_from_model_price():
with _db() as db:
model = ModelConfig(
id=1,
provider="test",
api_type="openai_compatible",
model_name="test-model",
api_url="https://example.com",
api_key="secret",
input_price_per_1k=Decimal("0.002"),
output_price_per_1k=Decimal("0.006"),
currency="CNY",
timeout_second=30,
)
db.add(model)
db.commit()
AiRequestLogService.write_success(
db,
session_id=1,
message_id=2,
user_id=3,
model_id=1,
model_name="test-model",
prompt="hello",
knowledge_ids="1",
retrieve_count=2,
input_token=1000,
output_token=500,
cost_ms=120,
)
db.commit()
log = db.query(AiRequestLog).one()
assert log.estimated_cost == Decimal("0.005000")
assert log.currency == "CNY"
assert log.knowledge_hit == 1
assert log.question_type == "knowledge_grounded"
assert "命中知识库" in (log.route_reason or "")

View File

@@ -649,6 +649,10 @@ AI 日志增加:
- 不同问题类型可以走不同模型; - 不同问题类型可以走不同模型;
- 日志能解释为什么选这个模型。 - 日志能解释为什么选这个模型。
#### 开发进度
- 2026-07-31一期已新增模型输入/输出千 Token 单价、币种、适用场景和可用能力字段AI 请求日志记录模型 ID、估算成本、币种、问题类型、知识命中和路由原因数据看板展示筛选范围内估算成本。暂未自动切换模型避免影响正式回答稳定性后续再基于这些字段做模型分流。
--- ---
### 10. 用户端“我的实修档案” ### 10. 用户端“我的实修档案”