diff --git a/config/models.js b/config/models.js index 16742bee..8391ff4f 100644 --- a/config/models.js +++ b/config/models.js @@ -5,6 +5,7 @@ const CLAUDE_MODELS = [ { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, { value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' }, { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' }, diff --git a/src/routes/admin/claudeConsoleAccounts.js b/src/routes/admin/claudeConsoleAccounts.js index 2f13f030..b907d3e6 100644 --- a/src/routes/admin/claudeConsoleAccounts.js +++ b/src/routes/admin/claudeConsoleAccounts.js @@ -485,10 +485,15 @@ router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async // 测试Claude Console账户连通性(流式响应)- 复用 claudeConsoleRelayService router.post('/claude-console-accounts/:accountId/test', authenticateAdmin, async (req, res) => { const { accountId } = req.params + const model = typeof req.body?.model === 'string' ? req.body.model.trim() : '' + + if (!model) { + return res.status(400).json({ error: 'model is required' }) + } try { // 直接调用服务层的测试方法 - await claudeConsoleRelayService.testAccountConnection(accountId, res) + await claudeConsoleRelayService.testAccountConnection(accountId, res, model) } catch (error) { logger.error(`❌ Failed to test Claude Console account:`, error) // 错误已在服务层处理,这里仅做日志记录 diff --git a/src/services/relay/claudeConsoleRelayService.js b/src/services/relay/claudeConsoleRelayService.js index d7dcabc1..2b8be5ce 100644 --- a/src/services/relay/claudeConsoleRelayService.js +++ b/src/services/relay/claudeConsoleRelayService.js @@ -1455,8 +1455,11 @@ class ClaudeConsoleRelayService { } // 🧪 测试账号连接(供Admin API使用) - async testAccountConnection(accountId, responseStream) { - const { sendStreamTestRequest } = require('../../utils/testPayloadHelper') + async testAccountConnection(accountId, responseStream, model) { + const { + createClaudeTestPayload, + sendStreamTestRequest + } = require('../../utils/testPayloadHelper') try { const account = await claudeConsoleAccountService.getAccount(accountId) @@ -1470,14 +1473,24 @@ class ClaudeConsoleRelayService { const apiUrl = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages?beta=true` + const payload = createClaudeTestPayload(model, { stream: true }) - await sendStreamTestRequest({ + const extraHeaders = account.userAgent ? { 'User-Agent': account.userAgent } : {} + const requestOptions = { apiUrl, - authorization: `Bearer ${account.apiKey}`, responseStream, + payload, proxyAgent: claudeConsoleAccountService._createProxyAgent(account.proxy), - extraHeaders: account.userAgent ? { 'User-Agent': account.userAgent } : {} - }) + extraHeaders + } + + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + requestOptions.extraHeaders['x-api-key'] = account.apiKey + } else { + requestOptions.authorization = `Bearer ${account.apiKey}` + } + + await sendStreamTestRequest(requestOptions) } catch (error) { logger.error(`❌ Test account connection failed:`, error) if (!responseStream.headersSent) { diff --git a/src/utils/testPayloadHelper.js b/src/utils/testPayloadHelper.js index 4a2187b5..66df8305 100644 --- a/src/utils/testPayloadHelper.js +++ b/src/utils/testPayloadHelper.js @@ -146,7 +146,7 @@ async function sendStreamTestRequest(options) { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'User-Agent': 'claude-cli/2.0.52 (external, cli)', - authorization, + ...(authorization ? { authorization } : {}), ...extraHeaders }, timeout, diff --git a/tests/claudeConsoleAccounts.test.js b/tests/claudeConsoleAccounts.test.js new file mode 100644 index 00000000..8b9cb52b --- /dev/null +++ b/tests/claudeConsoleAccounts.test.js @@ -0,0 +1,73 @@ +const express = require('express') +const request = require('supertest') + +jest.mock('../src/middleware/auth', () => ({ + authenticateAdmin: (req, res, next) => next() +})) + +jest.mock('../src/services/relay/claudeConsoleRelayService', () => ({ + testAccountConnection: jest.fn(async (accountId, res) => + res.status(200).json({ success: true, accountId }) + ) +})) + +jest.mock('../src/services/account/claudeConsoleAccountService', () => ({})) +jest.mock('../src/services/accountGroupService', () => ({})) +jest.mock('../src/services/apiKeyService', () => ({})) +jest.mock('../src/models/redis', () => ({})) +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() +})) +jest.mock('../src/utils/webhookNotifier', () => ({})) +jest.mock('../src/routes/admin/utils', () => ({ + formatAccountExpiry: jest.fn((account) => account), + mapExpiryField: jest.fn((updates) => updates) +})) + +const claudeConsoleRelayService = require('../src/services/relay/claudeConsoleRelayService') +const claudeConsoleAccountsRouter = require('../src/routes/admin/claudeConsoleAccounts') + +describe('POST /admin/claude-console-accounts/:accountId/test', () => { + const buildApp = () => { + const app = express() + app.use(express.json()) + app.use('/admin', claudeConsoleAccountsRouter) + return app + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns 400 when model is missing', async () => { + const app = buildApp() + + const response = await request(app) + .post('/admin/claude-console-accounts/account-1/test') + .send({}) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'model is required' }) + expect(claudeConsoleRelayService.testAccountConnection).not.toHaveBeenCalled() + }) + + it('passes model through to relay service when provided', async () => { + const app = buildApp() + + const response = await request(app) + .post('/admin/claude-console-accounts/account-1/test') + .send({ model: 'claude-sonnet-4-6' }) + + expect(response.status).toBe(200) + expect(claudeConsoleRelayService.testAccountConnection).toHaveBeenCalledTimes(1) + expect(claudeConsoleRelayService.testAccountConnection).toHaveBeenCalledWith( + 'account-1', + expect.any(Object), + 'claude-sonnet-4-6' + ) + }) +}) diff --git a/tests/claudeConsoleRelayService.test.js b/tests/claudeConsoleRelayService.test.js new file mode 100644 index 00000000..f81ad8a6 --- /dev/null +++ b/tests/claudeConsoleRelayService.test.js @@ -0,0 +1,93 @@ +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +})) + +jest.mock('../src/services/account/claudeConsoleAccountService', () => ({ + getAccount: jest.fn(), + _createProxyAgent: jest.fn() +})) + +jest.mock('../config/config', () => ({}), { + virtual: true +}) +jest.mock('../src/models/redis', () => ({})) + +jest.mock('../src/utils/testPayloadHelper', () => ({ + createClaudeTestPayload: jest.fn(), + sendStreamTestRequest: jest.fn() +})) + +const claudeConsoleRelayService = require('../src/services/relay/claudeConsoleRelayService') +const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService') +const { createClaudeTestPayload, sendStreamTestRequest } = require('../src/utils/testPayloadHelper') + +describe('claudeConsoleRelayService.testAccountConnection', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('passes selected model stream payload and bearer auth for non sk-ant key', async () => { + claudeConsoleAccountService.getAccount.mockResolvedValue({ + name: 'Console A1', + apiUrl: 'https://console.example.com', + apiKey: 'test-key', + proxy: null, + userAgent: null + }) + claudeConsoleAccountService._createProxyAgent.mockReturnValue(undefined) + + const payload = { + model: 'claude-sonnet-4-6', + stream: true + } + createClaudeTestPayload.mockReturnValue(payload) + sendStreamTestRequest.mockResolvedValue(undefined) + + const res = {} + await claudeConsoleRelayService.testAccountConnection('a1', res, 'claude-sonnet-4-6') + + expect(createClaudeTestPayload).toHaveBeenCalledWith('claude-sonnet-4-6', { stream: true }) + expect(sendStreamTestRequest).toHaveBeenCalledWith( + expect.objectContaining({ + payload, + authorization: 'Bearer test-key' + }) + ) + }) + + it('passes selected model stream payload and x-api-key for sk-ant key', async () => { + claudeConsoleAccountService.getAccount.mockResolvedValue({ + name: 'Console A1', + apiUrl: 'https://console.example.com', + apiKey: 'sk-ant-test-key', + proxy: null, + userAgent: null + }) + claudeConsoleAccountService._createProxyAgent.mockReturnValue(undefined) + + const payload = { + model: 'claude-sonnet-4-6', + stream: true + } + createClaudeTestPayload.mockReturnValue(payload) + sendStreamTestRequest.mockResolvedValue(undefined) + + const res = {} + await claudeConsoleRelayService.testAccountConnection('a1', res, 'claude-sonnet-4-6') + + expect(createClaudeTestPayload).toHaveBeenCalledWith('claude-sonnet-4-6', { stream: true }) + const requestOptions = sendStreamTestRequest.mock.calls[0][0] + expect(requestOptions).toEqual( + expect.objectContaining({ + payload, + extraHeaders: expect.objectContaining({ + 'x-api-key': 'sk-ant-test-key' + }) + }) + ) + expect(requestOptions).not.toHaveProperty('authorization') + }) +}) diff --git a/tests/modelsConfig.test.js b/tests/modelsConfig.test.js new file mode 100644 index 00000000..0d3ce5ed --- /dev/null +++ b/tests/modelsConfig.test.js @@ -0,0 +1,10 @@ +const { CLAUDE_MODELS } = require('../config/models') + +describe('models config', () => { + it('places Claude Sonnet 4.6 as the second Claude model option', () => { + expect(CLAUDE_MODELS[1]).toEqual({ + value: 'claude-sonnet-4-6', + label: 'Claude Sonnet 4.6' + }) + }) +})