diff --git a/README.md b/README.md
index 103eb05..0115b67 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,28 @@
↓
**上游平台**(New API · One API · OneHub · DoneHub · Veloera · AnyRouter · Sub2API …)
+```mermaid
+graph LR
+ %% 全局样式设置
+ classDef node rx:10,ry:10,font-size:14px,font-family:sans-serif;
+ classDef client fill:#F4F6F8,stroke:#476582,stroke-width:2px,color:#2C3E50;
+ classDef gateway fill:#FFF7E6,stroke:#FA8C16,stroke-width:2px,color:#873800,text-align:left;
+ classDef upstream fill:#F6FFED,stroke:#52C41A,stroke-width:2px,color:#135200;
+
+ %% 节点定义
+ C["💻 下游客户端
Cursor · Claude Code
Codex · Open WebUI
Cherry Studio"]:::client
+
+ G["🚀 Metapi 网关
────────────────────────
✨ 统一代理:兼容 OpenAI / Claude 全接口
🧠 智能路由:按成本、余额、可用率加权,失败重试
🔍 模型发现:自动聚合上游全部模型,零配置接入
🔄 格式转换:OpenAI ⇄ Claude 双向透明转换
📊 运维管理:自动签到 · 余额管理 · 告警 · 看板"]:::gateway
+
+ U["☁️ 上游平台
New API · One API
OneHub · DoneHub
Veloera · AnyRouter
Sub2API 等"]:::upstream
+
+ %% 连线
+ C -- "Authorization: Bearer " --> G
+ G ==>|请求分发| U
+```
+
+
+
---
## ✨ 核心功能
diff --git a/docs/plans/2026-03-02-platform-adapter-alignment-design.md b/docs/plans/2026-03-02-platform-adapter-alignment-design.md
new file mode 100644
index 0000000..7cea896
--- /dev/null
+++ b/docs/plans/2026-03-02-platform-adapter-alignment-design.md
@@ -0,0 +1,109 @@
+# Platform Adapter Alignment Design
+
+**Date**: 2026-03-02
+**Goal**: Align metapi platform adapters with all-api-hub behavior for full platform compatibility.
+
+## Background
+
+metapi supports 11 upstream platforms, but several adapters are incomplete compared to all-api-hub's implementations. This design covers 5 changes to close the gaps.
+
+## Changes
+
+### 1. Compatibility User-ID Headers (NewApiAdapter)
+
+**Problem**: metapi only sends `New-Api-User` header. VoAPI, RIX_API, neo-Api, and other forks read different header names.
+
+**Solution**: Modify `NewApiAdapter.authHeaders()` to fan out the userId across all 6 known header names:
+- `New-API-User`, `Veloera-User`, `voapi-user`, `User-id`, `Rix-Api-User`, `neo-api-user`
+
+**Files**: `src/server/services/platforms/newApi.ts`
+
+**Impact**: All NewAPI-family platforms (new-api, anyrouter, vo-api, super-api, rix-api, neo-api) benefit automatically.
+
+---
+
+### 2. Sub2API Adapter Rewrite
+
+**Problem**: Current Sub2ApiAdapter extends OneApiAdapter (9 lines, detection only). Sub2API uses JWT auth with completely different endpoints.
+
+**Solution**: Rewrite Sub2ApiAdapter as standalone class extending BasePlatformAdapter:
+- Auth: JWT Bearer token (no cookie, no session)
+- User info: `GET /api/v1/auth/me` → `{ code: 0, data: { id, username, email, balance_usd } }`
+- Balance: Convert `balance_usd` to quota units
+- Check-in: Not supported (return unsupported)
+- Models: `GET /v1/models` (OpenAI-compatible, same as current)
+- Login: Not supported (JWT only)
+- Token management: Not available
+
+**Files**: `src/server/services/platforms/sub2api.ts`
+
+---
+
+### 3. Veloera Adapter Enhancement
+
+**Problem**: metapi uses OneAPI's `/api/user/checkin` for Veloera check-in, but Veloera uses a different endpoint.
+
+**Solution**: Override `checkin()` to use Veloera's actual endpoint:
+- Check-in: `POST /api/user/checkin` (same path but Veloera also supports `/api/user/check_in_status` for status)
+- Quota conversion: Already correct at 1,000,000 divisor
+
+Note: Veloera's checkin path is actually the same as NewAPI. The key difference in all-api-hub is the check-in *status* endpoint (`/api/user/check_in_status`), which metapi doesn't use directly. The existing checkin implementation is likely fine. Main enhancement: add compat headers support.
+
+**Files**: `src/server/services/platforms/veloera.ts`
+
+---
+
+### 4. OneHub Adapter Enhancement
+
+**Problem**: OneHubAdapter is 10 lines, just URL detection. Missing OneHub-specific endpoints.
+
+**Solution**: Add OneHub-specific overrides:
+- Models: `GET /api/available_model` as fallback (returns `{ data: { model_name: {...}, ... } }`)
+- User groups: `GET /api/user_group_map` (returns `{ data: { group_name: ratio, ... } }`)
+- Token listing: Handle `{ data: [...] }` response envelope (vs bare array)
+
+**Files**: `src/server/services/platforms/oneHub.ts`
+
+---
+
+### 5. DoneHub Adapter Enhancement
+
+**Problem**: DoneHubAdapter has checkin disabled + fallback model discovery. Missing DoneHub-specific features.
+
+**Solution**: Add DoneHub-specific overrides:
+- Inherit OneHub's model/group methods
+- Override model discovery fallback: try `/api/available_model` (already done)
+- Keep checkin disabled (already done)
+
+DoneHub is already partially implemented. The main missing piece is inheriting from OneHubAdapter instead of OneApiAdapter to get OneHub's enhanced methods.
+
+**Files**: `src/server/services/platforms/doneHub.ts`
+
+---
+
+## Architecture
+
+No new patterns introduced. All changes fit within the existing class hierarchy:
+
+```
+BasePlatformAdapter
+├── OneApiAdapter
+│ ├── OneHubAdapter (enhanced)
+│ │ └── DoneHubAdapter (re-parented)
+│ └── (Sub2ApiAdapter removed from this branch)
+├── NewApiAdapter (compat headers added)
+│ └── AnyRouterAdapter
+├── VeloeraAdapter (minor enhancement)
+├── Sub2ApiAdapter (rewritten, now extends BasePlatformAdapter)
+└── ... (OpenAI, Claude, Gemini, CLIProxyAPI unchanged)
+```
+
+## Detection Order
+
+Update detection order in `index.ts` to ensure Sub2API is detected before OneAPI (already the case).
+
+## Risk Assessment
+
+- **Low risk**: Header changes are additive (extra headers don't break existing platforms)
+- **Medium risk**: Sub2API rewrite changes auth flow entirely (but current implementation doesn't work anyway)
+- **Low risk**: OneHub/DoneHub enhancements add fallback paths (existing paths still work)
diff --git a/docs/plans/2026-03-02-platform-adapter-alignment.md b/docs/plans/2026-03-02-platform-adapter-alignment.md
new file mode 100644
index 0000000..88be182
--- /dev/null
+++ b/docs/plans/2026-03-02-platform-adapter-alignment.md
@@ -0,0 +1,841 @@
+# Platform Adapter Alignment Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Align metapi's platform adapters with all-api-hub behavior so all supported platforms (VoAPI, Super-API, RIX_API, Neo-API, Sub2API, OneHub, DoneHub, Veloera) work correctly.
+
+**Architecture:** Modify 5 existing adapter files within the class-based adapter hierarchy. No new patterns or dependencies. All changes are additive or replacement of stub implementations.
+
+**Tech Stack:** TypeScript, Node.js, undici (HTTP), vitest (testing)
+
+---
+
+### Task 1: Add Compatibility User-ID Headers to NewApiAdapter
+
+**Files:**
+- Modify: `src/server/services/platforms/newApi.ts:32-36`
+- Test: `src/server/services/platforms/newApi.test.ts`
+
+**Step 1: Write the failing test**
+
+Add a test that verifies all 6 compat headers are sent when userId is present.
+
+```typescript
+// In newApi.test.ts, add:
+it('sends all compatibility user-id headers when userId is known', async () => {
+ const receivedHeaders: Record = {};
+ server = createServer((req, res) => {
+ // Capture headers from the request
+ 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((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();
+ // Use a fake JWT with id=42 so userId is discovered
+ 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');
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/newApi.test.ts`
+Expected: FAIL — only `new-api-user` header is present
+
+**Step 3: Write minimal implementation**
+
+In `src/server/services/platforms/newApi.ts`, replace the `authHeaders` method (lines 32-36):
+
+```typescript
+// Before:
+private authHeaders(accessToken: string, userId?: number): Record {
+ const headers: Record = { Authorization: `Bearer ${accessToken}` };
+ if (userId) headers['New-Api-User'] = String(userId);
+ return headers;
+}
+
+// After:
+private authHeaders(accessToken: string, userId?: number): Record {
+ const headers: Record = { Authorization: `Bearer ${accessToken}` };
+ 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;
+}
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/newApi.test.ts`
+Expected: PASS
+
+**Step 5: Run all existing tests to check for regressions**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run`
+Expected: All tests PASS
+
+**Step 6: Commit**
+
+```bash
+git add src/server/services/platforms/newApi.ts src/server/services/platforms/newApi.test.ts
+git commit -m "feat: add compatibility user-id headers to NewApiAdapter
+
+Fan out userId across all 6 known One-API/New-API family header names
+(New-API-User, Veloera-User, voapi-user, User-id, Rix-Api-User, neo-api-user)
+to match all-api-hub behavior. This ensures VoAPI, Super-API, RIX_API,
+Neo-API, and Veloera deployments correctly identify the requesting user."
+```
+
+---
+
+### Task 2: Rewrite Sub2API Adapter
+
+**Files:**
+- Rewrite: `src/server/services/platforms/sub2api.ts`
+- Create: `src/server/services/platforms/sub2api.test.ts`
+
+**Step 1: Write the failing tests**
+
+Create `src/server/services/platforms/sub2api.test.ts`:
+
+```typescript
+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;
+ let baseUrl: string;
+ let adapter: Sub2ApiAdapter;
+
+ beforeEach(() => {
+ adapter = new Sub2ApiAdapter();
+ });
+
+ afterEach(async () => {
+ if (server) {
+ await new Promise((resolve, reject) => {
+ server.close((err?: Error) => (err ? reject(err) : resolve()));
+ });
+ }
+ });
+
+ function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) {
+ return new Promise((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('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);
+ });
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/sub2api.test.ts`
+Expected: FAIL — current Sub2ApiAdapter doesn't have these methods
+
+**Step 3: Rewrite the adapter**
+
+Replace `src/server/services/platforms/sub2api.ts` entirely:
+
+```typescript
+import { BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo } from './base.js';
+
+/**
+ * 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 {
+ return url.toLowerCase().includes('sub2api');
+ }
+
+ /**
+ * Parse the Sub2API { code, message, data } envelope.
+ * code === 0 means success; anything else is an error.
+ */
+ private parseSub2ApiEnvelope(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(`${baseUrl}${endpoint}`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ const data = this.parseSub2ApiEnvelope(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(): 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 {
+ 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(): Promise {
+ return { success: false, message: 'Check-in is not supported by Sub2API' };
+ }
+
+ // --- Balance ---
+ async getBalance(baseUrl: string, accessToken: string): Promise {
+ 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 {
+ try {
+ const res = await this.fetchJson(`${baseUrl}/v1/models`, {
+ headers: { Authorization: `Bearer ${apiToken}` },
+ });
+ return (res?.data || []).map((m: any) => m.id).filter(Boolean);
+ } catch {
+ return [];
+ }
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/sub2api.test.ts`
+Expected: All PASS
+
+**Step 5: Run all tests for regressions**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run`
+Expected: All PASS
+
+**Step 6: Commit**
+
+```bash
+git add src/server/services/platforms/sub2api.ts src/server/services/platforms/sub2api.test.ts
+git commit -m "feat: rewrite Sub2API adapter with proper JWT auth
+
+Sub2API uses JWT-based auth with /api/v1/* endpoints, completely different
+from One-API. The old stub just inherited OneApiAdapter and couldn't work.
+
+Now implements:
+- User info + balance via GET /api/v1/auth/me ({code, message, data} envelope)
+- USD balance to quota conversion
+- Display name fallback (username → email local part)
+- Unsupported: login, check-in, token management
+- Models: standard /v1/models (OpenAI-compatible)"
+```
+
+---
+
+### Task 3: Enhance OneHub Adapter
+
+**Files:**
+- Modify: `src/server/services/platforms/oneHub.ts`
+- Create: `src/server/services/platforms/oneHub.test.ts`
+
+**Step 1: Write the failing tests**
+
+Create `src/server/services/platforms/oneHub.test.ts`:
+
+```typescript
+import { afterEach, beforeEach, 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;
+ let baseUrl: string;
+
+ afterEach(async () => {
+ if (server) {
+ await new Promise((resolve, reject) => {
+ server.close((err?: Error) => (err ? reject(err) : resolve()));
+ });
+ }
+ });
+
+ function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) {
+ return new Promise((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');
+ });
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/oneHub.test.ts`
+Expected: FAIL — OneHubAdapter doesn't override getModels or getUserGroups
+
+**Step 3: Implement OneHub overrides**
+
+Replace `src/server/services/platforms/oneHub.ts`:
+
+```typescript
+import { OneApiAdapter } from './oneApi.js';
+
+export class OneHubAdapter extends OneApiAdapter {
+ readonly platformName = 'one-hub';
+
+ async detect(url: string): Promise {
+ 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 {
+ // Try standard OpenAI-compatible endpoint first
+ let openAiModels: string[] = [];
+ try {
+ openAiModels = await super.getModels(baseUrl, apiToken, platformUserId);
+ } catch {}
+ if (openAiModels.length > 0) return openAiModels;
+
+ // Fall back to OneHub's /api/available_model
+ try {
+ const res = await this.fetchJson(`${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 {
+ try {
+ const res = await this.fetchJson(`${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 {}
+
+ // Fall back to parent implementation
+ return super.getUserGroups(baseUrl, accessToken);
+ }
+}
+```
+
+**Step 4: Run tests**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/oneHub.test.ts`
+Expected: All PASS
+
+**Step 5: Run all tests**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run`
+Expected: All PASS
+
+**Step 6: Commit**
+
+```bash
+git add src/server/services/platforms/oneHub.ts src/server/services/platforms/oneHub.test.ts
+git commit -m "feat: enhance OneHub adapter with model and group discovery
+
+Add OneHub-specific overrides:
+- getModels: fallback to /api/available_model when /v1/models unavailable
+- getUserGroups: use /api/user_group_map endpoint
+Aligns with all-api-hub's OneHub service implementation."
+```
+
+---
+
+### Task 4: Enhance DoneHub Adapter (Re-parent to OneHub)
+
+**Files:**
+- Modify: `src/server/services/platforms/doneHub.ts`
+- Modify: `src/server/services/platforms/doneHub.test.ts`
+
+**Step 1: Add test for inherited OneHub behavior**
+
+Add to existing `doneHub.test.ts`:
+
+```typescript
+it('inherits OneHub model discovery from /api/available_model', async () => {
+ // Already tested — existing test "falls back to /api/available_model" covers this
+ // Just verify it still works after re-parenting
+ const adapter = new DoneHubAdapter();
+ const models = await adapter.getModels(baseUrl, 'token');
+ expect(models).toEqual(['gpt-4o', 'deepseek-chat']);
+});
+
+it('inherits OneHub user group discovery', async () => {
+ // Add /api/user_group_map handler to server
+ // This requires modifying the server setup
+});
+```
+
+**Step 2: Re-parent DoneHub to extend OneHub**
+
+Modify `src/server/services/platforms/doneHub.ts`:
+
+```typescript
+import { OneHubAdapter } from './oneHub.js';
+import type { CheckinResult } from './base.js';
+
+export class DoneHubAdapter extends OneHubAdapter {
+ readonly platformName = 'done-hub';
+
+ async detect(url: string): Promise {
+ const normalized = url.toLowerCase();
+ return normalized.includes('donehub') || normalized.includes('done-hub');
+ }
+
+ // DoneHub deployments generally do not expose /api/user/checkin.
+ override async checkin(_baseUrl: string, _accessToken: string): Promise {
+ return { success: false, message: 'checkin endpoint not found' };
+ }
+
+ // getModels is inherited from OneHubAdapter which already has /api/available_model fallback
+ // No need to override — remove the duplicate implementation
+}
+```
+
+**Step 3: Run existing DoneHub tests**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/doneHub.test.ts`
+Expected: All PASS (behavior unchanged, just cleaner inheritance)
+
+**Step 4: Run all tests**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run`
+Expected: All PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/server/services/platforms/doneHub.ts src/server/services/platforms/doneHub.test.ts
+git commit -m "refactor: re-parent DoneHub to extend OneHub adapter
+
+DoneHub is downstream of OneHub. By extending OneHubAdapter instead of
+OneApiAdapter, DoneHub inherits OneHub's /api/available_model fallback
+and /api/user_group_map support. Removes duplicate getModels override
+that OneHub now provides."
+```
+
+---
+
+### Task 5: Enhance Veloera Adapter with Compat Headers
+
+**Files:**
+- Modify: `src/server/services/platforms/veloera.ts`
+
+**Step 1: Review current state**
+
+The Veloera adapter already has:
+- Correct quota conversion (1,000,000 divisor)
+- Working checkin via `/api/user/checkin`
+- Working balance via `/api/user/self`
+- Working models via `/v1/models`
+
+The main gap: Veloera doesn't send compat user-id headers in its requests. Since VeloeraAdapter extends BasePlatformAdapter (not NewApiAdapter), it uses simple `Authorization: Bearer` without any user-id headers.
+
+**Step 2: Add compat headers to Veloera balance/checkin requests**
+
+Modify `src/server/services/platforms/veloera.ts` to include `Veloera-User` header when userId is available:
+
+```typescript
+import { BasePlatformAdapter, CheckinResult, BalanceInfo } from './base.js';
+
+export class VeloeraAdapter extends BasePlatformAdapter {
+ readonly platformName = 'veloera';
+
+ async detect(url: string): Promise {
+ try {
+ const res = await this.fetchJson(`${url}/api/status`);
+ return res?.success === true && (
+ res?.data?.system_name?.toLowerCase().includes('veloera') ||
+ res?.data?.version?.includes('veloera')
+ );
+ } catch {
+ return false;
+ }
+ }
+
+ private veloeraHeaders(accessToken: string, userId?: number): Record {
+ const headers: Record = { 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 {
+ try {
+ const res = await this.fetchJson(`${baseUrl}/api/user/checkin`, {
+ method: 'POST',
+ headers: this.veloeraHeaders(accessToken, platformUserId),
+ });
+ if (res?.success) {
+ return { success: true, message: res.message || 'Check-in successful', reward: res.data?.reward?.toString() };
+ }
+ return { success: false, message: res?.message || 'Check-in failed' };
+ } catch (err: any) {
+ return { success: false, message: err.message };
+ }
+ }
+
+ async getBalance(baseUrl: string, accessToken: string, platformUserId?: number): Promise {
+ const res = await this.fetchJson(`${baseUrl}/api/user/self`, {
+ headers: this.veloeraHeaders(accessToken, platformUserId),
+ });
+ const data = res?.data;
+ const quota = (data?.quota || 0) / 1000000;
+ const used = (data?.used_quota || 0) / 1000000;
+ const todayIncome = Number.isFinite(data?.today_income) ? (data.today_income / 1000000) : undefined;
+ const todayQuotaConsumption = Number.isFinite(data?.today_quota_consumption) ? (data.today_quota_consumption / 1000000) : undefined;
+ return { balance: quota - used, used, quota, todayIncome, todayQuotaConsumption };
+ }
+
+ async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise {
+ const res = await this.fetchJson(`${baseUrl}/v1/models`, {
+ headers: { Authorization: `Bearer ${apiToken}` },
+ });
+ return (res?.data || []).map((m: any) => m.id).filter(Boolean);
+ }
+}
+```
+
+**Step 3: Run all tests**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run`
+Expected: All PASS
+
+**Step 4: Commit**
+
+```bash
+git add src/server/services/platforms/veloera.ts
+git commit -m "feat: add compat user-id headers to Veloera adapter
+
+Add Veloera-User, New-API-User, and User-id headers to balance and
+checkin requests. Accept platformUserId parameter in checkin/getBalance
+for consistency with other adapters."
+```
+
+---
+
+### Task 6: Update Income Log Fallback for Sub2API
+
+**Files:**
+- Check: `src/server/services/balanceService.ts`
+
+**Step 1: Verify Sub2API is NOT in the income log fallback list**
+
+The `supportsTodayIncomeLogFallback()` function only includes `new-api`, `anyrouter`, `one-api`, `veloera`. Sub2API should NOT be in this list (it doesn't support log endpoints). Verify this is already correct — no change needed.
+
+**Step 2: Run full test suite**
+
+Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run`
+Expected: All PASS — this is the final verification
+
+**Step 3: Final commit (if any remaining changes)**
+
+No code changes expected for this task. It's a verification step.
diff --git a/package-lock.json b/package-lock.json
index 216b893..5895608 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metapi",
- "version": "1.0.0",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metapi",
- "version": "1.0.0",
+ "version": "1.2.0",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts
new file mode 100644
index 0000000..09a6d4d
--- /dev/null
+++ b/src/server/routes/proxy/upstreamEndpoint.test.ts
@@ -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']);
+ });
+});
diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts
index 9320fad..c5917d5 100644
--- a/src/server/routes/proxy/upstreamEndpoint.ts
+++ b/src/server/routes/proxy/upstreamEndpoint.ts
@@ -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(
diff --git a/src/server/services/platforms/cliproxyapi.test.ts b/src/server/services/platforms/cliproxyapi.test.ts
new file mode 100644
index 0000000..d244985
--- /dev/null
+++ b/src/server/services/platforms/cliproxyapi.test.ts
@@ -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 | undefined;
+ let baseUrl = '';
+ let adapter: CliProxyApiAdapter;
+
+ beforeEach(() => {
+ adapter = new CliProxyApiAdapter();
+ });
+
+ afterEach(async () => {
+ if (!server) return;
+ const current = server;
+ server = undefined;
+ await new Promise((resolve, reject) => {
+ current.close((err?: Error) => (err ? reject(err) : resolve()));
+ });
+ });
+
+ async function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) {
+ await new Promise((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);
+ });
+});
diff --git a/src/server/services/platforms/cliproxyapi.ts b/src/server/services/platforms/cliproxyapi.ts
new file mode 100644
index 0000000..d175704
--- /dev/null
+++ b/src/server/services/platforms/cliproxyapi.ts
@@ -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 {
+ 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 {
+ return null;
+ }
+
+ async checkin(_baseUrl: string, _accessToken: string): Promise {
+ return { success: false, message: 'CLIProxyAPI does not support checkin' };
+ }
+
+ async getBalance(_baseUrl: string, _accessToken: string): Promise {
+ return { balance: 0, used: 0, quota: 0 };
+ }
+
+ async getModels(baseUrl: string, apiToken: string): Promise {
+ try {
+ const base = normalizeBaseUrl(baseUrl);
+ const res = await this.fetchJson(`${base}/v1/models`, {
+ headers: { Authorization: `Bearer ${apiToken}` },
+ });
+ return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
+ } catch {
+ return [];
+ }
+ }
+}
diff --git a/src/server/services/platforms/doneHub.ts b/src/server/services/platforms/doneHub.ts
index eac84b0..23dc940 100644
--- a/src/server/services/platforms/doneHub.ts
+++ b/src/server/services/platforms/doneHub.ts
@@ -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 {
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 {
- let openAiModels: string[] = [];
- try {
- openAiModels = await super.getModels(baseUrl, apiToken, _platformUserId);
- } catch {}
- if (openAiModels.length > 0) return openAiModels;
-
- try {
- const res = await this.fetchJson(`${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.
}
diff --git a/src/server/services/platforms/index.test.ts b/src/server/services/platforms/index.test.ts
index 62701f0..12b025b 100644
--- a/src/server/services/platforms/index.test.ts
+++ b/src/server/services/platforms/index.test.ts
@@ -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,
+) {
+ const server = createServer(handler);
+ await new Promise((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((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('One-Hub Console');
+ 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('Done-Hub Panel');
+ 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('Veloera 管理台');
+ 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('Super-API Dashboard');
+ return;
+ }
+ res.writeHead(404).end();
+ }, async (baseUrl) => {
+ const adapter = await detectPlatform(baseUrl);
+ expect(adapter?.platformName).toBe('new-api');
+ });
+ });
});
diff --git a/src/server/services/platforms/index.ts b/src/server/services/platforms/index.ts
index d305ce6..09a53d7 100644
--- a/src/server/services/platforms/index.ts
+++ b/src/server/services/platforms/index.ts
@@ -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 = {
@@ -50,6 +53,10 @@ const platformAliases: Record = {
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([
+ 'anyrouter',
+ 'done-hub',
+ 'one-hub',
+ 'veloera',
+ 'sub2api',
+]);
+
export async function detectPlatform(url: string): Promise {
+ 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;
}
diff --git a/src/server/services/platforms/newApi.test.ts b/src/server/services/platforms/newApi.test.ts
index 1f83af8..f56fde3 100644
--- a/src/server/services/platforms/newApi.test.ts
+++ b/src/server/services/platforms/newApi.test.ts
@@ -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((resolve, reject) => {
+ server.close((err?: Error) => (err ? reject(err) : resolve()));
+ });
+ const receivedHeaders: Record = {};
+ 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((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');
+ });
});
diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts
index 41d875d..2ec7a2d 100644
--- a/src/server/services/platforms/newApi.ts
+++ b/src/server/services/platforms/newApi.ts
@@ -31,7 +31,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
private authHeaders(accessToken: string, userId?: number): Record {
const headers: Record = { 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;
}
diff --git a/src/server/services/platforms/oneHub.test.ts b/src/server/services/platforms/oneHub.test.ts
new file mode 100644
index 0000000..548c663
--- /dev/null
+++ b/src/server/services/platforms/oneHub.test.ts
@@ -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 | undefined;
+ let baseUrl: string;
+
+ afterEach(async () => {
+ 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(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');
+ });
+});
diff --git a/src/server/services/platforms/oneHub.ts b/src/server/services/platforms/oneHub.ts
index e2dbfbb..55da00b 100644
--- a/src/server/services/platforms/oneHub.ts
+++ b/src/server/services/platforms/oneHub.ts
@@ -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 {
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 {
+ let openAiModels: string[] = [];
+ try {
+ openAiModels = await super.getModels(baseUrl, apiToken, platformUserId);
+ } catch {}
+ if (openAiModels.length > 0) return openAiModels;
+
+ try {
+ const res = await this.fetchJson(`${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 {
+ try {
+ const res = await this.fetchJson(`${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);
+ }
+}
diff --git a/src/server/services/platforms/sub2api.test.ts b/src/server/services/platforms/sub2api.test.ts
new file mode 100644
index 0000000..96c6927
--- /dev/null
+++ b/src/server/services/platforms/sub2api.test.ts
@@ -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 | undefined;
+ let baseUrl: string;
+ let adapter: Sub2ApiAdapter;
+
+ beforeEach(() => {
+ adapter = new Sub2ApiAdapter();
+ });
+
+ afterEach(async () => {
+ 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(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('Not Sub2');
+ });
+
+ 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);
+ });
+});
diff --git a/src/server/services/platforms/sub2api.ts b/src/server/services/platforms/sub2api.ts
index 754f8cc..286b917 100644
--- a/src/server/services/platforms/sub2api.ts
+++ b/src/server/services/platforms/sub2api.ts
@@ -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 {
- 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;
+ } | null): Promise => {
+ 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 | 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 /\s*sub2api\b/i.test(rootText);
+ }
+
+ /**
+ * Parse the Sub2API { code, message, data } envelope.
+ * code === 0 means success; anything else is an error.
+ */
+ private parseSub2ApiEnvelope(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(`${baseUrl}${endpoint}`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ const data = this.parseSub2ApiEnvelope(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 {
+ 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 {
+ return { success: false, message: 'Check-in is not supported by Sub2API' };
+ }
+
+ // --- Balance ---
+ async getBalance(baseUrl: string, accessToken: string): Promise {
+ 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 {
+ try {
+ const res = await this.fetchJson(`${baseUrl}/v1/models`, {
+ headers: { Authorization: `Bearer ${apiToken}` },
+ });
+ return (res?.data || []).map((m: any) => m.id).filter(Boolean);
+ } catch {
+ return [];
+ }
}
}
-
diff --git a/src/server/services/platforms/titleHint.ts b/src/server/services/platforms/titleHint.ts
new file mode 100644
index 0000000..57fe3de
--- /dev/null
+++ b/src/server/services/platforms/titleHint.ts
@@ -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(/]*>([\s\S]*?)<\/title>/i);
+ if (!match?.[1]) return '';
+ return match[1].replace(/\s+/g, ' ').trim();
+}
+
+export async function detectPlatformByTitle(url: string): Promise {
+ 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;
+ }
+}
diff --git a/src/server/services/platforms/veloera.ts b/src/server/services/platforms/veloera.ts
index bd89c99..6257127 100644
--- a/src/server/services/platforms/veloera.ts
+++ b/src/server/services/platforms/veloera.ts
@@ -15,11 +15,22 @@ export class VeloeraAdapter extends BasePlatformAdapter {
}
}
- async checkin(baseUrl: string, accessToken: string): Promise {
+ private veloeraHeaders(accessToken: string, userId?: number): Record {
+ const headers: Record = { 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 {
try {
const res = await this.fetchJson(`${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 {
+ async getBalance(baseUrl: string, accessToken: string, platformUserId?: number): Promise {
const res = await this.fetchJson(`${baseUrl}/api/user/self`, {
- headers: { Authorization: `Bearer ${accessToken}` },
+ headers: this.veloeraHeaders(accessToken, platformUserId),
});
const data = res?.data;
const quota = (data?.quota || 0) / 1000000;