llmapi-v2 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +40 -0
- package/Dockerfile +17 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.js +98 -0
- package/dist/config.js.map +1 -0
- package/dist/converter/request.d.ts +6 -0
- package/dist/converter/request.js +184 -0
- package/dist/converter/request.js.map +1 -0
- package/dist/converter/response.d.ts +6 -0
- package/dist/converter/response.js +76 -0
- package/dist/converter/response.js.map +1 -0
- package/dist/converter/stream.d.ts +54 -0
- package/dist/converter/stream.js +318 -0
- package/dist/converter/stream.js.map +1 -0
- package/dist/converter/types.d.ts +239 -0
- package/dist/converter/types.js +6 -0
- package/dist/converter/types.js.map +1 -0
- package/dist/data/posts.d.ts +19 -0
- package/dist/data/posts.js +462 -0
- package/dist/data/posts.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +233 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/api-key-auth.d.ts +6 -0
- package/dist/middleware/api-key-auth.js +76 -0
- package/dist/middleware/api-key-auth.js.map +1 -0
- package/dist/middleware/quota-guard.d.ts +10 -0
- package/dist/middleware/quota-guard.js +27 -0
- package/dist/middleware/quota-guard.js.map +1 -0
- package/dist/middleware/rate-limiter.d.ts +5 -0
- package/dist/middleware/rate-limiter.js +50 -0
- package/dist/middleware/rate-limiter.js.map +1 -0
- package/dist/middleware/request-logger.d.ts +6 -0
- package/dist/middleware/request-logger.js +37 -0
- package/dist/middleware/request-logger.js.map +1 -0
- package/dist/middleware/session-auth.d.ts +19 -0
- package/dist/middleware/session-auth.js +99 -0
- package/dist/middleware/session-auth.js.map +1 -0
- package/dist/providers/aliyun.d.ts +13 -0
- package/dist/providers/aliyun.js +20 -0
- package/dist/providers/aliyun.js.map +1 -0
- package/dist/providers/base-provider.d.ts +36 -0
- package/dist/providers/base-provider.js +133 -0
- package/dist/providers/base-provider.js.map +1 -0
- package/dist/providers/deepseek.d.ts +11 -0
- package/dist/providers/deepseek.js +18 -0
- package/dist/providers/deepseek.js.map +1 -0
- package/dist/providers/registry.d.ts +18 -0
- package/dist/providers/registry.js +98 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/providers/types.d.ts +17 -0
- package/dist/providers/types.js +3 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/routes/admin.d.ts +1 -0
- package/dist/routes/admin.js +153 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/auth.d.ts +2 -0
- package/dist/routes/auth.js +318 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/blog.d.ts +1 -0
- package/dist/routes/blog.js +29 -0
- package/dist/routes/blog.js.map +1 -0
- package/dist/routes/dashboard.d.ts +1 -0
- package/dist/routes/dashboard.js +184 -0
- package/dist/routes/dashboard.js.map +1 -0
- package/dist/routes/messages.d.ts +1 -0
- package/dist/routes/messages.js +309 -0
- package/dist/routes/messages.js.map +1 -0
- package/dist/routes/models.d.ts +1 -0
- package/dist/routes/models.js +39 -0
- package/dist/routes/models.js.map +1 -0
- package/dist/routes/payment.d.ts +1 -0
- package/dist/routes/payment.js +150 -0
- package/dist/routes/payment.js.map +1 -0
- package/dist/routes/sitemap.d.ts +1 -0
- package/dist/routes/sitemap.js +38 -0
- package/dist/routes/sitemap.js.map +1 -0
- package/dist/services/alipay.d.ts +27 -0
- package/dist/services/alipay.js +106 -0
- package/dist/services/alipay.js.map +1 -0
- package/dist/services/database.d.ts +4 -0
- package/dist/services/database.js +170 -0
- package/dist/services/database.js.map +1 -0
- package/dist/services/health-checker.d.ts +13 -0
- package/dist/services/health-checker.js +95 -0
- package/dist/services/health-checker.js.map +1 -0
- package/dist/services/mailer.d.ts +3 -0
- package/dist/services/mailer.js +91 -0
- package/dist/services/mailer.js.map +1 -0
- package/dist/services/metrics.d.ts +56 -0
- package/dist/services/metrics.js +94 -0
- package/dist/services/metrics.js.map +1 -0
- package/dist/services/remote-control.d.ts +20 -0
- package/dist/services/remote-control.js +209 -0
- package/dist/services/remote-control.js.map +1 -0
- package/dist/services/remote-ws.d.ts +5 -0
- package/dist/services/remote-ws.js +143 -0
- package/dist/services/remote-ws.js.map +1 -0
- package/dist/services/usage.d.ts +13 -0
- package/dist/services/usage.js +39 -0
- package/dist/services/usage.js.map +1 -0
- package/dist/utils/errors.d.ts +27 -0
- package/dist/utils/errors.js +48 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.js +14 -0
- package/dist/utils/logger.js.map +1 -0
- package/docker-compose.yml +19 -0
- package/package.json +39 -0
- package/public/robots.txt +8 -0
- package/src/config.ts +140 -0
- package/src/converter/request.ts +207 -0
- package/src/converter/response.ts +85 -0
- package/src/converter/stream.ts +373 -0
- package/src/converter/types.ts +257 -0
- package/src/data/posts.ts +474 -0
- package/src/index.ts +219 -0
- package/src/middleware/api-key-auth.ts +82 -0
- package/src/middleware/quota-guard.ts +28 -0
- package/src/middleware/rate-limiter.ts +61 -0
- package/src/middleware/request-logger.ts +36 -0
- package/src/middleware/session-auth.ts +91 -0
- package/src/providers/aliyun.ts +16 -0
- package/src/providers/base-provider.ts +148 -0
- package/src/providers/deepseek.ts +14 -0
- package/src/providers/registry.ts +111 -0
- package/src/providers/types.ts +26 -0
- package/src/routes/admin.ts +169 -0
- package/src/routes/auth.ts +369 -0
- package/src/routes/blog.ts +28 -0
- package/src/routes/dashboard.ts +208 -0
- package/src/routes/messages.ts +346 -0
- package/src/routes/models.ts +37 -0
- package/src/routes/payment.ts +189 -0
- package/src/routes/sitemap.ts +40 -0
- package/src/services/alipay.ts +116 -0
- package/src/services/database.ts +187 -0
- package/src/services/health-checker.ts +115 -0
- package/src/services/mailer.ts +90 -0
- package/src/services/metrics.ts +104 -0
- package/src/services/remote-control.ts +226 -0
- package/src/services/remote-ws.ts +145 -0
- package/src/services/usage.ts +57 -0
- package/src/types/express.d.ts +46 -0
- package/src/utils/errors.ts +44 -0
- package/src/utils/logger.ts +8 -0
- package/tsconfig.json +17 -0
- package/views/pages/404.ejs +14 -0
- package/views/pages/admin.ejs +307 -0
- package/views/pages/blog-post.ejs +378 -0
- package/views/pages/blog.ejs +148 -0
- package/views/pages/dashboard.ejs +441 -0
- package/views/pages/docs.ejs +807 -0
- package/views/pages/index.ejs +416 -0
- package/views/pages/login.ejs +170 -0
- package/views/pages/orders.ejs +111 -0
- package/views/pages/pricing.ejs +379 -0
- package/views/pages/register.ejs +397 -0
- package/views/pages/remote.ejs +334 -0
- package/views/pages/settings.ejs +373 -0
- package/views/partials/header.ejs +70 -0
- package/views/partials/nav.ejs +140 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BaseProvider } from './base-provider';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DeepSeek provider.
|
|
5
|
+
* Native Anthropic endpoint: https://api.deepseek.com/anthropic
|
|
6
|
+
*
|
|
7
|
+
* Best for: simple conversations, light tasks, cost-sensitive routing.
|
|
8
|
+
* Note: tool calling accuracy (~81.5%) is lower than Qwen.
|
|
9
|
+
*/
|
|
10
|
+
export class DeepSeekProvider extends BaseProvider {
|
|
11
|
+
constructor(anthropicBaseUrl: string, apiKey: string, timeout: number, openaiBaseUrl?: string) {
|
|
12
|
+
super('deepseek', anthropicBaseUrl, apiKey, timeout, openaiBaseUrl);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { AppConfig, ProviderConfig, SmartRoutingConfig } from '../config';
|
|
2
|
+
import type { LLMProvider, ResolvedRoute } from './types';
|
|
3
|
+
import { AliyunProvider } from './aliyun';
|
|
4
|
+
import { DeepSeekProvider } from './deepseek';
|
|
5
|
+
import { logger } from '../utils/logger';
|
|
6
|
+
|
|
7
|
+
const providers = new Map<string, LLMProvider>();
|
|
8
|
+
let modelRouting: Record<string, { provider: string; model: string; priority: number }[]> = {};
|
|
9
|
+
let smartRouting: SmartRoutingConfig;
|
|
10
|
+
|
|
11
|
+
export function initRegistry(config: AppConfig): void {
|
|
12
|
+
providers.clear();
|
|
13
|
+
|
|
14
|
+
for (const pc of config.providers) {
|
|
15
|
+
const provider = createProvider(pc);
|
|
16
|
+
if (provider) {
|
|
17
|
+
providers.set(pc.name, provider);
|
|
18
|
+
logger.info({ provider: pc.name, endpoint: pc.anthropicBaseUrl }, 'Provider registered (native Anthropic endpoint)');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
modelRouting = config.modelRouting;
|
|
23
|
+
smartRouting = config.smartRouting;
|
|
24
|
+
|
|
25
|
+
if (providers.size === 0) {
|
|
26
|
+
logger.warn('No providers configured! API requests will fail.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createProvider(pc: ProviderConfig): LLMProvider | null {
|
|
31
|
+
if (!pc.enabled || !pc.apiKey) return null;
|
|
32
|
+
|
|
33
|
+
switch (pc.name) {
|
|
34
|
+
case 'aliyun':
|
|
35
|
+
return new AliyunProvider(pc.anthropicBaseUrl, pc.apiKey, pc.timeout, pc.openaiBaseUrl);
|
|
36
|
+
case 'deepseek':
|
|
37
|
+
return new DeepSeekProvider(pc.anthropicBaseUrl, pc.apiKey, pc.timeout, pc.openaiBaseUrl);
|
|
38
|
+
default:
|
|
39
|
+
logger.warn({ provider: pc.name }, 'Unknown provider type');
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Smart routing: decide which provider/model based on request characteristics.
|
|
46
|
+
*
|
|
47
|
+
* Rules:
|
|
48
|
+
* - If request has tools -> coding task -> use primary coding model (Qwen)
|
|
49
|
+
* - If request is simple (no tools, few messages) -> use light model (DeepSeek)
|
|
50
|
+
* - Model name mapping: claude-opus -> best available, claude-haiku -> cheapest
|
|
51
|
+
* - Fallback: try providers in priority order
|
|
52
|
+
*/
|
|
53
|
+
export function smartResolve(
|
|
54
|
+
claudeModel: string,
|
|
55
|
+
hasTools: boolean,
|
|
56
|
+
messageCount: number,
|
|
57
|
+
): ResolvedRoute | null {
|
|
58
|
+
// Claude-haiku always goes to the cheap model
|
|
59
|
+
if (claudeModel.includes('haiku')) {
|
|
60
|
+
const p = providers.get(smartRouting.lightModel.provider);
|
|
61
|
+
if (p?.isHealthy()) {
|
|
62
|
+
return { provider: p, backendModel: smartRouting.lightModel.model };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// No tools + short conversation = light task
|
|
67
|
+
if (!hasTools && messageCount <= smartRouting.lightTaskMaxMessages) {
|
|
68
|
+
const p = providers.get(smartRouting.lightModel.provider);
|
|
69
|
+
if (p?.isHealthy()) {
|
|
70
|
+
return { provider: p, backendModel: smartRouting.lightModel.model };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Tools present or complex conversation = coding task
|
|
75
|
+
if (hasTools) {
|
|
76
|
+
const p = providers.get(smartRouting.codingModel.provider);
|
|
77
|
+
if (p?.isHealthy()) {
|
|
78
|
+
return { provider: p, backendModel: smartRouting.codingModel.model };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fallback to model-based routing
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve with failover: yield providers to try in priority order.
|
|
88
|
+
*/
|
|
89
|
+
export async function* resolveWithFailover(
|
|
90
|
+
claudeModel: string,
|
|
91
|
+
): AsyncGenerator<ResolvedRoute> {
|
|
92
|
+
const routes = modelRouting[claudeModel];
|
|
93
|
+
if (!routes || routes.length === 0) {
|
|
94
|
+
logger.error({ model: claudeModel }, 'No routes configured for model');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const route of routes) {
|
|
99
|
+
const provider = providers.get(route.provider);
|
|
100
|
+
if (!provider) continue;
|
|
101
|
+
if (!provider.isHealthy()) {
|
|
102
|
+
logger.debug({ provider: route.provider }, 'Skipping unhealthy provider');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
yield { provider, backendModel: route.model };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getProviders(): Map<string, LLMProvider> {
|
|
110
|
+
return providers;
|
|
111
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'http';
|
|
2
|
+
|
|
3
|
+
export interface LLMProvider {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly anthropicBaseUrl: string;
|
|
6
|
+
|
|
7
|
+
isHealthy(): boolean;
|
|
8
|
+
markUnhealthy(reason: string): void;
|
|
9
|
+
markHealthy(): void;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Transparent proxy: forward raw Anthropic request to provider's native endpoint.
|
|
13
|
+
* Returns the raw HTTP response for direct piping back to the client.
|
|
14
|
+
*/
|
|
15
|
+
proxy(
|
|
16
|
+
path: string,
|
|
17
|
+
body: Buffer | string,
|
|
18
|
+
headers: Record<string, string>,
|
|
19
|
+
isStream: boolean,
|
|
20
|
+
): Promise<IncomingMessage>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ResolvedRoute {
|
|
24
|
+
provider: LLMProvider;
|
|
25
|
+
backendModel: string;
|
|
26
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { Request, Response } from 'express';
|
|
3
|
+
import { getPool } from '../services/database';
|
|
4
|
+
import { adminAuth } from '../middleware/session-auth';
|
|
5
|
+
import { getHealthStatus } from '../services/health-checker';
|
|
6
|
+
import { logger } from '../utils/logger';
|
|
7
|
+
|
|
8
|
+
export const adminRouter = Router();
|
|
9
|
+
|
|
10
|
+
// All routes require admin auth
|
|
11
|
+
adminRouter.use(adminAuth);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GET /api/admin/stats
|
|
15
|
+
* Platform-wide statistics.
|
|
16
|
+
*/
|
|
17
|
+
adminRouter.get('/stats', async (_req: Request, res: Response) => {
|
|
18
|
+
try {
|
|
19
|
+
const pool = getPool();
|
|
20
|
+
|
|
21
|
+
const [users] = await pool.execute(`
|
|
22
|
+
SELECT
|
|
23
|
+
COUNT(*) as total,
|
|
24
|
+
SUM(CASE WHEN DATE(created_at) = CURRENT_DATE THEN 1 ELSE 0 END) as today_new
|
|
25
|
+
FROM users
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
const [activeUsers] = await pool.execute(`
|
|
29
|
+
SELECT COUNT(DISTINCT user_id) as cnt FROM usage_logs
|
|
30
|
+
WHERE created_at >= NOW() - INTERVAL '7 days'
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
const [requests] = await pool.execute(`
|
|
34
|
+
SELECT
|
|
35
|
+
COUNT(*) as total,
|
|
36
|
+
SUM(CASE WHEN DATE(created_at) = CURRENT_DATE THEN 1 ELSE 0 END) as today
|
|
37
|
+
FROM usage_logs
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
const [tokens] = await pool.execute(`
|
|
41
|
+
SELECT
|
|
42
|
+
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
|
43
|
+
COALESCE(SUM(provider_cost), 0) as total_cost
|
|
44
|
+
FROM usage_logs
|
|
45
|
+
`);
|
|
46
|
+
|
|
47
|
+
const [monthCost] = await pool.execute(`
|
|
48
|
+
SELECT COALESCE(SUM(provider_cost), 0) as cost
|
|
49
|
+
FROM usage_logs WHERE created_at >= date_trunc('month', NOW())
|
|
50
|
+
`);
|
|
51
|
+
|
|
52
|
+
const [revenue] = await pool.execute(`
|
|
53
|
+
SELECT COALESCE(SUM(amount), 0) as total FROM orders WHERE status = 'paid'
|
|
54
|
+
`);
|
|
55
|
+
|
|
56
|
+
const [monthRevenue] = await pool.execute(`
|
|
57
|
+
SELECT COALESCE(SUM(amount), 0) as total FROM orders
|
|
58
|
+
WHERE status = 'paid' AND paid_at >= date_trunc('month', NOW())
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
// Provider health
|
|
62
|
+
const providerHealth = getHealthStatus();
|
|
63
|
+
|
|
64
|
+
res.json({
|
|
65
|
+
success: true,
|
|
66
|
+
stats: {
|
|
67
|
+
users: (users as any[])[0],
|
|
68
|
+
activeUsers7d: (activeUsers as any[])[0].cnt,
|
|
69
|
+
requests: (requests as any[])[0],
|
|
70
|
+
tokens: (tokens as any[])[0],
|
|
71
|
+
monthCost: parseFloat((monthCost as any[])[0].cost),
|
|
72
|
+
revenue: parseFloat((revenue as any[])[0].total),
|
|
73
|
+
monthRevenue: parseFloat((monthRevenue as any[])[0].total),
|
|
74
|
+
providerHealth,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
} catch (err) {
|
|
78
|
+
logger.error({ err }, 'admin stats error');
|
|
79
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* GET /api/admin/users
|
|
85
|
+
* Full user list with plan and usage info.
|
|
86
|
+
*/
|
|
87
|
+
adminRouter.get('/users', async (_req: Request, res: Response) => {
|
|
88
|
+
try {
|
|
89
|
+
const pool = getPool();
|
|
90
|
+
const [rows] = await pool.execute(`
|
|
91
|
+
SELECT
|
|
92
|
+
u.id, u.email, u.name, u.role, u.status, u.created_at,
|
|
93
|
+
p.name as plan_name, p.display_name as plan_display,
|
|
94
|
+
COALESCE(s.tokens_used, 0) as tokens_used,
|
|
95
|
+
p.token_limit_monthly,
|
|
96
|
+
(SELECT COUNT(*) FROM usage_logs WHERE user_id = u.id) as total_requests,
|
|
97
|
+
(SELECT COUNT(*) FROM api_keys WHERE user_id = u.id AND status = 'active') as api_key_count
|
|
98
|
+
FROM users u
|
|
99
|
+
LEFT JOIN subscriptions s ON s.user_id = u.id
|
|
100
|
+
LEFT JOIN plans p ON s.plan_id = p.id
|
|
101
|
+
ORDER BY u.created_at DESC
|
|
102
|
+
`);
|
|
103
|
+
|
|
104
|
+
res.json({ success: true, users: rows });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
logger.error({ err }, 'admin users error');
|
|
107
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* PUT /api/admin/users/:id
|
|
113
|
+
* Update user status or plan.
|
|
114
|
+
*/
|
|
115
|
+
adminRouter.put('/users/:id', async (req: Request, res: Response) => {
|
|
116
|
+
try {
|
|
117
|
+
const pool = getPool();
|
|
118
|
+
const userId = req.params.id;
|
|
119
|
+
const { status, plan } = req.body;
|
|
120
|
+
|
|
121
|
+
if (status) {
|
|
122
|
+
await pool.execute('UPDATE users SET status = ? WHERE id = ?', [status, userId]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (plan) {
|
|
126
|
+
const [plans] = await pool.execute('SELECT id FROM plans WHERE name = ?', [plan]);
|
|
127
|
+
const planId = (plans as any[])[0]?.id;
|
|
128
|
+
if (planId) {
|
|
129
|
+
await pool.execute(
|
|
130
|
+
'UPDATE subscriptions SET plan_id = ?, tokens_used = 0 WHERE id = (SELECT id FROM subscriptions WHERE user_id = ? ORDER BY period_start DESC LIMIT 1)',
|
|
131
|
+
[planId, userId],
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
res.json({ success: true });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
logger.error({ err }, 'admin update user error');
|
|
139
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* GET /api/admin/cost-report
|
|
145
|
+
* Provider cost breakdown.
|
|
146
|
+
*/
|
|
147
|
+
adminRouter.get('/cost-report', async (_req: Request, res: Response) => {
|
|
148
|
+
try {
|
|
149
|
+
const pool = getPool();
|
|
150
|
+
const [daily] = await pool.execute(`
|
|
151
|
+
SELECT
|
|
152
|
+
DATE(created_at) as date,
|
|
153
|
+
provider_name,
|
|
154
|
+
COUNT(*) as requests,
|
|
155
|
+
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
156
|
+
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
157
|
+
COALESCE(SUM(provider_cost), 0) as cost
|
|
158
|
+
FROM usage_logs
|
|
159
|
+
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
|
|
160
|
+
GROUP BY DATE(created_at), provider_name
|
|
161
|
+
ORDER BY date DESC, provider_name
|
|
162
|
+
`);
|
|
163
|
+
|
|
164
|
+
res.json({ success: true, daily });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
logger.error({ err }, 'cost report error');
|
|
167
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { Request, Response } from 'express';
|
|
3
|
+
import bcrypt from 'bcryptjs';
|
|
4
|
+
import jwt from 'jsonwebtoken';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import { getPool } from '../services/database';
|
|
7
|
+
import { sessionAuth } from '../middleware/session-auth';
|
|
8
|
+
import { sendVerificationEmail, sendWelcomeEmail } from '../services/mailer';
|
|
9
|
+
import { logger } from '../utils/logger';
|
|
10
|
+
|
|
11
|
+
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '128504392054-4rfrtk05umm9fvfpd83l9qn9r4qnaa98.apps.googleusercontent.com';
|
|
12
|
+
|
|
13
|
+
export function createAuthRouter(jwtSecret: string): Router {
|
|
14
|
+
const router = Router();
|
|
15
|
+
|
|
16
|
+
// In-memory pending registrations (code -> {email, hash, name, expiresAt})
|
|
17
|
+
const pending = new Map<string, {
|
|
18
|
+
email: string;
|
|
19
|
+
passwordHash: string;
|
|
20
|
+
name: string;
|
|
21
|
+
code: string;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
// Cleanup expired entries every 5 min
|
|
26
|
+
setInterval(() => {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [key, val] of pending) {
|
|
29
|
+
if (val.expiresAt < now) pending.delete(key);
|
|
30
|
+
}
|
|
31
|
+
}, 5 * 60_000);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* POST /api/auth/send-code
|
|
35
|
+
* Step 1 of registration: validate inputs, send verification code.
|
|
36
|
+
*/
|
|
37
|
+
router.post('/send-code', async (req: Request, res: Response) => {
|
|
38
|
+
try {
|
|
39
|
+
const { email, password, name } = req.body;
|
|
40
|
+
|
|
41
|
+
if (!email || !password || !name) {
|
|
42
|
+
res.status(400).json({ success: false, error: 'Missing required fields' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (password.length < 8) {
|
|
46
|
+
res.status(400).json({ success: false, error: 'Password must be at least 8 characters' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pool = getPool();
|
|
51
|
+
const [existing] = await pool.execute('SELECT id FROM users WHERE email = ?', [email]);
|
|
52
|
+
if ((existing as any[]).length > 0) {
|
|
53
|
+
res.status(409).json({ success: false, error: 'Email already registered' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
58
|
+
const passwordHash = await bcrypt.hash(password, 10);
|
|
59
|
+
|
|
60
|
+
pending.set(email, {
|
|
61
|
+
email,
|
|
62
|
+
passwordHash,
|
|
63
|
+
name,
|
|
64
|
+
code,
|
|
65
|
+
expiresAt: Date.now() + 30 * 60_000, // 30 min
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await sendVerificationEmail(email, code, name);
|
|
69
|
+
|
|
70
|
+
res.json({ success: true, message: 'Verification code sent' });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logger.error({ err }, 'send-code error');
|
|
73
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* POST /api/auth/register
|
|
79
|
+
* Step 2: verify code, create user, issue JWT.
|
|
80
|
+
*/
|
|
81
|
+
router.post('/register', async (req: Request, res: Response) => {
|
|
82
|
+
try {
|
|
83
|
+
const { email, code } = req.body;
|
|
84
|
+
|
|
85
|
+
const entry = pending.get(email);
|
|
86
|
+
if (!entry || entry.code !== code || entry.expiresAt < Date.now()) {
|
|
87
|
+
res.status(400).json({ success: false, error: 'Invalid or expired verification code' });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pool = getPool();
|
|
92
|
+
|
|
93
|
+
// Create user
|
|
94
|
+
const [result] = await pool.execute(
|
|
95
|
+
'INSERT INTO users (email, password_hash, name, verified, status) VALUES (?, ?, ?, 1, ?)',
|
|
96
|
+
[entry.email, entry.passwordHash, entry.name, 'active'],
|
|
97
|
+
);
|
|
98
|
+
const userId = (result as any).insertId;
|
|
99
|
+
|
|
100
|
+
// Assign free plan
|
|
101
|
+
const [plans] = await pool.execute('SELECT id FROM plans WHERE name = ?', ['free']);
|
|
102
|
+
const planId = (plans as any[])[0]?.id;
|
|
103
|
+
if (planId) {
|
|
104
|
+
await pool.execute(
|
|
105
|
+
"INSERT INTO subscriptions (user_id, plan_id, period_end) VALUES (?, ?, NOW() + INTERVAL '100 years')",
|
|
106
|
+
[userId, planId],
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pending.delete(email);
|
|
111
|
+
|
|
112
|
+
// Issue JWT
|
|
113
|
+
const token = jwt.sign({ id: userId }, jwtSecret, { expiresIn: '7d' });
|
|
114
|
+
res.cookie('token', token, { httpOnly: true, maxAge: 7 * 24 * 3600_000, sameSite: 'lax' });
|
|
115
|
+
|
|
116
|
+
// Send welcome email (async, don't block)
|
|
117
|
+
sendWelcomeEmail(entry.email, entry.name).catch(() => {});
|
|
118
|
+
|
|
119
|
+
res.json({ success: true, user: { id: userId, email: entry.email, name: entry.name } });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logger.error({ err }, 'register error');
|
|
122
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* POST /api/auth/login
|
|
128
|
+
*/
|
|
129
|
+
router.post('/login', async (req: Request, res: Response) => {
|
|
130
|
+
try {
|
|
131
|
+
const { email, password } = req.body;
|
|
132
|
+
if (!email || !password) {
|
|
133
|
+
res.status(400).json({ success: false, error: 'Email and password required' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pool = getPool();
|
|
138
|
+
const [rows] = await pool.execute('SELECT * FROM users WHERE email = ?', [email]);
|
|
139
|
+
const user = (rows as any[])[0];
|
|
140
|
+
|
|
141
|
+
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
|
142
|
+
res.status(401).json({ success: false, error: 'Invalid email or password' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (user.status !== 'active') {
|
|
146
|
+
res.status(403).json({ success: false, error: 'Account suspended' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const token = jwt.sign({ id: user.id }, jwtSecret, { expiresIn: '7d' });
|
|
151
|
+
res.cookie('token', token, { httpOnly: true, maxAge: 7 * 24 * 3600_000, sameSite: 'lax' });
|
|
152
|
+
|
|
153
|
+
res.json({ success: true, user: { id: user.id, email: user.email, name: user.name } });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
logger.error({ err }, 'login error');
|
|
156
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* POST /api/auth/logout
|
|
162
|
+
*/
|
|
163
|
+
router.post('/logout', (_req: Request, res: Response) => {
|
|
164
|
+
res.clearCookie('token');
|
|
165
|
+
res.json({ success: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* GET /api/auth/me
|
|
170
|
+
*/
|
|
171
|
+
router.get('/me', sessionAuth, async (req: Request, res: Response) => {
|
|
172
|
+
try {
|
|
173
|
+
const pool = getPool();
|
|
174
|
+
const [subs] = await pool.execute(`
|
|
175
|
+
SELECT s.*, p.name as plan_name, p.display_name, p.token_limit_monthly, p.rate_limit_rpm, p.max_api_keys, p.price_monthly
|
|
176
|
+
FROM subscriptions s JOIN plans p ON s.plan_id = p.id
|
|
177
|
+
WHERE s.user_id = ? ORDER BY s.period_start DESC LIMIT 1
|
|
178
|
+
`, [req.userId!]);
|
|
179
|
+
|
|
180
|
+
res.json({
|
|
181
|
+
success: true,
|
|
182
|
+
user: req.user,
|
|
183
|
+
subscription: (subs as any[])[0] || null,
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
logger.error({ err }, 'me error');
|
|
187
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* POST /api/auth/update-profile
|
|
193
|
+
*/
|
|
194
|
+
router.post('/update-profile', sessionAuth, async (req: Request, res: Response) => {
|
|
195
|
+
try {
|
|
196
|
+
const { name } = req.body;
|
|
197
|
+
if (!name || name.length < 2) {
|
|
198
|
+
res.status(400).json({ success: false, error: 'Name must be at least 2 characters' });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const pool = getPool();
|
|
202
|
+
await pool.execute('UPDATE users SET name = ? WHERE id = ?', [name, req.userId!]);
|
|
203
|
+
res.json({ success: true });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
logger.error({ err }, 'update-profile error');
|
|
206
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* POST /api/auth/change-password
|
|
212
|
+
*/
|
|
213
|
+
router.post('/change-password', sessionAuth, async (req: Request, res: Response) => {
|
|
214
|
+
try {
|
|
215
|
+
const { currentPassword, newPassword } = req.body;
|
|
216
|
+
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
|
217
|
+
res.status(400).json({ success: false, error: 'Invalid password' });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const pool = getPool();
|
|
222
|
+
const [rows] = await pool.execute('SELECT password_hash FROM users WHERE id = ?', [req.userId!]);
|
|
223
|
+
const user = (rows as any[])[0];
|
|
224
|
+
|
|
225
|
+
if (!user || !(await bcrypt.compare(currentPassword, user.password_hash))) {
|
|
226
|
+
res.status(401).json({ success: false, error: 'Current password is incorrect' });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const hash = await bcrypt.hash(newPassword, 10);
|
|
231
|
+
await pool.execute('UPDATE users SET password_hash = ? WHERE id = ?', [hash, req.userId!]);
|
|
232
|
+
|
|
233
|
+
res.json({ success: true });
|
|
234
|
+
} catch (err) {
|
|
235
|
+
logger.error({ err }, 'change-password error');
|
|
236
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* POST /api/auth/delete-account
|
|
242
|
+
*/
|
|
243
|
+
router.post('/delete-account', sessionAuth, async (req: Request, res: Response) => {
|
|
244
|
+
try {
|
|
245
|
+
const { email } = req.body;
|
|
246
|
+
if (email !== req.user?.email) {
|
|
247
|
+
res.status(400).json({ success: false, error: 'Email confirmation does not match' });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const pool = getPool();
|
|
252
|
+
await pool.execute('DELETE FROM users WHERE id = ?', [req.userId!]);
|
|
253
|
+
res.clearCookie('token');
|
|
254
|
+
res.json({ success: true });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
logger.error({ err }, 'delete-account error');
|
|
257
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ============================================================
|
|
262
|
+
// Google Sign-In (frontend-based, no server-to-Google network calls)
|
|
263
|
+
// ============================================================
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Decode a Google ID token (JWT) without network access.
|
|
267
|
+
* We trust the token because it comes from Google's Sign-In SDK
|
|
268
|
+
* running in the user's browser with our client_id configured.
|
|
269
|
+
*/
|
|
270
|
+
function decodeGoogleIdToken(idToken: string) {
|
|
271
|
+
const parts = idToken.split('.');
|
|
272
|
+
if (parts.length !== 3) throw new Error('Invalid token');
|
|
273
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
274
|
+
if (!['accounts.google.com', 'https://accounts.google.com'].includes(payload.iss)) {
|
|
275
|
+
throw new Error('Invalid issuer');
|
|
276
|
+
}
|
|
277
|
+
if (payload.aud !== GOOGLE_CLIENT_ID) {
|
|
278
|
+
throw new Error('Invalid audience');
|
|
279
|
+
}
|
|
280
|
+
if (payload.exp * 1000 < Date.now()) {
|
|
281
|
+
throw new Error('Token expired');
|
|
282
|
+
}
|
|
283
|
+
return payload as { email: string; name?: string; sub: string; picture?: string };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* POST /api/auth/google/token
|
|
288
|
+
* Receive a Google ID token from the frontend, verify it, create/login user.
|
|
289
|
+
*/
|
|
290
|
+
router.post('/google/token', async (req: Request, res: Response) => {
|
|
291
|
+
try {
|
|
292
|
+
const { idToken } = req.body;
|
|
293
|
+
if (!idToken || typeof idToken !== 'string') {
|
|
294
|
+
res.status(400).json({ success: false, error: 'Missing idToken' });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!GOOGLE_CLIENT_ID) {
|
|
299
|
+
res.status(500).json({ success: false, error: 'Google OAuth not configured' });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let googleUser: { email: string; name?: string; sub: string; picture?: string };
|
|
304
|
+
try {
|
|
305
|
+
googleUser = decodeGoogleIdToken(idToken);
|
|
306
|
+
} catch (err: any) {
|
|
307
|
+
logger.warn({ err: err.message }, 'Invalid Google ID token');
|
|
308
|
+
res.status(401).json({ success: false, error: 'Invalid Google token' });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!googleUser.email) {
|
|
313
|
+
res.status(400).json({ success: false, error: 'No email in Google token' });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const pool = getPool();
|
|
318
|
+
|
|
319
|
+
// Check if user exists
|
|
320
|
+
const [existing] = await pool.execute('SELECT * FROM users WHERE email = ?', [googleUser.email]);
|
|
321
|
+
let userId: number;
|
|
322
|
+
|
|
323
|
+
if ((existing as any[]).length > 0) {
|
|
324
|
+
// Existing user — login
|
|
325
|
+
const user = (existing as any[])[0];
|
|
326
|
+
if (user.status !== 'active') {
|
|
327
|
+
res.status(403).json({ success: false, error: 'Account suspended' });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
userId = user.id;
|
|
331
|
+
} else {
|
|
332
|
+
// New user — register
|
|
333
|
+
const randomPassword = crypto.randomBytes(32).toString('hex');
|
|
334
|
+
const hash = await bcrypt.hash(randomPassword, 10);
|
|
335
|
+
const name = googleUser.name || googleUser.email.split('@')[0];
|
|
336
|
+
|
|
337
|
+
const [result] = await pool.execute(
|
|
338
|
+
'INSERT INTO users (email, password_hash, name, verified, status) VALUES (?, ?, ?, 1, ?) RETURNING id',
|
|
339
|
+
[googleUser.email, hash, name, 'active'],
|
|
340
|
+
);
|
|
341
|
+
userId = (result as any[])[0].id;
|
|
342
|
+
|
|
343
|
+
// Assign free plan
|
|
344
|
+
const [plans] = await pool.execute('SELECT id FROM plans WHERE name = ?', ['free']);
|
|
345
|
+
const planId = (plans as any[])[0]?.id;
|
|
346
|
+
if (planId) {
|
|
347
|
+
await pool.execute(
|
|
348
|
+
"INSERT INTO subscriptions (user_id, plan_id, period_end) VALUES (?, ?, NOW() + INTERVAL '100 years')",
|
|
349
|
+
[userId, planId],
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Welcome email (async)
|
|
354
|
+
sendWelcomeEmail(googleUser.email, name).catch(() => {});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Issue JWT
|
|
358
|
+
const token = jwt.sign({ id: userId }, jwtSecret, { expiresIn: '7d' });
|
|
359
|
+
res.cookie('token', token, { httpOnly: true, maxAge: 7 * 24 * 3600_000, sameSite: 'lax' });
|
|
360
|
+
res.json({ success: true, user: { id: userId, email: googleUser.email, name: googleUser.name } });
|
|
361
|
+
|
|
362
|
+
} catch (err) {
|
|
363
|
+
logger.error({ err }, 'Google token login error');
|
|
364
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return router;
|
|
369
|
+
}
|