refactor: consolidate single-source routing and protocol helpers

This commit is contained in:
Cita
2026-03-22 21:27:09 +08:00
parent 96e7614835
commit 9290e51ecc
64 changed files with 3571 additions and 2681 deletions
+9 -42
View File
@@ -1,20 +1,8 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
import { StandardApiProviderAdapterBase } from './standardApiProvider.js';
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function resolveClaudeModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export class ClaudeAdapter extends BasePlatformAdapter {
export class ClaudeAdapter extends StandardApiProviderAdapterBase {
readonly platformName = 'claude';
async detect(url: string): Promise<boolean> {
@@ -22,34 +10,13 @@ export class ClaudeAdapter extends BasePlatformAdapter {
return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Official Anthropic API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(resolveClaudeModelsUrl(baseUrl), {
headers: {
'x-api-key': apiToken,
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
},
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
return this.fetchModelsFromStandardEndpoint({
baseUrl,
headers: {
'x-api-key': apiToken,
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
},
});
}
}
+14 -37
View File
@@ -1,30 +1,27 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
import {
StandardApiProviderAdapterBase,
normalizePlatformBaseUrl,
resolveVersionedModelsUrl,
} from './standardApiProvider.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
export class CliProxyApiAdapter extends BasePlatformAdapter {
export class CliProxyApiAdapter extends StandardApiProviderAdapterBase {
readonly platformName = 'cliproxyapi';
protected override loginUnsupportedMessage = 'CLIProxyAPI does not support login';
protected override checkinUnsupportedMessage = 'CLIProxyAPI does not support checkin';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
// Quick check: default CLIProxyAPI port
if (/:8317(\/|$)/.test(normalized)) {
return true;
}
// Quick check: common hostname keyword
if (normalized.includes('cliproxy')) {
return true;
}
// Probe management endpoint with strict signature checks.
// Do not trust bare 401/403 because many non-CLIProxy sites may return
// those statuses for unknown/protected paths.
try {
const base = normalizeBaseUrl(url);
const base = normalizePlatformBaseUrl(url);
const { fetch } = await import('undici');
const res = await fetch(`${base}/v0/management/openai-compatibility`, {
method: 'GET',
@@ -53,31 +50,11 @@ export class CliProxyApiAdapter extends BasePlatformAdapter {
}
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false as const, message: 'CLIProxyAPI does not support login' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'CLIProxyAPI does not support checkin' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const base = normalizeBaseUrl(baseUrl);
const res = await this.fetchJson<any>(`${base}/v1/models`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
return this.fetchModelsFromStandardEndpoint({
baseUrl,
headers: { Authorization: `Bearer ${apiToken}` },
resolveUrl: resolveVersionedModelsUrl,
});
}
}
+16 -40
View File
@@ -1,8 +1,4 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
import { StandardApiProviderAdapterBase, normalizePlatformBaseUrl } from './standardApiProvider.js';
function stripModelPrefix(name: string): string {
const trimmed = name.trim();
@@ -24,13 +20,13 @@ function isOpenAiCompatGeminiBase(baseUrl: string): boolean {
}
function resolveGeminiOpenAiModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = normalizePlatformBaseUrl(baseUrl);
if (/\/models$/i.test(normalized)) return normalized;
return `${normalized}/models`;
}
function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = normalizePlatformBaseUrl(baseUrl);
const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized)
? normalized
: `${normalized}/v1beta`;
@@ -41,7 +37,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string
return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`;
}
export class GeminiAdapter extends BasePlatformAdapter {
export class GeminiAdapter extends StandardApiProviderAdapterBase {
readonly platformName: string = 'gemini';
async detect(url: string): Promise<boolean> {
@@ -53,34 +49,16 @@ export class GeminiAdapter extends BasePlatformAdapter {
);
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Gemini API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
const normalizedBase = normalizeBaseUrl(baseUrl);
const normalizedBase = normalizePlatformBaseUrl(baseUrl);
if (isOpenAiCompatGeminiBase(normalizedBase)) {
try {
const res = await this.fetchJson<any>(resolveGeminiOpenAiModelsUrl(normalizedBase), {
headers: { Authorization: `Bearer ${apiToken}` },
});
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
} catch {}
const openAiModels = await this.fetchModelsFromStandardEndpoint({
baseUrl: normalizedBase,
headers: { Authorization: `Bearer ${apiToken}` },
resolveUrl: resolveGeminiOpenAiModelsUrl,
});
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
}
try {
@@ -92,13 +70,11 @@ export class GeminiAdapter extends BasePlatformAdapter {
} catch {}
if (!isOpenAiCompatGeminiBase(normalizedBase)) {
try {
const res = await this.fetchJson<any>(`${normalizedBase}/v1beta/openai/models`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
} catch {}
const openAiModels = await this.fetchModelsFromStandardEndpoint({
baseUrl: `${normalizedBase}/v1beta/openai`,
headers: { Authorization: `Bearer ${apiToken}` },
});
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
}
return [];
@@ -4,6 +4,7 @@ import { AddressInfo } from 'node:net';
import { OpenAiAdapter } from './openai.js';
import { ClaudeAdapter } from './claude.js';
import { GeminiAdapter } from './gemini.js';
import { CliProxyApiAdapter } from './cliproxyapi.js';
interface RequestSnapshot {
method: string;
@@ -52,6 +53,20 @@ describe('official llm upstream adapters', () => {
return;
}
if (req.url === '/cliproxy/v1/models') {
if (req.headers.authorization !== 'Bearer sk-cpa') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: 'gpt-5.4' }, { id: 'gpt-5.2-codex' }],
}));
return;
}
if (req.url?.startsWith('/gemini/v1beta/models')) {
const url = new URL(req.url, 'http://127.0.0.1');
if (url.searchParams.get('key') !== 'gemini-key') {
@@ -112,6 +127,12 @@ describe('official llm upstream adapters', () => {
expect(models).toEqual(['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001']);
});
it('fetches models from cliproxy openai-compatible upstream', async () => {
const adapter = new CliProxyApiAdapter();
const models = await adapter.getModels(`${baseUrl}/cliproxy`, 'sk-cpa');
expect(models).toEqual(['gpt-5.4', 'gpt-5.2-codex']);
});
it('fetches models from gemini native endpoint and normalizes model names', async () => {
const adapter = new GeminiAdapter();
const models = await adapter.getModels(`${baseUrl}/gemini/v1beta`, 'gemini-key');
+6 -39
View File
@@ -1,18 +1,6 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
import { StandardApiProviderAdapterBase } from './standardApiProvider.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function resolveOpenAiModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export class OpenAiAdapter extends BasePlatformAdapter {
export class OpenAiAdapter extends StandardApiProviderAdapterBase {
readonly platformName = 'openai';
async detect(url: string): Promise<boolean> {
@@ -20,31 +8,10 @@ export class OpenAiAdapter extends BasePlatformAdapter {
return normalized.includes('api.openai.com');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Official OpenAI API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(resolveOpenAiModelsUrl(baseUrl), {
headers: { Authorization: `Bearer ${apiToken}` },
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
return this.fetchModelsFromStandardEndpoint({
baseUrl,
headers: { Authorization: `Bearer ${apiToken}` },
});
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
StandardApiProviderAdapterBase,
normalizePlatformBaseUrl,
resolveVersionedModelsUrl,
} from './standardApiProvider.js';
class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase {
readonly platformName = 'test-standard';
async detect(_url: string): Promise<boolean> {
return false;
}
async getModels(_baseUrl: string, _token: string): Promise<string[]> {
return [];
}
}
describe('standardApiProvider helpers', () => {
it('normalizes provider base urls and appends /v1/models when needed', () => {
expect(normalizePlatformBaseUrl('https://api.example.com///')).toBe('https://api.example.com');
expect(resolveVersionedModelsUrl('https://api.example.com')).toBe('https://api.example.com/v1/models');
expect(resolveVersionedModelsUrl('https://api.example.com/v1')).toBe('https://api.example.com/v1/models');
expect(resolveVersionedModelsUrl('https://api.example.com/v1beta')).toBe('https://api.example.com/v1beta/models');
});
it('provides shared unsupported login/checkin and zero-balance defaults', async () => {
const adapter = new TestStandardApiProviderAdapter();
await expect(adapter.login('https://api.example.com', 'user', 'pass')).resolves.toEqual({
success: false,
message: 'login endpoint not supported',
});
await expect(adapter.getUserInfo('https://api.example.com', 'token')).resolves.toBe(null);
await expect(adapter.checkin('https://api.example.com', 'token')).resolves.toEqual({
success: false,
message: 'checkin endpoint not supported',
});
await expect(adapter.getBalance('https://api.example.com', 'token')).resolves.toEqual({
balance: 0,
used: 0,
quota: 0,
});
});
});
@@ -0,0 +1,73 @@
import {
BasePlatformAdapter,
type BalanceInfo,
type CheckinResult,
type UserInfo,
} from './base.js';
type FetchModelsOptions = {
baseUrl: string;
headers?: Record<string, string>;
resolveUrl?: (normalizedBaseUrl: string) => string;
mapResponse?: (payload: any) => unknown[];
};
export function normalizePlatformBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
export function resolveVersionedModelsUrl(baseUrl: string): string {
const normalized = normalizePlatformBaseUrl(baseUrl);
if (/\/v\d+(?:\.\d+)?(?:beta)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export abstract class StandardApiProviderAdapterBase extends BasePlatformAdapter {
protected loginUnsupportedMessage = 'login endpoint not supported';
protected checkinUnsupportedMessage = 'checkin endpoint not supported';
override async login(_baseUrl: string, _username: string, _password: string) {
return {
success: false as const,
message: this.loginUnsupportedMessage,
};
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
override async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return {
success: false,
message: this.checkinUnsupportedMessage,
};
}
override async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
return { balance: 0, used: 0, quota: 0 };
}
protected async fetchModelsFromStandardEndpoint(options: FetchModelsOptions): Promise<string[]> {
const normalizedBaseUrl = normalizePlatformBaseUrl(options.baseUrl);
const url = options.resolveUrl
? options.resolveUrl(normalizedBaseUrl)
: resolveVersionedModelsUrl(normalizedBaseUrl);
try {
const payload = await this.fetchJson<any>(url, {
headers: options.headers,
});
const rows = options.mapResponse
? options.mapResponse(payload)
: (payload?.data || []).map((item: any) => item?.id);
return rows
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
} catch {
return [];
}
}
}