krasavacode 0.3.2 → 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.
@@ -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.2';
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.2",
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
- return { process: child, port: PORT, baseUrl, ownedByUs: true };
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
@@ -1,10 +1,20 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import { isGeminiConfigured } from './setup-gemini.js';
6
+ import { getQuotaInfo } from './metrics-proxy.js';
3
7
 
4
8
  const PLACEHOLDER_TOKEN = 'sk-krasavacode-local';
9
+ const CLAUDE_CONFIG_DIR = join(homedir(), '.krasavacode', 'claude-config');
5
10
 
6
11
  export async function launchClaude(paths, hub /*, detection */) {
7
12
  const geminiOn = await isGeminiConfigured();
13
+ // Isolate Claude Code's config/credentials from any real Anthropic login
14
+ // the student may have on this machine (~/.claude/). This is the *only*
15
+ // way to suppress the "Welcome back, NAME · publerplatforma@gmail.com's
16
+ // Organization · API Usage Billing" header on the welcome screen.
17
+ await mkdir(CLAUDE_CONFIG_DIR, { recursive: true });
8
18
 
9
19
  // Drop any pre-existing ANTHROPIC_API_KEY (from the user's shell or a real
10
20
  // Anthropic login) so it doesn't conflict with our auth-token, and so that
@@ -18,6 +28,8 @@ export async function launchClaude(paths, hub /*, detection */) {
18
28
  ...cleanEnv,
19
29
  ANTHROPIC_BASE_URL: hub.baseUrl,
20
30
  ANTHROPIC_AUTH_TOKEN: PLACEHOLDER_TOKEN,
31
+ // Isolate config/credentials: own dir, separate from ~/.claude/
32
+ ANTHROPIC_CONFIG_DIR: CLAUDE_CONFIG_DIR,
21
33
  DISABLE_AUTOUPDATER: '1',
22
34
  DISABLE_TELEMETRY: '1',
23
35
  DISABLE_ERROR_REPORTING: '1',
@@ -33,6 +45,7 @@ export async function launchClaude(paths, hub /*, detection */) {
33
45
  const pad = Math.max(0, W - 2 - [...txt].length);
34
46
  return '┃ ' + txt + ' '.repeat(pad) + '┃';
35
47
  };
48
+ const quota = await getQuotaInfo();
36
49
  console.log('');
37
50
  console.log('┏' + '━'.repeat(W - 1) + '┓');
38
51
  console.log(line(' K R A S A V A C O D E'));
@@ -40,10 +53,19 @@ export async function launchClaude(paths, hub /*, detection */) {
40
53
  console.log('┣' + '━'.repeat(W - 1) + '┫');
41
54
  if (geminiOn) {
42
55
  console.log(line(' ✓ Модель: Google Gemini 2.5 Flash'));
43
- console.log(line(' (1500 запросов в день, бесплатно)'));
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
+ }
44
65
  } else {
45
66
  console.log(line(' · Модель: gpt-oss-20b через Pollinations'));
46
67
  console.log(line(' (бесплатно, без логина)'));
68
+ console.log(line(` Сегодня сделал ${quota.used} запросов`));
47
69
  console.log(line(' 💡 Лучше модель: krasavacode setup-gemini'));
48
70
  }
49
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
+ }
package/src/preset.js CHANGED
@@ -19,11 +19,12 @@ function pollinationsProvider() {
19
19
  }
20
20
 
21
21
  function geminiProvider() {
22
+ // gemini-2.5-pro free tier = 0 requests; only flash is actually free.
22
23
  return {
23
24
  name: 'gemini',
24
25
  api_base_url: 'https://generativelanguage.googleapis.com/v1beta/models/',
25
26
  api_key: '$GEMINI_API_KEY',
26
- models: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-flash-latest'],
27
+ models: ['gemini-2.5-flash', 'gemini-flash-latest'],
27
28
  transformer: { use: ['gemini'] },
28
29
  };
29
30
  }
@@ -37,8 +38,8 @@ function buildConfig({ withGemini }) {
37
38
  ? {
38
39
  default: 'gemini,gemini-2.5-flash',
39
40
  background: 'gemini,gemini-2.5-flash',
40
- think: 'gemini,gemini-2.5-pro',
41
- longContext: 'gemini,gemini-2.5-pro',
41
+ think: 'gemini,gemini-2.5-flash',
42
+ longContext: 'gemini,gemini-2.5-flash',
42
43
  longContextThreshold: 60000,
43
44
  }
44
45
  : {