loren-code 0.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 +14 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/package.json +70 -0
- package/scripts/ClaudeWrapperLauncher.cs +78 -0
- package/scripts/claude-wrapper.js +216 -0
- package/scripts/install-claude-ollama.ps1 +184 -0
- package/scripts/loren.js +515 -0
- package/scripts/uninstall-claude-ollama.ps1 +73 -0
- package/src/bootstrap.js +30 -0
- package/src/cache.js +64 -0
- package/src/config-watcher.js +73 -0
- package/src/config.js +98 -0
- package/src/http-agents.js +80 -0
- package/src/key-manager.js +69 -0
- package/src/logger.js +46 -0
- package/src/metrics.js +210 -0
- package/src/schemas.js +66 -0
- package/src/server.js +1238 -0
- package/src/usage-tracker.js +346 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
$ErrorActionPreference = "Stop"
|
|
2
|
+
|
|
3
|
+
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
4
|
+
$userProfile = [Environment]::GetFolderPath("UserProfile")
|
|
5
|
+
$appData = [Environment]::GetFolderPath("ApplicationData")
|
|
6
|
+
$workspaceSettingsPath = Join-Path $appData "Code\\User\\settings.json"
|
|
7
|
+
$claudeSettingsPath = Join-Path $userProfile ".claude\\settings.json"
|
|
8
|
+
$bridgePidPath = Join-Path $repoRoot ".runtime\\bridge.pid"
|
|
9
|
+
$launcherExePath = Join-Path $repoRoot "scripts\\ClaudeWrapperLauncher.exe"
|
|
10
|
+
|
|
11
|
+
function Read-JsonFile {
|
|
12
|
+
param([string]$Path)
|
|
13
|
+
|
|
14
|
+
if (-not (Test-Path $Path)) {
|
|
15
|
+
return @{}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
$raw = Get-Content -LiteralPath $Path -Raw
|
|
19
|
+
if ([string]::IsNullOrWhiteSpace($raw)) {
|
|
20
|
+
return @{}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
$parsed = $raw | ConvertFrom-Json
|
|
24
|
+
if ($null -eq $parsed) {
|
|
25
|
+
return @{}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
$result = @{}
|
|
29
|
+
foreach ($property in $parsed.PSObject.Properties) {
|
|
30
|
+
$result[$property.Name] = $property.Value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return $result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Write-JsonFile {
|
|
37
|
+
param(
|
|
38
|
+
[string]$Path,
|
|
39
|
+
[hashtable]$Data
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
$json = $Data | ConvertTo-Json -Depth 20
|
|
43
|
+
Set-Content -LiteralPath $Path -Value ($json + "`n") -Encoding UTF8
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (Test-Path $workspaceSettingsPath) {
|
|
47
|
+
$settings = Read-JsonFile -Path $workspaceSettingsPath
|
|
48
|
+
[void]$settings.Remove("claudeCode.claudeProcessWrapper")
|
|
49
|
+
[void]$settings.Remove("claudeCode.disableLoginPrompt")
|
|
50
|
+
Write-JsonFile -Path $workspaceSettingsPath -Data $settings
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Test-Path $claudeSettingsPath) {
|
|
54
|
+
$settings = Read-JsonFile -Path $claudeSettingsPath
|
|
55
|
+
[void]$settings.Remove("model")
|
|
56
|
+
[void]$settings.Remove("availableModels")
|
|
57
|
+
Write-JsonFile -Path $claudeSettingsPath -Data $settings
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Test-Path $bridgePidPath) {
|
|
61
|
+
try {
|
|
62
|
+
$pid = [int](Get-Content -LiteralPath $bridgePidPath -Raw).Trim()
|
|
63
|
+
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
|
|
64
|
+
} catch {
|
|
65
|
+
}
|
|
66
|
+
Remove-Item -LiteralPath $bridgePidPath -Force -ErrorAction SilentlyContinue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Test-Path $launcherExePath) {
|
|
70
|
+
Remove-Item -LiteralPath $launcherExePath -Force -ErrorAction SilentlyContinue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Write-Host "Configurazione globale rimossa."
|
package/src/bootstrap.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function ensureRuntimeDir(projectRoot) {
|
|
5
|
+
fs.mkdirSync(path.join(projectRoot, ".runtime"), { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ensureEnvLocal(projectRoot, options = {}) {
|
|
9
|
+
const envLocalPath = path.join(projectRoot, ".env.local");
|
|
10
|
+
const envExamplePath = path.join(projectRoot, ".env.example");
|
|
11
|
+
const logger = options.logger ?? console;
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(envLocalPath)) {
|
|
14
|
+
return { created: false, path: envLocalPath };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!fs.existsSync(envExamplePath)) {
|
|
18
|
+
fs.writeFileSync(envLocalPath, "OLLAMA_API_KEYS=\nBRIDGE_HOST=127.0.0.1\nBRIDGE_PORT=8788\n", "utf8");
|
|
19
|
+
logger.warn?.(`Created ${envLocalPath}. Add your Ollama API key(s) before starting the bridge.`);
|
|
20
|
+
return { created: true, path: envLocalPath };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fs.copyFileSync(envExamplePath, envLocalPath);
|
|
24
|
+
logger.warn?.(`Created ${envLocalPath} from .env.example. Add your real Ollama API key(s) before starting the bridge.`);
|
|
25
|
+
return { created: true, path: envLocalPath };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getBridgeBaseUrl(config) {
|
|
29
|
+
return `http://${config.host}:${config.port}`;
|
|
30
|
+
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import NodeCache from 'node-cache';
|
|
2
|
+
import logger from './logger.js';
|
|
3
|
+
|
|
4
|
+
// Cache per i modelli (5 minuti di TTL)
|
|
5
|
+
export const modelCache = new NodeCache({
|
|
6
|
+
stdTTL: 300, // 5 minuti
|
|
7
|
+
checkperiod: 60, // Controlla ogni minuto se ci sono entry scadute
|
|
8
|
+
useClones: false // Performance migliore se non usiamo clones
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// Cache per le risposte delle API (30 secondi)
|
|
12
|
+
export const apiCache = new NodeCache({
|
|
13
|
+
stdTTL: 30,
|
|
14
|
+
checkperiod: 10,
|
|
15
|
+
useClones: false
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Funzione helper per il caching con error handling
|
|
19
|
+
export function getFromCache(cache, key) {
|
|
20
|
+
try {
|
|
21
|
+
const value = cache.get(key);
|
|
22
|
+
if (value !== undefined) {
|
|
23
|
+
logger.debug(`Cache hit for key: ${key}`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error(`Error reading from cache: ${error.message}`);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setInCache(cache, key, value, ttl) {
|
|
33
|
+
try {
|
|
34
|
+
if (ttl) {
|
|
35
|
+
cache.set(key, value, ttl);
|
|
36
|
+
} else {
|
|
37
|
+
cache.set(key, value);
|
|
38
|
+
}
|
|
39
|
+
logger.debug(`Cached value for key: ${key}`);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
logger.error(`Error writing to cache: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function deleteFromCache(cache, key) {
|
|
46
|
+
try {
|
|
47
|
+
cache.del(key);
|
|
48
|
+
logger.debug(`Deleted cache for key: ${key}`);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger.error(`Error deleting from cache: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Stats per il monitoraggio
|
|
55
|
+
export function getCacheStats(cache, name) {
|
|
56
|
+
const stats = cache.getStats();
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
hits: stats.hits,
|
|
60
|
+
misses: stats.misses,
|
|
61
|
+
keys: cache.keys().length,
|
|
62
|
+
hitRate: stats.hits > 0 ? (stats.hits / (stats.hits + stats.misses) * 100).toFixed(2) : 0
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import logger from './logger.js';
|
|
4
|
+
|
|
5
|
+
export class ConfigWatcher {
|
|
6
|
+
constructor(configFile, onChange) {
|
|
7
|
+
this.configFile = configFile;
|
|
8
|
+
this.onChange = onChange;
|
|
9
|
+
this.watcher = null;
|
|
10
|
+
this.debounceTimeout = 1000; // 1 secondo
|
|
11
|
+
this.debounceTimer = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
start() {
|
|
15
|
+
if (this.watcher) {
|
|
16
|
+
logger.warn('Config watcher already started');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
this.watcher = fs.watch(this.configFile, (eventType, filename) => {
|
|
22
|
+
if (eventType === 'change') {
|
|
23
|
+
logger.debug(`Config file changed: ${filename}`);
|
|
24
|
+
this.handleChange();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
logger.info(`Started watching config file: ${this.configFile}`);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
logger.error(`Failed to start config watcher: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stop() {
|
|
35
|
+
if (this.watcher) {
|
|
36
|
+
this.watcher.close();
|
|
37
|
+
this.watcher = null;
|
|
38
|
+
logger.info('Config watcher stopped');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (this.debounceTimer) {
|
|
42
|
+
clearTimeout(this.debounceTimer);
|
|
43
|
+
this.debounceTimer = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
handleChange() {
|
|
48
|
+
// Debounce per evitare multipli reload rapidi
|
|
49
|
+
if (this.debounceTimer) {
|
|
50
|
+
clearTimeout(this.debounceTimer);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.debounceTimer = setTimeout(async () => {
|
|
54
|
+
try {
|
|
55
|
+
logger.info('Reloading configuration...');
|
|
56
|
+
await this.onChange();
|
|
57
|
+
logger.info('Configuration reloaded successfully');
|
|
58
|
+
} catch (error) {
|
|
59
|
+
logger.error(`Failed to reload configuration: ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
}, this.debounceTimeout);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Funzione helper per creare un config watcher con reload automatico
|
|
66
|
+
export function createConfigWatcher(configFile, loadConfigFunction) {
|
|
67
|
+
const watcher = new ConfigWatcher(configFile, async () => {
|
|
68
|
+
const newConfig = loadConfigFunction();
|
|
69
|
+
return newConfig;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return watcher;
|
|
73
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PORT = 8788;
|
|
5
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
6
|
+
const DEFAULT_UPSTREAM = "https://ollama.com";
|
|
7
|
+
|
|
8
|
+
export function loadEnvFile(filePath = path.join(process.cwd(), ".env.local")) {
|
|
9
|
+
if (!fs.existsSync(filePath)) {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const result = {};
|
|
14
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
15
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const separator = trimmed.indexOf("=");
|
|
22
|
+
if (separator === -1) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const key = trimmed.slice(0, separator).trim();
|
|
27
|
+
let value = trimmed.slice(separator + 1).trim();
|
|
28
|
+
if (
|
|
29
|
+
(value.startsWith("\"") && value.endsWith("\"")) ||
|
|
30
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
31
|
+
) {
|
|
32
|
+
value = value.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
result[key] = value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseKeyList(value) {
|
|
42
|
+
if (!value) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return value
|
|
47
|
+
.split(/[,\r?\n]+/)
|
|
48
|
+
.map((entry) => entry.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseAliasMap(value) {
|
|
53
|
+
if (!value) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(value);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error("OLLAMA_MODEL_ALIASES must be valid JSON.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function saveEnvFile(filePath, envVars) {
|
|
65
|
+
const lines = Object.entries(envVars)
|
|
66
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
67
|
+
.join("\n");
|
|
68
|
+
fs.writeFileSync(filePath, `${lines}\n`, "utf8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadConfig() {
|
|
72
|
+
const fileEnv = loadEnvFile();
|
|
73
|
+
const getValue = (name, fallback = undefined) => {
|
|
74
|
+
if (Object.prototype.hasOwnProperty.call(fileEnv, name)) {
|
|
75
|
+
return fileEnv[name];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (Object.prototype.hasOwnProperty.call(process.env, name)) {
|
|
79
|
+
return process.env[name];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return fallback;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const apiKeys = parseKeyList(getValue("OLLAMA_API_KEYS") || getValue("OLLAMA_API_KEY"));
|
|
86
|
+
const aliases = parseAliasMap(getValue("OLLAMA_MODEL_ALIASES"));
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
host: getValue("BRIDGE_HOST", DEFAULT_HOST),
|
|
90
|
+
port: Number.parseInt(getValue("BRIDGE_PORT", `${DEFAULT_PORT}`), 10),
|
|
91
|
+
upstreamBaseUrl: (getValue("OLLAMA_UPSTREAM_BASE_URL", DEFAULT_UPSTREAM)).replace(/\/+$/, ""),
|
|
92
|
+
apiKeys,
|
|
93
|
+
aliases,
|
|
94
|
+
defaultModel:
|
|
95
|
+
getValue("DEFAULT_MODEL_ALIAS") ||
|
|
96
|
+
"ollama-free-auto",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import logger from './logger.js';
|
|
4
|
+
|
|
5
|
+
// Agent per connessioni HTTP (keep-alive abilitato)
|
|
6
|
+
export const httpAgent = new http.Agent({
|
|
7
|
+
keepAlive: true,
|
|
8
|
+
maxSockets: 50, // Massimo 50 connessioni contemporanee
|
|
9
|
+
maxFreeSockets: 10, // Mantieni fino a 10 connessioni aperte in idle
|
|
10
|
+
timeout: 60000, // Timeout di 60 secondi
|
|
11
|
+
freeSocketTimeout: 30000, // Chiudi socket idle dopo 30 secondi
|
|
12
|
+
scheduling: 'lifo' // Last In, First Out per miglior performance
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Agent per connessioni HTTPS
|
|
16
|
+
export const httpsAgent = new https.Agent({
|
|
17
|
+
keepAlive: true,
|
|
18
|
+
maxSockets: 50,
|
|
19
|
+
maxFreeSockets: 10,
|
|
20
|
+
timeout: 60000,
|
|
21
|
+
freeSocketTimeout: 30000,
|
|
22
|
+
scheduling: 'lifo'
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Funzione helper per ottenere l'agent corretto
|
|
26
|
+
export function getAgent(url) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = new URL(url);
|
|
29
|
+
return parsed.protocol === 'https:' ? httpsAgent : httpAgent;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
logger.error(`Invalid URL for agent selection: ${error.message}`);
|
|
32
|
+
return httpAgent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Stats per monitoraggio
|
|
37
|
+
export function getAgentStats() {
|
|
38
|
+
return {
|
|
39
|
+
http: {
|
|
40
|
+
totalSocketCount: httpAgent.totalSocketCount,
|
|
41
|
+
createSocketCount: httpAgent.createSocketCount,
|
|
42
|
+
timeoutSocketCount: httpAgent.timeoutSocketCount,
|
|
43
|
+
requestCount: httpAgent.requestCount,
|
|
44
|
+
freeSockets: Object.keys(httpAgent.freeSockets).length,
|
|
45
|
+
sockets: Object.keys(httpAgent.sockets).length
|
|
46
|
+
},
|
|
47
|
+
https: {
|
|
48
|
+
totalSocketCount: httpsAgent.totalSocketCount,
|
|
49
|
+
createSocketCount: httpsAgent.createSocketCount,
|
|
50
|
+
timeoutSocketCount: httpsAgent.timeoutSocketCount,
|
|
51
|
+
requestCount: httpsAgent.requestCount,
|
|
52
|
+
freeSockets: Object.keys(httpsAgent.freeSockets).length,
|
|
53
|
+
sockets: Object.keys(httpsAgent.sockets).length
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Cleanup function per chiudere tutti gli agent
|
|
59
|
+
export function closeAgents() {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
let pending = 2;
|
|
62
|
+
|
|
63
|
+
const done = () => {
|
|
64
|
+
if (--pending === 0) {
|
|
65
|
+
logger.info('All HTTP agents closed');
|
|
66
|
+
resolve();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
httpAgent.destroy(() => {
|
|
71
|
+
logger.debug('HTTP agent destroyed');
|
|
72
|
+
done();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
httpsAgent.destroy(() => {
|
|
76
|
+
logger.debug('HTTPS agent destroyed');
|
|
77
|
+
done();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import logger from './logger.js';
|
|
2
|
+
|
|
3
|
+
export class KeyManager {
|
|
4
|
+
constructor(keys) {
|
|
5
|
+
this.keys = keys.map(key => ({
|
|
6
|
+
key,
|
|
7
|
+
healthy: true,
|
|
8
|
+
lastUsed: null,
|
|
9
|
+
failureCount: 0,
|
|
10
|
+
lastFailure: null
|
|
11
|
+
}));
|
|
12
|
+
this.index = 0;
|
|
13
|
+
this.maxFailures = 3;
|
|
14
|
+
this.failureWindowMs = 5 * 60 * 1000; // 5 minuti
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async getHealthyKey() {
|
|
18
|
+
const startIndex = this.index;
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
|
|
21
|
+
do {
|
|
22
|
+
const keyInfo = this.keys[this.index];
|
|
23
|
+
|
|
24
|
+
// Resetta lo stato se è passato abbastanza tempo dall'ultimo fallimento
|
|
25
|
+
if (keyInfo.lastFailure && (now - keyInfo.lastFailure) > this.failureWindowMs) {
|
|
26
|
+
keyInfo.failureCount = 0;
|
|
27
|
+
keyInfo.healthy = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (keyInfo.healthy && keyInfo.failureCount < this.maxFailures) {
|
|
31
|
+
this.index = (this.index + 1) % this.keys.length;
|
|
32
|
+
keyInfo.lastUsed = now;
|
|
33
|
+
logger.debug(`Using API key at index ${this.index}, failures: ${keyInfo.failureCount}`);
|
|
34
|
+
return keyInfo.key;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.index = (this.index + 1) % this.keys.length;
|
|
38
|
+
} while (this.index !== startIndex);
|
|
39
|
+
|
|
40
|
+
logger.error('No healthy API keys available');
|
|
41
|
+
throw new Error('No healthy API keys available');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
markKeyFailed(key, error) {
|
|
45
|
+
const keyInfo = this.keys.find(k => k.key === key);
|
|
46
|
+
if (keyInfo) {
|
|
47
|
+
keyInfo.failureCount++;
|
|
48
|
+
keyInfo.lastFailure = Date.now();
|
|
49
|
+
if (keyInfo.failureCount >= this.maxFailures) {
|
|
50
|
+
keyInfo.healthy = false;
|
|
51
|
+
logger.warn(`API key marked as unhealthy after ${this.maxFailures} failures`);
|
|
52
|
+
}
|
|
53
|
+
logger.error(`API key failed (count: ${keyInfo.failureCount}): ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getStats() {
|
|
58
|
+
return {
|
|
59
|
+
total: this.keys.length,
|
|
60
|
+
healthy: this.keys.filter(k => k.healthy).length,
|
|
61
|
+
unhealthy: this.keys.filter(k => !k.healthy).length,
|
|
62
|
+
keys: this.keys.map(k => ({
|
|
63
|
+
healthy: k.healthy,
|
|
64
|
+
failureCount: k.failureCount,
|
|
65
|
+
lastUsed: k.lastUsed
|
|
66
|
+
}))
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
const logFormat = winston.format.combine(
|
|
9
|
+
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
10
|
+
winston.format.errors({ stack: true }),
|
|
11
|
+
winston.format.printf(({ level, message, timestamp, stack }) => {
|
|
12
|
+
return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
|
|
13
|
+
})
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const logger = winston.createLogger({
|
|
17
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
18
|
+
format: logFormat,
|
|
19
|
+
transports: [
|
|
20
|
+
new winston.transports.Console({
|
|
21
|
+
format: winston.format.combine(
|
|
22
|
+
winston.format.colorize(),
|
|
23
|
+
logFormat
|
|
24
|
+
)
|
|
25
|
+
}),
|
|
26
|
+
new winston.transports.File({
|
|
27
|
+
filename: path.join(__dirname, '..', '.runtime', 'error.log'),
|
|
28
|
+
level: 'error',
|
|
29
|
+
maxsize: 5242880, // 5MB
|
|
30
|
+
maxFiles: 5
|
|
31
|
+
}),
|
|
32
|
+
new winston.transports.File({
|
|
33
|
+
filename: path.join(__dirname, '..', '.runtime', 'combined.log'),
|
|
34
|
+
maxsize: 5242880, // 5MB
|
|
35
|
+
maxFiles: 5
|
|
36
|
+
})
|
|
37
|
+
],
|
|
38
|
+
exceptionHandlers: [
|
|
39
|
+
new winston.transports.File({ filename: path.join(__dirname, '..', '.runtime', 'exceptions.log') })
|
|
40
|
+
],
|
|
41
|
+
rejectionHandlers: [
|
|
42
|
+
new winston.transports.File({ filename: path.join(__dirname, '..', '.runtime', 'rejections.log') })
|
|
43
|
+
]
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export default logger;
|