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
package/src/index.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import cors from 'cors';
|
|
4
|
+
import helmet from 'helmet';
|
|
5
|
+
import { loadConfig } from './config';
|
|
6
|
+
import { initDatabase } from './services/database';
|
|
7
|
+
import { initRegistry } from './providers/registry';
|
|
8
|
+
import { startHealthChecker, stopHealthChecker, getHealthStatus } from './services/health-checker';
|
|
9
|
+
import { setJwtSecret, cookieParser, optionalAuth } from './middleware/session-auth';
|
|
10
|
+
import { setMailerApiKey } from './services/mailer';
|
|
11
|
+
import { setAlipayConfig } from './services/alipay';
|
|
12
|
+
import { messagesRouter } from './routes/messages';
|
|
13
|
+
import { modelsRouter } from './routes/models';
|
|
14
|
+
import { createAuthRouter } from './routes/auth';
|
|
15
|
+
import { dashboardRouter } from './routes/dashboard';
|
|
16
|
+
import { adminRouter } from './routes/admin';
|
|
17
|
+
import { paymentRouter } from './routes/payment';
|
|
18
|
+
import { blogRouter } from './routes/blog';
|
|
19
|
+
import { sitemapRouter } from './routes/sitemap';
|
|
20
|
+
import { initRemoteWs } from './services/remote-ws';
|
|
21
|
+
import { requestLogger } from './middleware/request-logger';
|
|
22
|
+
import { metrics } from './services/metrics';
|
|
23
|
+
import { logger } from './utils/logger';
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
|
|
28
|
+
// View engine
|
|
29
|
+
app.set('view engine', 'ejs');
|
|
30
|
+
app.set('views', path.join(__dirname, '..', 'views'));
|
|
31
|
+
|
|
32
|
+
// Middleware
|
|
33
|
+
app.use(helmet({ contentSecurityPolicy: false }));
|
|
34
|
+
app.use(cors());
|
|
35
|
+
app.use(express.json({ limit: '10mb' }));
|
|
36
|
+
app.use(express.urlencoded({ extended: true }));
|
|
37
|
+
app.use(cookieParser);
|
|
38
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
39
|
+
|
|
40
|
+
// Request logging
|
|
41
|
+
app.use(requestLogger);
|
|
42
|
+
|
|
43
|
+
// Graceful handling of streaming disconnects
|
|
44
|
+
app.use((req, res, next) => {
|
|
45
|
+
res.on('error', (err: NodeJS.ErrnoException) => {
|
|
46
|
+
if (['ERR_STREAM_WRITE_AFTER_END', 'EPIPE', 'ECONNRESET'].includes(err.code || '')) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
logger.error({ err }, 'Response stream error');
|
|
50
|
+
});
|
|
51
|
+
next();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Inject viewUser for page rendering (non-blocking)
|
|
55
|
+
app.use(optionalAuth);
|
|
56
|
+
|
|
57
|
+
// ---- API Routes ----
|
|
58
|
+
app.use('/v1', messagesRouter);
|
|
59
|
+
app.use('/v1', modelsRouter);
|
|
60
|
+
app.use('/api/auth', createAuthRouter(config.jwtSecret));
|
|
61
|
+
app.use('/api/dashboard', dashboardRouter);
|
|
62
|
+
app.use('/api/admin', adminRouter);
|
|
63
|
+
app.use('/api/payment', paymentRouter);
|
|
64
|
+
app.use(blogRouter);
|
|
65
|
+
app.use(sitemapRouter);
|
|
66
|
+
|
|
67
|
+
// ---- Page Routes ----
|
|
68
|
+
app.get('/', (req, res) => {
|
|
69
|
+
res.render('pages/index', { viewUser: req.user || null });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
app.get('/login', (req, res) => {
|
|
73
|
+
if (req.user) return res.redirect('/dashboard');
|
|
74
|
+
res.render('pages/login', { viewUser: null });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
app.get('/register', (req, res) => {
|
|
78
|
+
if (req.user) return res.redirect('/dashboard');
|
|
79
|
+
res.render('pages/register', { viewUser: null });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
app.get('/dashboard', (req, res) => {
|
|
83
|
+
if (!req.user) return res.redirect('/login');
|
|
84
|
+
res.render('pages/dashboard', { viewUser: req.user });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.get('/pricing', (req, res) => {
|
|
88
|
+
res.render('pages/pricing', { viewUser: req.user || null });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
app.get('/docs', (req, res) => {
|
|
92
|
+
res.render('pages/docs', { viewUser: req.user || null });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
app.get('/orders', (req, res) => {
|
|
96
|
+
if (!req.user) return res.redirect('/login');
|
|
97
|
+
res.render('pages/orders', { viewUser: req.user });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
app.get('/settings', (req, res) => {
|
|
101
|
+
if (!req.user) return res.redirect('/login');
|
|
102
|
+
res.render('pages/settings', { viewUser: req.user });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
app.get('/admin', (req, res) => {
|
|
106
|
+
if (!req.user || req.user.role !== 'admin') return res.redirect('/login');
|
|
107
|
+
res.render('pages/admin', { viewUser: req.user });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.get('/remote', (req, res) => {
|
|
111
|
+
res.render('pages/remote', { viewUser: req.user || null });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// API: fetch remote session credentials (used by remote page)
|
|
115
|
+
app.get('/api/remote/session/:sessionId', async (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const { getRemoteSessionKey } = await import('./services/remote-control');
|
|
118
|
+
const result = await getRemoteSessionKey(req.params.sessionId);
|
|
119
|
+
if (!result) {
|
|
120
|
+
res.status(404).json({ success: false, error: 'Session not found or expired' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
res.json({ success: true, ...result });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
res.status(500).json({ success: false, error: 'Internal error' });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Health check with provider status
|
|
130
|
+
app.get('/health', (_req, res) => {
|
|
131
|
+
const providers = getHealthStatus();
|
|
132
|
+
res.json({
|
|
133
|
+
status: 'ok',
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
providers,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Metrics endpoint (admin use)
|
|
140
|
+
app.get('/metrics', (_req, res) => {
|
|
141
|
+
res.json(metrics.getSnapshot());
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// 404
|
|
145
|
+
app.use((req, res) => {
|
|
146
|
+
res.status(404).render('pages/404', { viewUser: req.user || null });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Error handler
|
|
150
|
+
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
151
|
+
logger.error({ err }, 'Unhandled error');
|
|
152
|
+
res.status(500).json({
|
|
153
|
+
type: 'error',
|
|
154
|
+
error: { type: 'api_error', message: 'Internal server error' },
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---- Startup ----
|
|
159
|
+
async function start() {
|
|
160
|
+
// Initialize config-dependent services
|
|
161
|
+
setJwtSecret(config.jwtSecret);
|
|
162
|
+
|
|
163
|
+
if (process.env.RESEND_API_KEY) {
|
|
164
|
+
setMailerApiKey(process.env.RESEND_API_KEY);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (process.env.ALIPAY_APP_ID) {
|
|
168
|
+
setAlipayConfig({
|
|
169
|
+
appId: process.env.ALIPAY_APP_ID,
|
|
170
|
+
privateKey: process.env.ALIPAY_PRIVATE_KEY || '',
|
|
171
|
+
alipayPublicKey: process.env.ALIPAY_PUBLIC_KEY || '',
|
|
172
|
+
gateway: process.env.ALIPAY_GATEWAY || 'https://openapi.alipay.com/gateway.do',
|
|
173
|
+
notifyUrl: `${config.siteUrl}/api/payment/notify`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Database
|
|
178
|
+
await initDatabase(config.databaseUrl);
|
|
179
|
+
|
|
180
|
+
// Provider registry
|
|
181
|
+
initRegistry(config);
|
|
182
|
+
|
|
183
|
+
// Health checker (check every 60s)
|
|
184
|
+
startHealthChecker(5 * 60_000); // Every 5 min (uses zero-cost invalid model probe)
|
|
185
|
+
|
|
186
|
+
// Start server
|
|
187
|
+
const server = app.listen(config.port, () => {
|
|
188
|
+
logger.info({
|
|
189
|
+
port: config.port,
|
|
190
|
+
providers: config.providers.map(p => p.name),
|
|
191
|
+
siteUrl: config.siteUrl,
|
|
192
|
+
}, 'LLM API v2 started');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Initialize Remote Control WebSocket server
|
|
196
|
+
initRemoteWs(server);
|
|
197
|
+
|
|
198
|
+
// Graceful shutdown
|
|
199
|
+
const shutdown = (signal: string) => {
|
|
200
|
+
logger.info({ signal }, 'Shutting down...');
|
|
201
|
+
stopHealthChecker();
|
|
202
|
+
server.close(() => {
|
|
203
|
+
logger.info('Server closed');
|
|
204
|
+
process.exit(0);
|
|
205
|
+
});
|
|
206
|
+
// Force exit after 10s
|
|
207
|
+
setTimeout(() => {
|
|
208
|
+
logger.warn('Forced shutdown after timeout');
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}, 10_000);
|
|
211
|
+
};
|
|
212
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
213
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
start().catch((err) => {
|
|
217
|
+
logger.error({ err }, 'Failed to start');
|
|
218
|
+
process.exit(1);
|
|
219
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
3
|
+
import { getPool } from '../services/database';
|
|
4
|
+
import { AuthenticationError, PermissionError, RateLimitError } from '../utils/errors';
|
|
5
|
+
/// <reference path="../types/express.d.ts" />
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Authenticate requests using API key (x-api-key header or Bearer token).
|
|
9
|
+
* Used for /v1/messages and other API routes.
|
|
10
|
+
*/
|
|
11
|
+
export async function apiKeyAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
const apiKey = extractApiKey(req);
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new AuthenticationError('Missing API key. Include it in the x-api-key header or Authorization: Bearer header.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const pool = getPool();
|
|
19
|
+
|
|
20
|
+
// Lookup by prefix + SHA-256 hash
|
|
21
|
+
const keyPrefix = apiKey.substring(0, 12);
|
|
22
|
+
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
|
23
|
+
|
|
24
|
+
const [keys] = await pool.execute(
|
|
25
|
+
'SELECT * FROM api_keys WHERE key_prefix = ? AND key_hash = ? AND status = ?',
|
|
26
|
+
[keyPrefix, keyHash, 'active'],
|
|
27
|
+
);
|
|
28
|
+
const matched = (keys as any[])[0];
|
|
29
|
+
if (!matched) {
|
|
30
|
+
throw new AuthenticationError('Invalid API key.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fetch user
|
|
34
|
+
const [users] = await pool.execute('SELECT * FROM users WHERE id = ?', [matched.user_id]);
|
|
35
|
+
const user = (users as any[])[0];
|
|
36
|
+
if (!user || user.status !== 'active') {
|
|
37
|
+
throw new PermissionError('Account suspended or not found.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Fetch subscription + plan
|
|
41
|
+
const [subs] = await pool.execute(`
|
|
42
|
+
SELECT s.*, p.name as plan_name, p.token_limit_monthly, p.rate_limit_rpm
|
|
43
|
+
FROM subscriptions s JOIN plans p ON s.plan_id = p.id
|
|
44
|
+
WHERE s.user_id = ?
|
|
45
|
+
ORDER BY s.period_start DESC LIMIT 1
|
|
46
|
+
`, [user.id]);
|
|
47
|
+
const sub = (subs as any[])[0];
|
|
48
|
+
|
|
49
|
+
// Check token quota (-1 = unlimited)
|
|
50
|
+
if (sub && Number(sub.token_limit_monthly) !== -1 && Number(sub.tokens_used) >= Number(sub.token_limit_monthly)) {
|
|
51
|
+
throw new RateLimitError('Monthly token limit exceeded. Please upgrade your plan.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Attach to request
|
|
55
|
+
req.userId = user.id;
|
|
56
|
+
req.apiUser = user;
|
|
57
|
+
req.apiKey = { id: matched.id, rate_limit: matched.rate_limit };
|
|
58
|
+
req.subscription = sub;
|
|
59
|
+
|
|
60
|
+
next();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err instanceof AuthenticationError || err instanceof PermissionError || err instanceof RateLimitError) {
|
|
63
|
+
res.status(err.statusCode).json(err.toJSON());
|
|
64
|
+
} else {
|
|
65
|
+
next(err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function extractApiKey(req: Request): string | null {
|
|
71
|
+
// x-api-key header (Anthropic standard)
|
|
72
|
+
const xApiKey = req.headers['x-api-key'];
|
|
73
|
+
if (typeof xApiKey === 'string') return xApiKey;
|
|
74
|
+
|
|
75
|
+
// Authorization: Bearer <key>
|
|
76
|
+
const auth = req.headers.authorization;
|
|
77
|
+
if (auth && auth.startsWith('Bearer ')) {
|
|
78
|
+
return auth.slice(7);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { RateLimitError } from '../utils/errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pre-request quota check. Must run after apiKeyAuth.
|
|
6
|
+
* Rejects requests if the user has exhausted their monthly token quota.
|
|
7
|
+
*
|
|
8
|
+
* Note: This is a soft guard — the actual deduction happens after the request
|
|
9
|
+
* completes (in usage.ts). Concurrent requests may slightly exceed the quota,
|
|
10
|
+
* which is acceptable for this use case.
|
|
11
|
+
*/
|
|
12
|
+
export function quotaGuard(req: Request, res: Response, next: NextFunction): void {
|
|
13
|
+
const sub = req.subscription;
|
|
14
|
+
if (!sub) return next();
|
|
15
|
+
|
|
16
|
+
// -1 means unlimited (PG may return bigint as string)
|
|
17
|
+
if (Number(sub.token_limit_monthly) === -1) return next();
|
|
18
|
+
|
|
19
|
+
if (Number(sub.tokens_used) >= Number(sub.token_limit_monthly)) {
|
|
20
|
+
const err = new RateLimitError(
|
|
21
|
+
`Monthly token limit exceeded (${sub.tokens_used.toLocaleString()} / ${sub.token_limit_monthly.toLocaleString()}). Please upgrade your plan.`,
|
|
22
|
+
);
|
|
23
|
+
res.status(err.statusCode).json(err.toJSON());
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
next();
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { RateLimitError } from '../utils/errors';
|
|
3
|
+
|
|
4
|
+
interface WindowEntry {
|
|
5
|
+
timestamps: number[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** In-memory sliding window rate limiter, keyed by API key ID. */
|
|
9
|
+
const windows = new Map<number, WindowEntry>();
|
|
10
|
+
|
|
11
|
+
const WINDOW_MS = 60_000; // 1 minute sliding window
|
|
12
|
+
const CLEANUP_INTERVAL = 5 * 60_000; // Clean up every 5 min
|
|
13
|
+
|
|
14
|
+
// Periodic cleanup
|
|
15
|
+
setInterval(() => {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
for (const [key, entry] of windows) {
|
|
18
|
+
entry.timestamps = entry.timestamps.filter(t => now - t < WINDOW_MS);
|
|
19
|
+
if (entry.timestamps.length === 0) windows.delete(key);
|
|
20
|
+
}
|
|
21
|
+
}, CLEANUP_INTERVAL);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rate limit middleware. Must run after apiKeyAuth (needs req.apiKey and req.subscription).
|
|
25
|
+
*/
|
|
26
|
+
export function rateLimiter(req: Request, res: Response, next: NextFunction): void {
|
|
27
|
+
const keyId = req.apiKey?.id;
|
|
28
|
+
if (!keyId) return next();
|
|
29
|
+
|
|
30
|
+
const limit = req.subscription?.rate_limit_rpm || req.apiKey?.rate_limit || 60;
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
|
|
33
|
+
let entry = windows.get(keyId);
|
|
34
|
+
if (!entry) {
|
|
35
|
+
entry = { timestamps: [] };
|
|
36
|
+
windows.set(keyId, entry);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Remove expired timestamps
|
|
40
|
+
entry.timestamps = entry.timestamps.filter(t => now - t < WINDOW_MS);
|
|
41
|
+
|
|
42
|
+
if (entry.timestamps.length >= limit) {
|
|
43
|
+
const resetTime = entry.timestamps[0] + WINDOW_MS;
|
|
44
|
+
res.setHeader('X-RateLimit-Limit', String(limit));
|
|
45
|
+
res.setHeader('X-RateLimit-Remaining', '0');
|
|
46
|
+
res.setHeader('X-RateLimit-Reset', String(Math.ceil(resetTime / 1000)));
|
|
47
|
+
res.setHeader('Retry-After', String(Math.ceil((resetTime - now) / 1000)));
|
|
48
|
+
|
|
49
|
+
const err = new RateLimitError(`Rate limit exceeded. Max ${limit} requests per minute.`);
|
|
50
|
+
res.status(err.statusCode).json(err.toJSON());
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
entry.timestamps.push(now);
|
|
55
|
+
|
|
56
|
+
// Set rate limit headers
|
|
57
|
+
res.setHeader('X-RateLimit-Limit', String(limit));
|
|
58
|
+
res.setHeader('X-RateLimit-Remaining', String(limit - entry.timestamps.length));
|
|
59
|
+
|
|
60
|
+
next();
|
|
61
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { logger } from '../utils/logger';
|
|
3
|
+
/// <reference path="../types/express.d.ts" />
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Log every HTTP request with timing.
|
|
7
|
+
* Skips /health to avoid noise.
|
|
8
|
+
*/
|
|
9
|
+
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
|
10
|
+
if (req.path === '/health') return next();
|
|
11
|
+
|
|
12
|
+
const start = Date.now();
|
|
13
|
+
|
|
14
|
+
res.on('finish', () => {
|
|
15
|
+
const duration = Date.now() - start;
|
|
16
|
+
const logData = {
|
|
17
|
+
method: req.method,
|
|
18
|
+
path: req.path,
|
|
19
|
+
status: res.statusCode,
|
|
20
|
+
durationMs: duration,
|
|
21
|
+
ip: req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress,
|
|
22
|
+
userAgent: req.headers['user-agent']?.slice(0, 100),
|
|
23
|
+
userId: req.userId,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (res.statusCode >= 500) {
|
|
27
|
+
logger.error(logData, 'Request error');
|
|
28
|
+
} else if (res.statusCode >= 400) {
|
|
29
|
+
logger.warn(logData, 'Request client error');
|
|
30
|
+
} else {
|
|
31
|
+
logger.info(logData, 'Request completed');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
next();
|
|
36
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
3
|
+
import { getPool } from '../services/database';
|
|
4
|
+
/// <reference path="../types/express.d.ts" />
|
|
5
|
+
|
|
6
|
+
let jwtSecret = '';
|
|
7
|
+
|
|
8
|
+
export function setJwtSecret(secret: string): void {
|
|
9
|
+
jwtSecret = secret;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse cookies from Cookie header (lightweight, no dependency).
|
|
14
|
+
*/
|
|
15
|
+
export function cookieParser(req: Request, _res: Response, next: NextFunction): void {
|
|
16
|
+
const header = req.headers.cookie || '';
|
|
17
|
+
const cookies: Record<string, string> = {};
|
|
18
|
+
for (const pair of header.split(';')) {
|
|
19
|
+
const [key, ...rest] = pair.trim().split('=');
|
|
20
|
+
if (key) cookies[key] = decodeURIComponent(rest.join('='));
|
|
21
|
+
}
|
|
22
|
+
req.cookies = cookies;
|
|
23
|
+
next();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Session-based JWT authentication for web dashboard routes.
|
|
28
|
+
* Reads JWT from 'token' cookie.
|
|
29
|
+
*/
|
|
30
|
+
export async function sessionAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
31
|
+
const token = req.cookies?.token;
|
|
32
|
+
const isApi = req.path.startsWith('/api/');
|
|
33
|
+
|
|
34
|
+
const sendUnauth = () => {
|
|
35
|
+
if (isApi) {
|
|
36
|
+
res.status(401).json({ success: false, error: 'Authentication required' });
|
|
37
|
+
} else {
|
|
38
|
+
res.redirect('/login');
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (!token) { sendUnauth(); return; }
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const decoded = jwt.verify(token, jwtSecret) as { id: number };
|
|
46
|
+
const pool = getPool();
|
|
47
|
+
const [rows] = await pool.execute('SELECT id, email, name, role, status FROM users WHERE id = ?', [decoded.id]);
|
|
48
|
+
const user = (rows as any[])[0];
|
|
49
|
+
|
|
50
|
+
if (!user || user.status !== 'active') {
|
|
51
|
+
res.clearCookie('token');
|
|
52
|
+
sendUnauth();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
req.user = user;
|
|
57
|
+
req.userId = user.id;
|
|
58
|
+
next();
|
|
59
|
+
} catch {
|
|
60
|
+
res.clearCookie('token');
|
|
61
|
+
sendUnauth();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Admin authentication. Extends sessionAuth with role check.
|
|
67
|
+
*/
|
|
68
|
+
export async function adminAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
69
|
+
await sessionAuth(req, res, () => {
|
|
70
|
+
if (req.user?.role === 'admin') return next();
|
|
71
|
+
res.status(403).json({ success: false, error: 'Admin access required' });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Inject user info into req.user from cookie (non-blocking, for page rendering).
|
|
77
|
+
*/
|
|
78
|
+
export async function optionalAuth(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
|
79
|
+
const token = req.cookies?.token;
|
|
80
|
+
if (!token) { next(); return; }
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const decoded = jwt.verify(token, jwtSecret) as { id: number };
|
|
84
|
+
const pool = getPool();
|
|
85
|
+
const [rows] = await pool.execute('SELECT id, email, name, role, status FROM users WHERE id = ?', [decoded.id]);
|
|
86
|
+
req.user = (rows as any[])[0] || undefined;
|
|
87
|
+
req.userId = req.user?.id;
|
|
88
|
+
} catch {}
|
|
89
|
+
|
|
90
|
+
next();
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BaseProvider } from './base-provider';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Alibaba Cloud Bailian (DashScope) provider.
|
|
5
|
+
* Native Anthropic endpoint: https://dashscope-intl.aliyuncs.com/apps/anthropic
|
|
6
|
+
*
|
|
7
|
+
* Supports Qwen series models with full Anthropic API compatibility:
|
|
8
|
+
* - Streaming SSE
|
|
9
|
+
* - Tool calling (96.5% accuracy)
|
|
10
|
+
* - Context caching (automatic, reduces cost for repeated prefixes)
|
|
11
|
+
*/
|
|
12
|
+
export class AliyunProvider extends BaseProvider {
|
|
13
|
+
constructor(anthropicBaseUrl: string, apiKey: string, timeout: number, openaiBaseUrl?: string) {
|
|
14
|
+
super('aliyun', anthropicBaseUrl, apiKey, timeout, openaiBaseUrl);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import https from 'https';
|
|
3
|
+
import { URL } from 'url';
|
|
4
|
+
import type { IncomingMessage } from 'http';
|
|
5
|
+
import type { LLMProvider } from './types';
|
|
6
|
+
import { logger } from '../utils/logger';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Transparent Anthropic proxy provider.
|
|
10
|
+
*
|
|
11
|
+
* Forwards requests directly to the provider's native Anthropic-compatible
|
|
12
|
+
* endpoint WITHOUT format conversion. The provider handles all Anthropic
|
|
13
|
+
* protocol details (streaming SSE, tool calling, etc.).
|
|
14
|
+
*
|
|
15
|
+
* This is the highest-quality approach: we rely on the provider's own
|
|
16
|
+
* Anthropic compatibility layer rather than building our own.
|
|
17
|
+
*/
|
|
18
|
+
export class BaseProvider implements LLMProvider {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly anthropicBaseUrl: string;
|
|
21
|
+
readonly openaiBaseUrl: string | undefined;
|
|
22
|
+
private readonly apiKey: string;
|
|
23
|
+
private readonly timeout: number;
|
|
24
|
+
|
|
25
|
+
// Circuit breaker
|
|
26
|
+
private healthy = true;
|
|
27
|
+
private consecutiveFailures = 0;
|
|
28
|
+
private lastFailureTime = 0;
|
|
29
|
+
private static readonly FAILURE_THRESHOLD = 3;
|
|
30
|
+
private static readonly RECOVERY_TIME_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
name: string,
|
|
34
|
+
anthropicBaseUrl: string,
|
|
35
|
+
apiKey: string,
|
|
36
|
+
timeout: number,
|
|
37
|
+
openaiBaseUrl?: string,
|
|
38
|
+
) {
|
|
39
|
+
this.name = name;
|
|
40
|
+
this.anthropicBaseUrl = anthropicBaseUrl;
|
|
41
|
+
this.openaiBaseUrl = openaiBaseUrl;
|
|
42
|
+
this.apiKey = apiKey;
|
|
43
|
+
this.timeout = timeout;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
isHealthy(): boolean {
|
|
47
|
+
if (this.healthy) return true;
|
|
48
|
+
if (Date.now() - this.lastFailureTime > BaseProvider.RECOVERY_TIME_MS) {
|
|
49
|
+
this.healthy = true;
|
|
50
|
+
this.consecutiveFailures = 0;
|
|
51
|
+
logger.info({ provider: this.name }, 'Provider auto-recovered');
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
markUnhealthy(reason: string): void {
|
|
58
|
+
this.consecutiveFailures++;
|
|
59
|
+
this.lastFailureTime = Date.now();
|
|
60
|
+
if (this.consecutiveFailures >= BaseProvider.FAILURE_THRESHOLD) {
|
|
61
|
+
this.healthy = false;
|
|
62
|
+
logger.warn({ provider: this.name, reason, failures: this.consecutiveFailures },
|
|
63
|
+
'Provider marked unhealthy');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
markHealthy(): void {
|
|
68
|
+
if (!this.healthy) {
|
|
69
|
+
logger.info({ provider: this.name }, 'Provider recovered');
|
|
70
|
+
}
|
|
71
|
+
this.healthy = true;
|
|
72
|
+
this.consecutiveFailures = 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Transparent proxy: forward an Anthropic-format request body directly
|
|
77
|
+
* to the provider's native Anthropic endpoint.
|
|
78
|
+
*
|
|
79
|
+
* Returns the raw IncomingMessage so the caller can pipe it directly
|
|
80
|
+
* back to the client (for streaming) or buffer it (for non-streaming).
|
|
81
|
+
*/
|
|
82
|
+
proxy(
|
|
83
|
+
path: string,
|
|
84
|
+
body: Buffer | string,
|
|
85
|
+
headers: Record<string, string>,
|
|
86
|
+
isStream: boolean,
|
|
87
|
+
): Promise<IncomingMessage> {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const parsed = new URL(this.anthropicBaseUrl);
|
|
90
|
+
const isHttps = parsed.protocol === 'https:';
|
|
91
|
+
const transport = isHttps ? https : http;
|
|
92
|
+
|
|
93
|
+
const payload = typeof body === 'string' ? Buffer.from(body) : body;
|
|
94
|
+
const fullPath = parsed.pathname.replace(/\/+$/, '') + path;
|
|
95
|
+
|
|
96
|
+
// Forward most headers, but override auth
|
|
97
|
+
const proxyHeaders: Record<string, string | number> = {
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
'Content-Length': payload.length,
|
|
100
|
+
'x-api-key': this.apiKey,
|
|
101
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
102
|
+
};
|
|
103
|
+
// Pass through anthropic-specific headers
|
|
104
|
+
if (headers['anthropic-version']) proxyHeaders['anthropic-version'] = headers['anthropic-version'];
|
|
105
|
+
if (headers['anthropic-beta']) proxyHeaders['anthropic-beta'] = headers['anthropic-beta'];
|
|
106
|
+
|
|
107
|
+
const options: http.RequestOptions = {
|
|
108
|
+
hostname: parsed.hostname,
|
|
109
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
110
|
+
path: fullPath,
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: proxyHeaders,
|
|
113
|
+
timeout: this.timeout,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const req = transport.request(options, (res) => {
|
|
117
|
+
if (isStream || (res.statusCode && res.statusCode < 400)) {
|
|
118
|
+
resolve(res);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Non-stream error: buffer the error response to log it
|
|
123
|
+
const chunks: Buffer[] = [];
|
|
124
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
125
|
+
res.on('end', () => {
|
|
126
|
+
const errBody = Buffer.concat(chunks).toString();
|
|
127
|
+
let message = `Provider ${this.name} returned ${res.statusCode}`;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(errBody);
|
|
130
|
+
message = parsed.error?.message || message;
|
|
131
|
+
} catch {}
|
|
132
|
+
const err = new Error(message) as Error & { statusCode: number; rawBody: string };
|
|
133
|
+
err.statusCode = res.statusCode || 500;
|
|
134
|
+
err.rawBody = errBody;
|
|
135
|
+
reject(err);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
req.on('timeout', () => {
|
|
140
|
+
req.destroy();
|
|
141
|
+
reject(new Error(`Request to ${this.name} timed out after ${this.timeout}ms`));
|
|
142
|
+
});
|
|
143
|
+
req.on('error', reject);
|
|
144
|
+
req.write(payload);
|
|
145
|
+
req.end();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|