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

- Implemented `CliProxyApiAdapter` with detection logic for CLIProxyAPI.
- Added tests for `CliProxyApiAdapter` to verify detection and response handling.
- Created `OneHubAdapter` with methods for fetching models and user groups.
- Added tests for `OneHubAdapter` to ensure correct fallback and response parsing.
- Developed `Sub2ApiAdapter` with detection and user info fetching capabilities.
- Added comprehensive tests for `Sub2ApiAdapter` covering various scenarios.
- Introduced `TitleHintPlatform` type and detection logic based on HTML title.
- Implemented tests for title hint detection to identify platforms from URLs.
This commit is contained in:
ciat-777
2026-03-02 20:26:02 +08:00
parent 897273fad4
commit 487bd23a02
19 changed files with 2032 additions and 45 deletions
+22
View File
@@ -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.
+2 -2
View File
@@ -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']);
});
});
+3 -7
View File
@@ -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 [];
}
}
}
+5 -24
View File
@@ -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');
});
});
}); });
+26 -1
View File
@@ -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');
});
}); });
+9 -1
View File
@@ -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');
});
});
+44 -1
View File
@@ -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);
});
});
+201 -3
View File
@@ -1,10 +1,208 @@
import { OneApiAdapter } from './oneApi.js'; import { BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo } from './base.js';
export class Sub2ApiAdapter extends OneApiAdapter { function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
/**
* Sub2API adapter.
*
* Sub2API uses JWT-based auth with endpoints under /api/v1/*.
* It does NOT support: login, check-in, token management, or usage stats.
* Balance is derived from a USD amount returned by /api/v1/auth/me.
*/
export class Sub2ApiAdapter extends BasePlatformAdapter {
readonly platformName = 'sub2api'; 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 -4
View File
@@ -15,11 +15,22 @@ export class VeloeraAdapter extends BasePlatformAdapter {
} }
} }
async checkin(baseUrl: string, accessToken: string): Promise<CheckinResult> { private veloeraHeaders(accessToken: string, userId?: number): Record<string, string> {
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` };
if (userId) {
const value = String(userId);
headers['Veloera-User'] = value;
headers['New-API-User'] = value;
headers['User-id'] = value;
}
return headers;
}
async checkin(baseUrl: string, accessToken: string, platformUserId?: number): Promise<CheckinResult> {
try { 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;