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.
Files changed (162) hide show
  1. package/.env.example +40 -0
  2. package/Dockerfile +17 -0
  3. package/dist/config.d.ts +48 -0
  4. package/dist/config.js +98 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/converter/request.d.ts +6 -0
  7. package/dist/converter/request.js +184 -0
  8. package/dist/converter/request.js.map +1 -0
  9. package/dist/converter/response.d.ts +6 -0
  10. package/dist/converter/response.js +76 -0
  11. package/dist/converter/response.js.map +1 -0
  12. package/dist/converter/stream.d.ts +54 -0
  13. package/dist/converter/stream.js +318 -0
  14. package/dist/converter/stream.js.map +1 -0
  15. package/dist/converter/types.d.ts +239 -0
  16. package/dist/converter/types.js +6 -0
  17. package/dist/converter/types.js.map +1 -0
  18. package/dist/data/posts.d.ts +19 -0
  19. package/dist/data/posts.js +462 -0
  20. package/dist/data/posts.js.map +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +233 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/middleware/api-key-auth.d.ts +6 -0
  25. package/dist/middleware/api-key-auth.js +76 -0
  26. package/dist/middleware/api-key-auth.js.map +1 -0
  27. package/dist/middleware/quota-guard.d.ts +10 -0
  28. package/dist/middleware/quota-guard.js +27 -0
  29. package/dist/middleware/quota-guard.js.map +1 -0
  30. package/dist/middleware/rate-limiter.d.ts +5 -0
  31. package/dist/middleware/rate-limiter.js +50 -0
  32. package/dist/middleware/rate-limiter.js.map +1 -0
  33. package/dist/middleware/request-logger.d.ts +6 -0
  34. package/dist/middleware/request-logger.js +37 -0
  35. package/dist/middleware/request-logger.js.map +1 -0
  36. package/dist/middleware/session-auth.d.ts +19 -0
  37. package/dist/middleware/session-auth.js +99 -0
  38. package/dist/middleware/session-auth.js.map +1 -0
  39. package/dist/providers/aliyun.d.ts +13 -0
  40. package/dist/providers/aliyun.js +20 -0
  41. package/dist/providers/aliyun.js.map +1 -0
  42. package/dist/providers/base-provider.d.ts +36 -0
  43. package/dist/providers/base-provider.js +133 -0
  44. package/dist/providers/base-provider.js.map +1 -0
  45. package/dist/providers/deepseek.d.ts +11 -0
  46. package/dist/providers/deepseek.js +18 -0
  47. package/dist/providers/deepseek.js.map +1 -0
  48. package/dist/providers/registry.d.ts +18 -0
  49. package/dist/providers/registry.js +98 -0
  50. package/dist/providers/registry.js.map +1 -0
  51. package/dist/providers/types.d.ts +17 -0
  52. package/dist/providers/types.js +3 -0
  53. package/dist/providers/types.js.map +1 -0
  54. package/dist/routes/admin.d.ts +1 -0
  55. package/dist/routes/admin.js +153 -0
  56. package/dist/routes/admin.js.map +1 -0
  57. package/dist/routes/auth.d.ts +2 -0
  58. package/dist/routes/auth.js +318 -0
  59. package/dist/routes/auth.js.map +1 -0
  60. package/dist/routes/blog.d.ts +1 -0
  61. package/dist/routes/blog.js +29 -0
  62. package/dist/routes/blog.js.map +1 -0
  63. package/dist/routes/dashboard.d.ts +1 -0
  64. package/dist/routes/dashboard.js +184 -0
  65. package/dist/routes/dashboard.js.map +1 -0
  66. package/dist/routes/messages.d.ts +1 -0
  67. package/dist/routes/messages.js +309 -0
  68. package/dist/routes/messages.js.map +1 -0
  69. package/dist/routes/models.d.ts +1 -0
  70. package/dist/routes/models.js +39 -0
  71. package/dist/routes/models.js.map +1 -0
  72. package/dist/routes/payment.d.ts +1 -0
  73. package/dist/routes/payment.js +150 -0
  74. package/dist/routes/payment.js.map +1 -0
  75. package/dist/routes/sitemap.d.ts +1 -0
  76. package/dist/routes/sitemap.js +38 -0
  77. package/dist/routes/sitemap.js.map +1 -0
  78. package/dist/services/alipay.d.ts +27 -0
  79. package/dist/services/alipay.js +106 -0
  80. package/dist/services/alipay.js.map +1 -0
  81. package/dist/services/database.d.ts +4 -0
  82. package/dist/services/database.js +170 -0
  83. package/dist/services/database.js.map +1 -0
  84. package/dist/services/health-checker.d.ts +13 -0
  85. package/dist/services/health-checker.js +95 -0
  86. package/dist/services/health-checker.js.map +1 -0
  87. package/dist/services/mailer.d.ts +3 -0
  88. package/dist/services/mailer.js +91 -0
  89. package/dist/services/mailer.js.map +1 -0
  90. package/dist/services/metrics.d.ts +56 -0
  91. package/dist/services/metrics.js +94 -0
  92. package/dist/services/metrics.js.map +1 -0
  93. package/dist/services/remote-control.d.ts +20 -0
  94. package/dist/services/remote-control.js +209 -0
  95. package/dist/services/remote-control.js.map +1 -0
  96. package/dist/services/remote-ws.d.ts +5 -0
  97. package/dist/services/remote-ws.js +143 -0
  98. package/dist/services/remote-ws.js.map +1 -0
  99. package/dist/services/usage.d.ts +13 -0
  100. package/dist/services/usage.js +39 -0
  101. package/dist/services/usage.js.map +1 -0
  102. package/dist/utils/errors.d.ts +27 -0
  103. package/dist/utils/errors.js +48 -0
  104. package/dist/utils/errors.js.map +1 -0
  105. package/dist/utils/logger.d.ts +2 -0
  106. package/dist/utils/logger.js +14 -0
  107. package/dist/utils/logger.js.map +1 -0
  108. package/docker-compose.yml +19 -0
  109. package/package.json +39 -0
  110. package/public/robots.txt +8 -0
  111. package/src/config.ts +140 -0
  112. package/src/converter/request.ts +207 -0
  113. package/src/converter/response.ts +85 -0
  114. package/src/converter/stream.ts +373 -0
  115. package/src/converter/types.ts +257 -0
  116. package/src/data/posts.ts +474 -0
  117. package/src/index.ts +219 -0
  118. package/src/middleware/api-key-auth.ts +82 -0
  119. package/src/middleware/quota-guard.ts +28 -0
  120. package/src/middleware/rate-limiter.ts +61 -0
  121. package/src/middleware/request-logger.ts +36 -0
  122. package/src/middleware/session-auth.ts +91 -0
  123. package/src/providers/aliyun.ts +16 -0
  124. package/src/providers/base-provider.ts +148 -0
  125. package/src/providers/deepseek.ts +14 -0
  126. package/src/providers/registry.ts +111 -0
  127. package/src/providers/types.ts +26 -0
  128. package/src/routes/admin.ts +169 -0
  129. package/src/routes/auth.ts +369 -0
  130. package/src/routes/blog.ts +28 -0
  131. package/src/routes/dashboard.ts +208 -0
  132. package/src/routes/messages.ts +346 -0
  133. package/src/routes/models.ts +37 -0
  134. package/src/routes/payment.ts +189 -0
  135. package/src/routes/sitemap.ts +40 -0
  136. package/src/services/alipay.ts +116 -0
  137. package/src/services/database.ts +187 -0
  138. package/src/services/health-checker.ts +115 -0
  139. package/src/services/mailer.ts +90 -0
  140. package/src/services/metrics.ts +104 -0
  141. package/src/services/remote-control.ts +226 -0
  142. package/src/services/remote-ws.ts +145 -0
  143. package/src/services/usage.ts +57 -0
  144. package/src/types/express.d.ts +46 -0
  145. package/src/utils/errors.ts +44 -0
  146. package/src/utils/logger.ts +8 -0
  147. package/tsconfig.json +17 -0
  148. package/views/pages/404.ejs +14 -0
  149. package/views/pages/admin.ejs +307 -0
  150. package/views/pages/blog-post.ejs +378 -0
  151. package/views/pages/blog.ejs +148 -0
  152. package/views/pages/dashboard.ejs +441 -0
  153. package/views/pages/docs.ejs +807 -0
  154. package/views/pages/index.ejs +416 -0
  155. package/views/pages/login.ejs +170 -0
  156. package/views/pages/orders.ejs +111 -0
  157. package/views/pages/pricing.ejs +379 -0
  158. package/views/pages/register.ejs +397 -0
  159. package/views/pages/remote.ejs +334 -0
  160. package/views/pages/settings.ejs +373 -0
  161. package/views/partials/header.ejs +70 -0
  162. 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
+ }