diff --git a/README.md b/README.md index 3411305..29dfd98 100644 --- a/README.md +++ b/README.md @@ -141,42 +141,14 @@ + --- ## 🏛️ 架构概览 -**下游客户端**(Cursor · Claude Code · Codex · Open WebUI · Cherry Studio 等) - ↓  `Authorization: Bearer ` -**Metapi 网关** - • 统一代理 `/v1/*` — 兼容 OpenAI / Claude 全接口 - • 智能路由引擎 — 按成本、余额、可用率加权选路,失败自动冷却与重试 - • 模型发现 — 自动聚合上游全部模型,零配置 - • 格式转换 — OpenAI ⇄ Claude 双向透明转换 - • 自动签到 · 余额管理 · 告警通知 · 数据看板 - ↓ -**上游平台**(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 -``` - - +
+ Metapi: Federated AI Model Aggregation Gateway Architecture +
--- diff --git a/docs/README.md b/docs/README.md index b838876..07b5e96 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,18 +35,11 @@ npm run docs:build | 🧩 [FAQ/教程贡献规范](./community/faq-tutorial-guidelines.md) | 社区贡献者 | 统一沉淀 FAQ、教程与排障经验 | | 📁 [目录规范](./project-structure.md) | 开发者 | 项目目录组织与约定 | -## 架构概览 +## 🏛️ 架构概览 -**下游客户端**(Cursor · Claude Code · Codex · Open WebUI · Cherry Studio 等) - ↓  `Authorization: Bearer ` -**Metapi 网关** - • 统一代理 `/v1/*` — 兼容 OpenAI / Claude 全接口 - • 智能路由引擎 — 按成本、余额、可用率加权选路,失败自动冷却与重试 - • 模型发现 — 自动聚合上游全部模型,零配置 - • 格式转换 — OpenAI ⇄ Claude 双向透明转换 - • 自动签到 · 余额管理 · 告警通知 · 数据看板 - ↓ -**上游平台**(New API · One API · OneHub · DoneHub · Veloera · AnyRouter · Sub2API …) +
+ Metapi: Federated AI Model Aggregation Gateway Architecture +
## 核心概念 diff --git a/docs/screenshots/checkin.png b/docs/screenshots/checkin.png new file mode 100644 index 0000000..e6dcabe Binary files /dev/null and b/docs/screenshots/checkin.png differ diff --git a/docs/screenshots/metapi-architecture.png b/docs/screenshots/metapi-architecture.png new file mode 100644 index 0000000..e38c629 Binary files /dev/null and b/docs/screenshots/metapi-architecture.png differ diff --git a/docs/screenshots/monitor.png b/docs/screenshots/monitor.png new file mode 100644 index 0000000..c7a86c7 Binary files /dev/null and b/docs/screenshots/monitor.png differ diff --git a/docs/screenshots/notification-settings.png b/docs/screenshots/notification-settings.png new file mode 100644 index 0000000..369af0d Binary files /dev/null and b/docs/screenshots/notification-settings.png differ diff --git a/docs/screenshots/playground.png b/docs/screenshots/playground.png new file mode 100644 index 0000000..e529879 Binary files /dev/null and b/docs/screenshots/playground.png differ diff --git a/docs/screenshots/proxy-logs.png b/docs/screenshots/proxy-logs.png new file mode 100644 index 0000000..3f0ff1f Binary files /dev/null and b/docs/screenshots/proxy-logs.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png new file mode 100644 index 0000000..54f6ab7 Binary files /dev/null and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/sites.png b/docs/screenshots/sites.png new file mode 100644 index 0000000..2dbf280 Binary files /dev/null and b/docs/screenshots/sites.png differ diff --git a/docs/screenshots/tokens.png b/docs/screenshots/tokens.png new file mode 100644 index 0000000..cbae586 Binary files /dev/null and b/docs/screenshots/tokens.png differ diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index c84acd3..076a192 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; -import { and, eq, gte, lt } from 'drizzle-orm'; +import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { refreshBalance } from '../../services/balanceService.js'; import { getAdapter } from '../../services/platforms/index.js'; import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js'; @@ -159,18 +159,25 @@ export async function accountsRoutes(app: FastifyInstance) { const { localDay, startUtc, endUtc } = getLocalDayRangeUtc(); // Aggregate today's spend per account from proxy logs - const todayLogs = db.select().from(schema.proxyLogs) + const todaySpendRows = db.select({ + accountId: schema.proxyLogs.accountId, + totalSpend: sql`coalesce(sum(${schema.proxyLogs.estimatedCost}), 0)`, + }).from(schema.proxyLogs) .where(and(gte(schema.proxyLogs.createdAt, startUtc), lt(schema.proxyLogs.createdAt, endUtc))) + .groupBy(schema.proxyLogs.accountId) .all(); const spendByAccount: Record = {}; - for (const log of todayLogs) { - if (log.accountId == null) continue; - const cost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0; - spendByAccount[log.accountId] = (spendByAccount[log.accountId] || 0) + cost; - } - + for (const row of todaySpendRows) { + if (row.accountId == null) continue; + spendByAccount[row.accountId] = Number(row.totalSpend || 0); + } + // Aggregate today's checkin rewards per account - const todayCheckins = db.select().from(schema.checkinLogs) + const todayCheckins = db.select({ + accountId: schema.checkinLogs.accountId, + reward: schema.checkinLogs.reward, + message: schema.checkinLogs.message, + }).from(schema.checkinLogs) .where(and( gte(schema.checkinLogs.createdAt, startUtc), lt(schema.checkinLogs.createdAt, endUtc), diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index e102ac2..7943929 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { detectSite } from '../../services/siteDetector.js'; import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; @@ -35,11 +35,16 @@ export async function sitesRoutes(app: FastifyInstance) { // List all sites app.get('/api/sites', async () => { const siteRows = db.select().from(schema.sites).all(); - const accountRows = db.select().from(schema.accounts).all(); + const accountBalanceRows = db.select({ + siteId: schema.accounts.siteId, + totalBalance: sql`coalesce(sum(${schema.accounts.balance}), 0)`, + }).from(schema.accounts) + .groupBy(schema.accounts.siteId) + .all(); const totalBalanceBySiteId: Record = {}; - for (const account of accountRows) { - totalBalanceBySiteId[account.siteId] = (totalBalanceBySiteId[account.siteId] || 0) + (account.balance || 0); + for (const row of accountBalanceRows) { + totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0); } return siteRows.map((site) => ({ diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts new file mode 100644 index 0000000..9052d91 --- /dev/null +++ b/src/server/services/backupService.test.ts @@ -0,0 +1,198 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +type DbModule = typeof import('../db/index.js'); +type BackupServiceModule = typeof import('./backupService.js'); + +describe('backupService', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let backupService: BackupServiceModule; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-backup-service-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const serviceModule = await import('./backupService.js'); + + db = dbModule.db; + schema = dbModule.schema; + backupService = serviceModule; + }); + + beforeEach(() => { + db.delete(schema.routeChannels).run(); + db.delete(schema.tokenRoutes).run(); + db.delete(schema.tokenModelAvailability).run(); + db.delete(schema.modelAvailability).run(); + db.delete(schema.proxyLogs).run(); + db.delete(schema.checkinLogs).run(); + db.delete(schema.accountTokens).run(); + db.delete(schema.accounts).run(); + db.delete(schema.sites).run(); + db.delete(schema.settings).run(); + }); + + afterAll(() => { + delete process.env.DATA_DIR; + }); + + it('preserves extended fields in full backup import/export roundtrip', () => { + const now = new Date().toISOString(); + const site = db.insert(schema.sites).values({ + name: 'roundtrip-site', + url: 'https://roundtrip.example.com', + platform: 'new-api', + proxyUrl: 'http://127.0.0.1:8080', + status: 'active', + isPinned: true, + sortOrder: 9, + apiKey: 'site-api-key', + createdAt: now, + updatedAt: now, + }).returning().get(); + + const account = db.insert(schema.accounts).values({ + siteId: site.id, + username: 'roundtrip-user', + accessToken: 'session-token', + apiToken: 'api-token', + balance: 12.3, + balanceUsed: 4.5, + quota: 99.9, + unitCost: 0.2, + valueScore: 1.1, + status: 'active', + isPinned: true, + sortOrder: 7, + checkinEnabled: true, + extraConfig: JSON.stringify({ platformUserId: 123 }), + createdAt: now, + updatedAt: now, + }).returning().get(); + + const accountToken = db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'sk-roundtrip-token', + source: 'manual', + enabled: true, + isDefault: true, + createdAt: now, + updatedAt: now, + }).returning().get(); + + const route = db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-*', + displayName: 'gpt-route', + displayIcon: 'icon-gpt', + modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }), + enabled: true, + createdAt: now, + updatedAt: now, + }).returning().get(); + + db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: accountToken.id, + sourceModel: 'gpt-4o', + priority: 3, + weight: 5, + enabled: true, + manualOverride: false, + successCount: 10, + failCount: 1, + totalLatencyMs: 2500, + totalCost: 2.5, + lastUsedAt: now, + lastFailAt: now, + cooldownUntil: now, + }).run(); + + const exported = backupService.exportBackup('all'); + const result = backupService.importBackup(exported as unknown as Record); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + const restoredSite = db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get(); + const restoredAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + const restoredRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); + const restoredChannel = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get(); + + expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080'); + expect(restoredSite?.isPinned).toBe(true); + expect(restoredSite?.sortOrder).toBe(9); + + expect(restoredAccount?.isPinned).toBe(true); + expect(restoredAccount?.sortOrder).toBe(7); + + expect(restoredRoute?.displayName).toBe('gpt-route'); + expect(restoredRoute?.displayIcon).toBe('icon-gpt'); + + expect(restoredChannel?.sourceModel).toBe('gpt-4o'); + }); + + it('imports ALL-API-Hub style payload with accounts and preferences', () => { + const payload = { + timestamp: Date.now(), + accounts: { + accounts: [ + { + site_url: 'https://legacy.example.com', + site_type: 'new-api', + site_name: 'legacy-site', + username: 'legacy-user', + authType: 'session', + account_info: { + id: 7788, + username: 'legacy-user', + access_token: 'legacy-session-token', + quota: 100000, + today_quota_consumption: 50000, + }, + checkIn: { + autoCheckInEnabled: true, + }, + created_at: '2026-02-01T00:00:00.000Z', + updated_at: '2026-02-02T00:00:00.000Z', + }, + ], + }, + preferences: { + locale: 'zh-CN', + }, + channelConfigs: { + order: ['a', 'b'], + }, + apiCredentialProfiles: { + default: 'main', + }, + tagStore: { + groups: ['test'], + }, + } as Record; + + const result = backupService.importBackup(payload); + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + expect(result.sections.preferences).toBe(true); + expect(result.appliedSettings.length).toBeGreaterThan(0); + + const sites = db.select().from(schema.sites).all(); + const accounts = db.select().from(schema.accounts).all(); + const settings = db.select().from(schema.settings).all(); + + expect(sites.length).toBe(1); + expect(accounts.length).toBe(1); + expect(accounts[0].username).toBe('legacy-user'); + expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true); + }); +}); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 1e05d94..fd21513 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -461,7 +461,10 @@ function importAccountsSection(section: AccountsBackupSection) { name: row.name, url: row.url, platform: row.platform, + proxyUrl: row.proxyUrl ?? null, status: row.status || 'active', + isPinned: row.isPinned ?? false, + sortOrder: row.sortOrder ?? 0, apiKey: row.apiKey, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -481,6 +484,8 @@ function importAccountsSection(section: AccountsBackupSection) { unitCost: row.unitCost, valueScore: row.valueScore, status: row.status, + isPinned: row.isPinned ?? false, + sortOrder: row.sortOrder ?? 0, checkinEnabled: row.checkinEnabled, lastCheckinAt: row.lastCheckinAt, lastBalanceRefresh: row.lastBalanceRefresh, @@ -508,6 +513,8 @@ function importAccountsSection(section: AccountsBackupSection) { tx.insert(schema.tokenRoutes).values({ id: row.id, modelPattern: row.modelPattern, + displayName: row.displayName ?? null, + displayIcon: row.displayIcon ?? null, modelMapping: row.modelMapping, enabled: row.enabled, createdAt: row.createdAt, @@ -521,6 +528,7 @@ function importAccountsSection(section: AccountsBackupSection) { routeId: row.routeId, accountId: row.accountId, tokenId: row.tokenId, + sourceModel: row.sourceModel ?? null, priority: row.priority, weight: row.weight, enabled: row.enabled, diff --git a/src/server/services/downstreamApiKeyService.test.ts b/src/server/services/downstreamApiKeyService.test.ts index 917ddb4..892f9dc 100644 --- a/src/server/services/downstreamApiKeyService.test.ts +++ b/src/server/services/downstreamApiKeyService.test.ts @@ -167,4 +167,28 @@ describe('downstreamApiKeyService', () => { expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true); expect(service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false); }); + + it('accumulates managed key request/cost usage and applies limits', () => { + const row = db.insert(schema.downstreamApiKeys).values({ + name: 'metered-key', + key: 'sk-metered-key', + enabled: true, + maxRequests: 2, + maxCost: 1, + usedRequests: 0, + usedCost: 0, + }).returning().get(); + + service.consumeManagedKeyRequest(row.id); + service.consumeManagedKeyRequest(row.id); + service.recordManagedKeyCostUsage(row.id, 0.4); + service.recordManagedKeyCostUsage(row.id, 0.6); + + const latest = service.getDownstreamApiKeyById(row.id); + expect(latest?.usedRequests).toBe(2); + expect(latest?.usedCost).toBeCloseTo(1); + + const authResult = service.authorizeDownstreamToken(row.key); + expect(authResult.ok).toBe(false); + }); }); diff --git a/src/server/services/downstreamApiKeyService.ts b/src/server/services/downstreamApiKeyService.ts index f8d6345..146f10e 100644 --- a/src/server/services/downstreamApiKeyService.ts +++ b/src/server/services/downstreamApiKeyService.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { minimatch } from 'minimatch'; import { db, schema } from '../db/index.js'; import { config } from '../config.js'; @@ -360,31 +360,24 @@ export function authorizeDownstreamToken(token: string): DownstreamTokenAuthResu } export function consumeManagedKeyRequest(keyId: number): void { - const row = db.select().from(schema.downstreamApiKeys) - .where(eq(schema.downstreamApiKeys.id, keyId)) - .get(); - if (!row) return; - + const nowIso = new Date().toISOString(); db.update(schema.downstreamApiKeys).set({ - usedRequests: Math.max(0, (row.usedRequests || 0) + 1), - lastUsedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + // Atomic increment to avoid lost updates under multi-process concurrency. + usedRequests: sql`coalesce(${schema.downstreamApiKeys.usedRequests}, 0) + 1`, + lastUsedAt: nowIso, + updatedAt: nowIso, }).where(eq(schema.downstreamApiKeys.id, keyId)).run(); } export function recordManagedKeyCostUsage(keyId: number, estimatedCost: number): void { const cost = Number(estimatedCost); if (!Number.isFinite(cost) || cost <= 0) return; - - const row = db.select().from(schema.downstreamApiKeys) - .where(eq(schema.downstreamApiKeys.id, keyId)) - .get(); - if (!row) return; - + const nowIso = new Date().toISOString(); db.update(schema.downstreamApiKeys).set({ - usedCost: Math.max(0, (row.usedCost || 0) + cost), - lastUsedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + // Atomic increment to avoid lost updates under multi-process concurrency. + usedCost: sql`coalesce(${schema.downstreamApiKeys.usedCost}, 0) + ${cost}`, + lastUsedAt: nowIso, + updatedAt: nowIso, }).where(eq(schema.downstreamApiKeys.id, keyId)).run(); }