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:
@@ -156,6 +156,28 @@
|
|||||||
 ↓
|
 ↓
|
||||||
**上游平台**(New API · One API · OneHub · DoneHub · Veloera · AnyRouter · Sub2API …)
|
**上游平台**(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["<b>💻 下游客户端</b><br/><br/>Cursor · Claude Code<br/>Codex · Open WebUI<br/>Cherry Studio"]:::client
|
||||||
|
|
||||||
|
G["<b>🚀 Metapi 网关</b><br/>────────────────────────<br/>✨ <b>统一代理</b>:兼容 OpenAI / Claude 全接口<br/>🧠 <b>智能路由</b>:按成本、余额、可用率加权,失败重试<br/>🔍 <b>模型发现</b>:自动聚合上游全部模型,零配置接入<br/>🔄 <b>格式转换</b>:OpenAI ⇄ Claude 双向透明转换<br/>📊 <b>运维管理</b>:自动签到 · 余额管理 · 告警 · 看板"]:::gateway
|
||||||
|
|
||||||
|
U["<b>☁️ 上游平台</b><br/><br/>New API · One API<br/>OneHub · DoneHub<br/>Veloera · AnyRouter<br/>Sub2API 等"]:::upstream
|
||||||
|
|
||||||
|
%% 连线
|
||||||
|
C -- "Authorization: Bearer <PROXY_TOKEN>" --> G
|
||||||
|
G ==>|请求分发| U
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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<string, string> = {};
|
||||||
|
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<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();
|
||||||
|
// 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<string, string> {
|
||||||
|
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` };
|
||||||
|
if (userId) headers['New-Api-User'] = String(userId);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After:
|
||||||
|
private authHeaders(accessToken: string, userId?: number): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = { 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<typeof createServer>;
|
||||||
|
let baseUrl: string;
|
||||||
|
let adapter: Sub2ApiAdapter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new Sub2ApiAdapter();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.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('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<boolean> {
|
||||||
|
return url.toLowerCase().includes('sub2api');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(): 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(): 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<typeof createServer>;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<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[]> {
|
||||||
|
// 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<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 {}
|
||||||
|
|
||||||
|
// 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<boolean> {
|
||||||
|
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<CheckinResult> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchJson<any>(`${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<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: 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<BalanceInfo> {
|
||||||
|
const res = await this.fetchJson<any>(`${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<string[]> {
|
||||||
|
const res = await this.fetchJson<any>(`${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.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metapi",
|
"name": "metapi",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metapi",
|
"name": "metapi",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -381,13 +381,9 @@ function preferredEndpointOrder(downstreamFormat: EndpointPreference, sitePlatfo
|
|||||||
return ['messages'];
|
return ['messages'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downstreamFormat === 'responses') {
|
// Unknown/generic upstreams: try the most expressive protocol first,
|
||||||
return ['responses', 'chat', 'messages'];
|
// then degrade to anthropic messages, and finally chat completions.
|
||||||
}
|
return ['responses', 'messages', 'chat'];
|
||||||
|
|
||||||
return downstreamFormat === 'claude'
|
|
||||||
? ['messages', 'chat']
|
|
||||||
: ['chat', 'messages'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveUpstreamEndpointCandidates(
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { OneApiAdapter } from './oneApi.js';
|
import { OneHubAdapter } from './oneHub.js';
|
||||||
import type { CheckinResult } from './base.js';
|
import type { CheckinResult } from './base.js';
|
||||||
|
|
||||||
export class DoneHubAdapter extends OneApiAdapter {
|
export class DoneHubAdapter extends OneHubAdapter {
|
||||||
readonly platformName = 'done-hub';
|
readonly platformName: string = 'done-hub';
|
||||||
|
|
||||||
async detect(url: string): Promise<boolean> {
|
async detect(url: string): Promise<boolean> {
|
||||||
const normalized = url.toLowerCase();
|
const normalized = url.toLowerCase();
|
||||||
@@ -15,25 +15,6 @@ export class DoneHubAdapter extends OneApiAdapter {
|
|||||||
return { success: false, message: 'checkin endpoint not found' };
|
return { success: false, message: 'checkin endpoint not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
override async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise<string[]> {
|
// getModels is inherited from OneHubAdapter which already has /api/available_model fallback.
|
||||||
let openAiModels: string[] = [];
|
// No need to override here — OneHub's implementation handles this correctly.
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
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', () => {
|
describe('getAdapter platform aliases', () => {
|
||||||
it('returns dedicated anyrouter adapter for anyrouter alias', () => {
|
it('returns dedicated anyrouter adapter for anyrouter alias', () => {
|
||||||
const adapter = getAdapter('anyrouter');
|
const adapter = getAdapter('anyrouter');
|
||||||
@@ -41,4 +62,76 @@ describe('getAdapter platform aliases', () => {
|
|||||||
expect(claude?.platformName).toBe('claude');
|
expect(claude?.platformName).toBe('claude');
|
||||||
expect(gemini?.platformName).toBe('gemini');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,19 +9,22 @@ import { Sub2ApiAdapter } from './sub2api.js';
|
|||||||
import { OpenAiAdapter } from './openai.js';
|
import { OpenAiAdapter } from './openai.js';
|
||||||
import { ClaudeAdapter } from './claude.js';
|
import { ClaudeAdapter } from './claude.js';
|
||||||
import { GeminiAdapter } from './gemini.js';
|
import { GeminiAdapter } from './gemini.js';
|
||||||
|
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||||
|
import { detectPlatformByTitle } from './titleHint.js';
|
||||||
|
|
||||||
const adapters: PlatformAdapter[] = [
|
const adapters: PlatformAdapter[] = [
|
||||||
// Specific forks before generic adapters for better auto-detection.
|
// Specific forks before generic adapters for better auto-detection.
|
||||||
new OpenAiAdapter(),
|
new OpenAiAdapter(),
|
||||||
new ClaudeAdapter(),
|
new ClaudeAdapter(),
|
||||||
new GeminiAdapter(),
|
new GeminiAdapter(),
|
||||||
|
new CliProxyApiAdapter(),
|
||||||
new AnyRouterAdapter(),
|
new AnyRouterAdapter(),
|
||||||
new DoneHubAdapter(),
|
new DoneHubAdapter(),
|
||||||
new OneHubAdapter(),
|
new OneHubAdapter(),
|
||||||
|
new VeloeraAdapter(),
|
||||||
new NewApiAdapter(),
|
new NewApiAdapter(),
|
||||||
new Sub2ApiAdapter(),
|
new Sub2ApiAdapter(),
|
||||||
new OneApiAdapter(),
|
new OneApiAdapter(),
|
||||||
new VeloeraAdapter(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const platformAliases: Record<string, string> = {
|
const platformAliases: Record<string, string> = {
|
||||||
@@ -50,6 +53,10 @@ const platformAliases: Record<string, string> = {
|
|||||||
claude: 'claude',
|
claude: 'claude',
|
||||||
gemini: 'gemini',
|
gemini: 'gemini',
|
||||||
google: 'gemini',
|
google: 'gemini',
|
||||||
|
// CLIProxyAPI aliases
|
||||||
|
cliproxyapi: 'cliproxyapi',
|
||||||
|
cpa: 'cliproxyapi',
|
||||||
|
'cli-proxy-api': 'cliproxyapi',
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizePlatform(platform: string): string {
|
function normalizePlatform(platform: string): string {
|
||||||
@@ -62,9 +69,27 @@ export function getAdapter(platform: string): PlatformAdapter | undefined {
|
|||||||
return adapters.find((a) => a.platformName === normalized);
|
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> {
|
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) {
|
for (const adapter of adapters) {
|
||||||
if (await adapter.detect(url)) return adapter;
|
if (await adapter.detect(url)) return adapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (titleHint) {
|
||||||
|
return getAdapter(titleHint);
|
||||||
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,4 +411,37 @@ describe('NewApiAdapter', () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toBe('浠婂ぉ宸茬粡绛惧埌杩囧暒');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
|||||||
|
|
||||||
private authHeaders(accessToken: string, userId?: number): Record<string, string> {
|
private authHeaders(accessToken: string, userId?: number): Record<string, string> {
|
||||||
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` };
|
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;
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,54 @@
|
|||||||
import { OneApiAdapter } from './oneApi.js';
|
import { OneApiAdapter } from './oneApi.js';
|
||||||
|
|
||||||
export class OneHubAdapter extends OneApiAdapter {
|
export class OneHubAdapter extends OneApiAdapter {
|
||||||
readonly platformName = 'one-hub';
|
readonly platformName: string = 'one-hub';
|
||||||
|
|
||||||
async detect(url: string): Promise<boolean> {
|
async detect(url: string): Promise<boolean> {
|
||||||
const normalized = url.toLowerCase();
|
const normalized = url.toLowerCase();
|
||||||
return normalized.includes('onehub') || normalized.includes('one-hub');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
readonly platformName = 'sub2api';
|
||||||
|
|
||||||
async detect(url: string): Promise<boolean> {
|
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,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 {
|
try {
|
||||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/checkin`, {
|
const res = await this.fetchJson<any>(`${baseUrl}/api/user/checkin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: this.veloeraHeaders(accessToken, platformUserId),
|
||||||
});
|
});
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
return { success: true, message: res.message || 'Check-in successful', reward: res.data?.reward?.toString() };
|
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`, {
|
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: this.veloeraHeaders(accessToken, platformUserId),
|
||||||
});
|
});
|
||||||
const data = res?.data;
|
const data = res?.data;
|
||||||
const quota = (data?.quota || 0) / 1000000;
|
const quota = (data?.quota || 0) / 1000000;
|
||||||
|
|||||||
Reference in New Issue
Block a user