Prechádzať zdrojové kódy

feat: 添加 LiteLLM 价格表自动同步功能

核心功能:
- 新增价格同步服务 (src/lib/price-sync.ts)
  - 从 CDN 自动获取 LiteLLM 价格表
  - 失败时降级使用本地缓存
  - 10秒超时保护
- 新增 Server Action: syncLiteLLMPrices
  - 复用现有的 uploadPriceTable 逻辑
  - LiteLLM 优先策略 (覆盖现有价格)
- 新增 UI 组件: SyncLiteLLMButton
  - 一键同步按钮
  - 实时反馈同步结果
  - 显示新增/更新/失败统计

技术实现:
- 缓存机制: public/cache/litellm-prices.json
- 三级降级: CDN → 缓存 → 手动上传
- 零数据库变更,复用现有价格表结构

🤖 Generated with Claude Code
Co-Authored-By: Claude <[email protected]>
ding113 3 mesiacov pred
rodič
commit
4d4aa8ac64

+ 0 - 0
public/cache/.gitkeep


+ 45 - 0
src/actions/model-prices.ts

@@ -15,6 +15,7 @@ import type {
   ModelPriceData,
 } from "@/types/model-price";
 import type { ActionResult } from "./types";
+import { getPriceTableJson } from "@/lib/price-sync";
 
 /**
  * 检查价格数据是否相同
@@ -155,3 +156,47 @@ export async function hasPriceTable(): Promise<boolean> {
 /**
  * 获取指定模型的最新价格
  */
+
+/**
+ * 从 LiteLLM CDN 同步价格表到数据库
+ * @returns 同步结果
+ */
+export async function syncLiteLLMPrices(): Promise<ActionResult<PriceUpdateResult>> {
+  try {
+    // 权限检查:只有管理员可以同步价格表
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    console.log('🔄 Starting LiteLLM price sync...');
+
+    // 获取价格表 JSON(优先 CDN,降级缓存)
+    const jsonContent = await getPriceTableJson();
+
+    if (!jsonContent) {
+      console.error('❌ Failed to get price table from both CDN and cache');
+      return {
+        ok: false,
+        error: '无法从 CDN 或缓存获取价格表,请检查网络连接或稍后重试'
+      };
+    }
+
+    // 调用现有的上传逻辑(已包含权限检查,但这里直接处理以避免重复检查)
+    const result = await uploadPriceTable(jsonContent);
+
+    if (result.ok) {
+      console.log('✅ LiteLLM price sync completed:', result.data);
+    } else {
+      console.error('❌ LiteLLM price sync failed:', result.error);
+    }
+
+    return result;
+  } catch (error) {
+    console.error('❌ Sync LiteLLM prices failed:', error);
+    const message =
+      error instanceof Error ? error.message : '同步失败,请稍后重试';
+    return { ok: false, error: message };
+  }
+}
+

+ 71 - 0
src/app/settings/prices/_components/sync-litellm-button.tsx

@@ -0,0 +1,71 @@
+"use client";
+
+import { useState } from "react";
+import { RefreshCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { syncLiteLLMPrices } from "@/actions/model-prices";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+
+/**
+ * LiteLLM 价格同步按钮组件
+ */
+export function SyncLiteLLMButton() {
+  const router = useRouter();
+  const [syncing, setSyncing] = useState(false);
+
+  const handleSync = async () => {
+    setSyncing(true);
+
+    try {
+      const response = await syncLiteLLMPrices();
+
+      if (!response.ok) {
+        toast.error(response.error || "同步失败");
+        return;
+      }
+
+      if (!response.data) {
+        toast.error("同步成功但未返回处理结果");
+        return;
+      }
+
+      const { added, updated, unchanged, failed } = response.data;
+
+      // 显示详细结果
+      if (added.length > 0 || updated.length > 0) {
+        toast.success(
+          `同步成功:新增 ${added.length} 个,更新 ${updated.length} 个,未变化 ${unchanged.length} 个`
+        );
+      } else if (unchanged.length > 0) {
+        toast.info(`所有 ${unchanged.length} 个模型价格均为最新`);
+      } else {
+        toast.warning("未找到支持的模型价格");
+      }
+
+      if (failed.length > 0) {
+        toast.error(`${failed.length} 个模型处理失败`);
+      }
+
+      // 刷新页面数据
+      router.refresh();
+    } catch (error) {
+      console.error("同步失败:", error);
+      toast.error("同步失败,请重试");
+    } finally {
+      setSyncing(false);
+    }
+  };
+
+  return (
+    <Button
+      variant="outline"
+      size="sm"
+      onClick={handleSync}
+      disabled={syncing}
+    >
+      <RefreshCw className={`h-4 w-4 mr-2 ${syncing ? "animate-spin" : ""}`} />
+      {syncing ? "同步中..." : "同步 LiteLLM 价格"}
+    </Button>
+  );
+}

+ 2 - 2
src/app/settings/prices/_components/upload-price-dialog.tsx

@@ -213,8 +213,8 @@ export function UploadPriceDialog({
             </div>
 
             <div className="text-xs text-muted-foreground space-y-1">
-              <p>• 推荐使用 <a className="text-blue-500 underline" href="https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" target="_blank" rel="noopener noreferrer">LiteLLM</a> 的模型价格表</p>
-              <p>• 为避免网络问题,请您手动下载 json 文件并上传</p>
+              <p>• 推荐使用左侧&quot;同步 LiteLLM 价格&quot;按钮自动获取最新价格</p>
+              <p>• 也可以手动下载 <a className="text-blue-500 underline" href="https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" target="_blank" rel="noopener noreferrer">LiteLLM 价格表</a> 并上传</p>
               <p>• 支持 Claude 和 OpenAI 模型(claude-, gpt-, o1-, o3- 前缀)</p>
             </div>
           </div>

+ 7 - 1
src/app/settings/prices/page.tsx

@@ -2,6 +2,7 @@ import { getModelPrices } from "@/actions/model-prices";
 import { Section } from "@/components/section";
 import { PriceList } from "./_components/price-list";
 import { UploadPriceDialog } from "./_components/upload-price-dialog";
+import { SyncLiteLLMButton } from "./_components/sync-litellm-button";
 import { SettingsPageHeader } from "../_components/settings-page-header";
 
 export const dynamic = "force-dynamic";
@@ -28,7 +29,12 @@ export default async function SettingsPricesPage({
       <Section
         title="模型价格"
         description="管理 AI 模型的价格配置"
-        actions={<UploadPriceDialog defaultOpen={isRequired && isEmpty} isRequired={isRequired} />}
+        actions={
+          <div className="flex gap-2">
+            <SyncLiteLLMButton />
+            <UploadPriceDialog defaultOpen={isRequired && isEmpty} isRequired={isRequired} />
+          </div>
+        }
       >
         <PriceList prices={prices} />
       </Section>

+ 125 - 0
src/lib/price-sync.ts

@@ -0,0 +1,125 @@
+/**
+ * LiteLLM 价格表自动同步服务
+ *
+ * 核心功能:
+ * 1. 从 CDN 获取 LiteLLM 价格表
+ * 2. 失败时使用本地缓存降级
+ * 3. 成功后更新数据库并刷新缓存
+ */
+
+import fs from 'fs/promises';
+import path from 'path';
+
+const LITELLM_PRICE_URL = 'https://jsd-proxy.ygxz.in/gh/BerriAI/litellm/model_prices_and_context_window.json';
+const CACHE_FILE_PATH = path.join(process.cwd(), 'public', 'cache', 'litellm-prices.json');
+const FETCH_TIMEOUT_MS = 10000; // 10 秒超时
+
+/**
+ * 确保缓存目录存在
+ */
+async function ensureCacheDirectory(): Promise<void> {
+  const cacheDir = path.dirname(CACHE_FILE_PATH);
+  try {
+    await fs.access(cacheDir);
+  } catch {
+    await fs.mkdir(cacheDir, { recursive: true });
+  }
+}
+
+/**
+ * 从 CDN 获取 LiteLLM 价格表 JSON 字符串
+ * @returns JSON 字符串或 null(失败时)
+ */
+export async function fetchLiteLLMPrices(): Promise<string | null> {
+  try {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+
+    const response = await fetch(LITELLM_PRICE_URL, {
+      signal: controller.signal,
+      headers: {
+        'Accept': 'application/json',
+      },
+    });
+
+    clearTimeout(timeoutId);
+
+    if (!response.ok) {
+      console.error(`❌ Failed to fetch LiteLLM prices: HTTP ${response.status}`);
+      return null;
+    }
+
+    const jsonText = await response.text();
+
+    // 验证 JSON 格式
+    JSON.parse(jsonText);
+
+    console.log('✅ Successfully fetched LiteLLM prices from CDN');
+    return jsonText;
+  } catch (error) {
+    if (error instanceof Error) {
+      if (error.name === 'AbortError') {
+        console.error('❌ Fetch LiteLLM prices timeout after 10s');
+      } else {
+        console.error('❌ Failed to fetch LiteLLM prices:', error.message);
+      }
+    }
+    return null;
+  }
+}
+
+/**
+ * 从本地缓存读取价格表
+ * @returns JSON 字符串或 null(缓存不存在或损坏)
+ */
+export async function readCachedPrices(): Promise<string | null> {
+  try {
+    const cached = await fs.readFile(CACHE_FILE_PATH, 'utf-8');
+
+    // 验证 JSON 格式
+    JSON.parse(cached);
+
+    console.log('📦 Using cached LiteLLM prices');
+    return cached;
+  } catch (error) {
+    if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
+      console.log('ℹ️  No cached prices found');
+    } else {
+      console.error('❌ Failed to read cached prices:', error);
+    }
+    return null;
+  }
+}
+
+/**
+ * 将价格表保存到本地缓存
+ * @param jsonText - JSON 字符串
+ */
+export async function saveCachedPrices(jsonText: string): Promise<void> {
+  try {
+    await ensureCacheDirectory();
+    await fs.writeFile(CACHE_FILE_PATH, jsonText, 'utf-8');
+    console.log('💾 Saved prices to cache');
+  } catch (error) {
+    console.error('❌ Failed to save prices to cache:', error);
+  }
+}
+
+/**
+ * 获取价格表 JSON(优先 CDN,降级缓存)
+ * @returns JSON 字符串或 null
+ */
+export async function getPriceTableJson(): Promise<string | null> {
+  // 优先从 CDN 获取
+  const jsonText = await fetchLiteLLMPrices();
+
+  if (jsonText) {
+    // 成功后更新缓存
+    await saveCachedPrices(jsonText);
+    return jsonText;
+  }
+
+  // 失败时降级使用缓存
+  console.log('⚠️  CDN fetch failed, trying cache...');
+  return await readCachedPrices();
+}