kafox-cli 0.9.8 → 0.10.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/src/gateway.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const chalk = require("chalk");
2
2
  const storage = require("./storage");
3
3
  const { ensureSession, fetchApi } = require("./auth");
4
+ const { providerForModel } = require("./providers");
4
5
 
5
6
  /**
6
7
  * Modelo do free tier: o nome carrega o sufixo " (free)" (ou ":free"). Esses
@@ -11,8 +12,10 @@ function isFreeModel(model) {
11
12
  }
12
13
 
13
14
  /**
14
- * Garante sessao, saldo, modelo e chave antes de qualquer chamada.
15
- * A chave `kafox_live_...` e da propria conta: o gateway cobra por ela.
15
+ * Garante sessao, saldo, modelo e chave antes de qualquer chamada ao gateway
16
+ * kafox. A chave `kafox_live_...` e da propria conta: o gateway cobra por ela.
17
+ * Provedores alternativos (OpenAI-compatible) tem seu proprio caminho em
18
+ * `attemptOpenAI`: nao passam por login/saldo, so precisam da chave do provedor.
16
19
  */
17
20
  async function ensureReady(options = {}) {
18
21
  const token = await ensureSession();
@@ -133,48 +136,10 @@ function errorFrom(data, status) {
133
136
  }
134
137
 
135
138
  /**
136
- * Uma tentativa: request + leitura do stream.
137
- * Devolve `retryable` quando o erro foi de rede/servidor e ainda nao veio texto.
139
+ * Le um stream SSE de chat completions (OpenAI-compatible) e chama os
140
+ * callbacks a cada delta. Devolve o resultado final.
138
141
  */
139
- async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
140
- const ready = await ensureReady({ model });
141
- if (!ready.ok) return ready;
142
-
143
- const started = Date.now();
144
- let response;
145
- try {
146
- response = await fetchApi("/api/chat", {
147
- method: "POST",
148
- headers: { accept: "text/event-stream" },
149
- body: JSON.stringify({
150
- api_key: ready.apiKey,
151
- model: ready.model,
152
- messages,
153
- tools: tools && tools.length ? tools : undefined,
154
- stream: true,
155
- }),
156
- signal,
157
- });
158
- } catch (error) {
159
- if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
160
- return {
161
- ok: false,
162
- error: `Falha de rede: ${error.message}`,
163
- retryable: retryableMessage(error.message),
164
- keyCreated: ready.keyCreated,
165
- };
166
- }
167
-
168
- if (!response.ok) {
169
- const data = await response.json().catch(() => ({}));
170
- return {
171
- ...errorFrom(data, response.status),
172
- status: response.status,
173
- keyCreated: ready.keyCreated,
174
- retryable: RETRYABLE_STATUS.has(response.status),
175
- };
176
- }
177
-
142
+ async function readStream({ response, started, onDelta, onReasoning }) {
178
143
  const decoder = new TextDecoder();
179
144
  let buffer = "";
180
145
  let text = "";
@@ -238,16 +203,114 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
238
203
  };
239
204
  }
240
205
 
241
- return {
242
- ok: true,
243
- text,
244
- reasoning,
245
- toolCalls: toolCalls.filter(Boolean),
246
- usage,
247
- ms: Date.now() - started,
248
- model: ready.model,
249
- keyCreated: ready.keyCreated,
250
- };
206
+ return { ok: true, text, reasoning, toolCalls: toolCalls.filter(Boolean), usage, ms: Date.now() - started };
207
+ }
208
+
209
+ /**
210
+ * Uma tentativa no provedor kafox (gateway proprio): login/saldo/chave da conta
211
+ * e POST em `/api/chat`.
212
+ */
213
+ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
214
+ const socket = providerForModel(model);
215
+ if (socket.provider.kind === "openai") {
216
+ return attemptOpenAI({ messages, model: socket.model, provider: socket.provider, tools, onDelta, onReasoning, signal });
217
+ }
218
+
219
+ const ready = await ensureReady({ model });
220
+ if (!ready.ok) return ready;
221
+
222
+ const started = Date.now();
223
+ let response;
224
+ try {
225
+ response = await fetchApi("/api/chat", {
226
+ method: "POST",
227
+ headers: { accept: "text/event-stream" },
228
+ body: JSON.stringify({
229
+ api_key: ready.apiKey,
230
+ model: ready.model,
231
+ messages,
232
+ tools: tools && tools.length ? tools : undefined,
233
+ stream: true,
234
+ }),
235
+ signal,
236
+ });
237
+ } catch (error) {
238
+ if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
239
+ return {
240
+ ok: false,
241
+ error: `Falha de rede: ${error.message}`,
242
+ retryable: retryableMessage(error.message),
243
+ keyCreated: ready.keyCreated,
244
+ };
245
+ }
246
+
247
+ if (!response.ok) {
248
+ const data = await response.json().catch(() => ({}));
249
+ return {
250
+ ...errorFrom(data, response.status),
251
+ status: response.status,
252
+ keyCreated: ready.keyCreated,
253
+ retryable: RETRYABLE_STATUS.has(response.status),
254
+ };
255
+ }
256
+
257
+ const result = await readStream({ response, started, onDelta, onReasoning });
258
+ return { ...result, model: ready.model, keyCreated: ready.keyCreated };
259
+ }
260
+
261
+ /**
262
+ * Uma tentativa num provedor OpenAI-compatible: POST direto em
263
+ * `{baseUrl}/chat/completions` com a chave do usuario. Sem login/saldo da conta
264
+ * kafox; o custo e cobrado pelo proprio provedor.
265
+ */
266
+ async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReasoning, signal } = {}) {
267
+ const apiKey = storage.getProviderKey(provider.id);
268
+ if (!apiKey) {
269
+ return {
270
+ ok: false,
271
+ code: "no_provider_key",
272
+ error: `Provedor ${provider.label} sem chave. Rode: kafox key ${provider.id} <chave>`,
273
+ };
274
+ }
275
+
276
+ const started = Date.now();
277
+ let response;
278
+ try {
279
+ response = await fetch(`${provider.baseUrl}/chat/completions`, {
280
+ method: "POST",
281
+ headers: {
282
+ "content-type": "application/json",
283
+ accept: "text/event-stream",
284
+ authorization: `Bearer ${apiKey}`,
285
+ },
286
+ body: JSON.stringify({
287
+ model,
288
+ messages,
289
+ tools: tools && tools.length ? tools : undefined,
290
+ stream: true,
291
+ }),
292
+ signal,
293
+ });
294
+ } catch (error) {
295
+ if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
296
+ return {
297
+ ok: false,
298
+ error: `Falha de rede: ${error.message}`,
299
+ retryable: retryableMessage(error.message),
300
+ };
301
+ }
302
+
303
+ if (!response.ok) {
304
+ const data = await response.json().catch(() => ({}));
305
+ return {
306
+ ...errorFrom(data, response.status),
307
+ status: response.status,
308
+ retryable: RETRYABLE_STATUS.has(response.status),
309
+ };
310
+ }
311
+
312
+ const result = await readStream({ response, started, onDelta, onReasoning });
313
+ return { ...result, model, provider: provider.id };
251
314
  }
252
315
 
253
316
  /**
@@ -324,6 +387,9 @@ module.exports = {
324
387
  askModel,
325
388
  chat,
326
389
  ensureReady,
390
+ attemptChat,
391
+ attemptOpenAI,
392
+ readStream,
327
393
  isFreeModel,
328
394
  mergeToolCalls,
329
395
  backoffMs,
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Pareamento do kafox mobile.
3
+ *
4
+ * Fluxo (seguro por construcao):
5
+ * 1. a CLI gera um `pair_code` curto, valido por 120s, de uso unico;
6
+ * 2. o QR carrega SO esse codigo — nunca o token de sessao;
7
+ * 3. o celular troca o codigo por um `session_token` (12h) via POST /pair;
8
+ * 4. todas as rotas seguintes exigem o token no header `x-kafox-token`.
9
+ *
10
+ * O codigo e o token sao guardados apenas como SHA-256 em memoria (o par do
11
+ * arquivo de auditoria guarda so o hash, nunca o valor em claro).
12
+ */
13
+
14
+ const crypto = require("crypto");
15
+ const fs = require("fs");
16
+ const os = require("os");
17
+ const path = require("path");
18
+
19
+ const PAIR_TTL_MS = 120_000;
20
+ const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
21
+ const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // base32 sem 0/O/1/I
22
+
23
+ function randomCode(length = 8) {
24
+ const bytes = crypto.randomBytes(length);
25
+ let out = "";
26
+ for (let i = 0; i < length; i += 1) out += ALPHABET[bytes[i] % ALPHABET.length];
27
+ return out;
28
+ }
29
+
30
+ function sha256(value) {
31
+ return crypto.createHash("sha256").update(String(value)).digest("hex");
32
+ }
33
+
34
+ // Registro dos pareamentos em disco: { hash(token): { createdAt, expiresAt, device } }.
35
+ // Guarda so o SHA-256 do token (nunca o valor em claro), para o `--revoke`
36
+ // funcionar mesmo depois de reiniciar o CLI.
37
+ const SESSIONS_FILE =
38
+ process.env.KAFOX_MOBILE_SESSIONS || path.join(os.homedir(), ".kafox", "mobile-sessions.json");
39
+
40
+ function loadSessions() {
41
+ try {
42
+ const raw = fs.readFileSync(SESSIONS_FILE, "utf8");
43
+ const parsed = JSON.parse(raw);
44
+ return parsed && typeof parsed === "object" ? parsed : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function saveSessions(sessions) {
51
+ try {
52
+ fs.mkdirSync(path.dirname(SESSIONS_FILE), { recursive: true, mode: 0o700 });
53
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2), { mode: 0o600 });
54
+ } catch {
55
+ // persistencia nao pode derrubar o pareamento.
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Revoga os pareamentos salvos em disco. `{ all: true }` limpa tudo e devolve
61
+ * quantos foram removidos. `{ token }` remove so o hash daquele token.
62
+ */
63
+ function isTokenEntry(entry) {
64
+ // entradas de servidor tem kind:"server"; as antigas (sem kind) sao tokens.
65
+ return entry && entry.kind !== "server";
66
+ }
67
+
68
+ function revoke({ all = false, token = null } = {}) {
69
+ const sessions = loadSessions();
70
+ if (all) {
71
+ let count = 0;
72
+ for (const key of Object.keys(sessions)) {
73
+ // NAO apaga entradas de servidor (kind:"server") — elas nao sao pareamento.
74
+ if (isTokenEntry(sessions[key])) {
75
+ delete sessions[key];
76
+ count += 1;
77
+ }
78
+ }
79
+ saveSessions(sessions);
80
+ return count;
81
+ }
82
+ if (token) {
83
+ const hash = sha256(token);
84
+ if (!sessions[hash] || !isTokenEntry(sessions[hash])) return 0;
85
+ delete sessions[hash];
86
+ saveSessions(sessions);
87
+ return 1;
88
+ }
89
+ // sem alvo: limpa os que ja expiraram (apenas pareamentos).
90
+ const now = Date.now();
91
+ let removed = 0;
92
+ for (const key of Object.keys(sessions)) {
93
+ const entry = sessions[key];
94
+ if (!isTokenEntry(entry)) continue;
95
+ if (now > (entry?.expiresAt || 0)) {
96
+ delete sessions[key];
97
+ removed += 1;
98
+ }
99
+ }
100
+ if (removed) saveSessions(sessions);
101
+ return removed;
102
+ }
103
+
104
+ class PairingManager {
105
+ constructor({ auditLog = path.join(os.homedir(), ".kafox", "audit.log") } = {}) {
106
+ this.auditLog = auditLog;
107
+ this.pending = new Map(); // hash(pair_code) -> { createdAt, expiresAt, claims }
108
+ // Tokens pareados: carrega do disco (so o hash) e marca como ativos.
109
+ this.tokens = new Map();
110
+ const saved = loadSessions();
111
+ const now = Date.now();
112
+ for (const [hash, entry] of Object.entries(saved)) {
113
+ if (!isTokenEntry(entry)) continue; // pula entradas de servidor.
114
+ if (entry && now < (entry.expiresAt || 0)) this.tokens.set(hash, entry);
115
+ }
116
+ this.currentCode = null;
117
+ this.currentCodeHash = null;
118
+ }
119
+
120
+ /**
121
+ * Persiste o mapa de tokens (apenas hashes) no arquivo de sessoes,
122
+ * PRESERVANDO as entradas de servidor (kind:"server") ja registradas.
123
+ */
124
+ persistTokens() {
125
+ const out = {};
126
+ for (const [key, entry] of Object.entries(loadSessions())) {
127
+ if (isServerEntry(entry)) out[key] = entry;
128
+ }
129
+ for (const [hash, entry] of this.tokens) out[hash] = entry;
130
+ saveSessions(out);
131
+ }
132
+
133
+ /** Gera um par codigo+hash, expira o anterior e devolve o codigo em claro. */
134
+ issueCode() {
135
+ const code = randomCode(8);
136
+ const hash = sha256(code);
137
+ // um pareamento por vez: o codigo novo invalida o anterior.
138
+ if (this.currentCodeHash) this.pending.delete(this.currentCodeHash);
139
+ this.pending.set(hash, {
140
+ createdAt: Date.now(),
141
+ expiresAt: Date.now() + PAIR_TTL_MS,
142
+ claims: 0,
143
+ });
144
+ this.currentCode = code;
145
+ this.currentCodeHash = hash;
146
+ return code;
147
+ }
148
+
149
+ get expiresAt() {
150
+ const entry = this.currentCodeHash ? this.pending.get(this.currentCodeHash) : null;
151
+ return entry ? entry.expiresAt : 0;
152
+ }
153
+
154
+ /** True se o codigo e valido, nao expirou e nao foi usado. */
155
+ validateCode(code) {
156
+ const entry = this.pending.get(sha256(code));
157
+ if (!entry) return false;
158
+ if (Date.now() > entry.expiresAt) {
159
+ this.pending.delete(sha256(code));
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+
165
+ /** Troca um pair_code valido por um token de sessao. Uso unico. */
166
+ claim(code, device = {}) {
167
+ const hash = sha256(code);
168
+ const entry = this.pending.get(hash);
169
+ if (!entry || Date.now() > entry.expiresAt) return null;
170
+ // single-use: consome o codigo do servidor.
171
+ this.pending.delete(hash);
172
+ if (this.currentCodeHash === hash) this.currentCode = null;
173
+
174
+ const token = crypto.randomBytes(32).toString("hex");
175
+ this.tokens.set(sha256(token), {
176
+ createdAt: Date.now(),
177
+ expiresAt: Date.now() + TOKEN_TTL_MS,
178
+ code,
179
+ device,
180
+ });
181
+ this.persistTokens();
182
+ this.audit("pair", { device, codeHash: hash.slice(0, 12) });
183
+ return { token, expiresAt: Date.now() + TOKEN_TTL_MS };
184
+ }
185
+
186
+ /** True se o token de sessao existe e nao expirou. */
187
+ validateToken(token) {
188
+ if (!token) return false;
189
+ const entry = this.tokens.get(sha256(token));
190
+ if (!entry) return false;
191
+ if (Date.now() > entry.expiresAt) {
192
+ this.tokens.delete(sha256(token));
193
+ return false;
194
+ }
195
+ return true;
196
+ }
197
+
198
+
199
+
200
+ /** Revoga tudo (usado por `kafox mobile --revoke`). */
201
+ revokeAll() {
202
+ const count = this.tokens.size;
203
+ this.tokens.clear();
204
+ this.pending.clear();
205
+ this.currentCode = null;
206
+ this.currentCodeHash = null;
207
+ // preserva servidores registrados; apaga so os pareamentos.
208
+ const registry = loadSessions();
209
+ for (const key of Object.keys(registry)) {
210
+ if (isTokenEntry(registry[key])) delete registry[key];
211
+ }
212
+ saveSessions(registry);
213
+ this.audit("revoke-all", { tokens: count });
214
+ return count;
215
+ }
216
+
217
+ activeTokens() {
218
+ const now = Date.now();
219
+ for (const [hash, entry] of [...this.tokens]) if (now > entry.expiresAt) this.tokens.delete(hash);
220
+ return this.tokens.size;
221
+ }
222
+
223
+ audit(action, meta = {}) {
224
+ try {
225
+ fs.mkdirSync(path.dirname(this.auditLog), { recursive: true, mode: 0o700 });
226
+ fs.appendFileSync(
227
+ this.auditLog,
228
+ `${JSON.stringify({ at: new Date().toISOString(), action, ...meta })}\n`,
229
+ { mode: 0o600 }
230
+ );
231
+ } catch {
232
+ // auditoria nao pode quebrar o pareamento.
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Sessao mobile ativa: representa UM servidor `kafox mobile` de pe.
239
+ *
240
+ * O arquivo `mobile-sessions.json` guarda dois tipos de entrada (discriminadas
241
+ * pelo campo `kind`):
242
+ * - `kind: "token"` -> pareamento do celular (hash -> { createdAt, ... }).
243
+ * - `kind: "server"` -> servidor rodando (id -> { pid, port, sessionId, ... }).
244
+ *
245
+ * Entradas antigas (sem `kind`) sao tratadas como token, para nao perder os
246
+ * pareamentos ja existentes.
247
+ */
248
+ const SERVER_STATE_FILE =
249
+ process.env.KAFOX_MOBILE_SESSIONS || path.join(os.homedir(), ".kafox", "mobile-sessions.json");
250
+
251
+ function isServerEntry(entry) {
252
+ return entry && entry.kind === "server";
253
+ }
254
+
255
+ /** Le o registro bruto (tokens + servidores). */
256
+ function readRegistry() {
257
+ return loadSessions();
258
+ }
259
+
260
+ /** Grava o registro bruto. */
261
+ function writeRegistry(registry) {
262
+ saveSessions(registry);
263
+ }
264
+
265
+ /** True se o processo `pid` esta vivo (sinal 0). */
266
+ function pidAlive(pid) {
267
+ if (!pid) return false;
268
+ try {
269
+ process.kill(pid, 0);
270
+ return true;
271
+ } catch (error) {
272
+ // EPERM = existe mas sem permissao; tratar como vivo.
273
+ return error.code === "EPERM";
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Registra um servidor `kafox mobile` no disco. Id estavel = pid.
279
+ * Devolve o id usado.
280
+ */
281
+ function registerSession({ id = null, pid = process.pid, port, sessionId, base, readonly, log = null } = {}) {
282
+ const registry = readRegistry();
283
+ const key = String(id || pid);
284
+ registry[key] = {
285
+ kind: "server",
286
+ id: key,
287
+ pid: Number(pid),
288
+ port: Number(port),
289
+ sessionId: sessionId || null,
290
+ base: base || null,
291
+ readonly: Boolean(readonly),
292
+ log: log || process.env.KAFOX_MOBILE_LOG || null,
293
+ startedAt: Date.now(),
294
+ };
295
+ writeRegistry(registry);
296
+ return key;
297
+ }
298
+
299
+ /** Remove um servidor do registro. Devolve true se algo foi removido. */
300
+ function unregisterSession(id) {
301
+ const registry = readRegistry();
302
+ const key = String(id);
303
+ if (!registry[key] || !isServerEntry(registry[key])) return false;
304
+ delete registry[key];
305
+ writeRegistry(registry);
306
+ return true;
307
+ }
308
+
309
+ /**
310
+ * Lista os servidores registrados. Marca `alive` conforme o PID e poda os que
311
+ * ja morreram (limpeza do registro). Devolve array ordenado por start.
312
+ */
313
+ function listSessions({ prune = true } = {}) {
314
+ const registry = readRegistry();
315
+ const out = [];
316
+ let changed = false;
317
+ for (const [key, entry] of Object.entries(registry)) {
318
+ if (!isServerEntry(entry)) continue;
319
+ const alive = pidAlive(entry.pid);
320
+ if (!alive && prune) {
321
+ delete registry[key];
322
+ changed = true;
323
+ continue;
324
+ }
325
+ out.push({ ...entry, alive });
326
+ }
327
+ if (changed) writeRegistry(registry);
328
+ out.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0));
329
+ return out;
330
+ }
331
+
332
+ /** Encontra um servidor pelo id (ou pelo prefixo do id). */
333
+ function findSession(id) {
334
+ const needle = String(id);
335
+ const all = listSessions({ prune: false });
336
+ return all.find((s) => s.id === needle) || all.find((s) => s.id.startsWith(needle)) || null;
337
+ }
338
+
339
+ module.exports = {
340
+ PairingManager,
341
+ PAIR_TTL_MS,
342
+ TOKEN_TTL_MS,
343
+ revoke,
344
+ loadSessions,
345
+ saveSessions,
346
+ // registro de servidores `kafox mobile` (list/kill)
347
+ registerSession,
348
+ unregisterSession,
349
+ listSessions,
350
+ findSession,
351
+ pidAlive,
352
+ };