mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 04:45:19 +00:00
feat:管理后台支持模型价格配置入口
This commit is contained in:
parent
1d925ce46a
commit
194f80b787
5 changed files with 318 additions and 9 deletions
45
backend/check_billing_logs.py
Normal file
45
backend/check_billing_logs.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env python3
|
||||
"""检查最近的计费记录,诊断重复扣费问题"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "open_webui"))
|
||||
|
||||
from internal.db import get_db
|
||||
from models.billing import BillingLog
|
||||
from sqlalchemy import desc
|
||||
|
||||
def check_recent_logs(limit=10):
|
||||
"""查询最近的计费记录"""
|
||||
with get_db() as db:
|
||||
logs = (
|
||||
db.query(BillingLog)
|
||||
.order_by(desc(BillingLog.created_at))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
print(f"最近 {len(logs)} 条计费记录:\n")
|
||||
print("=" * 150)
|
||||
|
||||
for i, log in enumerate(logs, 1):
|
||||
# 转换时间戳(纳秒 → 秒)
|
||||
import datetime
|
||||
created_time = datetime.datetime.fromtimestamp(log.created_at / 1000000000)
|
||||
|
||||
print(f"{i}. ID: {log.id[:8]}...")
|
||||
print(f" 用户: {log.user_id}")
|
||||
print(f" 模型: {log.model_id}")
|
||||
print(f" 类型: {log.log_type}")
|
||||
print(f" 状态: {log.status}")
|
||||
print(f" Tokens: {log.prompt_tokens}+{log.completion_tokens}")
|
||||
print(f" 费用: {log.total_cost / 10000:.4f} 元")
|
||||
print(f" 余额: {log.balance_after / 10000:.4f} 元" if log.balance_after else " 余额: None")
|
||||
print(f" 预扣费ID: {log.precharge_id[:8] if log.precharge_id else 'None'}...")
|
||||
print(f" 时间: {created_time}")
|
||||
print("-" * 150)
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_recent_logs(15)
|
||||
|
|
@ -120,11 +120,23 @@ def calculate_cost(
|
|||
|
||||
# 2. 计算费用(毫)
|
||||
# 公式: (tokens * price_per_million) / 1000000
|
||||
input_cost = (prompt_tokens * input_price) // 1000000
|
||||
output_cost = (completion_tokens * output_price) // 1000000
|
||||
total_cost = input_cost + output_cost
|
||||
# 使用整数计算,先相加再向上取整,避免浮点数精度问题和重复向上取整
|
||||
|
||||
return int(total_cost)
|
||||
# 计算原始费用(未除以 1000000)
|
||||
input_cost_raw = prompt_tokens * input_price
|
||||
output_cost_raw = completion_tokens * output_price
|
||||
total_cost_raw = input_cost_raw + output_cost_raw
|
||||
|
||||
# 如果有 token 消耗
|
||||
if total_cost_raw > 0:
|
||||
# 向上取整: ceil(a/b) = (a + b - 1) // b
|
||||
total_cost = (total_cost_raw + 999999) // 1000000
|
||||
# 如果计算结果 < 1 毫,至少扣 1 毫
|
||||
total_cost = max(1, total_cost)
|
||||
else:
|
||||
total_cost = 0
|
||||
|
||||
return total_cost
|
||||
|
||||
|
||||
def deduct_balance(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
updateModelById,
|
||||
importModels
|
||||
} from '$lib/apis/models';
|
||||
import { listModelPricing } from '$lib/apis/billing';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import ManageModelsModal from './Models/ManageModelsModal.svelte';
|
||||
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
|
||||
import ModelPricingModal from '$lib/components/admin/Settings/Models/ModelPricingModal.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
||||
import Eye from '$lib/components/icons/Eye.svelte';
|
||||
|
|
@ -56,6 +58,11 @@
|
|||
let showConfigModal = false;
|
||||
let showManageModal = false;
|
||||
|
||||
// 定价相关
|
||||
let modelPricings = {};
|
||||
let showPricingModal = false;
|
||||
let selectedModelForPricing = null;
|
||||
|
||||
$: if (models) {
|
||||
filteredModels = models
|
||||
.filter((m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase()))
|
||||
|
|
@ -84,13 +91,27 @@
|
|||
workspaceModels = await getBaseModels(localStorage.token);
|
||||
baseModels = await getModels(localStorage.token, null, true);
|
||||
|
||||
// 加载所有模型定价
|
||||
try {
|
||||
const pricingList = await listModelPricing();
|
||||
modelPricings = pricingList.reduce((acc, p) => {
|
||||
acc[p.model_id] = p;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
console.error('加载定价失败:', error);
|
||||
modelPricings = {};
|
||||
}
|
||||
|
||||
models = baseModels.map((m) => {
|
||||
const workspaceModel = workspaceModels.find((wm) => wm.id === m.id);
|
||||
const pricing = modelPricings[m.id]; // 获取定价
|
||||
|
||||
if (workspaceModel) {
|
||||
return {
|
||||
...m,
|
||||
...workspaceModel
|
||||
...workspaceModel,
|
||||
pricing // 附加定价信息
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
|
|
@ -98,7 +119,8 @@
|
|||
id: m.id,
|
||||
name: m.name,
|
||||
|
||||
is_active: true
|
||||
is_active: true,
|
||||
pricing // 附加定价信息
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -245,6 +267,14 @@
|
|||
|
||||
<ConfigureModelsModal bind:show={showConfigModal} initHandler={init} />
|
||||
<ManageModelsModal bind:show={showManageModal} />
|
||||
<ModelPricingModal
|
||||
bind:show={showPricingModal}
|
||||
model={selectedModelForPricing}
|
||||
pricing={selectedModelForPricing?.pricing || modelPricings[selectedModelForPricing?.id]}
|
||||
on:save={async () => {
|
||||
await init(); // 重新加载数据
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if models !== null}
|
||||
{#if selectedModelId === null}
|
||||
|
|
@ -365,6 +395,63 @@
|
|||
: model.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 定价信息 -->
|
||||
{#if model.pricing || modelPricings[model.id]}
|
||||
{@const pricing = model.pricing || modelPricings[model.id]}
|
||||
<div class="flex items-center gap-2 text-xs mt-1.5">
|
||||
<!-- 定价显示 -->
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">入</span>
|
||||
<span class="font-mono">¥{(pricing.input_price / 10000).toFixed(2)}</span>
|
||||
<span class="mx-1.5 text-gray-300 dark:text-gray-600">|</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">出</span>
|
||||
<span class="font-mono">¥{(pricing.output_price / 10000).toFixed(2)}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 ml-0.5">/M</span>
|
||||
</span>
|
||||
|
||||
<!-- 编辑按钮(仅管理员) -->
|
||||
{#if $user?.role === 'admin'}
|
||||
<button
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
on:click|stopPropagation={() => {
|
||||
selectedModelForPricing = model;
|
||||
showPricingModal = true;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('编辑')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 默认定价 -->
|
||||
<div class="flex items-center gap-2 text-xs mt-1.5">
|
||||
<span class="text-gray-400 dark:text-gray-500">
|
||||
<span>入</span>
|
||||
<span class="font-mono">¥1.00</span>
|
||||
<span class="mx-1.5 text-gray-300 dark:text-gray-600">|</span>
|
||||
<span>出</span>
|
||||
<span class="font-mono">¥2.00</span>
|
||||
<span class="ml-0.5">/M</span>
|
||||
</span>
|
||||
<span class="text-orange-500 dark:text-orange-400 text-[10px] px-1 py-0.5 bg-orange-100 dark:bg-orange-900/30 rounded">
|
||||
{$i18n.t('默认')}
|
||||
</span>
|
||||
|
||||
<!-- 编辑按钮(仅管理员) -->
|
||||
{#if $user?.role === 'admin'}
|
||||
<button
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
on:click|stopPropagation={() => {
|
||||
selectedModelForPricing = model;
|
||||
showPricingModal = true;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('设置')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex flex-row gap-0.5 items-center self-center">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { createEventDispatcher, getContext } from 'svelte';
|
||||
import { setModelPricing } from '$lib/apis/billing';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let show = false;
|
||||
export let model: any;
|
||||
export let pricing: any;
|
||||
|
||||
let inputPrice = 0; // 元/M
|
||||
let outputPrice = 0; // 元/M
|
||||
let loading = false;
|
||||
let initialized = false;
|
||||
|
||||
// 只在弹窗首次打开时初始化,之后不再重置
|
||||
$: if (show && !initialized) {
|
||||
if (pricing) {
|
||||
// 初始化:毫 → 元
|
||||
inputPrice = pricing.input_price / 10000;
|
||||
outputPrice = pricing.output_price / 10000;
|
||||
} else {
|
||||
// 无定价时,使用 0
|
||||
inputPrice = 0;
|
||||
outputPrice = 0;
|
||||
}
|
||||
initialized = true;
|
||||
} else if (!show) {
|
||||
// 弹窗关闭时重置标志
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
const submitHandler = async () => {
|
||||
if (inputPrice < 0 || outputPrice < 0) {
|
||||
toast.error($i18n.t('价格不能为负数'));
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
// 提交:元 → 毫
|
||||
await setModelPricing(localStorage.token, {
|
||||
model_id: model.id,
|
||||
input_price: Math.round(inputPrice * 10000),
|
||||
output_price: Math.round(outputPrice * 10000)
|
||||
});
|
||||
|
||||
toast.success($i18n.t('定价更新成功'));
|
||||
dispatch('save');
|
||||
show = false;
|
||||
} catch (error: any) {
|
||||
toast.error(`${$i18n.t('更新失败')}: ${error.detail || error}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class="text-lg font-medium self-center">
|
||||
{$i18n.t('编辑模型定价')} - {model?.name}
|
||||
</div>
|
||||
<button class="self-center" on:click={() => (show = false)}>
|
||||
<XMark className={'size-5'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col w-full px-5 pb-5" on:submit|preventDefault={submitHandler}>
|
||||
<!-- 模型ID -->
|
||||
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('模型ID')}: <code class="font-mono">{model?.id}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入价格 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2 dark:text-gray-300">
|
||||
{$i18n.t('输入价格')} ({$i18n.t('元')}/{$i18n.t('百万tokens')})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={inputPrice}
|
||||
class="w-full px-3 py-2 border dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-gray-100"
|
||||
placeholder="{$i18n.t('例如')}: 2.5"
|
||||
required
|
||||
/>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{$i18n.t('当前')}: ¥{(inputPrice || 0).toFixed(2)}/M = {((inputPrice || 0) * 10000).toFixed(
|
||||
0
|
||||
)} {$i18n.t('毫')}/M
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出价格 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2 dark:text-gray-300">
|
||||
{$i18n.t('输出价格')} ({$i18n.t('元')}/{$i18n.t('百万tokens')})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={outputPrice}
|
||||
class="w-full px-3 py-2 border dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-gray-100"
|
||||
placeholder="{$i18n.t('例如')}: 10"
|
||||
required
|
||||
/>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{$i18n.t('当前')}: ¥{(outputPrice || 0).toFixed(2)}/M = {((outputPrice || 0) * 10000).toFixed(
|
||||
0
|
||||
)} {$i18n.t('毫')}/M
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览 -->
|
||||
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm">
|
||||
<div class="font-medium mb-2">
|
||||
{$i18n.t('计费预览')} ({$i18n.t('以')} 1000 tokens {$i18n.t('为例')})
|
||||
</div>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div>{$i18n.t('输入')}: ¥{((inputPrice || 0) * 0.001).toFixed(4)}</div>
|
||||
<div>{$i18n.t('输出')}: ¥{((outputPrice || 0) * 0.001).toFixed(4)}</div>
|
||||
<div class="font-medium">
|
||||
{$i18n.t('总计')}: ¥{(((inputPrice || 0) + (outputPrice || 0)) * 0.001).toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按钮 -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300"
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
{$i18n.t('取消')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? $i18n.t('保存中') + '...' : $i18n.t('保存')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
@ -207,9 +207,17 @@
|
|||
<span>实际消费:</span>
|
||||
<span class="font-mono">{formatCurrency(log.cost, true)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-green-600 dark:text-green-400">
|
||||
<span>退款金额:</span>
|
||||
<span class="font-mono">-{formatCurrency(log.refundAmount, true)}</span>
|
||||
<div class="flex justify-between" class:text-green-600={log.refundAmount > 0} class:text-red-600={log.refundAmount < 0} class:dark:text-green-400={log.refundAmount > 0} class:dark:text-red-400={log.refundAmount < 0}>
|
||||
<span>{log.refundAmount >= 0 ? '退款金额' : '补扣金额'}:</span>
|
||||
<span class="font-mono">
|
||||
{#if log.refundAmount > 0}
|
||||
+{formatCurrency(log.refundAmount, true)}
|
||||
{:else if log.refundAmount < 0}
|
||||
{formatCurrency(log.refundAmount, true)}
|
||||
{:else}
|
||||
{formatCurrency(0, true)}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if log.precharge_id}
|
||||
<div class="flex justify-between pt-1 mt-1 border-t border-gray-200 dark:border-gray-700">
|
||||
|
|
|
|||
Loading…
Reference in a new issue