diff --git a/README.md b/README.md index 103eb05..0115b67 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,28 @@  ↓ **上游平台**(New API · One API · OneHub · DoneHub · Veloera · AnyRouter · Sub2API …) +```mermaid +graph LR + %% 全局样式设置 + classDef node rx:10,ry:10,font-size:14px,font-family:sans-serif; + classDef client fill:#F4F6F8,stroke:#476582,stroke-width:2px,color:#2C3E50; + classDef gateway fill:#FFF7E6,stroke:#FA8C16,stroke-width:2px,color:#873800,text-align:left; + classDef upstream fill:#F6FFED,stroke:#52C41A,stroke-width:2px,color:#135200; + + %% 节点定义 + C["💻 下游客户端

Cursor · Claude Code
Codex · Open WebUI
Cherry Studio"]:::client + + G["🚀 Metapi 网关
────────────────────────
✨ 统一代理:兼容 OpenAI / Claude 全接口
🧠 智能路由:按成本、余额、可用率加权,失败重试
🔍 模型发现:自动聚合上游全部模型,零配置接入
🔄 格式转换:OpenAI ⇄ Claude 双向透明转换
📊 运维管理:自动签到 · 余额管理 · 告警 · 看板"]:::gateway + + U["☁️ 上游平台

New API · One API
OneHub · DoneHub
Veloera · AnyRouter
Sub2API 等"]:::upstream + + %% 连线 + C -- "Authorization: Bearer " --> G + G ==>|请求分发| U +``` + + + --- ## ✨ 核心功能 diff --git a/docs/plans/2026-03-02-platform-adapter-alignment-design.md b/docs/plans/2026-03-02-platform-adapter-alignment-design.md new file mode 100644 index 0000000..7cea896 --- /dev/null +++ b/docs/plans/2026-03-02-platform-adapter-alignment-design.md @@ -0,0 +1,109 @@ +# Platform Adapter Alignment Design + +**Date**: 2026-03-02 +**Goal**: Align metapi platform adapters with all-api-hub behavior for full platform compatibility. + +## Background + +metapi supports 11 upstream platforms, but several adapters are incomplete compared to all-api-hub's implementations. This design covers 5 changes to close the gaps. + +## Changes + +### 1. Compatibility User-ID Headers (NewApiAdapter) + +**Problem**: metapi only sends `New-Api-User` header. VoAPI, RIX_API, neo-Api, and other forks read different header names. + +**Solution**: Modify `NewApiAdapter.authHeaders()` to fan out the userId across all 6 known header names: +- `New-API-User`, `Veloera-User`, `voapi-user`, `User-id`, `Rix-Api-User`, `neo-api-user` + +**Files**: `src/server/services/platforms/newApi.ts` + +**Impact**: All NewAPI-family platforms (new-api, anyrouter, vo-api, super-api, rix-api, neo-api) benefit automatically. + +--- + +### 2. Sub2API Adapter Rewrite + +**Problem**: Current Sub2ApiAdapter extends OneApiAdapter (9 lines, detection only). Sub2API uses JWT auth with completely different endpoints. + +**Solution**: Rewrite Sub2ApiAdapter as standalone class extending BasePlatformAdapter: +- Auth: JWT Bearer token (no cookie, no session) +- User info: `GET /api/v1/auth/me` → `{ code: 0, data: { id, username, email, balance_usd } }` +- Balance: Convert `balance_usd` to quota units +- Check-in: Not supported (return unsupported) +- Models: `GET /v1/models` (OpenAI-compatible, same as current) +- Login: Not supported (JWT only) +- Token management: Not available + +**Files**: `src/server/services/platforms/sub2api.ts` + +--- + +### 3. Veloera Adapter Enhancement + +**Problem**: metapi uses OneAPI's `/api/user/checkin` for Veloera check-in, but Veloera uses a different endpoint. + +**Solution**: Override `checkin()` to use Veloera's actual endpoint: +- Check-in: `POST /api/user/checkin` (same path but Veloera also supports `/api/user/check_in_status` for status) +- Quota conversion: Already correct at 1,000,000 divisor + +Note: Veloera's checkin path is actually the same as NewAPI. The key difference in all-api-hub is the check-in *status* endpoint (`/api/user/check_in_status`), which metapi doesn't use directly. The existing checkin implementation is likely fine. Main enhancement: add compat headers support. + +**Files**: `src/server/services/platforms/veloera.ts` + +--- + +### 4. OneHub Adapter Enhancement + +**Problem**: OneHubAdapter is 10 lines, just URL detection. Missing OneHub-specific endpoints. + +**Solution**: Add OneHub-specific overrides: +- Models: `GET /api/available_model` as fallback (returns `{ data: { model_name: {...}, ... } }`) +- User groups: `GET /api/user_group_map` (returns `{ data: { group_name: ratio, ... } }`) +- Token listing: Handle `{ data: [...] }` response envelope (vs bare array) + +**Files**: `src/server/services/platforms/oneHub.ts` + +--- + +### 5. DoneHub Adapter Enhancement + +**Problem**: DoneHubAdapter has checkin disabled + fallback model discovery. Missing DoneHub-specific features. + +**Solution**: Add DoneHub-specific overrides: +- Inherit OneHub's model/group methods +- Override model discovery fallback: try `/api/available_model` (already done) +- Keep checkin disabled (already done) + +DoneHub is already partially implemented. The main missing piece is inheriting from OneHubAdapter instead of OneApiAdapter to get OneHub's enhanced methods. + +**Files**: `src/server/services/platforms/doneHub.ts` + +--- + +## Architecture + +No new patterns introduced. All changes fit within the existing class hierarchy: + +``` +BasePlatformAdapter +├── OneApiAdapter +│ ├── OneHubAdapter (enhanced) +│ │ └── DoneHubAdapter (re-parented) +│ └── (Sub2ApiAdapter removed from this branch) +├── NewApiAdapter (compat headers added) +│ └── AnyRouterAdapter +├── VeloeraAdapter (minor enhancement) +├── Sub2ApiAdapter (rewritten, now extends BasePlatformAdapter) +└── ... (OpenAI, Claude, Gemini, CLIProxyAPI unchanged) +``` + +## Detection Order + +Update detection order in `index.ts` to ensure Sub2API is detected before OneAPI (already the case). + +## Risk Assessment + +- **Low risk**: Header changes are additive (extra headers don't break existing platforms) +- **Medium risk**: Sub2API rewrite changes auth flow entirely (but current implementation doesn't work anyway) +- **Low risk**: OneHub/DoneHub enhancements add fallback paths (existing paths still work) diff --git a/docs/plans/2026-03-02-platform-adapter-alignment.md b/docs/plans/2026-03-02-platform-adapter-alignment.md new file mode 100644 index 0000000..88be182 --- /dev/null +++ b/docs/plans/2026-03-02-platform-adapter-alignment.md @@ -0,0 +1,841 @@ +# Platform Adapter Alignment Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Align metapi's platform adapters with all-api-hub behavior so all supported platforms (VoAPI, Super-API, RIX_API, Neo-API, Sub2API, OneHub, DoneHub, Veloera) work correctly. + +**Architecture:** Modify 5 existing adapter files within the class-based adapter hierarchy. No new patterns or dependencies. All changes are additive or replacement of stub implementations. + +**Tech Stack:** TypeScript, Node.js, undici (HTTP), vitest (testing) + +--- + +### Task 1: Add Compatibility User-ID Headers to NewApiAdapter + +**Files:** +- Modify: `src/server/services/platforms/newApi.ts:32-36` +- Test: `src/server/services/platforms/newApi.test.ts` + +**Step 1: Write the failing test** + +Add a test that verifies all 6 compat headers are sent when userId is present. + +```typescript +// In newApi.test.ts, add: +it('sends all compatibility user-id headers when userId is known', async () => { + const receivedHeaders: Record = {}; + server = createServer((req, res) => { + // Capture headers from the request + for (const name of ['new-api-user', 'veloera-user', 'voapi-user', 'user-id', 'rix-api-user', 'neo-api-user']) { + const val = req.headers[name]; + if (val) receivedHeaders[name] = String(val); + } + if (req.url === '/api/user/self') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { id: 42, username: 'test', quota: 500000, used_quota: 0 } })); + return; + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + + const adapter = new NewApiAdapter(); + // Use a fake JWT with id=42 so userId is discovered + const fakeJwt = `header.${Buffer.from(JSON.stringify({ id: 42 })).toString('base64url')}.sig`; + await adapter.getBalance(baseUrl, fakeJwt, 42); + + expect(receivedHeaders['new-api-user']).toBe('42'); + expect(receivedHeaders['veloera-user']).toBe('42'); + expect(receivedHeaders['voapi-user']).toBe('42'); + expect(receivedHeaders['user-id']).toBe('42'); + expect(receivedHeaders['rix-api-user']).toBe('42'); + expect(receivedHeaders['neo-api-user']).toBe('42'); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/newApi.test.ts` +Expected: FAIL — only `new-api-user` header is present + +**Step 3: Write minimal implementation** + +In `src/server/services/platforms/newApi.ts`, replace the `authHeaders` method (lines 32-36): + +```typescript +// Before: +private authHeaders(accessToken: string, userId?: number): Record { + const headers: Record = { Authorization: `Bearer ${accessToken}` }; + if (userId) headers['New-Api-User'] = String(userId); + return headers; +} + +// After: +private authHeaders(accessToken: string, userId?: number): Record { + const headers: Record = { Authorization: `Bearer ${accessToken}` }; + if (userId) { + const value = String(userId); + headers['New-API-User'] = value; + headers['Veloera-User'] = value; + headers['voapi-user'] = value; + headers['User-id'] = value; + headers['Rix-Api-User'] = value; + headers['neo-api-user'] = value; + } + return headers; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/newApi.test.ts` +Expected: PASS + +**Step 5: Run all existing tests to check for regressions** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add src/server/services/platforms/newApi.ts src/server/services/platforms/newApi.test.ts +git commit -m "feat: add compatibility user-id headers to NewApiAdapter + +Fan out userId across all 6 known One-API/New-API family header names +(New-API-User, Veloera-User, voapi-user, User-id, Rix-Api-User, neo-api-user) +to match all-api-hub behavior. This ensures VoAPI, Super-API, RIX_API, +Neo-API, and Veloera deployments correctly identify the requesting user." +``` + +--- + +### Task 2: Rewrite Sub2API Adapter + +**Files:** +- Rewrite: `src/server/services/platforms/sub2api.ts` +- Create: `src/server/services/platforms/sub2api.test.ts` + +**Step 1: Write the failing tests** + +Create `src/server/services/platforms/sub2api.test.ts`: + +```typescript +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { Sub2ApiAdapter } from './sub2api.js'; + +describe('Sub2ApiAdapter', () => { + let server: ReturnType; + let baseUrl: string; + let adapter: Sub2ApiAdapter; + + beforeEach(() => { + adapter = new Sub2ApiAdapter(); + }); + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } + }); + + function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) { + return new Promise((resolve) => { + server = createServer(handler); + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); + } + + it('detects sub2api from URL', async () => { + expect(await adapter.detect('https://sub2api.example.com')).toBe(true); + expect(await adapter.detect('https://example.com')).toBe(false); + }); + + it('returns unsupported for checkin', async () => { + const result = await adapter.checkin('http://localhost', 'token'); + expect(result.success).toBe(false); + expect(result.message).toContain('not supported'); + }); + + it('fetches balance from /api/v1/auth/me', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 }, + })); + return; + } + res.writeHead(404).end(); + }); + + const balance = await adapter.getBalance(baseUrl, 'jwt-token'); + expect(balance.balance).toBeGreaterThan(0); + expect(balance.used).toBe(0); + }); + + it('fetches user info from /api/v1/auth/me', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 5.0 }, + })); + return; + } + res.writeHead(404).end(); + }); + + const userInfo = await adapter.getUserInfo(baseUrl, 'jwt-token'); + expect(userInfo).not.toBeNull(); + expect(userInfo!.username).toBe('testuser'); + }); + + it('falls back to email local part when username is empty', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { id: 1, username: '', email: 'alice@example.com', balance: 0 }, + })); + return; + } + res.writeHead(404).end(); + }); + + const userInfo = await adapter.getUserInfo(baseUrl, 'jwt-token'); + expect(userInfo!.username).toBe('alice'); + }); + + it('fetches models via /v1/models', async () => { + await startServer((req, res) => { + if (req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: [{ id: 'gpt-4o' }, { id: 'claude-3-opus' }], + })); + return; + } + res.writeHead(404).end(); + }); + + const models = await adapter.getModels(baseUrl, 'jwt-token'); + expect(models).toEqual(['gpt-4o', 'claude-3-opus']); + }); + + it('handles non-zero code as error in /api/v1/auth/me', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 401, + message: 'token expired', + data: null, + })); + return; + } + res.writeHead(404).end(); + }); + + await expect(adapter.getBalance(baseUrl, 'expired-token')).rejects.toThrow(); + }); + + it('login returns unsupported', async () => { + const result = await adapter.login('http://localhost', 'user', 'pass'); + expect(result.success).toBe(false); + }); + + it('token management returns empty/false', async () => { + expect(await adapter.getApiToken('http://localhost', 'token')).toBeNull(); + expect(await adapter.getApiTokens('http://localhost', 'token')).toEqual([]); + expect(await adapter.createApiToken('http://localhost', 'token')).toBe(false); + expect(await adapter.deleteApiToken('http://localhost', 'token', 'key')).toBe(false); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/sub2api.test.ts` +Expected: FAIL — current Sub2ApiAdapter doesn't have these methods + +**Step 3: Rewrite the adapter** + +Replace `src/server/services/platforms/sub2api.ts` entirely: + +```typescript +import { BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo } from './base.js'; + +/** + * Sub2API adapter. + * + * Sub2API uses JWT-based auth with endpoints under /api/v1/*. + * It does NOT support: login, check-in, token management, or usage stats. + * Balance is derived from a USD amount returned by /api/v1/auth/me. + */ +export class Sub2ApiAdapter extends BasePlatformAdapter { + readonly platformName = 'sub2api'; + + async detect(url: string): Promise { + return url.toLowerCase().includes('sub2api'); + } + + /** + * Parse the Sub2API { code, message, data } envelope. + * code === 0 means success; anything else is an error. + */ + private parseSub2ApiEnvelope(body: any, endpoint: string): T { + if (!body || typeof body !== 'object') { + throw new Error(`Invalid response from ${endpoint}`); + } + if (typeof body.code !== 'number') { + throw new Error(`Invalid response format from ${endpoint}`); + } + if (body.code !== 0) { + const message = typeof body.message === 'string' && body.message.trim() + ? body.message.trim() + : `Error code ${body.code} from ${endpoint}`; + throw new Error(message); + } + if (body.data === undefined) { + throw new Error(`Missing data in response from ${endpoint}`); + } + return body.data as T; + } + + /** + * Extract display name: prefer username, fall back to email local part. + */ + private getDisplayName(username?: string, email?: string): string { + const name = (username || '').trim(); + if (name) return name; + const mail = (email || '').trim(); + if (!mail) return ''; + const atIndex = mail.indexOf('@'); + return atIndex > 0 ? mail.slice(0, atIndex) : mail; + } + + /** + * Fetch user data from /api/v1/auth/me. + */ + private async fetchAuthMe(baseUrl: string, accessToken: string): Promise<{ + id: number; + username: string; + email: string; + balance: number; + }> { + const endpoint = '/api/v1/auth/me'; + const res = await this.fetchJson(`${baseUrl}${endpoint}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const data = this.parseSub2ApiEnvelope(res, endpoint); + + const id = typeof data.id === 'number' ? data.id + : typeof data.id === 'string' ? Number.parseInt(data.id, 10) + : NaN; + if (!Number.isFinite(id) || id <= 0) { + throw new Error(`Invalid user ID in response from ${endpoint}`); + } + + const balance = typeof data.balance === 'number' ? data.balance + : typeof data.balance === 'string' ? Number.parseFloat(data.balance) + : 0; + + return { + id, + username: typeof data.username === 'string' ? data.username : '', + email: typeof data.email === 'string' ? data.email : '', + balance: Number.isFinite(balance) ? balance : 0, + }; + } + + /** + * Convert USD balance to internal quota unit. + * Uses the same conversion factor as all-api-hub (500000 per USD). + */ + private usdToQuota(balanceUsd: number): number { + return Math.round(Math.max(0, balanceUsd) * 500000); + } + + // --- Login: Not supported (JWT only) --- + override async login(): Promise<{ success: false; message: string }> { + return { success: false, message: 'Sub2API uses JWT authentication; login is not supported' }; + } + + // --- User Info --- + override async getUserInfo(baseUrl: string, accessToken: string): Promise { + try { + const user = await this.fetchAuthMe(baseUrl, accessToken); + return { + username: this.getDisplayName(user.username, user.email), + email: user.email || undefined, + }; + } catch { + return null; + } + } + + // --- Check-in: Not supported --- + async checkin(): Promise { + return { success: false, message: 'Check-in is not supported by Sub2API' }; + } + + // --- Balance --- + async getBalance(baseUrl: string, accessToken: string): Promise { + const user = await this.fetchAuthMe(baseUrl, accessToken); + const quotaValue = this.usdToQuota(user.balance); + // Sub2API only provides current balance, no usage breakdown + return { + balance: quotaValue / 500000, + used: 0, + quota: quotaValue / 500000, + }; + } + + // --- Models: Standard OpenAI-compatible endpoint --- + async getModels(baseUrl: string, apiToken: string): Promise { + try { + const res = await this.fetchJson(`${baseUrl}/v1/models`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }); + return (res?.data || []).map((m: any) => m.id).filter(Boolean); + } catch { + return []; + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/sub2api.test.ts` +Expected: All PASS + +**Step 5: Run all tests for regressions** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add src/server/services/platforms/sub2api.ts src/server/services/platforms/sub2api.test.ts +git commit -m "feat: rewrite Sub2API adapter with proper JWT auth + +Sub2API uses JWT-based auth with /api/v1/* endpoints, completely different +from One-API. The old stub just inherited OneApiAdapter and couldn't work. + +Now implements: +- User info + balance via GET /api/v1/auth/me ({code, message, data} envelope) +- USD balance to quota conversion +- Display name fallback (username → email local part) +- Unsupported: login, check-in, token management +- Models: standard /v1/models (OpenAI-compatible)" +``` + +--- + +### Task 3: Enhance OneHub Adapter + +**Files:** +- Modify: `src/server/services/platforms/oneHub.ts` +- Create: `src/server/services/platforms/oneHub.test.ts` + +**Step 1: Write the failing tests** + +Create `src/server/services/platforms/oneHub.test.ts`: + +```typescript +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { OneHubAdapter } from './oneHub.js'; + +describe('OneHubAdapter', () => { + let server: ReturnType; + let baseUrl: string; + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } + }); + + function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) { + return new Promise((resolve) => { + server = createServer(handler); + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); + } + + it('falls back to /api/available_model when /v1/models fails', async () => { + await startServer((req, res) => { + if (req.url === '/v1/models') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unauthorized' })); + return; + } + if (req.url === '/api/available_model') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: { + 'gpt-4o': { price: { input: 0.5, output: 1.5 } }, + 'claude-3-opus': { price: { input: 1, output: 3 } }, + }, + })); + return; + } + res.writeHead(404).end(); + }); + + const adapter = new OneHubAdapter(); + const models = await adapter.getModels(baseUrl, 'token'); + expect(models).toEqual(expect.arrayContaining(['gpt-4o', 'claude-3-opus'])); + }); + + it('returns user groups from /api/user_group_map', async () => { + await startServer((req, res) => { + if (req.url === '/api/user_group_map') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: { default: 1.0, vip: 0.8 } })); + return; + } + res.writeHead(404).end(); + }); + + const adapter = new OneHubAdapter(); + const groups = await adapter.getUserGroups(baseUrl, 'token'); + expect(groups).toEqual(expect.arrayContaining(['default', 'vip'])); + }); + + it('parses token list from {data: [...]} envelope', async () => { + await startServer((req, res) => { + if (req.url?.startsWith('/api/token/')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: [ + { key: 'sk-hub-abc', name: 'my-token', status: 1, id: 1 }, + ], + })); + return; + } + res.writeHead(404).end(); + }); + + const adapter = new OneHubAdapter(); + const tokens = await adapter.getApiTokens(baseUrl, 'token'); + expect(tokens.length).toBe(1); + expect(tokens[0].key).toBe('sk-hub-abc'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/oneHub.test.ts` +Expected: FAIL — OneHubAdapter doesn't override getModels or getUserGroups + +**Step 3: Implement OneHub overrides** + +Replace `src/server/services/platforms/oneHub.ts`: + +```typescript +import { OneApiAdapter } from './oneApi.js'; + +export class OneHubAdapter extends OneApiAdapter { + readonly platformName = 'one-hub'; + + async detect(url: string): Promise { + const normalized = url.toLowerCase(); + return normalized.includes('onehub') || normalized.includes('one-hub'); + } + + /** + * OneHub model discovery: try /v1/models first, fall back to /api/available_model. + * The /api/available_model endpoint returns { data: { model_name: { price: ... }, ... } } + * where the keys are model names. + */ + override async getModels(baseUrl: string, apiToken: string, platformUserId?: number): Promise { + // Try standard OpenAI-compatible endpoint first + let openAiModels: string[] = []; + try { + openAiModels = await super.getModels(baseUrl, apiToken, platformUserId); + } catch {} + if (openAiModels.length > 0) return openAiModels; + + // Fall back to OneHub's /api/available_model + try { + const res = await this.fetchJson(`${baseUrl}/api/available_model`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }); + const payload = res?.data && typeof res.data === 'object' ? res.data : res; + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const models = Object.keys(payload).filter(Boolean); + if (models.length > 0) return models; + } + } catch {} + + return []; + } + + /** + * OneHub user groups: /api/user_group_map returns { data: { group_name: ratio, ... } } + */ + override async getUserGroups(baseUrl: string, accessToken: string): Promise { + try { + const res = await this.fetchJson(`${baseUrl}/api/user_group_map`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const source = res?.data || res; + if (source && typeof source === 'object' && !Array.isArray(source)) { + const groups = Object.keys(source).map((k) => k.trim()).filter(Boolean); + if (groups.length > 0) return Array.from(new Set(groups)); + } + } catch {} + + // Fall back to parent implementation + return super.getUserGroups(baseUrl, accessToken); + } +} +``` + +**Step 4: Run tests** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/oneHub.test.ts` +Expected: All PASS + +**Step 5: Run all tests** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add src/server/services/platforms/oneHub.ts src/server/services/platforms/oneHub.test.ts +git commit -m "feat: enhance OneHub adapter with model and group discovery + +Add OneHub-specific overrides: +- getModels: fallback to /api/available_model when /v1/models unavailable +- getUserGroups: use /api/user_group_map endpoint +Aligns with all-api-hub's OneHub service implementation." +``` + +--- + +### Task 4: Enhance DoneHub Adapter (Re-parent to OneHub) + +**Files:** +- Modify: `src/server/services/platforms/doneHub.ts` +- Modify: `src/server/services/platforms/doneHub.test.ts` + +**Step 1: Add test for inherited OneHub behavior** + +Add to existing `doneHub.test.ts`: + +```typescript +it('inherits OneHub model discovery from /api/available_model', async () => { + // Already tested — existing test "falls back to /api/available_model" covers this + // Just verify it still works after re-parenting + const adapter = new DoneHubAdapter(); + const models = await adapter.getModels(baseUrl, 'token'); + expect(models).toEqual(['gpt-4o', 'deepseek-chat']); +}); + +it('inherits OneHub user group discovery', async () => { + // Add /api/user_group_map handler to server + // This requires modifying the server setup +}); +``` + +**Step 2: Re-parent DoneHub to extend OneHub** + +Modify `src/server/services/platforms/doneHub.ts`: + +```typescript +import { OneHubAdapter } from './oneHub.js'; +import type { CheckinResult } from './base.js'; + +export class DoneHubAdapter extends OneHubAdapter { + readonly platformName = 'done-hub'; + + async detect(url: string): Promise { + const normalized = url.toLowerCase(); + return normalized.includes('donehub') || normalized.includes('done-hub'); + } + + // DoneHub deployments generally do not expose /api/user/checkin. + override async checkin(_baseUrl: string, _accessToken: string): Promise { + return { success: false, message: 'checkin endpoint not found' }; + } + + // getModels is inherited from OneHubAdapter which already has /api/available_model fallback + // No need to override — remove the duplicate implementation +} +``` + +**Step 3: Run existing DoneHub tests** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run src/server/services/platforms/doneHub.test.ts` +Expected: All PASS (behavior unchanged, just cleaner inheritance) + +**Step 4: Run all tests** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add src/server/services/platforms/doneHub.ts src/server/services/platforms/doneHub.test.ts +git commit -m "refactor: re-parent DoneHub to extend OneHub adapter + +DoneHub is downstream of OneHub. By extending OneHubAdapter instead of +OneApiAdapter, DoneHub inherits OneHub's /api/available_model fallback +and /api/user_group_map support. Removes duplicate getModels override +that OneHub now provides." +``` + +--- + +### Task 5: Enhance Veloera Adapter with Compat Headers + +**Files:** +- Modify: `src/server/services/platforms/veloera.ts` + +**Step 1: Review current state** + +The Veloera adapter already has: +- Correct quota conversion (1,000,000 divisor) +- Working checkin via `/api/user/checkin` +- Working balance via `/api/user/self` +- Working models via `/v1/models` + +The main gap: Veloera doesn't send compat user-id headers in its requests. Since VeloeraAdapter extends BasePlatformAdapter (not NewApiAdapter), it uses simple `Authorization: Bearer` without any user-id headers. + +**Step 2: Add compat headers to Veloera balance/checkin requests** + +Modify `src/server/services/platforms/veloera.ts` to include `Veloera-User` header when userId is available: + +```typescript +import { BasePlatformAdapter, CheckinResult, BalanceInfo } from './base.js'; + +export class VeloeraAdapter extends BasePlatformAdapter { + readonly platformName = 'veloera'; + + async detect(url: string): Promise { + try { + const res = await this.fetchJson(`${url}/api/status`); + return res?.success === true && ( + res?.data?.system_name?.toLowerCase().includes('veloera') || + res?.data?.version?.includes('veloera') + ); + } catch { + return false; + } + } + + private veloeraHeaders(accessToken: string, userId?: number): Record { + const headers: Record = { Authorization: `Bearer ${accessToken}` }; + if (userId) { + const value = String(userId); + headers['Veloera-User'] = value; + headers['New-API-User'] = value; + headers['User-id'] = value; + } + return headers; + } + + async checkin(baseUrl: string, accessToken: string, platformUserId?: number): Promise { + try { + const res = await this.fetchJson(`${baseUrl}/api/user/checkin`, { + method: 'POST', + headers: this.veloeraHeaders(accessToken, platformUserId), + }); + if (res?.success) { + return { success: true, message: res.message || 'Check-in successful', reward: res.data?.reward?.toString() }; + } + return { success: false, message: res?.message || 'Check-in failed' }; + } catch (err: any) { + return { success: false, message: err.message }; + } + } + + async getBalance(baseUrl: string, accessToken: string, platformUserId?: number): Promise { + const res = await this.fetchJson(`${baseUrl}/api/user/self`, { + headers: this.veloeraHeaders(accessToken, platformUserId), + }); + const data = res?.data; + const quota = (data?.quota || 0) / 1000000; + const used = (data?.used_quota || 0) / 1000000; + const todayIncome = Number.isFinite(data?.today_income) ? (data.today_income / 1000000) : undefined; + const todayQuotaConsumption = Number.isFinite(data?.today_quota_consumption) ? (data.today_quota_consumption / 1000000) : undefined; + return { balance: quota - used, used, quota, todayIncome, todayQuotaConsumption }; + } + + async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise { + const res = await this.fetchJson(`${baseUrl}/v1/models`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }); + return (res?.data || []).map((m: any) => m.id).filter(Boolean); + } +} +``` + +**Step 3: Run all tests** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run` +Expected: All PASS + +**Step 4: Commit** + +```bash +git add src/server/services/platforms/veloera.ts +git commit -m "feat: add compat user-id headers to Veloera adapter + +Add Veloera-User, New-API-User, and User-id headers to balance and +checkin requests. Accept platformUserId parameter in checkin/getBalance +for consistency with other adapters." +``` + +--- + +### Task 6: Update Income Log Fallback for Sub2API + +**Files:** +- Check: `src/server/services/balanceService.ts` + +**Step 1: Verify Sub2API is NOT in the income log fallback list** + +The `supportsTodayIncomeLogFallback()` function only includes `new-api`, `anyrouter`, `one-api`, `veloera`. Sub2API should NOT be in this list (it doesn't support log endpoints). Verify this is already correct — no change needed. + +**Step 2: Run full test suite** + +Run: `cd /d/Desktop/metapi工作区/metapi && npx vitest run` +Expected: All PASS — this is the final verification + +**Step 3: Final commit (if any remaining changes)** + +No code changes expected for this task. It's a verification step. diff --git a/package-lock.json b/package-lock.json index 216b893..5895608 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metapi", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metapi", - "version": "1.0.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts new file mode 100644 index 0000000..09a6d4d --- /dev/null +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown) => null); + +vi.mock('../../services/modelPricingService.js', () => ({ + fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg), +})); + +import { resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js'; + +const baseContext = { + site: { + id: 1, + url: 'https://upstream.example.com', + platform: '', + apiKey: null, + }, + account: { + id: 2, + accessToken: 'token-demo', + apiToken: null, + }, +}; + +describe('resolveUpstreamEndpointCandidates', () => { + beforeEach(() => { + fetchModelPricingCatalogMock.mockReset(); + fetchModelPricingCatalogMock.mockResolvedValue(null); + }); + + it('uses responses -> messages -> chat for unknown platforms', async () => { + const openaiOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + ); + expect(openaiOrder).toEqual(['responses', 'messages', 'chat']); + + const claudeOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'claude', + ); + expect(claudeOrder).toEqual(['responses', 'messages', 'chat']); + + const responsesOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + ); + expect(responsesOrder).toEqual(['responses', 'messages', 'chat']); + }); + + it('keeps explicit platform priority rules', async () => { + const openaiOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'openai' }, + }, + 'gpt-5.3', + 'openai', + ); + expect(openaiOrder).toEqual(['chat', 'responses']); + + const openaiResponsesOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'openai' }, + }, + 'gpt-5.3', + 'responses', + ); + expect(openaiResponsesOrder).toEqual(['responses', 'chat']); + + const claudeOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'claude' }, + }, + 'claude-opus-4-6', + 'claude', + ); + expect(claudeOrder).toEqual(['messages']); + }); + + it('keeps anyrouter messages-first special case', async () => { + const openaiOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'anyrouter' }, + }, + 'claude-opus-4-6', + 'openai', + ); + expect(openaiOrder).toEqual(['messages', 'chat']); + + const responsesOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'anyrouter' }, + }, + 'claude-opus-4-6', + 'responses', + ); + expect(responsesOrder).toEqual(['responses', 'messages', 'chat']); + }); +}); diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 9320fad..c5917d5 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -381,13 +381,9 @@ function preferredEndpointOrder(downstreamFormat: EndpointPreference, sitePlatfo return ['messages']; } - if (downstreamFormat === 'responses') { - return ['responses', 'chat', 'messages']; - } - - return downstreamFormat === 'claude' - ? ['messages', 'chat'] - : ['chat', 'messages']; + // Unknown/generic upstreams: try the most expressive protocol first, + // then degrade to anthropic messages, and finally chat completions. + return ['responses', 'messages', 'chat']; } export async function resolveUpstreamEndpointCandidates( diff --git a/src/server/services/platforms/cliproxyapi.test.ts b/src/server/services/platforms/cliproxyapi.test.ts new file mode 100644 index 0000000..d244985 --- /dev/null +++ b/src/server/services/platforms/cliproxyapi.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { CliProxyApiAdapter } from './cliproxyapi.js'; + +describe('CliProxyApiAdapter detect', () => { + let server: ReturnType | undefined; + let baseUrl = ''; + let adapter: CliProxyApiAdapter; + + beforeEach(() => { + adapter = new CliProxyApiAdapter(); + }); + + afterEach(async () => { + if (!server) return; + const current = server; + server = undefined; + await new Promise((resolve, reject) => { + current.close((err?: Error) => (err ? reject(err) : resolve())); + }); + }); + + async function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) { + await new Promise((resolve) => { + server = createServer(handler); + server.listen(0, '127.0.0.1', () => { + const address = server!.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + resolve(); + }); + }); + } + + it('detects known default cliproxy port', async () => { + expect(await adapter.detect('http://127.0.0.1:8317')).toBe(true); + }); + + it('does not treat generic 401 response as cliproxy', async () => { + await startServer((req, res) => { + if (req.url === '/v0/management/openai-compatibility') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unauthorized' })); + return; + } + res.writeHead(404).end(); + }); + + expect(await adapter.detect(baseUrl)).toBe(false); + }); + + it('detects cliproxy management endpoint by X-CPA headers', async () => { + await startServer((req, res) => { + if (req.url === '/v0/management/openai-compatibility') { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'X-CPA-VERSION': 'v0.0.0-test', + }); + res.end(JSON.stringify({ error: 'missing management key' })); + return; + } + res.writeHead(404).end(); + }); + + expect(await adapter.detect(baseUrl)).toBe(true); + }); + + it('detects open management endpoint by response payload key', async () => { + await startServer((req, res) => { + if (req.url === '/v0/management/openai-compatibility') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ 'openai-compatibility': [] })); + return; + } + res.writeHead(404).end(); + }); + + expect(await adapter.detect(baseUrl)).toBe(true); + }); +}); diff --git a/src/server/services/platforms/cliproxyapi.ts b/src/server/services/platforms/cliproxyapi.ts new file mode 100644 index 0000000..d175704 --- /dev/null +++ b/src/server/services/platforms/cliproxyapi.ts @@ -0,0 +1,83 @@ +import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +export class CliProxyApiAdapter extends BasePlatformAdapter { + readonly platformName = 'cliproxyapi'; + + async detect(url: string): Promise { + const normalized = (url || '').toLowerCase(); + + // Quick check: default CLIProxyAPI port + if (/:8317(\/|$)/.test(normalized)) { + return true; + } + + // Quick check: common hostname keyword + if (normalized.includes('cliproxy')) { + return true; + } + + // Probe management endpoint with strict signature checks. + // Do not trust bare 401/403 because many non-CLIProxy sites may return + // those statuses for unknown/protected paths. + try { + const base = normalizeBaseUrl(url); + const { fetch } = await import('undici'); + const res = await fetch(`${base}/v0/management/openai-compatibility`, { + method: 'GET', + signal: AbortSignal.timeout(5000), + }); + + const hasCpaHeaders = Boolean( + res.headers.get('x-cpa-version') + || res.headers.get('x-cpa-commit') + || res.headers.get('x-cpa-build-date'), + ); + if (hasCpaHeaders) { + return res.status === 200 || res.status === 401 || res.status === 403; + } + + if (res.status === 200) { + const payload = await res.json().catch(() => null); + if (payload && typeof payload === 'object') { + return Object.prototype.hasOwnProperty.call(payload, 'openai-compatibility'); + } + } + + return false; + } catch { + return false; + } + } + + override async login(_baseUrl: string, _username: string, _password: string) { + return { success: false as const, message: 'CLIProxyAPI does not support login' }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + async checkin(_baseUrl: string, _accessToken: string): Promise { + return { success: false, message: 'CLIProxyAPI does not support checkin' }; + } + + async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + async getModels(baseUrl: string, apiToken: string): Promise { + try { + const base = normalizeBaseUrl(baseUrl); + const res = await this.fetchJson(`${base}/v1/models`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }); + return (res?.data || []).map((m: any) => m?.id).filter(Boolean); + } catch { + return []; + } + } +} diff --git a/src/server/services/platforms/doneHub.ts b/src/server/services/platforms/doneHub.ts index eac84b0..23dc940 100644 --- a/src/server/services/platforms/doneHub.ts +++ b/src/server/services/platforms/doneHub.ts @@ -1,8 +1,8 @@ -import { OneApiAdapter } from './oneApi.js'; +import { OneHubAdapter } from './oneHub.js'; import type { CheckinResult } from './base.js'; -export class DoneHubAdapter extends OneApiAdapter { - readonly platformName = 'done-hub'; +export class DoneHubAdapter extends OneHubAdapter { + readonly platformName: string = 'done-hub'; async detect(url: string): Promise { const normalized = url.toLowerCase(); @@ -15,25 +15,6 @@ export class DoneHubAdapter extends OneApiAdapter { return { success: false, message: 'checkin endpoint not found' }; } - override async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise { - let openAiModels: string[] = []; - try { - openAiModels = await super.getModels(baseUrl, apiToken, _platformUserId); - } catch {} - if (openAiModels.length > 0) return openAiModels; - - try { - const res = await this.fetchJson(`${baseUrl}/api/available_model`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - - const payload = (res?.data && typeof res.data === 'object') ? res.data : res; - if (payload && typeof payload === 'object' && !Array.isArray(payload)) { - const models = Object.keys(payload).filter(Boolean); - if (models.length > 0) return models; - } - } catch {} - - return []; - } + // getModels is inherited from OneHubAdapter which already has /api/available_model fallback. + // No need to override here — OneHub's implementation handles this correctly. } diff --git a/src/server/services/platforms/index.test.ts b/src/server/services/platforms/index.test.ts index 62701f0..12b025b 100644 --- a/src/server/services/platforms/index.test.ts +++ b/src/server/services/platforms/index.test.ts @@ -1,6 +1,27 @@ import { describe, expect, it } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { type AddressInfo } from 'node:net'; import { detectPlatform, getAdapter } from './index.js'; +async function withHttpServer( + handler: (req: IncomingMessage, res: ServerResponse) => void, + run: (baseUrl: string) => Promise, +) { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const { port } = server.address() as AddressInfo; + const baseUrl = `http://127.0.0.1:${port}`; + try { + await run(baseUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } +} + describe('getAdapter platform aliases', () => { it('returns dedicated anyrouter adapter for anyrouter alias', () => { const adapter = getAdapter('anyrouter'); @@ -41,4 +62,76 @@ describe('getAdapter platform aliases', () => { expect(claude?.platformName).toBe('claude'); expect(gemini?.platformName).toBe('gemini'); }); + + it('detects one-hub by title under custom domain before generic new-api', async () => { + await withHttpServer((req, res) => { + if (req.url === '/') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('One-Hub Console'); + return; + } + if (req.url === '/api/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + data: { system_name: 'New API' }, + })); + return; + } + res.writeHead(404).end(); + }, async (baseUrl) => { + const adapter = await detectPlatform(baseUrl); + expect(adapter?.platformName).toBe('one-hub'); + }); + }); + + it('detects done-hub by title under custom domain', async () => { + await withHttpServer((req, res) => { + if (req.url === '/') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('Done-Hub Panel'); + return; + } + res.writeHead(404).end(); + }, async (baseUrl) => { + const adapter = await detectPlatform(baseUrl); + expect(adapter?.platformName).toBe('done-hub'); + }); + }); + + it('detects veloera by title under custom domain before generic new-api', async () => { + await withHttpServer((req, res) => { + if (req.url === '/') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('Veloera 管理台'); + return; + } + if (req.url === '/api/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + data: { system_name: 'new-api fork' }, + })); + return; + } + res.writeHead(404).end(); + }, async (baseUrl) => { + const adapter = await detectPlatform(baseUrl); + expect(adapter?.platformName).toBe('veloera'); + }); + }); + + it('falls back to new-api by title when api/status is unavailable', async () => { + await withHttpServer((req, res) => { + if (req.url === '/') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('Super-API Dashboard'); + return; + } + res.writeHead(404).end(); + }, async (baseUrl) => { + const adapter = await detectPlatform(baseUrl); + expect(adapter?.platformName).toBe('new-api'); + }); + }); }); diff --git a/src/server/services/platforms/index.ts b/src/server/services/platforms/index.ts index d305ce6..09a53d7 100644 --- a/src/server/services/platforms/index.ts +++ b/src/server/services/platforms/index.ts @@ -9,19 +9,22 @@ import { Sub2ApiAdapter } from './sub2api.js'; import { OpenAiAdapter } from './openai.js'; import { ClaudeAdapter } from './claude.js'; import { GeminiAdapter } from './gemini.js'; +import { CliProxyApiAdapter } from './cliproxyapi.js'; +import { detectPlatformByTitle } from './titleHint.js'; const adapters: PlatformAdapter[] = [ // Specific forks before generic adapters for better auto-detection. new OpenAiAdapter(), new ClaudeAdapter(), new GeminiAdapter(), + new CliProxyApiAdapter(), new AnyRouterAdapter(), new DoneHubAdapter(), new OneHubAdapter(), + new VeloeraAdapter(), new NewApiAdapter(), new Sub2ApiAdapter(), new OneApiAdapter(), - new VeloeraAdapter(), ]; const platformAliases: Record = { @@ -50,6 +53,10 @@ const platformAliases: Record = { claude: 'claude', gemini: 'gemini', google: 'gemini', + // CLIProxyAPI aliases + cliproxyapi: 'cliproxyapi', + cpa: 'cliproxyapi', + 'cli-proxy-api': 'cliproxyapi', }; function normalizePlatform(platform: string): string { @@ -62,9 +69,27 @@ export function getAdapter(platform: string): PlatformAdapter | undefined { return adapters.find((a) => a.platformName === normalized); } +const titleFirstPlatforms = new Set([ + 'anyrouter', + 'done-hub', + 'one-hub', + 'veloera', + 'sub2api', +]); + export async function detectPlatform(url: string): Promise { + const titleHint = await detectPlatformByTitle(url); + if (titleHint && titleFirstPlatforms.has(titleHint)) { + return getAdapter(titleHint); + } + for (const adapter of adapters) { if (await adapter.detect(url)) return adapter; } + + if (titleHint) { + return getAdapter(titleHint); + } + return undefined; } diff --git a/src/server/services/platforms/newApi.test.ts b/src/server/services/platforms/newApi.test.ts index 1f83af8..f56fde3 100644 --- a/src/server/services/platforms/newApi.test.ts +++ b/src/server/services/platforms/newApi.test.ts @@ -411,4 +411,37 @@ describe('NewApiAdapter', () => { expect(result.success).toBe(false); expect(result.message).toBe('浠婂ぉ宸茬粡绛惧埌杩囧暒'); }); + + it('sends all compatibility user-id headers when userId is known', async () => { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + const receivedHeaders: Record = {}; + server = createServer((req, res) => { + for (const name of ['new-api-user', 'veloera-user', 'voapi-user', 'user-id', 'rix-api-user', 'neo-api-user']) { + const val = req.headers[name]; + if (val) receivedHeaders[name] = String(val); + } + if (req.url === '/api/user/self') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { id: 42, username: 'test', quota: 500000, used_quota: 0 } })); + return; + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + + const adapter = new NewApiAdapter(); + const fakeJwt = `header.${Buffer.from(JSON.stringify({ id: 42 })).toString('base64url')}.sig`; + await adapter.getBalance(baseUrl, fakeJwt, 42); + + expect(receivedHeaders['new-api-user']).toBe('42'); + expect(receivedHeaders['veloera-user']).toBe('42'); + expect(receivedHeaders['voapi-user']).toBe('42'); + expect(receivedHeaders['user-id']).toBe('42'); + expect(receivedHeaders['rix-api-user']).toBe('42'); + expect(receivedHeaders['neo-api-user']).toBe('42'); + }); }); diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts index 41d875d..2ec7a2d 100644 --- a/src/server/services/platforms/newApi.ts +++ b/src/server/services/platforms/newApi.ts @@ -31,7 +31,15 @@ export class NewApiAdapter extends BasePlatformAdapter { private authHeaders(accessToken: string, userId?: number): Record { const headers: Record = { Authorization: `Bearer ${accessToken}` }; - if (userId) headers['New-Api-User'] = String(userId); + if (userId) { + const value = String(userId); + headers['New-API-User'] = value; + headers['Veloera-User'] = value; + headers['voapi-user'] = value; + headers['User-id'] = value; + headers['Rix-Api-User'] = value; + headers['neo-api-user'] = value; + } return headers; } diff --git a/src/server/services/platforms/oneHub.test.ts b/src/server/services/platforms/oneHub.test.ts new file mode 100644 index 0000000..548c663 --- /dev/null +++ b/src/server/services/platforms/oneHub.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { OneHubAdapter } from './oneHub.js'; + +describe('OneHubAdapter', () => { + let server: ReturnType | undefined; + let baseUrl: string; + + afterEach(async () => { + if (server) { + const s = server; + server = undefined; + await new Promise((resolve, reject) => { + s.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } + }); + + function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) { + return new Promise((resolve) => { + server = createServer(handler); + server.listen(0, '127.0.0.1', () => { + const addr = server!.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); + } + + it('falls back to /api/available_model when /v1/models fails', async () => { + await startServer((req, res) => { + if (req.url === '/v1/models') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unauthorized' })); + return; + } + if (req.url === '/api/available_model') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: { + 'gpt-4o': { price: { input: 0.5, output: 1.5 } }, + 'claude-3-opus': { price: { input: 1, output: 3 } }, + }, + })); + return; + } + res.writeHead(404).end(); + }); + + const adapter = new OneHubAdapter(); + const models = await adapter.getModels(baseUrl, 'token'); + expect(models).toEqual(expect.arrayContaining(['gpt-4o', 'claude-3-opus'])); + }); + + it('returns user groups from /api/user_group_map', async () => { + await startServer((req, res) => { + if (req.url === '/api/user_group_map') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: { default: 1.0, vip: 0.8 } })); + return; + } + res.writeHead(404).end(); + }); + + const adapter = new OneHubAdapter(); + const groups = await adapter.getUserGroups(baseUrl, 'token'); + expect(groups).toEqual(expect.arrayContaining(['default', 'vip'])); + }); + + it('parses token list from {data: [...]} envelope', async () => { + await startServer((req, res) => { + if (req.url?.startsWith('/api/token/')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: [ + { key: 'sk-hub-abc', name: 'my-token', status: 1, id: 1 }, + ], + })); + return; + } + res.writeHead(404).end(); + }); + + const adapter = new OneHubAdapter(); + const tokens = await adapter.getApiTokens(baseUrl, 'token'); + expect(tokens.length).toBe(1); + expect(tokens[0].key).toBe('sk-hub-abc'); + }); +}); diff --git a/src/server/services/platforms/oneHub.ts b/src/server/services/platforms/oneHub.ts index e2dbfbb..55da00b 100644 --- a/src/server/services/platforms/oneHub.ts +++ b/src/server/services/platforms/oneHub.ts @@ -1,11 +1,54 @@ import { OneApiAdapter } from './oneApi.js'; export class OneHubAdapter extends OneApiAdapter { - readonly platformName = 'one-hub'; + readonly platformName: string = 'one-hub'; async detect(url: string): Promise { const normalized = url.toLowerCase(); return normalized.includes('onehub') || normalized.includes('one-hub'); } -} + /** + * OneHub model discovery: try /v1/models first, fall back to /api/available_model. + * The /api/available_model endpoint returns { data: { model_name: { price: ... }, ... } } + * where the keys are model names. + */ + override async getModels(baseUrl: string, apiToken: string, platformUserId?: number): Promise { + let openAiModels: string[] = []; + try { + openAiModels = await super.getModels(baseUrl, apiToken, platformUserId); + } catch {} + if (openAiModels.length > 0) return openAiModels; + + try { + const res = await this.fetchJson(`${baseUrl}/api/available_model`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }); + const payload = res?.data && typeof res.data === 'object' ? res.data : res; + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const models = Object.keys(payload).filter(Boolean); + if (models.length > 0) return models; + } + } catch {} + + return []; + } + + /** + * OneHub user groups: /api/user_group_map returns { data: { group_name: ratio, ... } } + */ + override async getUserGroups(baseUrl: string, accessToken: string): Promise { + try { + const res = await this.fetchJson(`${baseUrl}/api/user_group_map`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const source = res?.data || res; + if (source && typeof source === 'object' && !Array.isArray(source)) { + const groups = Object.keys(source).map((k) => k.trim()).filter(Boolean); + if (groups.length > 0) return Array.from(new Set(groups)); + } + } catch {} + + return super.getUserGroups(baseUrl, accessToken); + } +} diff --git a/src/server/services/platforms/sub2api.test.ts b/src/server/services/platforms/sub2api.test.ts new file mode 100644 index 0000000..96c6927 --- /dev/null +++ b/src/server/services/platforms/sub2api.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { Sub2ApiAdapter } from './sub2api.js'; + +describe('Sub2ApiAdapter', () => { + let server: ReturnType | undefined; + let baseUrl: string; + let adapter: Sub2ApiAdapter; + + beforeEach(() => { + adapter = new Sub2ApiAdapter(); + }); + + afterEach(async () => { + if (server) { + const s = server; + server = undefined; + await new Promise((resolve, reject) => { + s.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } + }); + + function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) { + return new Promise((resolve) => { + server = createServer(handler); + server.listen(0, '127.0.0.1', () => { + const addr = server!.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); + } + + it('detects sub2api from URL', async () => { + expect(await adapter.detect('https://sub2api.example.com')).toBe(true); + expect(await adapter.detect('https://example.com')).toBe(false); + }); + + it('detects sub2api by auth/me unauthorized envelope even without sub2api domain', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 'UNAUTHORIZED', + message: 'Authorization header is required', + })); + return; + } + if (req.url === '/v1/models') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 'API_KEY_REQUIRED', + message: 'API key is required', + })); + return; + } + res.writeHead(404).end(); + }); + + expect(await adapter.detect(baseUrl)).toBe(true); + }); + + it('does not mis-detect generic json 401 responses', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me' || req.url === '/v1/models') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unauthorized' })); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('Not Sub2'); + }); + + expect(await adapter.detect(baseUrl)).toBe(false); + }); + + it('returns unsupported for checkin', async () => { + const result = await adapter.checkin('http://localhost', 'token'); + expect(result.success).toBe(false); + expect(result.message).toContain('not supported'); + }); + + it('fetches balance from /api/v1/auth/me', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 }, + })); + return; + } + res.writeHead(404).end(); + }); + + const balance = await adapter.getBalance(baseUrl, 'jwt-token'); + expect(balance.balance).toBeGreaterThan(0); + expect(balance.used).toBe(0); + }); + + it('fetches user info from /api/v1/auth/me', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 5.0 }, + })); + return; + } + res.writeHead(404).end(); + }); + + const userInfo = await adapter.getUserInfo(baseUrl, 'jwt-token'); + expect(userInfo).not.toBeNull(); + expect(userInfo!.username).toBe('testuser'); + }); + + it('falls back to email local part when username is empty', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { id: 1, username: '', email: 'alice@example.com', balance: 0 }, + })); + return; + } + res.writeHead(404).end(); + }); + + const userInfo = await adapter.getUserInfo(baseUrl, 'jwt-token'); + expect(userInfo!.username).toBe('alice'); + }); + + it('fetches models via /v1/models', async () => { + await startServer((req, res) => { + if (req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: [{ id: 'gpt-4o' }, { id: 'claude-3-opus' }], + })); + return; + } + res.writeHead(404).end(); + }); + + const models = await adapter.getModels(baseUrl, 'jwt-token'); + expect(models).toEqual(['gpt-4o', 'claude-3-opus']); + }); + + it('handles non-zero code as error in /api/v1/auth/me', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/auth/me') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 401, + message: 'token expired', + data: null, + })); + return; + } + res.writeHead(404).end(); + }); + + await expect(adapter.getBalance(baseUrl, 'expired-token')).rejects.toThrow(); + }); + + it('login returns unsupported', async () => { + const result = await adapter.login('http://localhost', 'user', 'pass'); + expect(result.success).toBe(false); + }); + + it('token management returns empty/false', async () => { + expect(await adapter.getApiToken('http://localhost', 'token')).toBeNull(); + expect(await adapter.getApiTokens('http://localhost', 'token')).toEqual([]); + expect(await adapter.createApiToken('http://localhost', 'token')).toBe(false); + expect(await adapter.deleteApiToken('http://localhost', 'token', 'key')).toBe(false); + }); +}); diff --git a/src/server/services/platforms/sub2api.ts b/src/server/services/platforms/sub2api.ts index 754f8cc..286b917 100644 --- a/src/server/services/platforms/sub2api.ts +++ b/src/server/services/platforms/sub2api.ts @@ -1,10 +1,208 @@ -import { OneApiAdapter } from './oneApi.js'; +import { BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo } from './base.js'; -export class Sub2ApiAdapter extends OneApiAdapter { +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +/** + * Sub2API adapter. + * + * Sub2API uses JWT-based auth with endpoints under /api/v1/*. + * It does NOT support: login, check-in, token management, or usage stats. + * Balance is derived from a USD amount returned by /api/v1/auth/me. + */ +export class Sub2ApiAdapter extends BasePlatformAdapter { readonly platformName = 'sub2api'; async detect(url: string): Promise { - return url.toLowerCase().includes('sub2api'); + const normalized = (url || '').toLowerCase(); + if (normalized.includes('sub2api')) return true; + + const base = normalizeBaseUrl(url); + const { fetch } = await import('undici'); + const probeEndpoint = async (path: string) => { + try { + return await fetch(`${base}${path}`, { + method: 'GET', + signal: AbortSignal.timeout(5000), + }); + } catch { + return null; + } + }; + + const matchSub2ApiErrorEnvelope = async (res: { + headers: { get(name: string): string | null }; + json: () => Promise; + } | null): Promise => { + if (!res) return false; + const contentType = res.headers.get('content-type') || ''; + if (!contentType.toLowerCase().includes('application/json')) return false; + const body = await res.json().catch(() => null) as Record | null; + if (!body || typeof body !== 'object') return false; + const rawCode = body.code; + const code = typeof rawCode === 'string' ? rawCode.trim().toUpperCase() : ''; + const message = typeof body.message === 'string' ? body.message.trim().toLowerCase() : ''; + + if (code === 'UNAUTHORIZED' || code === 'API_KEY_REQUIRED') return true; + if ( + message.includes('authorization header is required') + || message.includes('api key is required') + ) { + return true; + } + + // Some Sub2API variants return numeric success envelope for authorized calls. + if (typeof rawCode === 'number' && rawCode === 0) { + return Object.prototype.hasOwnProperty.call(body, 'data'); + } + + return false; + }; + + const authProbe = await probeEndpoint('/api/v1/auth/me'); + if (await matchSub2ApiErrorEnvelope(authProbe)) return true; + + const modelsProbe = await probeEndpoint('/v1/models'); + if (await matchSub2ApiErrorEnvelope(modelsProbe)) return true; + + // Last fallback: many Sub2API UIs expose an identifying title on root. + const rootProbe = await probeEndpoint('/'); + if (!rootProbe) return false; + const rootType = rootProbe.headers.get('content-type') || ''; + if (!rootType.toLowerCase().includes('text/html')) return false; + const rootText = await rootProbe.text().catch(() => ''); + return /\s*sub2api\b/i.test(rootText); + } + + /** + * Parse the Sub2API { code, message, data } envelope. + * code === 0 means success; anything else is an error. + */ + private parseSub2ApiEnvelope<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 []; + } } } - diff --git a/src/server/services/platforms/titleHint.ts b/src/server/services/platforms/titleHint.ts new file mode 100644 index 0000000..57fe3de --- /dev/null +++ b/src/server/services/platforms/titleHint.ts @@ -0,0 +1,73 @@ +export type TitleHintPlatform = + | 'anyrouter' + | 'done-hub' + | 'one-hub' + | 'veloera' + | 'sub2api' + | 'new-api' + | 'one-api'; + +type TitleRule = { + platform: TitleHintPlatform; + regex: RegExp; +}; + +const TITLE_RULES: TitleRule[] = [ + { platform: 'anyrouter', regex: /\bany\s*router\b/i }, + { platform: 'done-hub', regex: /\bdone[-_ ]?hub\b/i }, + { platform: 'one-hub', regex: /\bone[-_ ]?hub\b/i }, + { platform: 'veloera', regex: /\bveloera\b/i }, + { platform: 'sub2api', regex: /\bsub2api\b/i }, + { platform: 'new-api', regex: /\bnew[-_ ]?api\b/i }, + { platform: 'new-api', regex: /\bvo[-_ ]?api\b/i }, + { platform: 'new-api', regex: /\bsuper[-_ ]?api\b/i }, + { platform: 'new-api', regex: /\brix[-_ ]?api\b/i }, + { platform: 'new-api', regex: /\bneo[-_ ]?api\b/i }, + { platform: 'new-api', regex: /wong\s*(?:\u516c\u76ca\u7ad9)/i }, + { platform: 'one-api', regex: /\bone[-_ ]?api\b/i }, +]; + +function normalizeBaseUrl(url: string): string { + const trimmed = (url || '').trim(); + if (!trimmed) return ''; + try { + const parsed = new URL(trimmed); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return trimmed.replace(/\/+$/, ''); + } +} + +function extractHtmlTitle(html: string): string { + const match = html.match(/<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; + } +} diff --git a/src/server/services/platforms/veloera.ts b/src/server/services/platforms/veloera.ts index bd89c99..6257127 100644 --- a/src/server/services/platforms/veloera.ts +++ b/src/server/services/platforms/veloera.ts @@ -15,11 +15,22 @@ export class VeloeraAdapter extends BasePlatformAdapter { } } - async checkin(baseUrl: string, accessToken: string): Promise<CheckinResult> { + private veloeraHeaders(accessToken: string, userId?: number): Record<string, string> { + const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` }; + if (userId) { + const value = String(userId); + headers['Veloera-User'] = value; + headers['New-API-User'] = value; + headers['User-id'] = value; + } + return headers; + } + + async checkin(baseUrl: string, accessToken: string, platformUserId?: number): Promise<CheckinResult> { try { const res = await this.fetchJson<any>(`${baseUrl}/api/user/checkin`, { method: 'POST', - headers: { Authorization: `Bearer ${accessToken}` }, + headers: this.veloeraHeaders(accessToken, platformUserId), }); if (res?.success) { return { success: true, message: res.message || 'Check-in successful', reward: res.data?.reward?.toString() }; @@ -30,9 +41,9 @@ export class VeloeraAdapter extends BasePlatformAdapter { } } - async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> { + async getBalance(baseUrl: string, accessToken: string, platformUserId?: number): Promise<BalanceInfo> { const res = await this.fetchJson<any>(`${baseUrl}/api/user/self`, { - headers: { Authorization: `Bearer ${accessToken}` }, + headers: this.veloeraHeaders(accessToken, platformUserId), }); const data = res?.data; const quota = (data?.quota || 0) / 1000000;