Add unit tests for backupService to validate import/export functionality
@@ -141,42 +141,14 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏛️ 架构概览
|
## 🏛️ 架构概览
|
||||||
|
|
||||||
**下游客户端**(Cursor · Claude Code · Codex · Open WebUI · Cherry Studio 等)
|
<div align="center">
|
||||||
 ↓  `Authorization: Bearer <PROXY_TOKEN>`
|
<img src="docs/screenshots/metapi-architecture.png" alt="Metapi: Federated AI Model Aggregation Gateway Architecture" style="max-width: 100%; height: auto;" />
|
||||||
**Metapi 网关**
|
</div>
|
||||||
 • 统一代理 `/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["<b>💻 下游客户端</b><br/><br/>Cursor · Claude Code<br/>Codex · Open WebUI<br/>Cherry Studio"]:::client
|
|
||||||
|
|
||||||
G["<b>🚀 Metapi 网关</b><br/>────────────────────────<br/>✨ <b>统一代理</b>:兼容 OpenAI / Claude 全接口<br/>🧠 <b>智能路由</b>:按成本、余额、可用率加权,失败重试<br/>🔍 <b>模型发现</b>:自动聚合上游全部模型,零配置接入<br/>🔄 <b>格式转换</b>:OpenAI ⇄ Claude 双向透明转换<br/>📊 <b>运维管理</b>:自动签到 · 余额管理 · 告警 · 看板"]:::gateway
|
|
||||||
|
|
||||||
U["<b>☁️ 上游平台</b><br/><br/>New API · One API<br/>OneHub · DoneHub<br/>Veloera · AnyRouter<br/>Sub2API 等"]:::upstream
|
|
||||||
|
|
||||||
%% 连线
|
|
||||||
C -- "Authorization: Bearer <PROXY_TOKEN>" --> G
|
|
||||||
G ==>|请求分发| U
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -35,18 +35,11 @@ npm run docs:build
|
|||||||
| 🧩 [FAQ/教程贡献规范](./community/faq-tutorial-guidelines.md) | 社区贡献者 | 统一沉淀 FAQ、教程与排障经验 |
|
| 🧩 [FAQ/教程贡献规范](./community/faq-tutorial-guidelines.md) | 社区贡献者 | 统一沉淀 FAQ、教程与排障经验 |
|
||||||
| 📁 [目录规范](./project-structure.md) | 开发者 | 项目目录组织与约定 |
|
| 📁 [目录规范](./project-structure.md) | 开发者 | 项目目录组织与约定 |
|
||||||
|
|
||||||
## 架构概览
|
## 🏛️ 架构概览
|
||||||
|
|
||||||
**下游客户端**(Cursor · Claude Code · Codex · Open WebUI · Cherry Studio 等)
|
<div align="center">
|
||||||
 ↓  `Authorization: Bearer <PROXY_TOKEN>`
|
<img src="./screenshots/metapi-architecture.png" alt="Metapi: Federated AI Model Aggregation Gateway Architecture" style="max-width: 100%; height: auto;" />
|
||||||
**Metapi 网关**
|
</div>
|
||||||
 • 统一代理 `/v1/*` — 兼容 OpenAI / Claude 全接口
|
|
||||||
 • 智能路由引擎 — 按成本、余额、可用率加权选路,失败自动冷却与重试
|
|
||||||
 • 模型发现 — 自动聚合上游全部模型,零配置
|
|
||||||
 • 格式转换 — OpenAI ⇄ Claude 双向透明转换
|
|
||||||
 • 自动签到 · 余额管理 · 告警通知 · 数据看板
|
|
||||||
 ↓
|
|
||||||
**上游平台**(New API · One API · OneHub · DoneHub · Veloera · AnyRouter · Sub2API …)
|
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 696 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 504 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
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 { refreshBalance } from '../../services/balanceService.js';
|
||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
|
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
|
||||||
@@ -159,18 +159,25 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc();
|
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc();
|
||||||
|
|
||||||
// Aggregate today's spend per account from proxy logs
|
// 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<number>`coalesce(sum(${schema.proxyLogs.estimatedCost}), 0)`,
|
||||||
|
}).from(schema.proxyLogs)
|
||||||
.where(and(gte(schema.proxyLogs.createdAt, startUtc), lt(schema.proxyLogs.createdAt, endUtc)))
|
.where(and(gte(schema.proxyLogs.createdAt, startUtc), lt(schema.proxyLogs.createdAt, endUtc)))
|
||||||
|
.groupBy(schema.proxyLogs.accountId)
|
||||||
.all();
|
.all();
|
||||||
const spendByAccount: Record<number, number> = {};
|
const spendByAccount: Record<number, number> = {};
|
||||||
for (const log of todayLogs) {
|
for (const row of todaySpendRows) {
|
||||||
if (log.accountId == null) continue;
|
if (row.accountId == null) continue;
|
||||||
const cost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
|
spendByAccount[row.accountId] = Number(row.totalSpend || 0);
|
||||||
spendByAccount[log.accountId] = (spendByAccount[log.accountId] || 0) + cost;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate today's checkin rewards per account
|
// 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(
|
.where(and(
|
||||||
gte(schema.checkinLogs.createdAt, startUtc),
|
gte(schema.checkinLogs.createdAt, startUtc),
|
||||||
lt(schema.checkinLogs.createdAt, endUtc),
|
lt(schema.checkinLogs.createdAt, endUtc),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
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 { detectSite } from '../../services/siteDetector.js';
|
||||||
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||||
|
|
||||||
@@ -35,11 +35,16 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
// List all sites
|
// List all sites
|
||||||
app.get('/api/sites', async () => {
|
app.get('/api/sites', async () => {
|
||||||
const siteRows = db.select().from(schema.sites).all();
|
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<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
|
||||||
|
}).from(schema.accounts)
|
||||||
|
.groupBy(schema.accounts.siteId)
|
||||||
|
.all();
|
||||||
|
|
||||||
const totalBalanceBySiteId: Record<number, number> = {};
|
const totalBalanceBySiteId: Record<number, number> = {};
|
||||||
for (const account of accountRows) {
|
for (const row of accountBalanceRows) {
|
||||||
totalBalanceBySiteId[account.siteId] = (totalBalanceBySiteId[account.siteId] || 0) + (account.balance || 0);
|
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return siteRows.map((site) => ({
|
return siteRows.map((site) => ({
|
||||||
|
|||||||
@@ -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<string, unknown>);
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -461,7 +461,10 @@ function importAccountsSection(section: AccountsBackupSection) {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
platform: row.platform,
|
platform: row.platform,
|
||||||
|
proxyUrl: row.proxyUrl ?? null,
|
||||||
status: row.status || 'active',
|
status: row.status || 'active',
|
||||||
|
isPinned: row.isPinned ?? false,
|
||||||
|
sortOrder: row.sortOrder ?? 0,
|
||||||
apiKey: row.apiKey,
|
apiKey: row.apiKey,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
@@ -481,6 +484,8 @@ function importAccountsSection(section: AccountsBackupSection) {
|
|||||||
unitCost: row.unitCost,
|
unitCost: row.unitCost,
|
||||||
valueScore: row.valueScore,
|
valueScore: row.valueScore,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
isPinned: row.isPinned ?? false,
|
||||||
|
sortOrder: row.sortOrder ?? 0,
|
||||||
checkinEnabled: row.checkinEnabled,
|
checkinEnabled: row.checkinEnabled,
|
||||||
lastCheckinAt: row.lastCheckinAt,
|
lastCheckinAt: row.lastCheckinAt,
|
||||||
lastBalanceRefresh: row.lastBalanceRefresh,
|
lastBalanceRefresh: row.lastBalanceRefresh,
|
||||||
@@ -508,6 +513,8 @@ function importAccountsSection(section: AccountsBackupSection) {
|
|||||||
tx.insert(schema.tokenRoutes).values({
|
tx.insert(schema.tokenRoutes).values({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
modelPattern: row.modelPattern,
|
modelPattern: row.modelPattern,
|
||||||
|
displayName: row.displayName ?? null,
|
||||||
|
displayIcon: row.displayIcon ?? null,
|
||||||
modelMapping: row.modelMapping,
|
modelMapping: row.modelMapping,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
@@ -521,6 +528,7 @@ function importAccountsSection(section: AccountsBackupSection) {
|
|||||||
routeId: row.routeId,
|
routeId: row.routeId,
|
||||||
accountId: row.accountId,
|
accountId: row.accountId,
|
||||||
tokenId: row.tokenId,
|
tokenId: row.tokenId,
|
||||||
|
sourceModel: row.sourceModel ?? null,
|
||||||
priority: row.priority,
|
priority: row.priority,
|
||||||
weight: row.weight,
|
weight: row.weight,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
|
|||||||
@@ -167,4 +167,28 @@ describe('downstreamApiKeyService', () => {
|
|||||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
|
||||||
expect(service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||||
import { minimatch } from 'minimatch';
|
import { minimatch } from 'minimatch';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
@@ -360,31 +360,24 @@ export function authorizeDownstreamToken(token: string): DownstreamTokenAuthResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function consumeManagedKeyRequest(keyId: number): void {
|
export function consumeManagedKeyRequest(keyId: number): void {
|
||||||
const row = db.select().from(schema.downstreamApiKeys)
|
const nowIso = new Date().toISOString();
|
||||||
.where(eq(schema.downstreamApiKeys.id, keyId))
|
|
||||||
.get();
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
db.update(schema.downstreamApiKeys).set({
|
db.update(schema.downstreamApiKeys).set({
|
||||||
usedRequests: Math.max(0, (row.usedRequests || 0) + 1),
|
// Atomic increment to avoid lost updates under multi-process concurrency.
|
||||||
lastUsedAt: new Date().toISOString(),
|
usedRequests: sql`coalesce(${schema.downstreamApiKeys.usedRequests}, 0) + 1`,
|
||||||
updatedAt: new Date().toISOString(),
|
lastUsedAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
|
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordManagedKeyCostUsage(keyId: number, estimatedCost: number): void {
|
export function recordManagedKeyCostUsage(keyId: number, estimatedCost: number): void {
|
||||||
const cost = Number(estimatedCost);
|
const cost = Number(estimatedCost);
|
||||||
if (!Number.isFinite(cost) || cost <= 0) return;
|
if (!Number.isFinite(cost) || cost <= 0) return;
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
const row = db.select().from(schema.downstreamApiKeys)
|
|
||||||
.where(eq(schema.downstreamApiKeys.id, keyId))
|
|
||||||
.get();
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
db.update(schema.downstreamApiKeys).set({
|
db.update(schema.downstreamApiKeys).set({
|
||||||
usedCost: Math.max(0, (row.usedCost || 0) + cost),
|
// Atomic increment to avoid lost updates under multi-process concurrency.
|
||||||
lastUsedAt: new Date().toISOString(),
|
usedCost: sql`coalesce(${schema.downstreamApiKeys.usedCost}, 0) + ${cost}`,
|
||||||
updatedAt: new Date().toISOString(),
|
lastUsedAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
|
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||