krasavacode 0.3.3 → 0.3.4
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 +1 -1
- package/package.json +1 -1
- package/src/hub.js +15 -1
- package/src/launch.js +12 -1
- package/src/metrics-proxy.js +147 -0
package/bin/krasavacode.js
CHANGED
|
@@ -8,7 +8,7 @@ import { runDoctor } from '../src/doctor.js';
|
|
|
8
8
|
import { runSetupGemini } from '../src/setup-gemini.js';
|
|
9
9
|
|
|
10
10
|
// Hardcoded so it works inside Bun --compile (no FS access to package.json)
|
|
11
|
-
const VERSION = '0.3.
|
|
11
|
+
const VERSION = '0.3.4';
|
|
12
12
|
|
|
13
13
|
const cmd = process.argv[2];
|
|
14
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "krasavacode",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "KRASAVACODE — однокнопочный бесплатный вайбкодинг для учеников. Claude Code на бесплатных провайдерах через локальный gateway. Сам ставит Node при необходимости.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/hub.js
CHANGED
|
@@ -2,6 +2,7 @@ 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
4
|
import { loadGeminiKey } from './setup-gemini.js';
|
|
5
|
+
import { startMetricsProxy } from './metrics-proxy.js';
|
|
5
6
|
|
|
6
7
|
const HOST = '127.0.0.1';
|
|
7
8
|
const PORT = CCR_PORT;
|
|
@@ -88,11 +89,24 @@ export async function startHub(paths) {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
// Front the ccr endpoint with our metrics-counting proxy. Claude Code
|
|
93
|
+
// talks to the proxy; the proxy forwards to ccr; we count requests and
|
|
94
|
+
// translate 429 errors into friendly Russian messages.
|
|
95
|
+
const metrics = await startMetricsProxy(baseUrl);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
process: child,
|
|
99
|
+
port: PORT,
|
|
100
|
+
ccrBaseUrl: baseUrl,
|
|
101
|
+
baseUrl: metrics.baseUrl, // <-- Claude Code будет ходить сюда
|
|
102
|
+
metrics,
|
|
103
|
+
ownedByUs: true,
|
|
104
|
+
};
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
export async function stopHub(hub) {
|
|
95
108
|
if (!hub) return;
|
|
109
|
+
if (hub.metrics) await hub.metrics.stop().catch(() => {});
|
|
96
110
|
if (!hub.ownedByUs) return; // we didn't start it; leave it running for the user
|
|
97
111
|
if (!hub.process || hub.process.killed) return;
|
|
98
112
|
|
package/src/launch.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { isGeminiConfigured } from './setup-gemini.js';
|
|
6
|
+
import { getQuotaInfo } from './metrics-proxy.js';
|
|
6
7
|
|
|
7
8
|
const PLACEHOLDER_TOKEN = 'sk-krasavacode-local';
|
|
8
9
|
const CLAUDE_CONFIG_DIR = join(homedir(), '.krasavacode', 'claude-config');
|
|
@@ -44,6 +45,7 @@ export async function launchClaude(paths, hub /*, detection */) {
|
|
|
44
45
|
const pad = Math.max(0, W - 2 - [...txt].length);
|
|
45
46
|
return '┃ ' + txt + ' '.repeat(pad) + '┃';
|
|
46
47
|
};
|
|
48
|
+
const quota = await getQuotaInfo();
|
|
47
49
|
console.log('');
|
|
48
50
|
console.log('┏' + '━'.repeat(W - 1) + '┓');
|
|
49
51
|
console.log(line(' K R A S A V A C O D E'));
|
|
@@ -51,10 +53,19 @@ export async function launchClaude(paths, hub /*, detection */) {
|
|
|
51
53
|
console.log('┣' + '━'.repeat(W - 1) + '┫');
|
|
52
54
|
if (geminiOn) {
|
|
53
55
|
console.log(line(' ✓ Модель: Google Gemini 2.5 Flash'));
|
|
54
|
-
|
|
56
|
+
const left = quota.perDay - quota.used;
|
|
57
|
+
if (left > 100) {
|
|
58
|
+
console.log(line(` Сегодня осталось: ${left} из ${quota.perDay} запросов`));
|
|
59
|
+
} else if (left > 0) {
|
|
60
|
+
console.log(line(` ⚠️ Осталось ${left} из ${quota.perDay} — обнулится завтра`));
|
|
61
|
+
} else {
|
|
62
|
+
console.log(line(` ❌ Лимит на сегодня исчерпан (${quota.used} из ${quota.perDay})`));
|
|
63
|
+
console.log(line(` Обнулится в ~21:00 МСК`));
|
|
64
|
+
}
|
|
55
65
|
} else {
|
|
56
66
|
console.log(line(' · Модель: gpt-oss-20b через Pollinations'));
|
|
57
67
|
console.log(line(' (бесплатно, без логина)'));
|
|
68
|
+
console.log(line(` Сегодня сделал ${quota.used} запросов`));
|
|
58
69
|
console.log(line(' 💡 Лучше модель: krasavacode setup-gemini'));
|
|
59
70
|
}
|
|
60
71
|
console.log('┗' + '━'.repeat(W - 1) + '┛');
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { isGeminiConfigured } from './setup-gemini.js';
|
|
7
|
+
|
|
8
|
+
const ROOT = join(homedir(), '.krasavacode');
|
|
9
|
+
const USAGE_FILE = join(ROOT, 'usage.json');
|
|
10
|
+
|
|
11
|
+
const FREE_QUOTA = {
|
|
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
|
+
}
|
|
19
|
+
|
|
20
|
+
async function readUsage() {
|
|
21
|
+
try { return JSON.parse(await readFile(USAGE_FILE, 'utf8')); }
|
|
22
|
+
catch { return {}; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function writeUsage(u) {
|
|
26
|
+
await mkdir(ROOT, { recursive: true });
|
|
27
|
+
await writeFile(USAGE_FILE, JSON.stringify(u, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function bump() {
|
|
31
|
+
const u = await readUsage();
|
|
32
|
+
const day = todayKey();
|
|
33
|
+
u[day] = (u[day] || 0) + 1;
|
|
34
|
+
u.lastRequestAt = new Date().toISOString();
|
|
35
|
+
// keep only last 30 days
|
|
36
|
+
for (const k of Object.keys(u)) {
|
|
37
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(k)) {
|
|
38
|
+
const age = (Date.now() - new Date(k).getTime()) / 86400000;
|
|
39
|
+
if (age > 30) delete u[k];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await writeUsage(u);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getTodayUsage() {
|
|
46
|
+
const u = await readUsage();
|
|
47
|
+
return u[todayKey()] || 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function getQuotaInfo() {
|
|
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 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getFreePort() {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const srv = net.createServer();
|
|
60
|
+
srv.unref();
|
|
61
|
+
srv.on('error', reject);
|
|
62
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
63
|
+
const { port } = srv.address();
|
|
64
|
+
srv.close(() => resolve(port));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const FRIENDLY_429 = (provider, used) => ({
|
|
70
|
+
type: 'error',
|
|
71
|
+
error: {
|
|
72
|
+
type: 'rate_limit_error',
|
|
73
|
+
message:
|
|
74
|
+
provider === 'gemini'
|
|
75
|
+
? `Закончились бесплатные запросы Google Gemini на сегодня (использовано ${used} из 1500).\n\n` +
|
|
76
|
+
`Квота обнулится в ~10:00 PT (~21:00 МСК).\n\n` +
|
|
77
|
+
`Что делать сейчас:\n` +
|
|
78
|
+
` • Подожди до завтра, и продолжи\n` +
|
|
79
|
+
` • Или подключи второй Google-аккаунт через krasavacode setup-gemini\n` +
|
|
80
|
+
` • Или временно вернись на Pollinations: удали ~/.krasavacode/gemini.env`
|
|
81
|
+
: `Pollinations на минуту перегружен. Подожди ~30 секунд и нажми Enter ещё раз.\n` +
|
|
82
|
+
`Или подключи Gemini для стабильности: krasavacode setup-gemini`,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Proxy: Claude Code → metrics-proxy (this) → ccr → upstream.
|
|
88
|
+
*
|
|
89
|
+
* - Counts every successful POST /v1/messages as one request, written to ~/.krasavacode/usage.json
|
|
90
|
+
* - Replaces 429 responses with a friendly Russian message
|
|
91
|
+
* - Streams everything else through unmodified (so SSE works)
|
|
92
|
+
*/
|
|
93
|
+
export async function startMetricsProxy(upstreamBaseUrl) {
|
|
94
|
+
const upstream = new URL(upstreamBaseUrl);
|
|
95
|
+
const port = await getFreePort();
|
|
96
|
+
|
|
97
|
+
const debug = process.env.KRASAVACODE_DEBUG === '1';
|
|
98
|
+
const server = http.createServer((req, res) => {
|
|
99
|
+
const path = (req.url || '').split('?')[0];
|
|
100
|
+
const isMessages = req.method === 'POST' && path === '/v1/messages';
|
|
101
|
+
if (debug) console.error(`[metrics] ${req.method} ${req.url}`);
|
|
102
|
+
|
|
103
|
+
const proxyReq = http.request({
|
|
104
|
+
hostname: upstream.hostname,
|
|
105
|
+
port: upstream.port,
|
|
106
|
+
path: req.url,
|
|
107
|
+
method: req.method,
|
|
108
|
+
headers: req.headers,
|
|
109
|
+
}, async (upRes) => {
|
|
110
|
+
if (debug) console.error(`[metrics] ← ${upRes.statusCode} ${req.url}`);
|
|
111
|
+
// Treat any 2xx on /v1/messages as one billable request — count immediately.
|
|
112
|
+
if (isMessages && upRes.statusCode >= 200 && upRes.statusCode < 300) {
|
|
113
|
+
bump().catch(e => debug && console.error('[metrics] bump fail', e));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 429: try to replace body with a friendly message (non-streaming).
|
|
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);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
res.writeHead(upRes.statusCode, upRes.headers);
|
|
132
|
+
upRes.pipe(res);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
proxyReq.on('error', (err) => {
|
|
136
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
137
|
+
res.end(JSON.stringify({ error: { type: 'upstream_error', message: err.message } }));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
req.pipe(proxyReq);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await new Promise(r => server.listen(port, '127.0.0.1', r));
|
|
144
|
+
|
|
145
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
146
|
+
return { server, port, baseUrl, stop: () => new Promise(r => server.close(r)) };
|
|
147
|
+
}
|