fix: 三端联调测试 2 P1 + 2 P2 + 4 P3 修复
P1-07: billing get_or_create_usage 同步 max_* 列到当前计划限额 P1-08: relay handler 增加直接配额检查 (relay_requests/input/output_tokens) P2-09: relay failover 成功后记录 tokens 并标记 completed P2-10: Tauri agentStore saas-relay 模式下从 SaaS API 获取真实用量 P2-14: super_admin 合成 subscription + check_quota 放行 P3-19: 新建 ApiKeys.tsx 页面替代 ModelServices 路由 P3-15: antd destroyOnClose → destroyOnHidden (3处) P3-16: ProTable onSearch → onSubmit (2处)
This commit is contained in:
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Space, Popconfirm, Typography } from 'antd'
|
||||||
|
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { apiKeyService } from '@/services/api-keys'
|
||||||
|
import type { TokenInfo } from '@/types'
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
const PERMISSION_OPTIONS = [
|
||||||
|
{ label: 'Relay Chat', value: 'relay:use' },
|
||||||
|
{ label: 'Knowledge Read', value: 'knowledge:read' },
|
||||||
|
{ label: 'Knowledge Write', value: 'knowledge:write' },
|
||||||
|
{ label: 'Agent Read', value: 'agent:read' },
|
||||||
|
{ label: 'Agent Write', value: 'agent:write' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ApiKeys() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [newToken, setNewToken] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['api-keys', page, pageSize],
|
||||||
|
queryFn: ({ signal }) => apiKeyService.list({ page, page_size: pageSize }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (values: { name: string; expires_days?: number; permissions: string[] }) =>
|
||||||
|
apiKeyService.create(values),
|
||||||
|
onSuccess: (result: TokenInfo) => {
|
||||||
|
message.success('API 密钥创建成功')
|
||||||
|
if (result.token) {
|
||||||
|
setNewToken(result.token)
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiKeyService.revoke(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('密钥已吊销')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '吊销失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<TokenInfo>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 180 },
|
||||||
|
{
|
||||||
|
title: '前缀',
|
||||||
|
dataIndex: 'token_prefix',
|
||||||
|
width: 120,
|
||||||
|
render: (val: string) => <Text code>{val}...</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '权限',
|
||||||
|
dataIndex: 'permissions',
|
||||||
|
width: 240,
|
||||||
|
render: (perms: string[]) =>
|
||||||
|
perms?.map((p) => <Tag key={p}>{p}</Tag>) || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后使用',
|
||||||
|
dataIndex: 'last_used_at',
|
||||||
|
width: 180,
|
||||||
|
render: (val: string) => (val ? new Date(val).toLocaleString() : <Text type="secondary">从未使用</Text>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '过期时间',
|
||||||
|
dataIndex: 'expires_at',
|
||||||
|
width: 180,
|
||||||
|
render: (val: string) =>
|
||||||
|
val ? new Date(val).toLocaleString() : <Text type="secondary">永不过期</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (val: string) => new Date(val).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, record: TokenInfo) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定吊销此密钥?"
|
||||||
|
description="吊销后使用该密钥的所有请求将被拒绝"
|
||||||
|
onConfirm={() => revokeMutation.mutate(record.id)}
|
||||||
|
>
|
||||||
|
<Button danger size="small">吊销</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<ProTable<TokenInfo>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items || []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total || 0,
|
||||||
|
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||||
|
创建密钥
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="创建 API 密钥"
|
||||||
|
open={createOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setCreateOpen(false); setNewToken(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{newToken ? (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Paragraph type="warning">
|
||||||
|
请立即复制密钥,关闭后将无法再次查看。
|
||||||
|
</Paragraph>
|
||||||
|
<Space>
|
||||||
|
<Text code style={{ fontSize: 13 }}>{newToken}</Text>
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => { navigator.clipboard.writeText(newToken); message.success('已复制') }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="密钥名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||||
|
<Input placeholder="例如: 生产环境 API Key" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="expires_days" label="有效期 (天)">
|
||||||
|
<InputNumber min={1} max={3650} placeholder="留空表示永不过期" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限" rules={[{ required: true, message: '请选择至少一项权限' }]}>
|
||||||
|
<Select mode="multiple" options={PERMISSION_OPTIONS} placeholder="选择权限" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -144,7 +144,7 @@ function IndustryListPanel() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
search={{
|
search={{
|
||||||
onReset: () => { setFilters({}); setPage(1) },
|
onReset: () => { setFilters({}); setPage(1) },
|
||||||
onSearch: (values) => { setFilters(values); setPage(1) },
|
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||||||
}}
|
}}
|
||||||
toolBarRender={() => [
|
toolBarRender={() => [
|
||||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||||
@@ -225,7 +225,7 @@ function IndustryEditModal({ open, industryId, onClose }: {
|
|||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
confirmLoading={updateMutation.isPending}
|
confirmLoading={updateMutation.isPending}
|
||||||
width={720}
|
width={720}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-8"><Spin /></div>
|
<div className="flex justify-center py-8"><Spin /></div>
|
||||||
@@ -300,7 +300,7 @@ function IndustryCreateModal({ open, onClose }: {
|
|||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
confirmLoading={createMutation.isPending}
|
confirmLoading={createMutation.isPending}
|
||||||
width={640}
|
width={640}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ function ItemsPanel() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
search={{
|
search={{
|
||||||
onReset: () => { setFilters({}); setPage(1) },
|
onReset: () => { setFilters({}); setPage(1) },
|
||||||
onSearch: (values) => { setFilters(values); setPage(1) },
|
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||||||
}}
|
}}
|
||||||
toolBarRender={() => [
|
toolBarRender={() => [
|
||||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export default function ScheduledTasks() {
|
|||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
width={520}
|
width={520}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" className="mt-4">
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
|
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
||||||
|
|||||||
@@ -39,9 +39,23 @@ pub async fn get_subscription(
|
|||||||
let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?;
|
let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?;
|
||||||
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
|
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
|
||||||
|
|
||||||
|
// P2-14 修复: super_admin 无订阅时合成一个 "active" subscription
|
||||||
|
let sub_value = if sub.is_none() && ctx.role == "super_admin" {
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"id": format!("sub-admin-{}", &ctx.account_id.chars().take(8).collect::<String>()),
|
||||||
|
"account_id": ctx.account_id,
|
||||||
|
"plan_id": plan.id,
|
||||||
|
"status": "active",
|
||||||
|
"current_period_start": usage.period_start,
|
||||||
|
"current_period_end": usage.period_end,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
sub.map(|s| serde_json::to_value(s).unwrap_or_default())
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"plan": plan,
|
"plan": plan,
|
||||||
"subscription": sub,
|
"subscription": sub_value,
|
||||||
"usage": usage,
|
"usage": usage,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,26 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(usage) = existing {
|
if let Some(usage) = existing {
|
||||||
return Ok(usage);
|
// P1-07 修复: 同步当前计划限额到 max_* 列(防止计划变更后数据不一致)
|
||||||
|
let plan = get_account_plan(pool, account_id).await?;
|
||||||
|
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
|
||||||
|
.unwrap_or_else(|_| PlanLimits::free());
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE billing_usage_quotas SET max_input_tokens=$2, max_output_tokens=$3, \
|
||||||
|
max_relay_requests=$4, max_hand_executions=$5, max_pipeline_runs=$6, updated_at=NOW() \
|
||||||
|
WHERE id=$1"
|
||||||
|
)
|
||||||
|
.bind(&usage.id)
|
||||||
|
.bind(limits.max_input_tokens_monthly)
|
||||||
|
.bind(limits.max_output_tokens_monthly)
|
||||||
|
.bind(limits.max_relay_requests_monthly)
|
||||||
|
.bind(limits.max_hand_executions_monthly)
|
||||||
|
.bind(limits.max_pipeline_runs_monthly)
|
||||||
|
.execute(pool).await?;
|
||||||
|
let updated = sqlx::query_as::<_, UsageQuota>(
|
||||||
|
"SELECT * FROM billing_usage_quotas WHERE id = $1"
|
||||||
|
).bind(&usage.id).fetch_one(pool).await?;
|
||||||
|
return Ok(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前计划限额
|
// 获取当前计划限额
|
||||||
@@ -288,8 +307,13 @@ pub async fn increment_dimension_by(
|
|||||||
pub async fn check_quota(
|
pub async fn check_quota(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
account_id: &str,
|
account_id: &str,
|
||||||
|
role: &str,
|
||||||
quota_type: &str,
|
quota_type: &str,
|
||||||
) -> SaasResult<QuotaCheck> {
|
) -> SaasResult<QuotaCheck> {
|
||||||
|
// P2-14 修复: super_admin 不受配额限制
|
||||||
|
if role == "super_admin" {
|
||||||
|
return Ok(QuotaCheck { allowed: true, reason: None, current: 0, limit: None, remaining: None });
|
||||||
|
}
|
||||||
let usage = get_or_create_usage(pool, account_id).await?;
|
let usage = get_or_create_usage(pool, account_id).await?;
|
||||||
// 从当前 Plan 读取真实限额,而非 usage 表的 stale 冗余列
|
// 从当前 Plan 读取真实限额,而非 usage 表的 stale 冗余列
|
||||||
let plan = get_account_plan(pool, account_id).await?;
|
let plan = get_account_plan(pool, account_id).await?;
|
||||||
|
|||||||
@@ -119,13 +119,13 @@ pub async fn quota_check_middleware(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从扩展中获取认证上下文
|
// 从扩展中获取认证上下文
|
||||||
let account_id = match req.extensions().get::<AuthContext>() {
|
let (account_id, role) = match req.extensions().get::<AuthContext>() {
|
||||||
Some(ctx) => ctx.account_id.clone(),
|
Some(ctx) => (ctx.account_id.clone(), ctx.role.clone()),
|
||||||
None => return next.run(req).await,
|
None => return next.run(req).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查 relay_requests 配额
|
// 检查 relay_requests 配额
|
||||||
match crate::billing::service::check_quota(&state.db, &account_id, "relay_requests").await {
|
match crate::billing::service::check_quota(&state.db, &account_id, &role, "relay_requests").await {
|
||||||
Ok(check) if !check.allowed => {
|
Ok(check) if !check.allowed => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Quota exceeded for account {}: {} ({}/{})",
|
"Quota exceeded for account {}: {} ({}/{})",
|
||||||
@@ -146,7 +146,7 @@ pub async fn quota_check_middleware(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// P1-8 修复: 同时检查 input_tokens 配额
|
// P1-8 修复: 同时检查 input_tokens 配额
|
||||||
match crate::billing::service::check_quota(&state.db, &account_id, "input_tokens").await {
|
match crate::billing::service::check_quota(&state.db, &account_id, &role, "input_tokens").await {
|
||||||
Ok(check) if !check.allowed => {
|
Ok(check) if !check.allowed => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Token quota exceeded for account {}: {} ({}/{})",
|
"Token quota exceeded for account {}: {} ({}/{})",
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ pub async fn chat_completions(
|
|||||||
) -> SaasResult<Response> {
|
) -> SaasResult<Response> {
|
||||||
check_permission(&ctx, "relay:use")?;
|
check_permission(&ctx, "relay:use")?;
|
||||||
|
|
||||||
|
// P1-08 修复: 直接配额检查(不依赖中间件,防御性编程)
|
||||||
|
for quota_type in &["relay_requests", "input_tokens", "output_tokens"] {
|
||||||
|
let check = crate::billing::service::check_quota(
|
||||||
|
&state.db, &ctx.account_id, &ctx.role, quota_type,
|
||||||
|
).await?;
|
||||||
|
if !check.allowed {
|
||||||
|
return Err(SaasError::RateLimited(
|
||||||
|
check.reason.unwrap_or_else(|| format!("{} 配额已用尽", quota_type))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 队列容量检查:使用内存 AtomicI64 计数器,消除 DB COUNT 查询
|
// 队列容量检查:使用内存 AtomicI64 计数器,消除 DB COUNT 查询
|
||||||
let max_queue_size = {
|
let max_queue_size = {
|
||||||
let config = state.config.read().await;
|
let config = state.config.read().await;
|
||||||
|
|||||||
@@ -591,6 +591,17 @@ pub async fn execute_relay_with_failover(
|
|||||||
candidate.model_id
|
candidate.model_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// P2-09 修复: 非 SSE 响应在 failover 成功后记录 tokens 并标记 completed
|
||||||
|
if let RelayResponse::Json(ref body) = response {
|
||||||
|
let (input_tokens, output_tokens) = extract_token_usage(body);
|
||||||
|
if input_tokens > 0 || output_tokens > 0 {
|
||||||
|
if let Err(e) = update_task_status(db, task_id, "completed",
|
||||||
|
Some(input_tokens), Some(output_tokens), None).await {
|
||||||
|
tracing::warn!("Failed to update task {} tokens after failover: {}", task_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SSE 响应由 StreamBridge 后台任务处理,无需在此更新
|
||||||
return Ok((response, candidate.provider_id.clone(), candidate.model_id.clone()));
|
return Ok((response, candidate.provider_id.clone(), candidate.model_id.clone()));
|
||||||
}
|
}
|
||||||
Err(SaasError::RateLimited(msg)) => {
|
Err(SaasError::RateLimited(msg)) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { AgentTemplateFull } from '../lib/saas-client';
|
|||||||
import { saasClient } from '../lib/saas-client';
|
import { saasClient } from '../lib/saas-client';
|
||||||
import { useChatStore } from './chatStore';
|
import { useChatStore } from './chatStore';
|
||||||
import { useConversationStore } from './chat/conversationStore';
|
import { useConversationStore } from './chat/conversationStore';
|
||||||
|
import { getGatewayVersion } from './connectionStore';
|
||||||
import { useSaaSStore } from './saasStore';
|
import { useSaaSStore } from './saasStore';
|
||||||
import { createLogger } from '../lib/logger';
|
import { createLogger } from '../lib/logger';
|
||||||
|
|
||||||
@@ -338,6 +339,22 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
byModel: {},
|
byModel: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// P2-10 修复: saas-relay 模式下从服务端获取真实用量
|
||||||
|
const gwVersion = getGatewayVersion();
|
||||||
|
if (gwVersion === 'saas-relay') {
|
||||||
|
try {
|
||||||
|
const sub = await saasClient.getSubscription();
|
||||||
|
if (sub?.usage) {
|
||||||
|
const serverTokens = (sub.usage.input_tokens ?? 0) + (sub.usage.output_tokens ?? 0);
|
||||||
|
if (serverTokens > 0) {
|
||||||
|
stats.totalTokens = serverTokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 降级到本地计数器
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
set({ usageStats: stats });
|
set({ usageStats: stats });
|
||||||
} catch {
|
} catch {
|
||||||
// Usage stats are non-critical, ignore errors silently
|
// Usage stats are non-critical, ignore errors silently
|
||||||
|
|||||||
Reference in New Issue
Block a user