Add platform adapters and tests for CLIProxyAPI, OneHub, Sub2API, and title hint detection

- Implemented `CliProxyApiAdapter` with detection logic for CLIProxyAPI.
- Added tests for `CliProxyApiAdapter` to verify detection and response handling.
- Created `OneHubAdapter` with methods for fetching models and user groups.
- Added tests for `OneHubAdapter` to ensure correct fallback and response parsing.
- Developed `Sub2ApiAdapter` with detection and user info fetching capabilities.
- Added comprehensive tests for `Sub2ApiAdapter` covering various scenarios.
- Introduced `TitleHintPlatform` type and detection logic based on HTML title.
- Implemented tests for title hint detection to identify platforms from URLs.
This commit is contained in:
ciat-777
2026-03-02 20:26:02 +08:00
parent 897273fad4
commit 487bd23a02
19 changed files with 2032 additions and 45 deletions
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown) => null);
vi.mock('../../services/modelPricingService.js', () => ({
fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg),
}));
import { resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js';
const baseContext = {
site: {
id: 1,
url: 'https://upstream.example.com',
platform: '',
apiKey: null,
},
account: {
id: 2,
accessToken: 'token-demo',
apiToken: null,
},
};
describe('resolveUpstreamEndpointCandidates', () => {
beforeEach(() => {
fetchModelPricingCatalogMock.mockReset();
fetchModelPricingCatalogMock.mockResolvedValue(null);
});
it('uses responses -> messages -> chat for unknown platforms', async () => {
const openaiOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(openaiOrder).toEqual(['responses', 'messages', 'chat']);
const claudeOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'claude',
);
expect(claudeOrder).toEqual(['responses', 'messages', 'chat']);
const responsesOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'responses',
);
expect(responsesOrder).toEqual(['responses', 'messages', 'chat']);
});
it('keeps explicit platform priority rules', async () => {
const openaiOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'openai' },
},
'gpt-5.3',
'openai',
);
expect(openaiOrder).toEqual(['chat', 'responses']);
const openaiResponsesOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'openai' },
},
'gpt-5.3',
'responses',
);
expect(openaiResponsesOrder).toEqual(['responses', 'chat']);
const claudeOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'claude' },
},
'claude-opus-4-6',
'claude',
);
expect(claudeOrder).toEqual(['messages']);
});
it('keeps anyrouter messages-first special case', async () => {
const openaiOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'anyrouter' },
},
'claude-opus-4-6',
'openai',
);
expect(openaiOrder).toEqual(['messages', 'chat']);
const responsesOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'anyrouter' },
},
'claude-opus-4-6',
'responses',
);
expect(responsesOrder).toEqual(['responses', 'messages', 'chat']);
});
});
+3 -7
View File
@@ -381,13 +381,9 @@ function preferredEndpointOrder(downstreamFormat: EndpointPreference, sitePlatfo
return ['messages'];
}
if (downstreamFormat === 'responses') {
return ['responses', 'chat', 'messages'];
}
return downstreamFormat === 'claude'
? ['messages', 'chat']
: ['chat', 'messages'];
// Unknown/generic upstreams: try the most expressive protocol first,
// then degrade to anthropic messages, and finally chat completions.
return ['responses', 'messages', 'chat'];
}
export async function resolveUpstreamEndpointCandidates(
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { CliProxyApiAdapter } from './cliproxyapi.js';
describe('CliProxyApiAdapter detect', () => {
let server: ReturnType<typeof createServer> | undefined;
let baseUrl = '';
let adapter: CliProxyApiAdapter;
beforeEach(() => {
adapter = new CliProxyApiAdapter();
});
afterEach(async () => {
if (!server) return;
const current = server;
server = undefined;
await new Promise<void>((resolve, reject) => {
current.close((err?: Error) => (err ? reject(err) : resolve()));
});
});
async function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) {
await new Promise<void>((resolve) => {
server = createServer(handler);
server.listen(0, '127.0.0.1', () => {
const address = server!.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${address.port}`;
resolve();
});
});
}
it('detects known default cliproxy port', async () => {
expect(await adapter.detect('http://127.0.0.1:8317')).toBe(true);
});
it('does not treat generic 401 response as cliproxy', async () => {
await startServer((req, res) => {
if (req.url === '/v0/management/openai-compatibility') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'unauthorized' }));
return;
}
res.writeHead(404).end();
});
expect(await adapter.detect(baseUrl)).toBe(false);
});
it('detects cliproxy management endpoint by X-CPA headers', async () => {
await startServer((req, res) => {
if (req.url === '/v0/management/openai-compatibility') {
res.writeHead(401, {
'Content-Type': 'application/json',
'X-CPA-VERSION': 'v0.0.0-test',
});
res.end(JSON.stringify({ error: 'missing management key' }));
return;
}
res.writeHead(404).end();
});
expect(await adapter.detect(baseUrl)).toBe(true);
});
it('detects open management endpoint by response payload key', async () => {
await startServer((req, res) => {
if (req.url === '/v0/management/openai-compatibility') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ 'openai-compatibility': [] }));
return;
}
res.writeHead(404).end();
});
expect(await adapter.detect(baseUrl)).toBe(true);
});
});
@@ -0,0 +1,83 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
export class CliProxyApiAdapter extends BasePlatformAdapter {
readonly platformName = 'cliproxyapi';
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 { fetch } = await import('undici');
const res = await fetch(`${base}/v0/management/openai-compatibility`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
const hasCpaHeaders = Boolean(
res.headers.get('x-cpa-version')
|| res.headers.get('x-cpa-commit')
|| res.headers.get('x-cpa-build-date'),
);
if (hasCpaHeaders) {
return res.status === 200 || res.status === 401 || res.status === 403;
}
if (res.status === 200) {
const payload = await res.json().catch(() => null);
if (payload && typeof payload === 'object') {
return Object.prototype.hasOwnProperty.call(payload, 'openai-compatibility');
}
}
return false;
} catch {
return false;
}
}
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 [];
}
}
}
+5 -24
View File
@@ -1,8 +1,8 @@
import { OneApiAdapter } from './oneApi.js';
import { OneHubAdapter } from './oneHub.js';
import type { CheckinResult } from './base.js';
export class DoneHubAdapter extends OneApiAdapter {
readonly platformName = 'done-hub';
export class DoneHubAdapter extends OneHubAdapter {
readonly platformName: string = 'done-hub';
async detect(url: string): Promise<boolean> {
const normalized = url.toLowerCase();
@@ -15,25 +15,6 @@ export class DoneHubAdapter extends OneApiAdapter {
return { success: false, message: 'checkin endpoint not found' };
}
override async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise<string[]> {
let openAiModels: string[] = [];
try {
openAiModels = await super.getModels(baseUrl, apiToken, _platformUserId);
} catch {}
if (openAiModels.length > 0) return openAiModels;
try {
const res = await this.fetchJson<any>(`${baseUrl}/api/available_model`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
const payload = (res?.data && typeof res.data === 'object') ? res.data : res;
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const models = Object.keys(payload).filter(Boolean);
if (models.length > 0) return models;
}
} catch {}
return [];
}
// getModels is inherited from OneHubAdapter which already has /api/available_model fallback.
// No need to override here — OneHub's implementation handles this correctly.
}
@@ -1,6 +1,27 @@
import { describe, expect, it } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { type AddressInfo } from 'node:net';
import { detectPlatform, getAdapter } from './index.js';
async function withHttpServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
run: (baseUrl: string) => Promise<void>,
) {
const server = createServer(handler);
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const { port } = server.address() as AddressInfo;
const baseUrl = `http://127.0.0.1:${port}`;
try {
await run(baseUrl);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => (err ? reject(err) : resolve()));
});
}
}
describe('getAdapter platform aliases', () => {
it('returns dedicated anyrouter adapter for anyrouter alias', () => {
const adapter = getAdapter('anyrouter');
@@ -41,4 +62,76 @@ describe('getAdapter platform aliases', () => {
expect(claude?.platformName).toBe('claude');
expect(gemini?.platformName).toBe('gemini');
});
it('detects one-hub by title under custom domain before generic new-api', async () => {
await withHttpServer((req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<html><head><title>One-Hub Console</title></head><body></body></html>');
return;
}
if (req.url === '/api/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: { system_name: 'New API' },
}));
return;
}
res.writeHead(404).end();
}, async (baseUrl) => {
const adapter = await detectPlatform(baseUrl);
expect(adapter?.platformName).toBe('one-hub');
});
});
it('detects done-hub by title under custom domain', async () => {
await withHttpServer((req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<html><head><title>Done-Hub Panel</title></head><body></body></html>');
return;
}
res.writeHead(404).end();
}, async (baseUrl) => {
const adapter = await detectPlatform(baseUrl);
expect(adapter?.platformName).toBe('done-hub');
});
});
it('detects veloera by title under custom domain before generic new-api', async () => {
await withHttpServer((req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<html><head><title>Veloera 管理台</title></head><body></body></html>');
return;
}
if (req.url === '/api/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: { system_name: 'new-api fork' },
}));
return;
}
res.writeHead(404).end();
}, async (baseUrl) => {
const adapter = await detectPlatform(baseUrl);
expect(adapter?.platformName).toBe('veloera');
});
});
it('falls back to new-api by title when api/status is unavailable', async () => {
await withHttpServer((req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<html><head><title>Super-API Dashboard</title></head><body></body></html>');
return;
}
res.writeHead(404).end();
}, async (baseUrl) => {
const adapter = await detectPlatform(baseUrl);
expect(adapter?.platformName).toBe('new-api');
});
});
});
+26 -1
View File
@@ -9,19 +9,22 @@ import { Sub2ApiAdapter } from './sub2api.js';
import { OpenAiAdapter } from './openai.js';
import { ClaudeAdapter } from './claude.js';
import { GeminiAdapter } from './gemini.js';
import { CliProxyApiAdapter } from './cliproxyapi.js';
import { detectPlatformByTitle } from './titleHint.js';
const adapters: PlatformAdapter[] = [
// Specific forks before generic adapters for better auto-detection.
new OpenAiAdapter(),
new ClaudeAdapter(),
new GeminiAdapter(),
new CliProxyApiAdapter(),
new AnyRouterAdapter(),
new DoneHubAdapter(),
new OneHubAdapter(),
new VeloeraAdapter(),
new NewApiAdapter(),
new Sub2ApiAdapter(),
new OneApiAdapter(),
new VeloeraAdapter(),
];
const platformAliases: Record<string, string> = {
@@ -50,6 +53,10 @@ const platformAliases: Record<string, string> = {
claude: 'claude',
gemini: 'gemini',
google: 'gemini',
// CLIProxyAPI aliases
cliproxyapi: 'cliproxyapi',
cpa: 'cliproxyapi',
'cli-proxy-api': 'cliproxyapi',
};
function normalizePlatform(platform: string): string {
@@ -62,9 +69,27 @@ export function getAdapter(platform: string): PlatformAdapter | undefined {
return adapters.find((a) => a.platformName === normalized);
}
const titleFirstPlatforms = new Set<string>([
'anyrouter',
'done-hub',
'one-hub',
'veloera',
'sub2api',
]);
export async function detectPlatform(url: string): Promise<PlatformAdapter | undefined> {
const titleHint = await detectPlatformByTitle(url);
if (titleHint && titleFirstPlatforms.has(titleHint)) {
return getAdapter(titleHint);
}
for (const adapter of adapters) {
if (await adapter.detect(url)) return adapter;
}
if (titleHint) {
return getAdapter(titleHint);
}
return undefined;
}
@@ -411,4 +411,37 @@ describe('NewApiAdapter', () => {
expect(result.success).toBe(false);
expect(result.message).toBe('浠婂ぉ宸茬粡绛惧埌杩囧暒');
});
it('sends all compatibility user-id headers when userId is known', async () => {
await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => (err ? reject(err) : resolve()));
});
const receivedHeaders: Record<string, string> = {};
server = createServer((req, res) => {
for (const name of ['new-api-user', 'veloera-user', 'voapi-user', 'user-id', 'rix-api-user', 'neo-api-user']) {
const val = req.headers[name];
if (val) receivedHeaders[name] = String(val);
}
if (req.url === '/api/user/self') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: { id: 42, username: 'test', quota: 500000, used_quota: 0 } }));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const addr = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
const adapter = new NewApiAdapter();
const fakeJwt = `header.${Buffer.from(JSON.stringify({ id: 42 })).toString('base64url')}.sig`;
await adapter.getBalance(baseUrl, fakeJwt, 42);
expect(receivedHeaders['new-api-user']).toBe('42');
expect(receivedHeaders['veloera-user']).toBe('42');
expect(receivedHeaders['voapi-user']).toBe('42');
expect(receivedHeaders['user-id']).toBe('42');
expect(receivedHeaders['rix-api-user']).toBe('42');
expect(receivedHeaders['neo-api-user']).toBe('42');
});
});
+9 -1
View File
@@ -31,7 +31,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
private authHeaders(accessToken: string, userId?: number): Record<string, string> {
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` };
if (userId) headers['New-Api-User'] = String(userId);
if (userId) {
const value = String(userId);
headers['New-API-User'] = value;
headers['Veloera-User'] = value;
headers['voapi-user'] = value;
headers['User-id'] = value;
headers['Rix-Api-User'] = value;
headers['neo-api-user'] = value;
}
return headers;
}
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { OneHubAdapter } from './oneHub.js';
describe('OneHubAdapter', () => {
let server: ReturnType<typeof createServer> | undefined;
let baseUrl: string;
afterEach(async () => {
if (server) {
const s = server;
server = undefined;
await new Promise<void>((resolve, reject) => {
s.close((err?: Error) => (err ? reject(err) : resolve()));
});
}
});
function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) {
return new Promise<void>((resolve) => {
server = createServer(handler);
server.listen(0, '127.0.0.1', () => {
const addr = server!.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
}
it('falls back to /api/available_model when /v1/models fails', async () => {
await startServer((req, res) => {
if (req.url === '/v1/models') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'unauthorized' }));
return;
}
if (req.url === '/api/available_model') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: {
'gpt-4o': { price: { input: 0.5, output: 1.5 } },
'claude-3-opus': { price: { input: 1, output: 3 } },
},
}));
return;
}
res.writeHead(404).end();
});
const adapter = new OneHubAdapter();
const models = await adapter.getModels(baseUrl, 'token');
expect(models).toEqual(expect.arrayContaining(['gpt-4o', 'claude-3-opus']));
});
it('returns user groups from /api/user_group_map', async () => {
await startServer((req, res) => {
if (req.url === '/api/user_group_map') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ data: { default: 1.0, vip: 0.8 } }));
return;
}
res.writeHead(404).end();
});
const adapter = new OneHubAdapter();
const groups = await adapter.getUserGroups(baseUrl, 'token');
expect(groups).toEqual(expect.arrayContaining(['default', 'vip']));
});
it('parses token list from {data: [...]} envelope', async () => {
await startServer((req, res) => {
if (req.url?.startsWith('/api/token/')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: [
{ key: 'sk-hub-abc', name: 'my-token', status: 1, id: 1 },
],
}));
return;
}
res.writeHead(404).end();
});
const adapter = new OneHubAdapter();
const tokens = await adapter.getApiTokens(baseUrl, 'token');
expect(tokens.length).toBe(1);
expect(tokens[0].key).toBe('sk-hub-abc');
});
});
+45 -2
View File
@@ -1,11 +1,54 @@
import { OneApiAdapter } from './oneApi.js';
export class OneHubAdapter extends OneApiAdapter {
readonly platformName = 'one-hub';
readonly platformName: string = 'one-hub';
async detect(url: string): Promise<boolean> {
const normalized = url.toLowerCase();
return normalized.includes('onehub') || normalized.includes('one-hub');
}
}
/**
* OneHub model discovery: try /v1/models first, fall back to /api/available_model.
* The /api/available_model endpoint returns { data: { model_name: { price: ... }, ... } }
* where the keys are model names.
*/
override async getModels(baseUrl: string, apiToken: string, platformUserId?: number): Promise<string[]> {
let openAiModels: string[] = [];
try {
openAiModels = await super.getModels(baseUrl, apiToken, platformUserId);
} catch {}
if (openAiModels.length > 0) return openAiModels;
try {
const res = await this.fetchJson<any>(`${baseUrl}/api/available_model`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
const payload = res?.data && typeof res.data === 'object' ? res.data : res;
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const models = Object.keys(payload).filter(Boolean);
if (models.length > 0) return models;
}
} catch {}
return [];
}
/**
* OneHub user groups: /api/user_group_map returns { data: { group_name: ratio, ... } }
*/
override async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const source = res?.data || res;
if (source && typeof source === 'object' && !Array.isArray(source)) {
const groups = Object.keys(source).map((k) => k.trim()).filter(Boolean);
if (groups.length > 0) return Array.from(new Set(groups));
}
} catch {}
return super.getUserGroups(baseUrl, accessToken);
}
}
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { Sub2ApiAdapter } from './sub2api.js';
describe('Sub2ApiAdapter', () => {
let server: ReturnType<typeof createServer> | undefined;
let baseUrl: string;
let adapter: Sub2ApiAdapter;
beforeEach(() => {
adapter = new Sub2ApiAdapter();
});
afterEach(async () => {
if (server) {
const s = server;
server = undefined;
await new Promise<void>((resolve, reject) => {
s.close((err?: Error) => (err ? reject(err) : resolve()));
});
}
});
function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) {
return new Promise<void>((resolve) => {
server = createServer(handler);
server.listen(0, '127.0.0.1', () => {
const addr = server!.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
}
it('detects sub2api from URL', async () => {
expect(await adapter.detect('https://sub2api.example.com')).toBe(true);
expect(await adapter.detect('https://example.com')).toBe(false);
});
it('detects sub2api by auth/me unauthorized envelope even without sub2api domain', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 'UNAUTHORIZED',
message: 'Authorization header is required',
}));
return;
}
if (req.url === '/v1/models') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 'API_KEY_REQUIRED',
message: 'API key is required',
}));
return;
}
res.writeHead(404).end();
});
expect(await adapter.detect(baseUrl)).toBe(true);
});
it('does not mis-detect generic json 401 responses', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me' || req.url === '/v1/models') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'unauthorized' }));
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><head><title>Not Sub2</title></head><body></body></html>');
});
expect(await adapter.detect(baseUrl)).toBe(false);
});
it('returns unsupported for checkin', async () => {
const result = await adapter.checkin('http://localhost', 'token');
expect(result.success).toBe(false);
expect(result.message).toContain('not supported');
});
it('fetches balance from /api/v1/auth/me', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
}));
return;
}
res.writeHead(404).end();
});
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
expect(balance.balance).toBeGreaterThan(0);
expect(balance.used).toBe(0);
});
it('fetches user info from /api/v1/auth/me', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 5.0 },
}));
return;
}
res.writeHead(404).end();
});
const userInfo = await adapter.getUserInfo(baseUrl, 'jwt-token');
expect(userInfo).not.toBeNull();
expect(userInfo!.username).toBe('testuser');
});
it('falls back to email local part when username is empty', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: '', email: 'alice@example.com', balance: 0 },
}));
return;
}
res.writeHead(404).end();
});
const userInfo = await adapter.getUserInfo(baseUrl, 'jwt-token');
expect(userInfo!.username).toBe('alice');
});
it('fetches models via /v1/models', async () => {
await startServer((req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: [{ id: 'gpt-4o' }, { id: 'claude-3-opus' }],
}));
return;
}
res.writeHead(404).end();
});
const models = await adapter.getModels(baseUrl, 'jwt-token');
expect(models).toEqual(['gpt-4o', 'claude-3-opus']);
});
it('handles non-zero code as error in /api/v1/auth/me', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 401,
message: 'token expired',
data: null,
}));
return;
}
res.writeHead(404).end();
});
await expect(adapter.getBalance(baseUrl, 'expired-token')).rejects.toThrow();
});
it('login returns unsupported', async () => {
const result = await adapter.login('http://localhost', 'user', 'pass');
expect(result.success).toBe(false);
});
it('token management returns empty/false', async () => {
expect(await adapter.getApiToken('http://localhost', 'token')).toBeNull();
expect(await adapter.getApiTokens('http://localhost', 'token')).toEqual([]);
expect(await adapter.createApiToken('http://localhost', 'token')).toBe(false);
expect(await adapter.deleteApiToken('http://localhost', 'token', 'key')).toBe(false);
});
});
+202 -4
View File
@@ -1,10 +1,208 @@
import { OneApiAdapter } from './oneApi.js';
import { BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo } from './base.js';
export class Sub2ApiAdapter extends OneApiAdapter {
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
/**
* Sub2API adapter.
*
* Sub2API uses JWT-based auth with endpoints under /api/v1/*.
* It does NOT support: login, check-in, token management, or usage stats.
* Balance is derived from a USD amount returned by /api/v1/auth/me.
*/
export class Sub2ApiAdapter extends BasePlatformAdapter {
readonly platformName = 'sub2api';
async detect(url: string): Promise<boolean> {
return url.toLowerCase().includes('sub2api');
const normalized = (url || '').toLowerCase();
if (normalized.includes('sub2api')) return true;
const base = normalizeBaseUrl(url);
const { fetch } = await import('undici');
const probeEndpoint = async (path: string) => {
try {
return await fetch(`${base}${path}`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
} catch {
return null;
}
};
const matchSub2ApiErrorEnvelope = async (res: {
headers: { get(name: string): string | null };
json: () => Promise<unknown>;
} | null): Promise<boolean> => {
if (!res) return false;
const contentType = res.headers.get('content-type') || '';
if (!contentType.toLowerCase().includes('application/json')) return false;
const body = await res.json().catch(() => null) as Record<string, unknown> | null;
if (!body || typeof body !== 'object') return false;
const rawCode = body.code;
const code = typeof rawCode === 'string' ? rawCode.trim().toUpperCase() : '';
const message = typeof body.message === 'string' ? body.message.trim().toLowerCase() : '';
if (code === 'UNAUTHORIZED' || code === 'API_KEY_REQUIRED') return true;
if (
message.includes('authorization header is required')
|| message.includes('api key is required')
) {
return true;
}
// Some Sub2API variants return numeric success envelope for authorized calls.
if (typeof rawCode === 'number' && rawCode === 0) {
return Object.prototype.hasOwnProperty.call(body, 'data');
}
return false;
};
const authProbe = await probeEndpoint('/api/v1/auth/me');
if (await matchSub2ApiErrorEnvelope(authProbe)) return true;
const modelsProbe = await probeEndpoint('/v1/models');
if (await matchSub2ApiErrorEnvelope(modelsProbe)) return true;
// Last fallback: many Sub2API UIs expose an identifying title on root.
const rootProbe = await probeEndpoint('/');
if (!rootProbe) return false;
const rootType = rootProbe.headers.get('content-type') || '';
if (!rootType.toLowerCase().includes('text/html')) return false;
const rootText = await rootProbe.text().catch(() => '');
return /<title>\s*sub2api\b/i.test(rootText);
}
/**
* Parse the Sub2API { code, message, data } envelope.
* code === 0 means success; anything else is an error.
*/
private parseSub2ApiEnvelope<T>(body: any, endpoint: string): T {
if (!body || typeof body !== 'object') {
throw new Error(`Invalid response from ${endpoint}`);
}
if (typeof body.code !== 'number') {
throw new Error(`Invalid response format from ${endpoint}`);
}
if (body.code !== 0) {
const message = typeof body.message === 'string' && body.message.trim()
? body.message.trim()
: `Error code ${body.code} from ${endpoint}`;
throw new Error(message);
}
if (body.data === undefined) {
throw new Error(`Missing data in response from ${endpoint}`);
}
return body.data as T;
}
/**
* Extract display name: prefer username, fall back to email local part.
*/
private getDisplayName(username?: string, email?: string): string {
const name = (username || '').trim();
if (name) return name;
const mail = (email || '').trim();
if (!mail) return '';
const atIndex = mail.indexOf('@');
return atIndex > 0 ? mail.slice(0, atIndex) : mail;
}
/**
* Fetch user data from /api/v1/auth/me.
*/
private async fetchAuthMe(baseUrl: string, accessToken: string): Promise<{
id: number;
username: string;
email: string;
balance: number;
}> {
const endpoint = '/api/v1/auth/me';
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
const id = typeof data.id === 'number' ? data.id
: typeof data.id === 'string' ? Number.parseInt(data.id, 10)
: NaN;
if (!Number.isFinite(id) || id <= 0) {
throw new Error(`Invalid user ID in response from ${endpoint}`);
}
const balance = typeof data.balance === 'number' ? data.balance
: typeof data.balance === 'string' ? Number.parseFloat(data.balance)
: 0;
return {
id,
username: typeof data.username === 'string' ? data.username : '',
email: typeof data.email === 'string' ? data.email : '',
balance: Number.isFinite(balance) ? balance : 0,
};
}
/**
* Convert USD balance to internal quota unit.
* Uses the same conversion factor as all-api-hub (500000 per USD).
*/
private usdToQuota(balanceUsd: number): number {
return Math.round(Math.max(0, balanceUsd) * 500000);
}
// --- Login: Not supported (JWT only) ---
override async login(
_baseUrl: string,
_username: string,
_password: string,
): Promise<{ success: false; message: string }> {
return { success: false, message: 'Sub2API uses JWT authentication; login is not supported' };
}
// --- User Info ---
override async getUserInfo(baseUrl: string, accessToken: string): Promise<UserInfo | null> {
try {
const user = await this.fetchAuthMe(baseUrl, accessToken);
return {
username: this.getDisplayName(user.username, user.email),
email: user.email || undefined,
};
} catch {
return null;
}
}
// --- Check-in: Not supported ---
async checkin(
_baseUrl: string,
_accessToken: string,
): Promise<CheckinResult> {
return { success: false, message: 'Check-in is not supported by Sub2API' };
}
// --- Balance ---
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
const user = await this.fetchAuthMe(baseUrl, accessToken);
const quotaValue = this.usdToQuota(user.balance);
// Sub2API only provides current balance, no usage breakdown
return {
balance: quotaValue / 500000,
used: 0,
quota: quotaValue / 500000,
};
}
// --- Models: Standard OpenAI-compatible endpoint ---
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(`${baseUrl}/v1/models`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
return (res?.data || []).map((m: any) => m.id).filter(Boolean);
} catch {
return [];
}
}
}
@@ -0,0 +1,73 @@
export type TitleHintPlatform =
| 'anyrouter'
| 'done-hub'
| 'one-hub'
| 'veloera'
| 'sub2api'
| 'new-api'
| 'one-api';
type TitleRule = {
platform: TitleHintPlatform;
regex: RegExp;
};
const TITLE_RULES: TitleRule[] = [
{ platform: 'anyrouter', regex: /\bany\s*router\b/i },
{ platform: 'done-hub', regex: /\bdone[-_ ]?hub\b/i },
{ platform: 'one-hub', regex: /\bone[-_ ]?hub\b/i },
{ platform: 'veloera', regex: /\bveloera\b/i },
{ platform: 'sub2api', regex: /\bsub2api\b/i },
{ platform: 'new-api', regex: /\bnew[-_ ]?api\b/i },
{ platform: 'new-api', regex: /\bvo[-_ ]?api\b/i },
{ platform: 'new-api', regex: /\bsuper[-_ ]?api\b/i },
{ platform: 'new-api', regex: /\brix[-_ ]?api\b/i },
{ platform: 'new-api', regex: /\bneo[-_ ]?api\b/i },
{ platform: 'new-api', regex: /wong\s*(?:\u516c\u76ca\u7ad9)/i },
{ platform: 'one-api', regex: /\bone[-_ ]?api\b/i },
];
function normalizeBaseUrl(url: string): string {
const trimmed = (url || '').trim();
if (!trimmed) return '';
try {
const parsed = new URL(trimmed);
return `${parsed.protocol}//${parsed.host}`;
} catch {
return trimmed.replace(/\/+$/, '');
}
}
function extractHtmlTitle(html: string): string {
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!match?.[1]) return '';
return match[1].replace(/\s+/g, ' ').trim();
}
export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatform | undefined> {
const base = normalizeBaseUrl(url);
if (!base) return undefined;
try {
const { fetch } = await import('undici');
const res = await fetch(`${base}/`, {
method: 'GET',
headers: { Accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
signal: AbortSignal.timeout(5000),
});
const contentType = (res.headers.get('content-type') || '').toLowerCase();
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
return undefined;
}
const title = extractHtmlTitle(await res.text());
if (!title) return undefined;
for (const rule of TITLE_RULES) {
if (rule.regex.test(title)) return rule.platform;
}
return undefined;
} catch {
return undefined;
}
}
+15 -4
View File
@@ -15,11 +15,22 @@ export class VeloeraAdapter extends BasePlatformAdapter {
}
}
async checkin(baseUrl: string, accessToken: string): Promise<CheckinResult> {
private veloeraHeaders(accessToken: string, userId?: number): Record<string, string> {
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` };
if (userId) {
const value = String(userId);
headers['Veloera-User'] = value;
headers['New-API-User'] = value;
headers['User-id'] = value;
}
return headers;
}
async checkin(baseUrl: string, accessToken: string, platformUserId?: number): Promise<CheckinResult> {
try {
const res = await this.fetchJson<any>(`${baseUrl}/api/user/checkin`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
headers: this.veloeraHeaders(accessToken, platformUserId),
});
if (res?.success) {
return { success: true, message: res.message || 'Check-in successful', reward: res.data?.reward?.toString() };
@@ -30,9 +41,9 @@ export class VeloeraAdapter extends BasePlatformAdapter {
}
}
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
async getBalance(baseUrl: string, accessToken: string, platformUserId?: number): Promise<BalanceInfo> {
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self`, {
headers: { Authorization: `Bearer ${accessToken}` },
headers: this.veloeraHeaders(accessToken, platformUserId),
});
const data = res?.data;
const quota = (data?.quota || 0) / 1000000;