From d606cb2e3846ca8bcde5e40b27ce215f9f16165d Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 16 Oct 2025 10:46:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E6=96=87=E4=BB=B6=E6=9B=B4=E6=96=B0=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/sync-model-pricing.yml | 62 ++++++++++ config/pricingSource.js | 17 +++ resources/model-pricing/README.md | 12 +- scripts/update-model-pricing.js | 18 ++- src/routes/openaiClaudeRoutes.js | 19 ++- src/services/pricingService.js | 149 +++++++++++++++++++++-- 6 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/sync-model-pricing.yml create mode 100644 config/pricingSource.js diff --git a/.github/workflows/sync-model-pricing.yml b/.github/workflows/sync-model-pricing.yml new file mode 100644 index 00000000..d7a46c6d --- /dev/null +++ b/.github/workflows/sync-model-pricing.yml @@ -0,0 +1,62 @@ +name: 同步模型价格数据 + +on: + schedule: + - cron: '*/10 * * * *' + workflow_dispatch: {} + +jobs: + sync-pricing: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: 检出 price-mirror 分支 + uses: actions/checkout@v4 + with: + ref: price-mirror + fetch-depth: 0 + + - name: 下载上游价格文件 + id: fetch + run: | + set -euo pipefail + curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json \ + -o model_prices_and_context_window.json.new + + NEW_HASH=$(sha256sum model_prices_and_context_window.json.new | awk '{print $1}') + + if [ -f model_prices_and_context_window.sha256 ]; then + OLD_HASH=$(cat model_prices_and_context_window.sha256 | tr -d ' \n\r') + else + OLD_HASH="" + fi + + if [ "$NEW_HASH" = "$OLD_HASH" ]; then + echo "价格文件无变化,跳过提交" + echo "changed=false" >> "$GITHUB_OUTPUT" + rm -f model_prices_and_context_window.json.new + exit 0 + fi + + mv model_prices_and_context_window.json.new model_prices_and_context_window.json + echo "$NEW_HASH" > model_prices_and_context_window.sha256 + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "hash=$NEW_HASH" >> "$GITHUB_OUTPUT" + + - name: 提交并推送变更 + if: steps.fetch.outputs.changed == 'true' + env: + NEW_HASH: ${{ steps.fetch.outputs.hash }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add model_prices_and_context_window.json model_prices_and_context_window.sha256 + COMMIT_MSG="chore: 同步模型价格数据" + if [ -n "${NEW_HASH}" ]; then + COMMIT_MSG="$COMMIT_MSG (${NEW_HASH})" + fi + git commit -m "$COMMIT_MSG" + git push origin price-mirror diff --git a/config/pricingSource.js b/config/pricingSource.js new file mode 100644 index 00000000..84e4d523 --- /dev/null +++ b/config/pricingSource.js @@ -0,0 +1,17 @@ +const repository = + process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service' +const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror' +const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json' +const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256' + +const baseUrl = process.env.PRICE_MIRROR_BASE_URL + ? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '') + : `https://raw.githubusercontent.com/${repository}/${branch}` + +module.exports = { + pricingFileName, + hashFileName, + pricingUrl: + process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`, + hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}` +} diff --git a/resources/model-pricing/README.md b/resources/model-pricing/README.md index e6cfac4e..d755de73 100644 --- a/resources/model-pricing/README.md +++ b/resources/model-pricing/README.md @@ -1,11 +1,11 @@ # Model Pricing Data -This directory contains a local copy of the LiteLLM model pricing data as a fallback mechanism. +This directory contains a local copy of the mirrored model pricing data as a fallback mechanism. ## Source -The original file is maintained by the LiteLLM project: -- Repository: https://github.com/BerriAI/litellm -- File: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json +The original file is maintained by the LiteLLM project and mirrored into the `price-mirror` branch of this repository via GitHub Actions: +- Mirror branch (configurable via `PRICE_MIRROR_REPO`): https://raw.githubusercontent.com//price-mirror/model_prices_and_context_window.json +- Upstream source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json ## Purpose This local copy serves as a fallback when the remote file cannot be downloaded due to: @@ -22,7 +22,7 @@ The pricingService will: 3. Log a warning when using the fallback file ## Manual Update -To manually update this file with the latest pricing data: +To manually update this file with the latest pricing data (if automation is unavailable): ```bash curl -s https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -o model_prices_and_context_window.json ``` @@ -34,4 +34,4 @@ The file contains JSON data with model pricing information including: - Context window sizes - Model capabilities -Last updated: 2025-08-10 \ No newline at end of file +Last updated: 2025-08-10 diff --git a/scripts/update-model-pricing.js b/scripts/update-model-pricing.js index 0b1e2323..3e670f24 100644 --- a/scripts/update-model-pricing.js +++ b/scripts/update-model-pricing.js @@ -2,12 +2,14 @@ /** * 手动更新模型价格数据脚本 - * 从 LiteLLM 仓库下载最新的模型价格和上下文窗口信息 + * 从价格镜像分支下载最新的模型价格和上下文窗口信息 */ const fs = require('fs') const path = require('path') const https = require('https') +const crypto = require('crypto') +const pricingSource = require('../config/pricingSource') // 颜色输出 const colors = { @@ -32,8 +34,8 @@ const log = { const config = { dataDir: path.join(process.cwd(), 'data'), pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'), - pricingUrl: - 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json', + hashFile: path.join(process.cwd(), 'data', 'model_pricing.sha256'), + pricingUrl: pricingSource.pricingUrl, fallbackFile: path.join( process.cwd(), 'resources', @@ -85,8 +87,8 @@ function restoreBackup() { // 下载价格数据 function downloadPricingData() { return new Promise((resolve, reject) => { - log.info('Downloading model pricing data from LiteLLM...') - log.info(`URL: ${config.pricingUrl}`) + log.info('正在从价格镜像分支拉取最新的模型价格数据...') + log.info(`拉取地址: ${config.pricingUrl}`) const request = https.get(config.pricingUrl, (response) => { if (response.statusCode !== 200) { @@ -115,7 +117,11 @@ function downloadPricingData() { } // 保存到文件 - fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2)) + const formattedJson = JSON.stringify(jsonData, null, 2) + fs.writeFileSync(config.pricingFile, formattedJson) + + const hash = crypto.createHash('sha256').update(formattedJson).digest('hex') + fs.writeFileSync(config.hashFile, `${hash}\n`) const modelCount = Object.keys(jsonData).length const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024) diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index 87a9d38f..3492168c 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -17,11 +17,26 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const sessionHelper = require('../utils/sessionHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const dataDir = path.join(__dirname, '../../data') +const localPricingPath = path.join(dataDir, 'model_pricing.json') +const fallbackPricingPath = path.join( + __dirname, + '../../resources/model-pricing/model_prices_and_context_window.json' +) + // 加载模型定价数据 let modelPricingData = {} try { - const pricingPath = path.join(__dirname, '../../data/model_pricing.json') - const pricingContent = fs.readFileSync(pricingPath, 'utf8') + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }) + } + + if (!fs.existsSync(localPricingPath) && fs.existsSync(fallbackPricingPath)) { + fs.copyFileSync(fallbackPricingPath, localPricingPath) + logger.warn('⚠️ 未找到 data/model_pricing.json,已使用备用价格文件初始化') + } + + const pricingContent = fs.readFileSync(localPricingPath, 'utf8') modelPricingData = JSON.parse(pricingContent) logger.info('✅ Model pricing data loaded successfully') } catch (error) { diff --git a/src/services/pricingService.js b/src/services/pricingService.js index 4f590580..c3f5ffef 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -1,25 +1,31 @@ const fs = require('fs') const path = require('path') const https = require('https') +const crypto = require('crypto') +const pricingSource = require('../../config/pricingSource') const logger = require('../utils/logger') class PricingService { constructor() { this.dataDir = path.join(process.cwd(), 'data') this.pricingFile = path.join(this.dataDir, 'model_pricing.json') - this.pricingUrl = - 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' + this.pricingUrl = pricingSource.pricingUrl + this.hashUrl = pricingSource.hashUrl this.fallbackFile = path.join( process.cwd(), 'resources', 'model-pricing', 'model_prices_and_context_window.json' ) + this.localHashFile = path.join(this.dataDir, 'model_pricing.sha256') this.pricingData = null this.lastUpdated = null this.updateInterval = 24 * 60 * 60 * 1000 // 24小时 + this.hashCheckInterval = 10 * 60 * 1000 // 10分钟哈希校验 this.fileWatcher = null // 文件监听器 this.reloadDebounceTimer = null // 防抖定时器 + this.hashCheckTimer = null // 哈希轮询定时器 + this.hashSyncInProgress = false // 哈希同步状态 // 硬编码的 1 小时缓存价格(美元/百万 token) // ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost @@ -81,11 +87,17 @@ class PricingService { // 检查是否需要下载或更新价格数据 await this.checkAndUpdatePricing() + // 初次启动时执行一次哈希校验,确保与远端保持一致 + await this.syncWithRemoteHash() + // 设置定时更新 setInterval(() => { this.checkAndUpdatePricing() }, this.updateInterval) + // 设置哈希轮询 + this.setupHashCheck() + // 设置文件监听器 this.setupFileWatcher() @@ -145,12 +157,58 @@ class PricingService { } } - // 实际的下载逻辑 - _downloadFromRemote() { + // 哈希轮询设置 + setupHashCheck() { + if (this.hashCheckTimer) { + clearInterval(this.hashCheckTimer) + } + + this.hashCheckTimer = setInterval(() => { + this.syncWithRemoteHash() + }, this.hashCheckInterval) + + logger.info('🕒 已启用价格文件哈希轮询(每10分钟校验一次)') + } + + // 与远端哈希对比 + async syncWithRemoteHash() { + if (this.hashSyncInProgress) { + return + } + + this.hashSyncInProgress = true + try { + const remoteHash = await this.fetchRemoteHash() + + if (!remoteHash) { + return + } + + const localHash = this.computeLocalHash() + + if (!localHash) { + logger.info('📄 本地价格文件缺失,尝试下载最新版本') + await this.downloadPricingData() + return + } + + if (remoteHash !== localHash) { + logger.info('🔁 检测到远端价格文件更新,开始下载最新数据') + await this.downloadPricingData() + } + } catch (error) { + logger.warn(`⚠️ 哈希校验失败:${error.message}`) + } finally { + this.hashSyncInProgress = false + } + } + + // 获取远端哈希值 + fetchRemoteHash() { return new Promise((resolve, reject) => { - const request = https.get(this.pricingUrl, (response) => { + const request = https.get(this.hashUrl, (response) => { if (response.statusCode !== 200) { - reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) + reject(new Error(`哈希文件获取失败:HTTP ${response.statusCode}`)) return } @@ -160,11 +218,77 @@ class PricingService { }) response.on('end', () => { - try { - const jsonData = JSON.parse(data) + const hash = data.trim().split(/\s+/)[0] - // 保存到文件 - fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2)) + if (!hash) { + reject(new Error('哈希文件内容为空')) + return + } + + resolve(hash) + }) + }) + + request.on('error', (error) => { + reject(new Error(`网络错误:${error.message}`)) + }) + + request.setTimeout(30000, () => { + request.destroy() + reject(new Error('获取哈希超时(30秒)')) + }) + }) + } + + // 计算本地文件哈希 + computeLocalHash() { + if (!fs.existsSync(this.pricingFile)) { + return null + } + + if (fs.existsSync(this.localHashFile)) { + const cached = fs.readFileSync(this.localHashFile, 'utf8').trim() + if (cached) { + return cached + } + } + + const fileBuffer = fs.readFileSync(this.pricingFile) + return this.persistLocalHash(fileBuffer) + } + + // 写入本地哈希文件 + persistLocalHash(content) { + const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8') + const hash = crypto.createHash('sha256').update(buffer).digest('hex') + fs.writeFileSync(this.localHashFile, `${hash}\n`) + return hash + } + + // 实际的下载逻辑 + _downloadFromRemote() { + return new Promise((resolve, reject) => { + const request = https.get(this.pricingUrl, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) + return + } + + const chunks = [] + response.on('data', (chunk) => { + const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + chunks.push(bufferChunk) + }) + + response.on('end', () => { + try { + const buffer = Buffer.concat(chunks) + const rawContent = buffer.toString('utf8') + const jsonData = JSON.parse(rawContent) + + // 保存到文件并更新哈希 + fs.writeFileSync(this.pricingFile, rawContent) + this.persistLocalHash(buffer) // 更新内存中的数据 this.pricingData = jsonData @@ -226,8 +350,11 @@ class PricingService { const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8') const jsonData = JSON.parse(fallbackData) + const formattedJson = JSON.stringify(jsonData, null, 2) + // 保存到data目录 - fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2)) + fs.writeFileSync(this.pricingFile, formattedJson) + this.persistLocalHash(formattedJson) // 更新内存中的数据 this.pricingData = jsonData