commons-proxy 2.0.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/LICENSE +21 -0
- package/README.md +757 -0
- package/bin/cli.js +146 -0
- package/package.json +97 -0
- package/public/Complaint Details.pdf +0 -0
- package/public/Cyber Crime Portal.pdf +0 -0
- package/public/app.js +229 -0
- package/public/css/src/input.css +523 -0
- package/public/css/style.css +1 -0
- package/public/favicon.png +0 -0
- package/public/index.html +549 -0
- package/public/js/components/account-manager.js +356 -0
- package/public/js/components/add-account-modal.js +414 -0
- package/public/js/components/claude-config.js +420 -0
- package/public/js/components/dashboard/charts.js +605 -0
- package/public/js/components/dashboard/filters.js +362 -0
- package/public/js/components/dashboard/stats.js +110 -0
- package/public/js/components/dashboard.js +236 -0
- package/public/js/components/logs-viewer.js +100 -0
- package/public/js/components/models.js +36 -0
- package/public/js/components/server-config.js +349 -0
- package/public/js/config/constants.js +102 -0
- package/public/js/data-store.js +375 -0
- package/public/js/settings-store.js +58 -0
- package/public/js/store.js +99 -0
- package/public/js/translations/en.js +367 -0
- package/public/js/translations/id.js +412 -0
- package/public/js/translations/pt.js +308 -0
- package/public/js/translations/tr.js +358 -0
- package/public/js/translations/zh.js +373 -0
- package/public/js/utils/account-actions.js +189 -0
- package/public/js/utils/error-handler.js +96 -0
- package/public/js/utils/model-config.js +42 -0
- package/public/js/utils/ui-logger.js +143 -0
- package/public/js/utils/validators.js +77 -0
- package/public/js/utils.js +69 -0
- package/public/proxy-server-64.png +0 -0
- package/public/views/accounts.html +361 -0
- package/public/views/dashboard.html +484 -0
- package/public/views/logs.html +97 -0
- package/public/views/models.html +331 -0
- package/public/views/settings.html +1327 -0
- package/src/account-manager/credentials.js +378 -0
- package/src/account-manager/index.js +462 -0
- package/src/account-manager/onboarding.js +112 -0
- package/src/account-manager/rate-limits.js +369 -0
- package/src/account-manager/storage.js +160 -0
- package/src/account-manager/strategies/base-strategy.js +109 -0
- package/src/account-manager/strategies/hybrid-strategy.js +339 -0
- package/src/account-manager/strategies/index.js +79 -0
- package/src/account-manager/strategies/round-robin-strategy.js +76 -0
- package/src/account-manager/strategies/sticky-strategy.js +138 -0
- package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
- package/src/account-manager/strategies/trackers/index.js +9 -0
- package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
- package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
- package/src/auth/database.js +169 -0
- package/src/auth/oauth.js +548 -0
- package/src/auth/token-extractor.js +117 -0
- package/src/cli/accounts.js +648 -0
- package/src/cloudcode/index.js +29 -0
- package/src/cloudcode/message-handler.js +510 -0
- package/src/cloudcode/model-api.js +248 -0
- package/src/cloudcode/rate-limit-parser.js +235 -0
- package/src/cloudcode/request-builder.js +93 -0
- package/src/cloudcode/session-manager.js +47 -0
- package/src/cloudcode/sse-parser.js +121 -0
- package/src/cloudcode/sse-streamer.js +293 -0
- package/src/cloudcode/streaming-handler.js +615 -0
- package/src/config.js +125 -0
- package/src/constants.js +407 -0
- package/src/errors.js +242 -0
- package/src/fallback-config.js +29 -0
- package/src/format/content-converter.js +193 -0
- package/src/format/index.js +20 -0
- package/src/format/request-converter.js +255 -0
- package/src/format/response-converter.js +120 -0
- package/src/format/schema-sanitizer.js +673 -0
- package/src/format/signature-cache.js +88 -0
- package/src/format/thinking-utils.js +648 -0
- package/src/index.js +148 -0
- package/src/modules/usage-stats.js +205 -0
- package/src/providers/anthropic-provider.js +258 -0
- package/src/providers/base-provider.js +157 -0
- package/src/providers/cloudcode.js +94 -0
- package/src/providers/copilot.js +399 -0
- package/src/providers/github-provider.js +287 -0
- package/src/providers/google-provider.js +192 -0
- package/src/providers/index.js +211 -0
- package/src/providers/openai-compatible.js +265 -0
- package/src/providers/openai-provider.js +271 -0
- package/src/providers/openrouter-provider.js +325 -0
- package/src/providers/setup.js +83 -0
- package/src/server.js +870 -0
- package/src/utils/claude-config.js +245 -0
- package/src/utils/helpers.js +51 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/native-module-helper.js +162 -0
- package/src/webui/index.js +1134 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express Server - Anthropic-compatible API
|
|
3
|
+
* Proxies to Google Cloud Code via Antigravity
|
|
4
|
+
* Supports multi-account load balancing
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import cors from 'cors';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js';
|
|
12
|
+
import { mountWebUI } from './webui/index.js';
|
|
13
|
+
import { config } from './config.js';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
import { forceRefresh } from './auth/token-extractor.js';
|
|
18
|
+
import { REQUEST_BODY_LIMIT } from './constants.js';
|
|
19
|
+
import { AccountManager } from './account-manager/index.js';
|
|
20
|
+
import { clearThinkingSignatureCache } from './format/signature-cache.js';
|
|
21
|
+
import { formatDuration } from './utils/helpers.js';
|
|
22
|
+
import { logger } from './utils/logger.js';
|
|
23
|
+
import usageStats from './modules/usage-stats.js';
|
|
24
|
+
|
|
25
|
+
// Parse fallback flag directly from command line args to avoid circular dependency
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const FALLBACK_ENABLED = args.includes('--fallback') || process.env.FALLBACK === 'true';
|
|
28
|
+
|
|
29
|
+
// Parse --strategy flag (format: --strategy=sticky or --strategy sticky)
|
|
30
|
+
let STRATEGY_OVERRIDE = null;
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
if (args[i].startsWith('--strategy=')) {
|
|
33
|
+
STRATEGY_OVERRIDE = args[i].split('=')[1];
|
|
34
|
+
} else if (args[i] === '--strategy' && args[i + 1]) {
|
|
35
|
+
STRATEGY_OVERRIDE = args[i + 1];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const app = express();
|
|
40
|
+
|
|
41
|
+
// Disable x-powered-by header for security
|
|
42
|
+
app.disable('x-powered-by');
|
|
43
|
+
|
|
44
|
+
// Initialize account manager (will be fully initialized on first request or startup)
|
|
45
|
+
export const accountManager = new AccountManager();
|
|
46
|
+
|
|
47
|
+
// Track initialization status
|
|
48
|
+
let isInitialized = false;
|
|
49
|
+
let initError = null;
|
|
50
|
+
let initPromise = null;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Ensure account manager is initialized (with race condition protection)
|
|
54
|
+
*/
|
|
55
|
+
async function ensureInitialized() {
|
|
56
|
+
if (isInitialized) return;
|
|
57
|
+
|
|
58
|
+
// If initialization is already in progress, wait for it
|
|
59
|
+
if (initPromise) return initPromise;
|
|
60
|
+
|
|
61
|
+
initPromise = (async () => {
|
|
62
|
+
try {
|
|
63
|
+
await accountManager.initialize(STRATEGY_OVERRIDE);
|
|
64
|
+
isInitialized = true;
|
|
65
|
+
const status = accountManager.getStatus();
|
|
66
|
+
logger.success(`[Server] Account pool initialized: ${status.summary}`);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
initError = error;
|
|
69
|
+
initPromise = null; // Allow retry on failure
|
|
70
|
+
logger.error('[Server] Failed to initialize account manager:', error.message);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
|
|
75
|
+
return initPromise;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Middleware
|
|
79
|
+
app.use(cors());
|
|
80
|
+
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
|
|
81
|
+
|
|
82
|
+
// API Key authentication middleware for /v1/* endpoints
|
|
83
|
+
app.use('/v1', (req, res, next) => {
|
|
84
|
+
// Skip validation if apiKey is not configured
|
|
85
|
+
if (!config.apiKey) {
|
|
86
|
+
return next();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const authHeader = req.headers['authorization'];
|
|
90
|
+
const xApiKey = req.headers['x-api-key'];
|
|
91
|
+
|
|
92
|
+
let providedKey = '';
|
|
93
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
94
|
+
providedKey = authHeader.substring(7);
|
|
95
|
+
} else if (xApiKey) {
|
|
96
|
+
providedKey = xApiKey;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!providedKey || providedKey !== config.apiKey) {
|
|
100
|
+
logger.warn(`[API] Unauthorized request from ${req.ip}, invalid API key`);
|
|
101
|
+
return res.status(401).json({
|
|
102
|
+
type: 'error',
|
|
103
|
+
error: {
|
|
104
|
+
type: 'authentication_error',
|
|
105
|
+
message: 'Invalid or missing API key'
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
next();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Setup usage statistics middleware
|
|
114
|
+
usageStats.setupMiddleware(app);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Silent handler for Claude Code CLI root POST requests
|
|
118
|
+
* Claude Code sends heartbeat/event requests to POST / which we don't need
|
|
119
|
+
* Using app.use instead of app.post for earlier middleware interception
|
|
120
|
+
*/
|
|
121
|
+
app.use((req, res, next) => {
|
|
122
|
+
// Handle Claude Code event logging requests silently
|
|
123
|
+
if (req.method === 'POST' && req.path === '/api/event_logging/batch') {
|
|
124
|
+
return res.status(200).json({ status: 'ok' });
|
|
125
|
+
}
|
|
126
|
+
// Handle Claude Code root POST requests silently
|
|
127
|
+
if (req.method === 'POST' && req.path === '/') {
|
|
128
|
+
return res.status(200).json({ status: 'ok' });
|
|
129
|
+
}
|
|
130
|
+
next();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Mount WebUI (optional web interface for account management)
|
|
134
|
+
mountWebUI(app, __dirname, accountManager);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse error message to extract error type, status code, and user-friendly message
|
|
138
|
+
*/
|
|
139
|
+
function parseError(error) {
|
|
140
|
+
let errorType = 'api_error';
|
|
141
|
+
let statusCode = 500;
|
|
142
|
+
let errorMessage = error.message;
|
|
143
|
+
|
|
144
|
+
if (error.message.includes('401') || error.message.includes('UNAUTHENTICATED')) {
|
|
145
|
+
errorType = 'authentication_error';
|
|
146
|
+
statusCode = 401;
|
|
147
|
+
errorMessage = 'Authentication failed. Make sure Antigravity is running with a valid token.';
|
|
148
|
+
} else if (error.message.includes('429') || error.message.includes('RESOURCE_EXHAUSTED') || error.message.includes('QUOTA_EXHAUSTED')) {
|
|
149
|
+
errorType = 'invalid_request_error'; // Use invalid_request_error to force client to purge/stop
|
|
150
|
+
statusCode = 400; // Use 400 to ensure client does not retry (429 and 529 trigger retries)
|
|
151
|
+
|
|
152
|
+
// Try to extract the quota reset time from the error
|
|
153
|
+
const resetMatch = error.message.match(/quota will reset after ([\dh\dm\ds]+)/i);
|
|
154
|
+
// Try to extract model from our error format "Rate limited on <model>" or JSON format
|
|
155
|
+
const modelMatch = error.message.match(/Rate limited on ([^.]+)\./) || error.message.match(/"model":\s*"([^"]+)"/);
|
|
156
|
+
const model = modelMatch ? modelMatch[1] : 'the model';
|
|
157
|
+
|
|
158
|
+
if (resetMatch) {
|
|
159
|
+
errorMessage = `You have exhausted your capacity on ${model}. Quota will reset after ${resetMatch[1]}.`;
|
|
160
|
+
} else {
|
|
161
|
+
errorMessage = `You have exhausted your capacity on ${model}. Please wait for your quota to reset.`;
|
|
162
|
+
}
|
|
163
|
+
} else if (error.message.includes('invalid_request_error') || error.message.includes('INVALID_ARGUMENT')) {
|
|
164
|
+
errorType = 'invalid_request_error';
|
|
165
|
+
statusCode = 400;
|
|
166
|
+
const msgMatch = error.message.match(/"message":"([^"]+)"/);
|
|
167
|
+
if (msgMatch) errorMessage = msgMatch[1];
|
|
168
|
+
} else if (error.message.includes('All endpoints failed')) {
|
|
169
|
+
errorType = 'api_error';
|
|
170
|
+
statusCode = 503;
|
|
171
|
+
errorMessage = 'Unable to connect to Claude API. Check that Antigravity is running.';
|
|
172
|
+
} else if (error.message.includes('PERMISSION_DENIED')) {
|
|
173
|
+
errorType = 'permission_error';
|
|
174
|
+
statusCode = 403;
|
|
175
|
+
errorMessage = 'Permission denied. Check your Antigravity license.';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { errorType, statusCode, errorMessage };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Request logging middleware
|
|
182
|
+
app.use((req, res, next) => {
|
|
183
|
+
const start = Date.now();
|
|
184
|
+
|
|
185
|
+
// Log response on finish
|
|
186
|
+
res.on('finish', () => {
|
|
187
|
+
const duration = Date.now() - start;
|
|
188
|
+
const status = res.statusCode;
|
|
189
|
+
const logMsg = `[${req.method}] ${req.path} ${status} (${duration}ms)`;
|
|
190
|
+
|
|
191
|
+
// Skip standard logging for event logging batch unless in debug mode
|
|
192
|
+
if (req.path === '/api/event_logging/batch' || req.path === '/v1/messages/count_tokens') {
|
|
193
|
+
if (logger.isDebugEnabled) {
|
|
194
|
+
logger.debug(logMsg);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
// Colorize status code
|
|
198
|
+
if (status >= 500) {
|
|
199
|
+
logger.error(logMsg);
|
|
200
|
+
} else if (status >= 400) {
|
|
201
|
+
logger.warn(logMsg);
|
|
202
|
+
} else {
|
|
203
|
+
logger.info(logMsg);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
next();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Silent handler for Claude Code CLI root POST requests
|
|
213
|
+
* Claude Code sends heartbeat/event requests to POST / which we don't need
|
|
214
|
+
*/
|
|
215
|
+
app.post('/', (req, res) => {
|
|
216
|
+
res.status(200).json({ status: 'ok' });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Test endpoint - Clear thinking signature cache
|
|
221
|
+
* Used for testing cold cache scenarios in cross-model tests
|
|
222
|
+
*/
|
|
223
|
+
app.post('/test/clear-signature-cache', (req, res) => {
|
|
224
|
+
clearThinkingSignatureCache();
|
|
225
|
+
logger.debug('[Test] Cleared thinking signature cache');
|
|
226
|
+
res.json({ success: true, message: 'Thinking signature cache cleared' });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Health check endpoint - Detailed status
|
|
231
|
+
* Returns status of all accounts including rate limits and model quotas
|
|
232
|
+
*/
|
|
233
|
+
app.get('/health', async (req, res) => {
|
|
234
|
+
try {
|
|
235
|
+
await ensureInitialized();
|
|
236
|
+
const start = Date.now();
|
|
237
|
+
|
|
238
|
+
// Get high-level status first
|
|
239
|
+
const status = accountManager.getStatus();
|
|
240
|
+
const allAccounts = accountManager.getAllAccounts();
|
|
241
|
+
|
|
242
|
+
// Fetch quotas for each account in parallel to get detailed model info
|
|
243
|
+
const accountDetails = await Promise.allSettled(
|
|
244
|
+
allAccounts.map(async (account) => {
|
|
245
|
+
// Check model-specific rate limits
|
|
246
|
+
const activeModelLimits = Object.entries(account.modelRateLimits || {})
|
|
247
|
+
.filter(([_, limit]) => limit.isRateLimited && limit.resetTime > Date.now());
|
|
248
|
+
const isRateLimited = activeModelLimits.length > 0;
|
|
249
|
+
const soonestReset = activeModelLimits.length > 0
|
|
250
|
+
? Math.min(...activeModelLimits.map(([_, l]) => l.resetTime))
|
|
251
|
+
: null;
|
|
252
|
+
|
|
253
|
+
const baseInfo = {
|
|
254
|
+
email: account.email,
|
|
255
|
+
lastUsed: account.lastUsed ? new Date(account.lastUsed).toISOString() : null,
|
|
256
|
+
modelRateLimits: account.modelRateLimits || {},
|
|
257
|
+
rateLimitCooldownRemaining: soonestReset ? Math.max(0, soonestReset - Date.now()) : 0
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Skip invalid accounts for quota check
|
|
261
|
+
if (account.isInvalid) {
|
|
262
|
+
return {
|
|
263
|
+
...baseInfo,
|
|
264
|
+
status: 'invalid',
|
|
265
|
+
error: account.invalidReason,
|
|
266
|
+
models: {}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const token = await accountManager.getTokenForAccount(account);
|
|
272
|
+
const projectId = account.subscription?.projectId || null;
|
|
273
|
+
const quotas = await getModelQuotas(token, projectId);
|
|
274
|
+
|
|
275
|
+
// Format quotas for readability
|
|
276
|
+
const formattedQuotas = {};
|
|
277
|
+
for (const [modelId, info] of Object.entries(quotas)) {
|
|
278
|
+
formattedQuotas[modelId] = {
|
|
279
|
+
remaining: info.remainingFraction !== null ? `${Math.round(info.remainingFraction * 100)}%` : 'N/A',
|
|
280
|
+
remainingFraction: info.remainingFraction,
|
|
281
|
+
resetTime: info.resetTime || null
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
...baseInfo,
|
|
287
|
+
status: isRateLimited ? 'rate-limited' : 'ok',
|
|
288
|
+
models: formattedQuotas
|
|
289
|
+
};
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return {
|
|
292
|
+
...baseInfo,
|
|
293
|
+
status: 'error',
|
|
294
|
+
error: error.message,
|
|
295
|
+
models: {}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Process results
|
|
302
|
+
const detailedAccounts = accountDetails.map((result, index) => {
|
|
303
|
+
if (result.status === 'fulfilled') {
|
|
304
|
+
return result.value;
|
|
305
|
+
} else {
|
|
306
|
+
const acc = allAccounts[index];
|
|
307
|
+
return {
|
|
308
|
+
email: acc.email,
|
|
309
|
+
status: 'error',
|
|
310
|
+
error: result.reason?.message || 'Unknown error',
|
|
311
|
+
modelRateLimits: acc.modelRateLimits || {}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
res.json({
|
|
317
|
+
status: 'ok',
|
|
318
|
+
timestamp: new Date().toISOString(),
|
|
319
|
+
latencyMs: Date.now() - start,
|
|
320
|
+
summary: status.summary,
|
|
321
|
+
counts: {
|
|
322
|
+
total: status.total,
|
|
323
|
+
available: status.available,
|
|
324
|
+
rateLimited: status.rateLimited,
|
|
325
|
+
invalid: status.invalid
|
|
326
|
+
},
|
|
327
|
+
accounts: detailedAccounts
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
} catch (error) {
|
|
331
|
+
logger.error('[API] Health check failed:', error);
|
|
332
|
+
res.status(503).json({
|
|
333
|
+
status: 'error',
|
|
334
|
+
error: error.message,
|
|
335
|
+
timestamp: new Date().toISOString()
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Account limits endpoint - fetch quota/limits for all accounts × all models
|
|
342
|
+
* Returns a table showing remaining quota and reset time for each combination
|
|
343
|
+
* Use ?format=table for ASCII table output, default is JSON
|
|
344
|
+
*/
|
|
345
|
+
app.get('/account-limits', async (req, res) => {
|
|
346
|
+
try {
|
|
347
|
+
await ensureInitialized();
|
|
348
|
+
const allAccounts = accountManager.getAllAccounts();
|
|
349
|
+
const format = req.query.format || 'json';
|
|
350
|
+
const includeHistory = req.query.includeHistory === 'true';
|
|
351
|
+
|
|
352
|
+
// Fetch quotas for each account in parallel
|
|
353
|
+
const results = await Promise.allSettled(
|
|
354
|
+
allAccounts.map(async (account) => {
|
|
355
|
+
// Skip invalid accounts
|
|
356
|
+
if (account.isInvalid) {
|
|
357
|
+
return {
|
|
358
|
+
email: account.email,
|
|
359
|
+
status: 'invalid',
|
|
360
|
+
error: account.invalidReason,
|
|
361
|
+
models: {}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const token = await accountManager.getTokenForAccount(account);
|
|
367
|
+
|
|
368
|
+
// Use provider-aware quota fetching
|
|
369
|
+
const { getProviderForAccount } = await import('./providers/index.js');
|
|
370
|
+
const provider = getProviderForAccount(account);
|
|
371
|
+
|
|
372
|
+
// Fetch subscription tier and quotas using provider
|
|
373
|
+
const subscription = await provider.getSubscriptionTier(account, token);
|
|
374
|
+
const quotas = await provider.getQuotas(account, token);
|
|
375
|
+
|
|
376
|
+
// Update account object with fresh data
|
|
377
|
+
account.subscription = {
|
|
378
|
+
tier: subscription.tier,
|
|
379
|
+
projectId: subscription.projectId,
|
|
380
|
+
detectedAt: Date.now()
|
|
381
|
+
};
|
|
382
|
+
account.quota = {
|
|
383
|
+
models: quotas.models || quotas,
|
|
384
|
+
lastChecked: Date.now()
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// Save updated account data to disk (async, don't wait)
|
|
388
|
+
accountManager.saveToDisk().catch(err => {
|
|
389
|
+
logger.error('[Server] Failed to save account data:', err);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
email: account.email,
|
|
394
|
+
status: 'ok',
|
|
395
|
+
provider: account.provider || 'google',
|
|
396
|
+
subscription: account.subscription,
|
|
397
|
+
models: quotas.models || quotas
|
|
398
|
+
};
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return {
|
|
401
|
+
email: account.email,
|
|
402
|
+
status: 'error',
|
|
403
|
+
error: error.message,
|
|
404
|
+
provider: account.provider || 'google',
|
|
405
|
+
subscription: account.subscription || { tier: 'unknown', projectId: null },
|
|
406
|
+
models: {}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Process results
|
|
413
|
+
const accountLimits = results.map((result, index) => {
|
|
414
|
+
if (result.status === 'fulfilled') {
|
|
415
|
+
return result.value;
|
|
416
|
+
} else {
|
|
417
|
+
return {
|
|
418
|
+
email: allAccounts[index].email,
|
|
419
|
+
status: 'error',
|
|
420
|
+
error: result.reason?.message || 'Unknown error',
|
|
421
|
+
models: {}
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Collect all unique model IDs
|
|
427
|
+
const allModelIds = new Set();
|
|
428
|
+
for (const account of accountLimits) {
|
|
429
|
+
for (const modelId of Object.keys(account.models || {})) {
|
|
430
|
+
allModelIds.add(modelId);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const sortedModels = Array.from(allModelIds).sort();
|
|
435
|
+
|
|
436
|
+
// Return ASCII table format
|
|
437
|
+
if (format === 'table') {
|
|
438
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
439
|
+
|
|
440
|
+
// Build table
|
|
441
|
+
const lines = [];
|
|
442
|
+
const timestamp = new Date().toLocaleString();
|
|
443
|
+
lines.push(`Account Limits (${timestamp})`);
|
|
444
|
+
|
|
445
|
+
// Get account status info
|
|
446
|
+
const status = accountManager.getStatus();
|
|
447
|
+
lines.push(`Accounts: ${status.total} total, ${status.available} available, ${status.rateLimited} rate-limited, ${status.invalid} invalid`);
|
|
448
|
+
lines.push('');
|
|
449
|
+
|
|
450
|
+
// Table 1: Account status
|
|
451
|
+
const accColWidth = 25;
|
|
452
|
+
const statusColWidth = 15;
|
|
453
|
+
const lastUsedColWidth = 25;
|
|
454
|
+
const resetColWidth = 25;
|
|
455
|
+
|
|
456
|
+
let accHeader = 'Account'.padEnd(accColWidth) + 'Status'.padEnd(statusColWidth) + 'Last Used'.padEnd(lastUsedColWidth) + 'Quota Reset';
|
|
457
|
+
lines.push(accHeader);
|
|
458
|
+
lines.push('─'.repeat(accColWidth + statusColWidth + lastUsedColWidth + resetColWidth));
|
|
459
|
+
|
|
460
|
+
for (const acc of status.accounts) {
|
|
461
|
+
const shortEmail = acc.email.split('@')[0].slice(0, 22);
|
|
462
|
+
const lastUsed = acc.lastUsed ? new Date(acc.lastUsed).toLocaleString() : 'never';
|
|
463
|
+
|
|
464
|
+
// Get status and error from accountLimits
|
|
465
|
+
const accLimit = accountLimits.find(a => a.email === acc.email);
|
|
466
|
+
let accStatus;
|
|
467
|
+
if (acc.isInvalid) {
|
|
468
|
+
accStatus = 'invalid';
|
|
469
|
+
} else if (accLimit?.status === 'error') {
|
|
470
|
+
accStatus = 'error';
|
|
471
|
+
} else {
|
|
472
|
+
// Count exhausted models (0% or null remaining)
|
|
473
|
+
const models = accLimit?.models || {};
|
|
474
|
+
const modelCount = Object.keys(models).length;
|
|
475
|
+
const exhaustedCount = Object.values(models).filter(
|
|
476
|
+
q => q.remainingFraction === 0 || q.remainingFraction === null
|
|
477
|
+
).length;
|
|
478
|
+
|
|
479
|
+
if (exhaustedCount === 0) {
|
|
480
|
+
accStatus = 'ok';
|
|
481
|
+
} else {
|
|
482
|
+
accStatus = `(${exhaustedCount}/${modelCount}) limited`;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Get reset time from quota API
|
|
487
|
+
const claudeModel = sortedModels.find(m => m.includes('claude'));
|
|
488
|
+
const quota = claudeModel && accLimit?.models?.[claudeModel];
|
|
489
|
+
const resetTime = quota?.resetTime
|
|
490
|
+
? new Date(quota.resetTime).toLocaleString()
|
|
491
|
+
: '-';
|
|
492
|
+
|
|
493
|
+
let row = shortEmail.padEnd(accColWidth) + accStatus.padEnd(statusColWidth) + lastUsed.padEnd(lastUsedColWidth) + resetTime;
|
|
494
|
+
|
|
495
|
+
// Add error on next line if present
|
|
496
|
+
if (accLimit?.error) {
|
|
497
|
+
lines.push(row);
|
|
498
|
+
lines.push(' └─ ' + accLimit.error);
|
|
499
|
+
} else {
|
|
500
|
+
lines.push(row);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
lines.push('');
|
|
504
|
+
|
|
505
|
+
// Calculate column widths - need more space for reset time info
|
|
506
|
+
const modelColWidth = Math.max(28, ...sortedModels.map(m => m.length)) + 2;
|
|
507
|
+
const accountColWidth = 30;
|
|
508
|
+
|
|
509
|
+
// Header row
|
|
510
|
+
let header = 'Model'.padEnd(modelColWidth);
|
|
511
|
+
for (const acc of accountLimits) {
|
|
512
|
+
const shortEmail = acc.email.split('@')[0].slice(0, 26);
|
|
513
|
+
header += shortEmail.padEnd(accountColWidth);
|
|
514
|
+
}
|
|
515
|
+
lines.push(header);
|
|
516
|
+
lines.push('─'.repeat(modelColWidth + accountLimits.length * accountColWidth));
|
|
517
|
+
|
|
518
|
+
// Data rows
|
|
519
|
+
for (const modelId of sortedModels) {
|
|
520
|
+
let row = modelId.padEnd(modelColWidth);
|
|
521
|
+
for (const acc of accountLimits) {
|
|
522
|
+
const quota = acc.models?.[modelId];
|
|
523
|
+
let cell;
|
|
524
|
+
if (acc.status !== 'ok' && acc.status !== 'rate-limited') {
|
|
525
|
+
cell = `[${acc.status}]`;
|
|
526
|
+
} else if (!quota) {
|
|
527
|
+
cell = '-';
|
|
528
|
+
} else if (quota.remainingFraction === 0 || quota.remainingFraction === null) {
|
|
529
|
+
// Show reset time for exhausted models
|
|
530
|
+
if (quota.resetTime) {
|
|
531
|
+
const resetMs = new Date(quota.resetTime).getTime() - Date.now();
|
|
532
|
+
if (resetMs > 0) {
|
|
533
|
+
cell = `0% (wait ${formatDuration(resetMs)})`;
|
|
534
|
+
} else {
|
|
535
|
+
cell = '0% (resetting...)';
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
cell = '0% (exhausted)';
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
const pct = Math.round(quota.remainingFraction * 100);
|
|
542
|
+
cell = `${pct}%`;
|
|
543
|
+
}
|
|
544
|
+
row += cell.padEnd(accountColWidth);
|
|
545
|
+
}
|
|
546
|
+
lines.push(row);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return res.send(lines.join('\n'));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Get account metadata from AccountManager
|
|
553
|
+
const accountStatus = accountManager.getStatus();
|
|
554
|
+
const accountMetadataMap = new Map(
|
|
555
|
+
accountStatus.accounts.map(a => [a.email, a])
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// Build response data
|
|
559
|
+
const responseData = {
|
|
560
|
+
timestamp: new Date().toLocaleString(),
|
|
561
|
+
totalAccounts: allAccounts.length,
|
|
562
|
+
models: sortedModels,
|
|
563
|
+
modelConfig: config.modelMapping || {},
|
|
564
|
+
accounts: accountLimits.map(acc => {
|
|
565
|
+
// Merge quota data with account metadata
|
|
566
|
+
const metadata = accountMetadataMap.get(acc.email) || {};
|
|
567
|
+
return {
|
|
568
|
+
email: acc.email,
|
|
569
|
+
status: acc.status,
|
|
570
|
+
error: acc.error || null,
|
|
571
|
+
// Include metadata from AccountManager (WebUI needs these)
|
|
572
|
+
source: metadata.source || 'unknown',
|
|
573
|
+
enabled: metadata.enabled !== false,
|
|
574
|
+
projectId: metadata.projectId || null,
|
|
575
|
+
isInvalid: metadata.isInvalid || false,
|
|
576
|
+
invalidReason: metadata.invalidReason || null,
|
|
577
|
+
lastUsed: metadata.lastUsed || null,
|
|
578
|
+
modelRateLimits: metadata.modelRateLimits || {},
|
|
579
|
+
// Subscription data (new)
|
|
580
|
+
subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
|
|
581
|
+
// Quota limits
|
|
582
|
+
limits: Object.fromEntries(
|
|
583
|
+
sortedModels.map(modelId => {
|
|
584
|
+
const quota = acc.models?.[modelId];
|
|
585
|
+
if (!quota) {
|
|
586
|
+
return [modelId, null];
|
|
587
|
+
}
|
|
588
|
+
return [modelId, {
|
|
589
|
+
remaining: quota.remainingFraction !== null
|
|
590
|
+
? `${Math.round(quota.remainingFraction * 100)}%`
|
|
591
|
+
: 'N/A',
|
|
592
|
+
remainingFraction: quota.remainingFraction,
|
|
593
|
+
resetTime: quota.resetTime || null
|
|
594
|
+
}];
|
|
595
|
+
})
|
|
596
|
+
)
|
|
597
|
+
};
|
|
598
|
+
})
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// Optionally include usage history (for dashboard performance optimization)
|
|
602
|
+
if (includeHistory) {
|
|
603
|
+
responseData.history = usageStats.getHistory();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
res.json(responseData);
|
|
607
|
+
} catch (error) {
|
|
608
|
+
res.status(500).json({
|
|
609
|
+
status: 'error',
|
|
610
|
+
error: error.message
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Force token refresh endpoint
|
|
617
|
+
*/
|
|
618
|
+
app.post('/refresh-token', async (req, res) => {
|
|
619
|
+
try {
|
|
620
|
+
await ensureInitialized();
|
|
621
|
+
// Clear all caches
|
|
622
|
+
accountManager.clearTokenCache();
|
|
623
|
+
accountManager.clearProjectCache();
|
|
624
|
+
// Force refresh default token
|
|
625
|
+
const token = await forceRefresh();
|
|
626
|
+
res.json({
|
|
627
|
+
status: 'ok',
|
|
628
|
+
message: 'Token caches cleared and refreshed',
|
|
629
|
+
tokenPrefix: token.substring(0, 10) + '...'
|
|
630
|
+
});
|
|
631
|
+
} catch (error) {
|
|
632
|
+
res.status(500).json({
|
|
633
|
+
status: 'error',
|
|
634
|
+
error: error.message
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* List models endpoint (OpenAI-compatible format)
|
|
641
|
+
*/
|
|
642
|
+
app.get('/v1/models', async (req, res) => {
|
|
643
|
+
try {
|
|
644
|
+
await ensureInitialized();
|
|
645
|
+
const { account } = accountManager.selectAccount();
|
|
646
|
+
if (!account) {
|
|
647
|
+
return res.status(503).json({
|
|
648
|
+
type: 'error',
|
|
649
|
+
error: {
|
|
650
|
+
type: 'api_error',
|
|
651
|
+
message: 'No accounts available'
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
const token = await accountManager.getTokenForAccount(account);
|
|
656
|
+
const models = await listModels(token);
|
|
657
|
+
res.json(models);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
logger.error('[API] Error listing models:', error);
|
|
660
|
+
res.status(500).json({
|
|
661
|
+
type: 'error',
|
|
662
|
+
error: {
|
|
663
|
+
type: 'api_error',
|
|
664
|
+
message: error.message
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Count tokens endpoint - Anthropic Messages API compatible
|
|
672
|
+
* Uses local tokenization with official tokenizers (@anthropic-ai/tokenizer for Claude, @lenml/tokenizer-gemini for Gemini)
|
|
673
|
+
*/
|
|
674
|
+
app.post('/v1/messages/count_tokens', (req, res) => {
|
|
675
|
+
res.status(501).json({
|
|
676
|
+
type: 'error',
|
|
677
|
+
error: {
|
|
678
|
+
type: 'not_implemented',
|
|
679
|
+
message: 'Token counting is not implemented. Use /v1/messages with max_tokens or configure your client to skip token counting.'
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Main messages endpoint - Anthropic Messages API compatible
|
|
686
|
+
*/
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Anthropic-compatible Messages API
|
|
691
|
+
* POST /v1/messages
|
|
692
|
+
*/
|
|
693
|
+
app.post('/v1/messages', async (req, res) => {
|
|
694
|
+
try {
|
|
695
|
+
// Ensure account manager is initialized
|
|
696
|
+
await ensureInitialized();
|
|
697
|
+
|
|
698
|
+
const {
|
|
699
|
+
model,
|
|
700
|
+
messages,
|
|
701
|
+
stream,
|
|
702
|
+
system,
|
|
703
|
+
max_tokens,
|
|
704
|
+
tools,
|
|
705
|
+
tool_choice,
|
|
706
|
+
thinking,
|
|
707
|
+
top_p,
|
|
708
|
+
top_k,
|
|
709
|
+
temperature
|
|
710
|
+
} = req.body;
|
|
711
|
+
|
|
712
|
+
// Resolve model mapping if configured
|
|
713
|
+
let requestedModel = model || 'claude-3-5-sonnet-20241022';
|
|
714
|
+
const modelMapping = config.modelMapping || {};
|
|
715
|
+
if (modelMapping[requestedModel] && modelMapping[requestedModel].mapping) {
|
|
716
|
+
const targetModel = modelMapping[requestedModel].mapping;
|
|
717
|
+
logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
|
|
718
|
+
requestedModel = targetModel;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const modelId = requestedModel;
|
|
722
|
+
|
|
723
|
+
// Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
|
|
724
|
+
// If we have some available accounts, we try them first.
|
|
725
|
+
if (accountManager.isAllRateLimited(modelId)) {
|
|
726
|
+
logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
|
|
727
|
+
accountManager.resetAllRateLimits();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Validate required fields
|
|
731
|
+
if (!messages || !Array.isArray(messages)) {
|
|
732
|
+
return res.status(400).json({
|
|
733
|
+
type: 'error',
|
|
734
|
+
error: {
|
|
735
|
+
type: 'invalid_request_error',
|
|
736
|
+
message: 'messages is required and must be an array'
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Filter out "count" requests (often automated background checks)
|
|
742
|
+
if (messages.length === 1 && messages[0].content === 'count') {
|
|
743
|
+
return res.json({});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Build the request object
|
|
747
|
+
const request = {
|
|
748
|
+
model: modelId,
|
|
749
|
+
messages,
|
|
750
|
+
max_tokens: max_tokens || 4096,
|
|
751
|
+
stream,
|
|
752
|
+
system,
|
|
753
|
+
tools,
|
|
754
|
+
tool_choice,
|
|
755
|
+
thinking,
|
|
756
|
+
top_p,
|
|
757
|
+
top_k,
|
|
758
|
+
temperature
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
logger.info(`[API] Request for model: ${request.model}, stream: ${!!stream}`);
|
|
762
|
+
|
|
763
|
+
// Debug: Log message structure to diagnose tool_use/tool_result ordering
|
|
764
|
+
if (logger.isDebugEnabled) {
|
|
765
|
+
logger.debug('[API] Message structure:');
|
|
766
|
+
messages.forEach((msg, i) => {
|
|
767
|
+
const contentTypes = Array.isArray(msg.content)
|
|
768
|
+
? msg.content.map(c => c.type || 'text').join(', ')
|
|
769
|
+
: (typeof msg.content === 'string' ? 'text' : 'unknown');
|
|
770
|
+
logger.debug(` [${i}] ${msg.role}: ${contentTypes}`);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (stream) {
|
|
775
|
+
// Handle streaming response
|
|
776
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
777
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
778
|
+
res.setHeader('Connection', 'keep-alive');
|
|
779
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
780
|
+
|
|
781
|
+
// Flush headers immediately to start the stream
|
|
782
|
+
res.flushHeaders();
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
// Use the streaming generator with account manager
|
|
786
|
+
for await (const event of sendMessageStream(request, accountManager, FALLBACK_ENABLED)) {
|
|
787
|
+
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
788
|
+
// Flush after each event for real-time streaming
|
|
789
|
+
if (res.flush) res.flush();
|
|
790
|
+
}
|
|
791
|
+
res.end();
|
|
792
|
+
|
|
793
|
+
} catch (streamError) {
|
|
794
|
+
logger.error('[API] Stream error:', streamError);
|
|
795
|
+
|
|
796
|
+
const { errorType, errorMessage } = parseError(streamError);
|
|
797
|
+
|
|
798
|
+
res.write(`event: error\ndata: ${JSON.stringify({
|
|
799
|
+
type: 'error',
|
|
800
|
+
error: { type: errorType, message: errorMessage }
|
|
801
|
+
})}\n\n`);
|
|
802
|
+
res.end();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
} else {
|
|
806
|
+
// Handle non-streaming response
|
|
807
|
+
const response = await sendMessage(request, accountManager, FALLBACK_ENABLED);
|
|
808
|
+
res.json(response);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
} catch (error) {
|
|
812
|
+
logger.error('[API] Error:', error);
|
|
813
|
+
|
|
814
|
+
let { errorType, statusCode, errorMessage } = parseError(error);
|
|
815
|
+
|
|
816
|
+
// For auth errors, try to refresh token
|
|
817
|
+
if (errorType === 'authentication_error') {
|
|
818
|
+
logger.warn('[API] Token might be expired, attempting refresh...');
|
|
819
|
+
try {
|
|
820
|
+
accountManager.clearProjectCache();
|
|
821
|
+
accountManager.clearTokenCache();
|
|
822
|
+
await forceRefresh();
|
|
823
|
+
errorMessage = 'Token was expired and has been refreshed. Please retry your request.';
|
|
824
|
+
} catch (refreshError) {
|
|
825
|
+
errorMessage = 'Could not refresh token. Make sure Antigravity is running.';
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
logger.warn(`[API] Returning error response: ${statusCode} ${errorType} - ${errorMessage}`);
|
|
830
|
+
|
|
831
|
+
// Check if headers have already been sent (for streaming that failed mid-way)
|
|
832
|
+
if (res.headersSent) {
|
|
833
|
+
logger.warn('[API] Headers already sent, writing error as SSE event');
|
|
834
|
+
res.write(`event: error\ndata: ${JSON.stringify({
|
|
835
|
+
type: 'error',
|
|
836
|
+
error: { type: errorType, message: errorMessage }
|
|
837
|
+
})}\n\n`);
|
|
838
|
+
res.end();
|
|
839
|
+
} else {
|
|
840
|
+
res.status(statusCode).json({
|
|
841
|
+
type: 'error',
|
|
842
|
+
error: {
|
|
843
|
+
type: errorType,
|
|
844
|
+
message: errorMessage
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Catch-all for unsupported endpoints
|
|
853
|
+
*/
|
|
854
|
+
usageStats.setupRoutes(app);
|
|
855
|
+
|
|
856
|
+
app.use('*', (req, res) => {
|
|
857
|
+
// Log 404s (use originalUrl since wildcard strips req.path)
|
|
858
|
+
if (logger.isDebugEnabled) {
|
|
859
|
+
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);
|
|
860
|
+
}
|
|
861
|
+
res.status(404).json({
|
|
862
|
+
type: 'error',
|
|
863
|
+
error: {
|
|
864
|
+
type: 'not_found_error',
|
|
865
|
+
message: `Endpoint ${req.method} ${req.originalUrl} not found`
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
export default app;
|