krasavacode 0.3.5 → 0.4.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/bin/krasavacode.js +4 -4
- package/package.json +1 -1
- package/src/cooldowns.js +54 -0
- package/src/doctor.js +19 -0
- package/src/hub.js +7 -5
- package/src/launch.js +19 -20
- package/src/metrics-proxy.js +162 -75
- package/src/preset.js +35 -58
- package/src/providers.js +234 -0
- package/src/setup.js +409 -0
- package/src/setup-gemini.js +0 -427
package/bin/krasavacode.js
CHANGED
|
@@ -5,18 +5,18 @@ import { ensurePreset } from '../src/preset.js';
|
|
|
5
5
|
import { launchClaude } from '../src/launch.js';
|
|
6
6
|
import { runUpgrade } from '../src/upgrade.js';
|
|
7
7
|
import { runDoctor } from '../src/doctor.js';
|
|
8
|
-
import {
|
|
8
|
+
import { runSetup } from '../src/setup.js';
|
|
9
9
|
|
|
10
10
|
// Hardcoded so it works inside Bun --compile (no FS access to package.json)
|
|
11
|
-
const VERSION = '0.
|
|
11
|
+
const VERSION = '0.4.0';
|
|
12
12
|
|
|
13
13
|
const cmd = process.argv[2];
|
|
14
14
|
|
|
15
15
|
async function main() {
|
|
16
16
|
if (cmd === 'doctor') return runDoctor();
|
|
17
17
|
if (cmd === 'upgrade') return runUpgrade();
|
|
18
|
-
if (cmd === 'setup-gemini' || cmd === 'gemini') {
|
|
19
|
-
const result = await
|
|
18
|
+
if (cmd === 'setup' || cmd === 'setup-gemini' || cmd === 'gemini') {
|
|
19
|
+
const result = await runSetup();
|
|
20
20
|
if (!result?.launchAfter) return;
|
|
21
21
|
// fall through to normal launch flow below
|
|
22
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "krasavacode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "KRASAVACODE — однокнопочный бесплатный вайбкодинг для учеников. Claude Code на бесплатных провайдерах через локальный gateway. Сам ставит Node при необходимости.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cooldowns.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider cooldown tracking.
|
|
3
|
+
*
|
|
4
|
+
* When a provider returns 429, metrics-proxy stamps a cooldown until X.
|
|
5
|
+
* - per-minute rate limit (RPM): 60 sec cooldown
|
|
6
|
+
* - per-day quota (RPD/TPD): until 11:00 МСК next day (~daily reset)
|
|
7
|
+
*
|
|
8
|
+
* The custom router (~/.krasavacode/router.js) reads this file on every
|
|
9
|
+
* request and skips providers whose cooldown is still in the future.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const ROOT = join(homedir(), '.krasavacode');
|
|
17
|
+
const COOLDOWN_FILE = join(ROOT, 'cooldowns.json');
|
|
18
|
+
|
|
19
|
+
export async function getCooldowns() {
|
|
20
|
+
try { return JSON.parse(await readFile(COOLDOWN_FILE, 'utf8')); }
|
|
21
|
+
catch { return {}; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function setCooldown(providerId, until) {
|
|
25
|
+
await mkdir(ROOT, { recursive: true });
|
|
26
|
+
const cd = await getCooldowns();
|
|
27
|
+
cd[providerId] = until.toISOString();
|
|
28
|
+
cd._lastUpdated = new Date().toISOString();
|
|
29
|
+
await writeFile(COOLDOWN_FILE, JSON.stringify(cd, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function clearCooldown(providerId) {
|
|
33
|
+
const cd = await getCooldowns();
|
|
34
|
+
delete cd[providerId];
|
|
35
|
+
await writeFile(COOLDOWN_FILE, JSON.stringify(cd, null, 2));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function isOnCooldown(providerId) {
|
|
39
|
+
const cd = await getCooldowns();
|
|
40
|
+
if (!cd[providerId]) return false;
|
|
41
|
+
return new Date(cd[providerId]).getTime() > Date.now();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Compute when to lift cooldown based on the 429 reason. */
|
|
45
|
+
export function cooldownUntil(reason) {
|
|
46
|
+
if (reason === 'per-minute') {
|
|
47
|
+
return new Date(Date.now() + 60_000);
|
|
48
|
+
}
|
|
49
|
+
// per-day or unknown — until 11:00 MSK tomorrow (≈ midnight Pacific reset)
|
|
50
|
+
const next = new Date();
|
|
51
|
+
next.setUTCHours(8, 0, 0, 0); // 11:00 MSK == 08:00 UTC
|
|
52
|
+
if (next.getTime() < Date.now()) next.setUTCDate(next.getUTCDate() + 1);
|
|
53
|
+
return next;
|
|
54
|
+
}
|
package/src/doctor.js
CHANGED
|
@@ -3,6 +3,8 @@ import { homedir, platform, arch, totalmem } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { access, readFile } from 'node:fs/promises';
|
|
5
5
|
import net from 'node:net';
|
|
6
|
+
import { configuredProviders, PROVIDERS } from './providers.js';
|
|
7
|
+
import { getCooldowns } from './cooldowns.js';
|
|
6
8
|
|
|
7
9
|
const ROOT = join(homedir(), '.krasavacode');
|
|
8
10
|
|
|
@@ -63,6 +65,23 @@ export async function runDoctor() {
|
|
|
63
65
|
check('3456 (claude-code-router)', await checkPort(3456), 'свободен или используется ccr');
|
|
64
66
|
check('20128 (omniroute upgrade)', await checkPort(20128));
|
|
65
67
|
|
|
68
|
+
console.log('\nПровайдеры:');
|
|
69
|
+
const cfg = await configuredProviders();
|
|
70
|
+
const cd = await getCooldowns();
|
|
71
|
+
if (cfg.length === 0) {
|
|
72
|
+
console.log(' пусто (используется Pollinations gpt-oss-20b — слабая модель)');
|
|
73
|
+
console.log(' → krasavacode setup для подключения Cerebras / Groq / Gemini');
|
|
74
|
+
} else {
|
|
75
|
+
let i = 1;
|
|
76
|
+
for (const id of cfg) {
|
|
77
|
+
const p = PROVIDERS[id];
|
|
78
|
+
const onCD = cd[id] && new Date(cd[id]).getTime() > Date.now();
|
|
79
|
+
const status = onCD ? `⏳ cooldown до ${new Date(cd[id]).toLocaleString('ru')}` : '✓ готов';
|
|
80
|
+
console.log(` ${i++}. ${p.name} — ${status}`);
|
|
81
|
+
}
|
|
82
|
+
console.log(` ${i}. Pollinations (последний резерв)`);
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
console.log('\nState:');
|
|
67
86
|
try {
|
|
68
87
|
const state = JSON.parse(await readFile(join(ROOT, 'state.json'), 'utf8'));
|
package/src/hub.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
3
|
import { CCR_PORT } from './preset.js';
|
|
4
|
-
import {
|
|
4
|
+
import { configuredProviders, loadProviderKey, getProviderEnvVarName } from './providers.js';
|
|
5
5
|
import { startMetricsProxy } from './metrics-proxy.js';
|
|
6
6
|
|
|
7
7
|
const HOST = '127.0.0.1';
|
|
@@ -40,11 +40,13 @@ export async function startHub(paths) {
|
|
|
40
40
|
|
|
41
41
|
process.stdout.write(`🚀 Поднимаю локальный gateway на порту ${PORT}… `);
|
|
42
42
|
|
|
43
|
-
// Inject
|
|
44
|
-
//
|
|
43
|
+
// Inject every configured provider's API key as env var so that ccr's
|
|
44
|
+
// config.json can reference them via interpolation ($CEREBRAS_API_KEY etc).
|
|
45
45
|
const ccrEnv = { ...paths.env };
|
|
46
|
-
const
|
|
47
|
-
|
|
46
|
+
for (const id of await configuredProviders()) {
|
|
47
|
+
const key = await loadProviderKey(id);
|
|
48
|
+
if (key) ccrEnv[getProviderEnvVarName(id)] = key;
|
|
49
|
+
}
|
|
48
50
|
|
|
49
51
|
const child = spawn(paths.ccrBin, ['start'], {
|
|
50
52
|
stdio: process.env.KRASAVACODE_DEBUG ? 'inherit' : 'pipe',
|
package/src/launch.js
CHANGED
|
@@ -2,14 +2,15 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { mkdir } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { configuredProviders, PROVIDERS } from './providers.js';
|
|
6
|
+
import { getCooldowns } from './cooldowns.js';
|
|
7
7
|
|
|
8
8
|
const PLACEHOLDER_TOKEN = 'sk-krasavacode-local';
|
|
9
9
|
const CLAUDE_CONFIG_DIR = join(homedir(), '.krasavacode', 'claude-config');
|
|
10
10
|
|
|
11
11
|
export async function launchClaude(paths, hub /*, detection */) {
|
|
12
|
-
const
|
|
12
|
+
const configured = await configuredProviders();
|
|
13
|
+
const cooldowns = await getCooldowns();
|
|
13
14
|
// Isolate Claude Code's config/credentials from any real Anthropic login
|
|
14
15
|
// the student may have on this machine (~/.claude/). This is the *only*
|
|
15
16
|
// way to suppress the "Welcome back, NAME · publerplatforma@gmail.com's
|
|
@@ -47,7 +48,7 @@ export async function launchClaude(paths, hub /*, detection */) {
|
|
|
47
48
|
// Set KRASAVACODE_BARE=0 to disable for debugging.
|
|
48
49
|
const useBare = process.env.KRASAVACODE_BARE !== '0';
|
|
49
50
|
const passthroughArgs = process.argv.slice(2)
|
|
50
|
-
.filter(a => !['doctor', 'upgrade', 'setup-gemini', 'gemini'].includes(a));
|
|
51
|
+
.filter(a => !['doctor', 'upgrade', 'setup', 'setup-gemini', 'gemini'].includes(a));
|
|
51
52
|
if (useBare && !passthroughArgs.includes('--bare')) passthroughArgs.unshift('--bare');
|
|
52
53
|
|
|
53
54
|
const W = 64;
|
|
@@ -55,28 +56,26 @@ export async function launchClaude(paths, hub /*, detection */) {
|
|
|
55
56
|
const pad = Math.max(0, W - 2 - [...txt].length);
|
|
56
57
|
return '┃ ' + txt + ' '.repeat(pad) + '┃';
|
|
57
58
|
};
|
|
58
|
-
const quota = await getQuotaInfo();
|
|
59
59
|
console.log('');
|
|
60
60
|
console.log('┏' + '━'.repeat(W - 1) + '┓');
|
|
61
61
|
console.log(line(' K R A S A V A C O D E'));
|
|
62
62
|
console.log(line(' Бесплатный вайбкодинг через локальный hub'));
|
|
63
63
|
console.log('┣' + '━'.repeat(W - 1) + '┫');
|
|
64
|
-
if (
|
|
65
|
-
console.log(line('
|
|
66
|
-
|
|
67
|
-
if (left > 100) {
|
|
68
|
-
console.log(line(` Сегодня осталось: ${left} из ${quota.perDay} запросов`));
|
|
69
|
-
} else if (left > 0) {
|
|
70
|
-
console.log(line(` ⚠️ Осталось ${left} из ${quota.perDay} — обнулится завтра`));
|
|
71
|
-
} else {
|
|
72
|
-
console.log(line(` ❌ Лимит на сегодня исчерпан (${quota.used} из ${quota.perDay})`));
|
|
73
|
-
console.log(line(` Обнулится в ~21:00 МСК`));
|
|
74
|
-
}
|
|
64
|
+
if (configured.length === 0) {
|
|
65
|
+
console.log(line(' · Pollinations (gpt-oss-20b, без квот, слабая модель)'));
|
|
66
|
+
console.log(line(' 💡 Лучше модель бесплатно: krasavacode setup'));
|
|
75
67
|
} else {
|
|
76
|
-
console.log(line('
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
console.log(line(' Активная цепочка фолбэков:'));
|
|
69
|
+
let i = 1;
|
|
70
|
+
for (const id of configured) {
|
|
71
|
+
const p = PROVIDERS[id];
|
|
72
|
+
const cd = cooldowns[id];
|
|
73
|
+
const onCooldown = cd && new Date(cd).getTime() > Date.now();
|
|
74
|
+
const tag = onCooldown ? '⏳ на cooldown' : '✓ готов';
|
|
75
|
+
console.log(line(` ${i++}. ${p.name} — ${tag}`));
|
|
76
|
+
}
|
|
77
|
+
console.log(line(` ${i}. Pollinations (последний резерв)`));
|
|
78
|
+
console.log(line(' При 429 — автоматически прыгает на следующий'));
|
|
80
79
|
}
|
|
81
80
|
console.log('┗' + '━'.repeat(W - 1) + '┛');
|
|
82
81
|
console.log('');
|
package/src/metrics-proxy.js
CHANGED
|
@@ -1,38 +1,33 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import net from 'node:net';
|
|
3
|
-
import { readFile, writeFile, mkdir
|
|
3
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import {
|
|
6
|
+
import { configuredProviders, PROVIDERS, PROVIDER_PRIORITY } from './providers.js';
|
|
7
|
+
import { setCooldown, getCooldowns, cooldownUntil } from './cooldowns.js';
|
|
7
8
|
|
|
8
9
|
const ROOT = join(homedir(), '.krasavacode');
|
|
9
10
|
const USAGE_FILE = join(ROOT, 'usage.json');
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
gemini: { perDay: 1500, label: 'Google Gemini 2.5 Flash (free tier)' },
|
|
13
|
-
pollinations: { perDay: null, label: 'Pollinations (free)' },
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
function todayKey() {
|
|
17
|
-
return new Date().toISOString().slice(0, 10);
|
|
18
|
-
}
|
|
12
|
+
function todayKey() { return new Date().toISOString().slice(0, 10); }
|
|
19
13
|
|
|
20
14
|
async function readUsage() {
|
|
21
15
|
try { return JSON.parse(await readFile(USAGE_FILE, 'utf8')); }
|
|
22
16
|
catch { return {}; }
|
|
23
17
|
}
|
|
24
|
-
|
|
25
18
|
async function writeUsage(u) {
|
|
26
19
|
await mkdir(ROOT, { recursive: true });
|
|
27
20
|
await writeFile(USAGE_FILE, JSON.stringify(u, null, 2));
|
|
28
21
|
}
|
|
29
22
|
|
|
30
|
-
async function bump() {
|
|
23
|
+
async function bump(providerId) {
|
|
31
24
|
const u = await readUsage();
|
|
32
25
|
const day = todayKey();
|
|
33
|
-
u[day]
|
|
26
|
+
if (!u[day]) u[day] = {};
|
|
27
|
+
if (typeof u[day] === 'number') u[day] = { _total: u[day] };
|
|
28
|
+
u[day][providerId || '_unknown'] = (u[day][providerId || '_unknown'] || 0) + 1;
|
|
29
|
+
u[day]._total = (u[day]._total || 0) + 1;
|
|
34
30
|
u.lastRequestAt = new Date().toISOString();
|
|
35
|
-
// keep only last 30 days
|
|
36
31
|
for (const k of Object.keys(u)) {
|
|
37
32
|
if (/^\d{4}-\d{2}-\d{2}$/.test(k)) {
|
|
38
33
|
const age = (Date.now() - new Date(k).getTime()) / 86400000;
|
|
@@ -44,14 +39,10 @@ async function bump() {
|
|
|
44
39
|
|
|
45
40
|
export async function getTodayUsage() {
|
|
46
41
|
const u = await readUsage();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const provider = (await isGeminiConfigured()) ? 'gemini' : 'pollinations';
|
|
52
|
-
const used = await getTodayUsage();
|
|
53
|
-
const { perDay, label } = FREE_QUOTA[provider];
|
|
54
|
-
return { provider, used, perDay, label, remaining: perDay ? Math.max(0, perDay - used) : null };
|
|
42
|
+
const today = u[todayKey()];
|
|
43
|
+
if (!today) return 0;
|
|
44
|
+
if (typeof today === 'number') return today;
|
|
45
|
+
return today._total || 0;
|
|
55
46
|
}
|
|
56
47
|
|
|
57
48
|
function getFreePort() {
|
|
@@ -66,82 +57,178 @@ function getFreePort() {
|
|
|
66
57
|
});
|
|
67
58
|
}
|
|
68
59
|
|
|
69
|
-
|
|
60
|
+
/** Pick the first available provider not on cooldown, in priority order. */
|
|
61
|
+
async function chooseProvider() {
|
|
62
|
+
const cd = await getCooldowns();
|
|
63
|
+
const configured = await configuredProviders();
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const onCooldown = (id) => cd[id] && new Date(cd[id]).getTime() > now;
|
|
66
|
+
|
|
67
|
+
for (const id of configured) {
|
|
68
|
+
if (!onCooldown(id)) return { id, model: PROVIDERS[id].defaultModel };
|
|
69
|
+
}
|
|
70
|
+
// All custom providers exhausted — fall back to Pollinations
|
|
71
|
+
if (!onCooldown('pollinations')) return { id: 'pollinations', model: 'openai' };
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseQuotaReason(upstreamBody) {
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(upstreamBody);
|
|
78
|
+
const violations = parsed.error?.details?.find(d => d['@type']?.includes('QuotaFailure'))?.violations;
|
|
79
|
+
if (violations?.length) {
|
|
80
|
+
const id = violations[0].quotaId || violations[0].quotaMetric || '';
|
|
81
|
+
if (/PerMinute/i.test(id)) return 'per-minute';
|
|
82
|
+
return 'per-day';
|
|
83
|
+
}
|
|
84
|
+
const msg = String(parsed.error?.message || '').toLowerCase();
|
|
85
|
+
if (msg.includes('per minute') || msg.includes('per-minute') || msg.includes('rpm')) return 'per-minute';
|
|
86
|
+
if (msg.includes('per day') || msg.includes('per-day') || msg.includes('rpd') || msg.includes('quota')) return 'per-day';
|
|
87
|
+
} catch {}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const FRIENDLY_429 = () => ({
|
|
70
92
|
type: 'error',
|
|
71
93
|
error: {
|
|
72
94
|
type: 'rate_limit_error',
|
|
73
95
|
message:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
` • Или подключи второй Google-аккаунт через krasavacode setup-gemini\n` +
|
|
80
|
-
` • Или временно вернись на Pollinations: удали ~/.krasavacode/gemini.env`
|
|
81
|
-
: `Pollinations на минуту перегружен. Подожди ~30 секунд и нажми Enter ещё раз.\n` +
|
|
82
|
-
`Или подключи Gemini для стабильности: krasavacode setup-gemini`,
|
|
96
|
+
`Все настроенные AI-провайдеры исчерпаны или временно перегружены.\n\n` +
|
|
97
|
+
`Что делать:\n` +
|
|
98
|
+
` • Подожди 1–2 минуты (если упёрлись в RPM) и попробуй опять\n` +
|
|
99
|
+
` • Подключи ещё провайдер: krasavacode setup\n` +
|
|
100
|
+
` • Дневные лимиты обновляются в ~11:00 МСК`,
|
|
83
101
|
},
|
|
84
102
|
});
|
|
85
103
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
function rewriteBodyWithProvider(originalBody, providerId, modelName) {
|
|
105
|
+
// claude-code-router treats body.model in form "provider,modelName" as a
|
|
106
|
+
// direct route, bypassing Router config. We use that to fully control
|
|
107
|
+
// provider selection from the proxy layer.
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(originalBody);
|
|
110
|
+
parsed.model = `${providerId},${modelName}`;
|
|
111
|
+
return Buffer.from(JSON.stringify(parsed));
|
|
112
|
+
} catch {
|
|
113
|
+
return originalBody;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function forward(upstream, method, path, headers, bodyBuffer) {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const req = http.request({
|
|
120
|
+
hostname: upstream.hostname,
|
|
121
|
+
port: upstream.port,
|
|
122
|
+
path,
|
|
123
|
+
method,
|
|
124
|
+
headers: {
|
|
125
|
+
...headers,
|
|
126
|
+
host: `${upstream.hostname}:${upstream.port}`,
|
|
127
|
+
'content-length': bodyBuffer ? bodyBuffer.length : 0,
|
|
128
|
+
},
|
|
129
|
+
}, (res) => resolve(res));
|
|
130
|
+
req.on('error', reject);
|
|
131
|
+
if (bodyBuffer && bodyBuffer.length) req.write(bodyBuffer);
|
|
132
|
+
req.end();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
export async function startMetricsProxy(upstreamBaseUrl) {
|
|
94
137
|
const upstream = new URL(upstreamBaseUrl);
|
|
95
138
|
const port = await getFreePort();
|
|
96
|
-
|
|
97
139
|
const debug = process.env.KRASAVACODE_DEBUG === '1';
|
|
98
|
-
|
|
140
|
+
|
|
141
|
+
const server = http.createServer(async (req, res) => {
|
|
99
142
|
const path = (req.url || '').split('?')[0];
|
|
100
143
|
const isMessages = req.method === 'POST' && path === '/v1/messages';
|
|
101
144
|
if (debug) console.error(`[metrics] ${req.method} ${req.url}`);
|
|
102
145
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// If body is streaming/SSE we still let it through — Claude Code shows it.
|
|
118
|
-
if (upRes.statusCode === 429 && !/text\/event-stream/.test(upRes.headers['content-type'] || '')) {
|
|
119
|
-
const used = await getTodayUsage();
|
|
120
|
-
const provider = (await isGeminiConfigured()) ? 'gemini' : 'pollinations';
|
|
121
|
-
const body = JSON.stringify(FRIENDLY_429(provider, used));
|
|
122
|
-
const headers = { ...upRes.headers, 'content-type': 'application/json' };
|
|
123
|
-
delete headers['content-length'];
|
|
124
|
-
delete headers['content-encoding'];
|
|
125
|
-
res.writeHead(429, headers);
|
|
126
|
-
upRes.resume(); // drain the original
|
|
127
|
-
res.end(body);
|
|
146
|
+
const chunks = [];
|
|
147
|
+
req.on('data', d => chunks.push(d));
|
|
148
|
+
req.on('end', async () => {
|
|
149
|
+
const originalBody = Buffer.concat(chunks);
|
|
150
|
+
|
|
151
|
+
if (!isMessages) {
|
|
152
|
+
try {
|
|
153
|
+
const upRes = await forward(upstream, req.method, req.url, req.headers, originalBody);
|
|
154
|
+
res.writeHead(upRes.statusCode, upRes.headers);
|
|
155
|
+
upRes.pipe(res);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
158
|
+
res.end(JSON.stringify({ error: { type: 'upstream_error', message: e.message } }));
|
|
159
|
+
}
|
|
128
160
|
return;
|
|
129
161
|
}
|
|
130
162
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
163
|
+
// /v1/messages: provider selection with retry-on-429
|
|
164
|
+
for (let attempt = 1; attempt <= 4; attempt++) {
|
|
165
|
+
const choice = await chooseProvider();
|
|
166
|
+
if (!choice) {
|
|
167
|
+
if (debug) console.error('[metrics] all providers on cooldown');
|
|
168
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
169
|
+
res.end(JSON.stringify(FRIENDLY_429()));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rewrittenBody = rewriteBodyWithProvider(originalBody, choice.id, choice.model);
|
|
174
|
+
if (debug) console.error(`[metrics] attempt ${attempt}: routing to ${choice.id},${choice.model}`);
|
|
175
|
+
|
|
176
|
+
let upRes;
|
|
177
|
+
try {
|
|
178
|
+
upRes = await forward(upstream, req.method, req.url, req.headers, rewrittenBody);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
181
|
+
res.end(JSON.stringify({ error: { type: 'upstream_error', message: e.message } }));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (debug) console.error(`[metrics] attempt ${attempt} → ${upRes.statusCode}`);
|
|
186
|
+
|
|
187
|
+
if (upRes.statusCode !== 429) {
|
|
188
|
+
if (upRes.statusCode >= 200 && upRes.statusCode < 300) {
|
|
189
|
+
bump(choice.id).catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
res.writeHead(upRes.statusCode, upRes.headers);
|
|
192
|
+
upRes.pipe(res);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 429 — buffer body, set cooldown for THIS provider, retry with next
|
|
197
|
+
const errChunks = [];
|
|
198
|
+
upRes.on('data', d => errChunks.push(d));
|
|
199
|
+
await new Promise(r => upRes.on('end', r));
|
|
200
|
+
const upBody = Buffer.concat(errChunks).toString('utf8');
|
|
201
|
+
if (debug) console.error(`[metrics] 429 from ${choice.id}: ${upBody.slice(0, 200)}`);
|
|
202
|
+
|
|
203
|
+
const reason = parseQuotaReason(upBody);
|
|
204
|
+
// Pollinations has no daily quota — only short burst-throttling.
|
|
205
|
+
// Treat its 429 as a 60s cooldown so we don't block it until tomorrow.
|
|
206
|
+
const effectiveReason = choice.id === 'pollinations' ? 'per-minute' : reason;
|
|
207
|
+
await setCooldown(choice.id, cooldownUntil(effectiveReason));
|
|
208
|
+
// loop continues — next iteration picks a different provider
|
|
209
|
+
}
|
|
134
210
|
|
|
135
|
-
|
|
136
|
-
res.writeHead(
|
|
137
|
-
res.end(JSON.stringify(
|
|
211
|
+
// Exhausted attempts
|
|
212
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
213
|
+
res.end(JSON.stringify(FRIENDLY_429()));
|
|
138
214
|
});
|
|
139
215
|
|
|
140
|
-
req.
|
|
216
|
+
req.on('error', () => {});
|
|
141
217
|
});
|
|
142
218
|
|
|
143
219
|
await new Promise(r => server.listen(port, '127.0.0.1', r));
|
|
220
|
+
return {
|
|
221
|
+
server,
|
|
222
|
+
port,
|
|
223
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
224
|
+
stop: () => new Promise(r => server.close(r)),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
144
227
|
|
|
145
|
-
|
|
146
|
-
return {
|
|
228
|
+
export async function getQuotaInfo() {
|
|
229
|
+
return {
|
|
230
|
+
used: await getTodayUsage(),
|
|
231
|
+
configured: await configuredProviders(),
|
|
232
|
+
cooldowns: await getCooldowns(),
|
|
233
|
+
};
|
|
147
234
|
}
|
package/src/preset.js
CHANGED
|
@@ -1,85 +1,62 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile, copyFile, access } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { PROVIDERS, configuredProviders, pollinationsProvider } from './providers.js';
|
|
5
5
|
|
|
6
|
+
const ROOT = join(homedir(), '.krasavacode');
|
|
6
7
|
const CCR_DIR = join(homedir(), '.claude-code-router');
|
|
7
8
|
const CCR_CONFIG = join(CCR_DIR, 'config.json');
|
|
8
|
-
const STATE_FILE = join(
|
|
9
|
+
const STATE_FILE = join(ROOT, 'state.json');
|
|
9
10
|
|
|
10
11
|
const KRASAVACODE_MARKER = 'krasavacode/managed';
|
|
11
12
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
api_base_url: 'https://text.pollinations.ai/openai/chat/completions',
|
|
16
|
-
api_key: 'public',
|
|
17
|
-
models: ['openai', 'openai-fast', 'gpt-oss-20b'],
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function geminiProvider() {
|
|
22
|
-
// gemini-2.5-pro free tier = 0 requests; only flash is actually free.
|
|
23
|
-
return {
|
|
24
|
-
name: 'gemini',
|
|
25
|
-
api_base_url: 'https://generativelanguage.googleapis.com/v1beta/models/',
|
|
26
|
-
api_key: '$GEMINI_API_KEY',
|
|
27
|
-
models: ['gemini-2.5-flash', 'gemini-flash-latest'],
|
|
28
|
-
transformer: { use: ['gemini'] },
|
|
29
|
-
};
|
|
30
|
-
}
|
|
13
|
+
async function readState() { try { return JSON.parse(await readFile(STATE_FILE, 'utf8')); } catch { return {}; } }
|
|
14
|
+
async function writeState(s) { await writeFile(STATE_FILE, JSON.stringify(s, null, 2)); }
|
|
15
|
+
async function exists(p) { return access(p).then(() => true).catch(() => false); }
|
|
31
16
|
|
|
32
|
-
function buildConfig(
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
17
|
+
async function buildConfig() {
|
|
18
|
+
const configured = await configuredProviders();
|
|
19
|
+
const providers = configured.map(id => PROVIDERS[id].ccrProvider());
|
|
20
|
+
// Pollinations always last — final no-key fallback
|
|
21
|
+
providers.push(pollinationsProvider());
|
|
36
22
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
background: 'gemini,gemini-2.5-flash',
|
|
41
|
-
think: 'gemini,gemini-2.5-flash',
|
|
42
|
-
longContext: 'gemini,gemini-2.5-flash',
|
|
43
|
-
longContextThreshold: 60000,
|
|
44
|
-
}
|
|
45
|
-
: {
|
|
46
|
-
default: 'pollinations,openai',
|
|
47
|
-
background: 'pollinations,openai-fast',
|
|
48
|
-
think: 'pollinations,openai',
|
|
49
|
-
longContext: 'pollinations,openai',
|
|
50
|
-
longContextThreshold: 60000,
|
|
51
|
-
};
|
|
23
|
+
const firstId = configured[0];
|
|
24
|
+
const firstModel = firstId ? PROVIDERS[firstId].defaultModel : 'openai';
|
|
25
|
+
const firstProv = firstId ? firstId : 'pollinations';
|
|
52
26
|
|
|
53
|
-
|
|
27
|
+
const config = {
|
|
54
28
|
HOST: '127.0.0.1',
|
|
55
29
|
PORT: 3456,
|
|
56
30
|
LOG: false,
|
|
57
31
|
API_TIMEOUT_MS: 600000,
|
|
58
|
-
Providers,
|
|
59
|
-
Router
|
|
32
|
+
Providers: providers,
|
|
33
|
+
Router: {
|
|
34
|
+
// Static fallback if custom-router returns null
|
|
35
|
+
default: `${firstProv},${firstModel}`,
|
|
36
|
+
background: `${firstProv},${firstModel}`,
|
|
37
|
+
think: `${firstProv},${firstModel}`,
|
|
38
|
+
longContext: `${firstProv},${firstModel}`,
|
|
39
|
+
longContextThreshold: 60000,
|
|
40
|
+
},
|
|
60
41
|
_krasavacode: KRASAVACODE_MARKER,
|
|
61
42
|
};
|
|
62
|
-
}
|
|
63
43
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
44
|
+
// No custom router: provider selection is done at the metrics-proxy layer,
|
|
45
|
+
// which rewrites body.model = "provider,name" so ccr forwards directly.
|
|
46
|
+
return config;
|
|
67
47
|
}
|
|
68
|
-
async function writeState(s) { await writeFile(STATE_FILE, JSON.stringify(s, null, 2)); }
|
|
69
|
-
async function exists(p) { return access(p).then(() => true).catch(() => false); }
|
|
70
48
|
|
|
71
49
|
/**
|
|
72
|
-
* Generates ~/.claude-code-router/config.json
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* Returns { withGemini: boolean }.
|
|
50
|
+
* Generates ~/.claude-code-router/config.json AND the custom router file
|
|
51
|
+
* ~/.krasavacode/router.js. Backs up any pre-existing user config that
|
|
52
|
+
* isn't ours.
|
|
77
53
|
*/
|
|
78
54
|
export async function ensurePreset() {
|
|
55
|
+
await mkdir(ROOT, { recursive: true });
|
|
79
56
|
await mkdir(CCR_DIR, { recursive: true });
|
|
57
|
+
|
|
80
58
|
const state = await readState();
|
|
81
|
-
const
|
|
82
|
-
const config = buildConfig({ withGemini });
|
|
59
|
+
const config = await buildConfig();
|
|
83
60
|
|
|
84
61
|
if (await exists(CCR_CONFIG)) {
|
|
85
62
|
let existing;
|
|
@@ -93,12 +70,12 @@ export async function ensurePreset() {
|
|
|
93
70
|
await copyFile(CCR_CONFIG, backupPath);
|
|
94
71
|
state.userConfigBackedUp = backupPath;
|
|
95
72
|
await writeState(state);
|
|
96
|
-
console.log(`💾 Найден свой config.json у claude-code-router —
|
|
73
|
+
console.log(`💾 Найден свой config.json у claude-code-router — резервная копия: ${backupPath}`);
|
|
97
74
|
}
|
|
98
75
|
}
|
|
99
76
|
|
|
100
77
|
await writeFile(CCR_CONFIG, JSON.stringify(config, null, 2));
|
|
101
|
-
return {
|
|
78
|
+
return { configured: await configuredProviders() };
|
|
102
79
|
}
|
|
103
80
|
|
|
104
81
|
export const CCR_PORT = 3456;
|