refactor: consolidate single-source routing and protocol helpers
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user