diff --git a/VERSION b/VERSION index 3e3c866e..e58c88de 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.222 +1.1.227 diff --git a/scripts/test-official-models.js b/scripts/test-official-models.js new file mode 100644 index 00000000..d87953fa --- /dev/null +++ b/scripts/test-official-models.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * 官方模型版本识别测试 - 最终版 v2 + */ + +const { isOpus45OrNewer } = require('../src/utils/modelHelper') + +// 官方模型 +const officialModels = [ + { name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false }, + { name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false }, + { name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false }, + { name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true } +] + +// 非 Opus 模型 +const nonOpusModels = [ + { name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' }, + { name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' }, + { name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' }, + { name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' }, + { name: 'claude-3-haiku-20240307', desc: 'Haiku 3' }, + { name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' } +] + +// 其他格式测试 +const otherFormats = [ + { name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' }, + { name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' }, + { name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' }, + { name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' }, + { name: 'opus-latest', expected: true, desc: 'Opus latest' }, + { name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' }, + { name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' }, + { name: 'opus-4.0', expected: false, desc: 'Opus 4.0' }, + { name: 'opus-4.1', expected: false, desc: 'Opus 4.1' }, + { name: 'opus-4.4', expected: false, desc: 'Opus 4.4' }, + { name: 'opus-4', expected: false, desc: 'Opus 4' }, + { name: 'opus-4-0', expected: false, desc: 'Opus 4-0' }, + { name: 'opus-4-1', expected: false, desc: 'Opus 4-1' }, + { name: 'opus-4-4', expected: false, desc: 'Opus 4-4' }, + { name: 'opus', expected: false, desc: '仅 opus' }, + { name: null, expected: false, desc: 'null' }, + { name: '', expected: false, desc: '空字符串' } +] + +console.log('='.repeat(90)) +console.log('官方模型版本识别测试 - 最终版 v2') +console.log('='.repeat(90)) +console.log() + +let passed = 0 +let failed = 0 + +// 测试官方 Opus 模型 +console.log('📌 官方 Opus 模型:') +for (const m of officialModels) { + const result = isOpus45OrNewer(m.name) + const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL' + if (result === m.expectPro) { + passed++ + } else { + failed++ + } + const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌' + console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`) +} + +console.log() +console.log('📌 非 Opus 模型 (不受此函数影响):') +for (const m of nonOpusModels) { + const result = isOpus45OrNewer(m.name) + console.log( + ` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}` + ) + if (result) { + failed++ // 非 Opus 模型不应返回 true + } +} + +console.log() +console.log('📌 其他格式测试:') +for (const m of otherFormats) { + const result = isOpus45OrNewer(m.name) + const status = result === m.expected ? '✅ PASS' : '❌ FAIL' + if (result === m.expected) { + passed++ + } else { + failed++ + } + const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name + console.log( + ` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}` + ) +} + +console.log() +console.log('='.repeat(90)) +console.log('测试结果:', passed, '通过,', failed, '失败') +console.log('='.repeat(90)) + +if (failed > 0) { + console.log('\n❌ 有测试失败,请检查函数逻辑') + process.exit(1) +} else { + console.log('\n✅ 所有测试通过!函数可以安全使用') + process.exit(0) +} diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 29a7821e..77630364 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -16,6 +16,22 @@ const { const tokenRefreshService = require('./tokenRefreshService') const LRUCache = require('../utils/lruCache') const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper') +const { isOpus45OrNewer } = require('../utils/modelHelper') + +/** + * Check if account is Pro (not Max) + * Compatible with both API real-time data (hasClaudePro) and local config (accountType) + * @param {Object} info - Subscription info object + * @returns {boolean} + */ +function isProAccount(info) { + // API real-time status takes priority + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return true + } + // Local configured account type + return info.accountType === 'claude_pro' +} class ClaudeAccountService { constructor() { @@ -852,31 +868,39 @@ class ClaudeAccountService { !this.isSubscriptionExpired(account) ) - // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + // Filter Opus models based on account type and model version if (modelName && modelName.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(modelName) + activeAccounts = activeAccounts.filter((account) => { - // 检查账号的订阅信息 if (account.subscriptionInfo) { try { const info = JSON.parse(account.subscriptionInfo) - // Pro 和 Free 账号不支持 Opus - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - return false // Claude Pro 不支持 Opus + + // Free account: does not support any Opus model + if (info.accountType === 'free') { + return false } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - return false // 明确标记为 Pro 或 Free 的账号不支持 + + // Pro account: only supports Opus 4.5+ + if (isProAccount(info)) { + return isNewOpus } + + // Max account: supports all Opus versions + return true } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // Parse failed, assume legacy data (Max), default support return true } } - // 没有订阅信息的账号,默认当作支持(兼容旧数据) + // Account without subscription info, default to supported (legacy data compatibility) return true }) if (activeAccounts.length === 0) { - throw new Error('No Claude accounts available that support Opus model') + const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)' + throw new Error(`No Claude accounts available that support ${modelDesc} model`) } } @@ -970,31 +994,39 @@ class ClaudeAccountService { !this.isSubscriptionExpired(account) ) - // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + // Filter Opus models based on account type and model version if (modelName && modelName.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(modelName) + sharedAccounts = sharedAccounts.filter((account) => { - // 检查账号的订阅信息 if (account.subscriptionInfo) { try { const info = JSON.parse(account.subscriptionInfo) - // Pro 和 Free 账号不支持 Opus - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - return false // Claude Pro 不支持 Opus + + // Free account: does not support any Opus model + if (info.accountType === 'free') { + return false } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - return false // 明确标记为 Pro 或 Free 的账号不支持 + + // Pro account: only supports Opus 4.5+ + if (isProAccount(info)) { + return isNewOpus } + + // Max account: supports all Opus versions + return true } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // Parse failed, assume legacy data (Max), default support return true } } - // 没有订阅信息的账号,默认当作支持(兼容旧数据) + // Account without subscription info, default to supported (legacy data compatibility) return true }) if (sharedAccounts.length === 0) { - throw new Error('No shared Claude accounts available that support Opus model') + const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)' + throw new Error(`No shared Claude accounts available that support ${modelDesc} model`) } } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 54446ec7..aac79971 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -5,7 +5,33 @@ const ccrAccountService = require('./ccrAccountService') const accountGroupService = require('./accountGroupService') const redis = require('../models/redis') const logger = require('../utils/logger') -const { parseVendorPrefixedModel } = require('../utils/modelHelper') +const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper') + +/** + * Check if account is Pro (not Max) + * + * ACCOUNT TYPE LOGIC (as of 2025-12-05): + * Pro accounts can be identified by either: + * 1. API real-time data: hasClaudePro=true && hasClaudeMax=false + * 2. Local config data: accountType='claude_pro' + * + * Account type restrictions for Opus models: + * - Free account: No Opus access at all + * - Pro account: Only Opus 4.5+ (new versions) + * - Max account: All Opus versions (legacy 3.x, 4.0, 4.1 and new 4.5+) + * + * Compatible with both API real-time data (hasClaudePro) and local config (accountType) + * @param {Object} info - Subscription info object + * @returns {boolean} - true if Pro account (not Free, not Max) + */ +function isProAccount(info) { + // API real-time status takes priority + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return true + } + // Local configured account type + return info.accountType === 'claude_pro' +} class UnifiedClaudeScheduler { constructor() { @@ -46,8 +72,14 @@ class UnifiedClaudeScheduler { return false } - // 2. Opus 模型的订阅级别检查 + // 2. Opus model subscription level check + // VERSION RESTRICTION LOGIC: + // - Free: No Opus models + // - Pro: Only Opus 4.5+ (isOpus45OrNewer = true) + // - Max: All Opus versions if (requestedModel.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(requestedModel) + if (account.subscriptionInfo) { try { const info = @@ -55,27 +87,36 @@ class UnifiedClaudeScheduler { ? JSON.parse(account.subscriptionInfo) : account.subscriptionInfo - // Pro 和 Free 账号不支持 Opus - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + // Free account: does not support any Opus model + if (info.accountType === 'free') { logger.info( - `🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}` + `🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}` ) return false } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - logger.info( - `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}` - ) - return false + + // Pro account: only supports Opus 4.5+ + // Reject legacy Opus (3.x, 4.0-4.4) but allow new Opus (4.5+) + if (isProAccount(info)) { + if (!isNewOpus) { + logger.info( + `🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}` + ) + return false + } + // Opus 4.5+ supported + return true } + + // Max account: supports all Opus versions (no restriction) } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // Parse failed, assume legacy data (Max), default support logger.debug( `Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max` ) } } - // 没有订阅信息的账号,默认当作支持(兼容旧数据) + // Account without subscription info, default to supported (legacy data compatibility) } } diff --git a/src/utils/modelHelper.js b/src/utils/modelHelper.js index cc954cc2..a42ee317 100644 --- a/src/utils/modelHelper.js +++ b/src/utils/modelHelper.js @@ -70,9 +70,119 @@ function getVendorType(modelStr) { return vendor } +/** + * Check if the model is Opus 4.5 or newer. + * + * VERSION LOGIC (as of 2025-12-05): + * - Opus 4.5+ (including 5.0, 6.0, etc.) → returns true (Pro account eligible) + * - Opus 4.4 and below (including 3.x, 4.0, 4.1) → returns false (Max account only) + * + * Supported naming formats: + * - New format: claude-opus-{major}[-{minor}][-date], e.g., claude-opus-4-5-20251101 + * - New format: claude-opus-{major}.{minor}, e.g., claude-opus-4.5 + * - Old format: claude-{version}-opus[-date], e.g., claude-3-opus-20240229 + * - Special: opus-latest, claude-opus-latest → always returns true + * + * @param {string} modelName - Model name + * @returns {boolean} - Whether the model is Opus 4.5 or newer + */ +function isOpus45OrNewer(modelName) { + if (!modelName) { + return false + } + + const lowerModel = modelName.toLowerCase() + if (!lowerModel.includes('opus')) { + return false + } + + // Handle 'latest' special case + if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) { + return true + } + + // Old format: claude-{version}-opus (version before opus) + // e.g., claude-3-opus-20240229, claude-3.5-opus + const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/) + if (oldFormatMatch) { + const majorVersion = parseInt(oldFormatMatch[1], 10) + const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0 + + // Old format version refers to Claude major version + // majorVersion > 4: 5.x, 6.x, ... → true + // majorVersion === 4 && minorVersion >= 5: 4.5, 4.6, ... → true + // Others (3.x, 4.0-4.4): → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // New format 1: opus-{major}.{minor} (dot-separated) + // e.g., claude-opus-4.5, opus-4.5 + const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/) + if (dotFormatMatch) { + const majorVersion = parseInt(dotFormatMatch[1], 10) + const minorVersion = parseInt(dotFormatMatch[2], 10) + + // Same version logic as old format + // opus-5.0, opus-6.0 → true + // opus-4.5, opus-4.6 → true + // opus-4.0, opus-4.4 → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // New format 2: opus-{major}[-{minor}][-date] (hyphen-separated) + // e.g., claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805 + // If opus-{major} is followed by 8-digit date, there's no minor version + + // Extract content after 'opus' + const opusIndex = lowerModel.indexOf('opus') + const afterOpus = lowerModel.substring(opusIndex + 4) + + // Match: -{major}-{minor}-{date} or -{major}-{date} or -{major} + // IMPORTANT: Minor version regex is (\d{1,2}) not (\d+) + // This prevents matching 8-digit dates as minor version + // Example: opus-4-20250514 → major=4, minor=undefined (not 20250514) + // Example: opus-4-5-20251101 → major=4, minor=5 + // Future-proof: Supports up to 2-digit minor versions (0-99) + const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d{1,2})(?=[- ]\d{8}|$))?/) + + if (versionMatch) { + const majorVersion = parseInt(versionMatch[1], 10) + const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0 + + // Same version logic: >= 4.5 returns true + // opus-5-0-date, opus-6-date → true + // opus-4-5-date, opus-4-10-date → true (supports 2-digit minor) + // opus-4-date (no minor, treated as 4.0) → false + // opus-4-1-date, opus-4-4-date → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // Other cases containing 'opus' but cannot parse version, assume legacy + return false +} + module.exports = { parseVendorPrefixedModel, hasVendorPrefix, getEffectiveModel, - getVendorType + getVendorType, + isOpus45OrNewer }