From ed1e6e92d16ea78408f3706da190d41ef9a3c14f Mon Sep 17 00:00:00 2001 From: Bluice Zhen Date: Thu, 14 May 2026 20:03:14 +0800 Subject: [PATCH] fix: support Claude anthropic model discovery fallback Allow Claude sites configured under an /anthropic path to fall back to the parent OpenAI-compatible models endpoint so model refresh works without changing chat routing. Co-Authored-By: Claude Opus 4.7 --- src/server/services/platforms/claude.test.ts | 96 ++++++++++++++++++++ src/server/services/platforms/claude.ts | 32 +++++-- 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 src/server/services/platforms/claude.test.ts diff --git a/src/server/services/platforms/claude.test.ts b/src/server/services/platforms/claude.test.ts new file mode 100644 index 00000000..444c7ee7 --- /dev/null +++ b/src/server/services/platforms/claude.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { ClaudeAdapter } from './claude.js'; + +vi.mock('../siteProxy.js', () => ({ + withSiteProxyRequestInit: (_url: string, options: unknown) => options, +})); + +describe('ClaudeAdapter', () => { + let server: ReturnType | undefined; + let baseUrl: string; + const requests: Array<{ url: string | undefined; headers: IncomingMessage['headers'] }> = []; + + afterEach(async () => { + requests.length = 0; + if (server) { + const s = server; + server = undefined; + await new Promise((resolve, reject) => { + s.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } + }); + + function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) { + return new Promise((resolve) => { + server = createServer((req, res) => { + requests.push({ url: req.url, headers: req.headers }); + handler(req, res); + }); + server.listen(0, '127.0.0.1', () => { + const addr = server!.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); + } + + it('reads models from the configured Claude models endpoint', async () => { + await startServer((req, res) => { + if (req.url === '/anthropic/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: [{ id: 'claude-sonnet-test' }] })); + return; + } + res.writeHead(404).end(); + }); + + const adapter = new ClaudeAdapter(); + const models = await adapter.getModels(`${baseUrl}/anthropic`, 'tp-test'); + + expect(models).toEqual(['claude-sonnet-test']); + expect(requests).toHaveLength(1); + expect(requests[0].headers['x-api-key']).toBe('tp-test'); + expect(requests[0].headers.authorization).toBeUndefined(); + }); + + it('falls back from /anthropic to the parent OpenAI-compatible models endpoint', async () => { + await startServer((req, res) => { + if (req.url === '/anthropic/v1/models') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + return; + } + if (req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: [{ id: 'mimo-v2.5' }, { id: 'mimo-v2.5-pro' }] })); + return; + } + res.writeHead(404).end(); + }); + + const adapter = new ClaudeAdapter(); + const models = await adapter.getModels(`${baseUrl}/anthropic`, 'tp-test'); + + expect(models).toEqual(['mimo-v2.5', 'mimo-v2.5-pro']); + expect(requests.map((request) => request.url)).toEqual(['/anthropic/v1/models', '/v1/models']); + expect(requests[0].headers['x-api-key']).toBe('tp-test'); + expect(requests[1].headers.authorization).toBe('Bearer tp-test'); + expect(requests[1].headers['x-api-key']).toBeUndefined(); + }); + + it('does not fall back for non-anthropic base urls', async () => { + await startServer((_req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + + const adapter = new ClaudeAdapter(); + const models = await adapter.getModels(baseUrl, 'tp-test'); + + expect(models).toEqual([]); + expect(requests.map((request) => request.url)).toEqual(['/v1/models']); + }); +}); diff --git a/src/server/services/platforms/claude.ts b/src/server/services/platforms/claude.ts index d56a5282..f31925d0 100644 --- a/src/server/services/platforms/claude.ts +++ b/src/server/services/platforms/claude.ts @@ -1,5 +1,12 @@ import { StandardApiProviderAdapterBase } from './standardApiProvider.js'; -import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from '../oauth/claudeProvider.js'; + +const CLAUDE_DEFAULT_ANTHROPIC_VERSION = '2023-06-01'; + +function resolveOpenAiCompatibleBaseUrl(baseUrl: string): string | null { + const normalized = (baseUrl || '').trim().replace(/\/+$/, ''); + const match = normalized.match(/^(.*)\/anthropic$/i); + return match?.[1] || null; +} export class ClaudeAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'claude'; @@ -10,12 +17,25 @@ export class ClaudeAdapter extends StandardApiProviderAdapterBase { } async getModels(baseUrl: string, apiToken: string): Promise { + const openAiCompatibleBaseUrl = resolveOpenAiCompatibleBaseUrl(baseUrl); + try { + const claudeModels = await this.fetchModelsFromStandardEndpoint({ + baseUrl, + headers: { + 'x-api-key': apiToken, + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, + }, + }); + if (claudeModels.length > 0) return claudeModels; + } catch (error) { + if (!openAiCompatibleBaseUrl) throw error; + } + + if (!openAiCompatibleBaseUrl) return []; + return this.fetchModelsFromStandardEndpoint({ - baseUrl, - headers: { - 'x-api-key': apiToken, - 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, - }, + baseUrl: openAiCompatibleBaseUrl, + headers: { Authorization: `Bearer ${apiToken}` }, }); } }