opsiom-cli 1.1.8__tar.gz → 1.2.0__tar.gz
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.
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/PKG-INFO +14 -1
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/README.md +13 -0
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/pyproject.toml +1 -1
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/src/opsiom_cli/cli.py +125 -96
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/src/opsiom_cli.egg-info/PKG-INFO +14 -1
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/setup.cfg +0 -0
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/src/opsiom_cli/__init__.py +0 -0
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/src/opsiom_cli.egg-info/SOURCES.txt +0 -0
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/src/opsiom_cli.egg-info/dependency_links.txt +0 -0
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/src/opsiom_cli.egg-info/entry_points.txt +0 -0
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/src/opsiom_cli.egg-info/requires.txt +0 -0
- {opsiom_cli-1.1.8 → opsiom_cli-1.2.0}/src/opsiom_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opsiom-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: CLI de chat pour Opsiom, l'assistant IA francophone.
|
|
5
5
|
Author-email: Jules Ecorcheville <jules.ecorcheville@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -47,12 +47,25 @@ Reconfigurer :
|
|
|
47
47
|
opsiom --configure
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## Quota
|
|
51
|
+
|
|
52
|
+
Chaque clé API dispose de 500 tokens par jour. Le CLI suit sa propre
|
|
53
|
+
consommation (estimée) dans `~/.config/opsiom/usage.json`, réinitialisée
|
|
54
|
+
chaque jour, et refuse d'envoyer un nouveau message une fois le quota
|
|
55
|
+
atteint. Il affiche aussi clairement quand la clé API est invalide ou
|
|
56
|
+
expirée (le serveur répond alors 401/403).
|
|
57
|
+
|
|
58
|
+
Ce compteur est un garde-fou côté client, pas une mesure de sécurité —
|
|
59
|
+
l'application réelle du quota doit rester du côté du serveur qui vérifie
|
|
60
|
+
la clé.
|
|
61
|
+
|
|
50
62
|
## Commandes du chat
|
|
51
63
|
|
|
52
64
|
- `/model` — menu numéroté pour choisir le modèle
|
|
53
65
|
- `/model <id|numéro>` — change directement de modèle
|
|
54
66
|
- `/models` — liste les modèles disponibles
|
|
55
67
|
- `/status` — réaffiche le panneau de connexion
|
|
68
|
+
- `/quota` — affiche le quota de tokens restant aujourd'hui (500/jour par clé API)
|
|
56
69
|
- `/clear` — efface l'écran
|
|
57
70
|
- `/help` — aide
|
|
58
71
|
- `/quit`, `/exit` — quitte
|
|
@@ -35,12 +35,25 @@ Reconfigurer :
|
|
|
35
35
|
opsiom --configure
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Quota
|
|
39
|
+
|
|
40
|
+
Chaque clé API dispose de 500 tokens par jour. Le CLI suit sa propre
|
|
41
|
+
consommation (estimée) dans `~/.config/opsiom/usage.json`, réinitialisée
|
|
42
|
+
chaque jour, et refuse d'envoyer un nouveau message une fois le quota
|
|
43
|
+
atteint. Il affiche aussi clairement quand la clé API est invalide ou
|
|
44
|
+
expirée (le serveur répond alors 401/403).
|
|
45
|
+
|
|
46
|
+
Ce compteur est un garde-fou côté client, pas une mesure de sécurité —
|
|
47
|
+
l'application réelle du quota doit rester du côté du serveur qui vérifie
|
|
48
|
+
la clé.
|
|
49
|
+
|
|
38
50
|
## Commandes du chat
|
|
39
51
|
|
|
40
52
|
- `/model` — menu numéroté pour choisir le modèle
|
|
41
53
|
- `/model <id|numéro>` — change directement de modèle
|
|
42
54
|
- `/models` — liste les modèles disponibles
|
|
43
55
|
- `/status` — réaffiche le panneau de connexion
|
|
56
|
+
- `/quota` — affiche le quota de tokens restant aujourd'hui (500/jour par clé API)
|
|
44
57
|
- `/clear` — efface l'écran
|
|
45
58
|
- `/help` — aide
|
|
46
59
|
- `/quit`, `/exit` — quitte
|
|
@@ -22,6 +22,7 @@ import sys
|
|
|
22
22
|
import json
|
|
23
23
|
import time
|
|
24
24
|
import shutil
|
|
25
|
+
import hashlib
|
|
25
26
|
import textwrap
|
|
26
27
|
import argparse
|
|
27
28
|
import threading
|
|
@@ -39,10 +40,12 @@ try:
|
|
|
39
40
|
except ImportError:
|
|
40
41
|
readline = None # absent par défaut sur certains Windows (pyreadline3 comble le manque)
|
|
41
42
|
|
|
42
|
-
VERSION = "1.
|
|
43
|
+
VERSION = "1.2.0"
|
|
43
44
|
CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
|
|
44
45
|
HISTORY_PATH = Path.home() / ".local" / "share" / "opsiom" / "history"
|
|
45
46
|
TRANSCRIPTS_DIR = Path.home() / "opsiom-conversations"
|
|
47
|
+
USAGE_PATH = Path.home() / ".config" / "opsiom" / "usage.json"
|
|
48
|
+
DAILY_TOKEN_QUOTA = 500
|
|
46
49
|
|
|
47
50
|
|
|
48
51
|
# ----------------------------------------------------------------------------
|
|
@@ -138,6 +141,10 @@ def print_header(url, connected, api_key, models_loaded, current_model, show_mar
|
|
|
138
141
|
status_symbol = ok_text("✓") if connected else error_text("✗")
|
|
139
142
|
print(f" {status_symbol} {muted('connecté à')} {url}")
|
|
140
143
|
print(f" {muted('auth')} {muted('clé API' if api_key else 'aucune')}")
|
|
144
|
+
if api_key:
|
|
145
|
+
used, remaining = get_quota_state(api_key)
|
|
146
|
+
quota_color = error_text if remaining <= 0 else muted
|
|
147
|
+
print(f" {muted('quota')} {quota_color(f'{used}/{DAILY_TOKEN_QUOTA} tokens aujourd’hui ({remaining} restants)')}")
|
|
141
148
|
if models_loaded:
|
|
142
149
|
print(f" {muted('modèles')} {' · '.join(models_loaded)}")
|
|
143
150
|
if current_model:
|
|
@@ -220,14 +227,14 @@ def save_transcript(transcript, filename=None):
|
|
|
220
227
|
if not filename.endswith(".md"):
|
|
221
228
|
filename += ".md"
|
|
222
229
|
path = TRANSCRIPTS_DIR / filename
|
|
223
|
-
lines = ["# Conversation Opsiom", ""]
|
|
230
|
+
lines = ["# Conversation Opsiom ", ""]
|
|
224
231
|
for entry in transcript:
|
|
225
232
|
if entry["role"] == "user":
|
|
226
233
|
lines.append(f"**Vous :** {entry['text']}")
|
|
227
234
|
else:
|
|
228
235
|
lines.append(f"**Opsiom ({entry['model']}) :** {entry['text']} _({entry['elapsed_ms']} ms)_")
|
|
229
236
|
lines.append("")
|
|
230
|
-
path.write_text("
|
|
237
|
+
path.write_text(" ".join(lines), encoding="utf-8")
|
|
231
238
|
return path
|
|
232
239
|
|
|
233
240
|
|
|
@@ -256,7 +263,7 @@ def prompt_for_config():
|
|
|
256
263
|
api_key = input(" Clé API (vide si aucune) : ").strip()
|
|
257
264
|
save = input(muted(" Sauvegarder localement pour la prochaine fois ? [O/n] ")).strip().lower()
|
|
258
265
|
config = {"url": url, "api_key": api_key}
|
|
259
|
-
if save in ("", "o", "oui", "y", "yes"):
|
|
266
|
+
if save in (" ", "o ", "oui ", "y ", "yes"):
|
|
260
267
|
save_config(config)
|
|
261
268
|
print(muted(f" → enregistré dans {CONFIG_PATH} "))
|
|
262
269
|
return config
|
|
@@ -273,6 +280,70 @@ def resolve_settings(args):
|
|
|
273
280
|
return url, api_key
|
|
274
281
|
|
|
275
282
|
|
|
283
|
+
# ----------------------------------------------------------------------------
|
|
284
|
+
# Quota local — 500 tokens/jour par clé API. C'est un garde-fou côté client
|
|
285
|
+
# (pratique pour prévenir l'utilisateur avant d'envoyer une requête inutile),
|
|
286
|
+
# pas une mesure de sécurité : le fichier usage.json est modifiable à la main.
|
|
287
|
+
# L'application réelle du quota doit se faire côté serveur (là où la clé est
|
|
288
|
+
# vérifiée), ceci n'est qu'un affichage/avertissement local.
|
|
289
|
+
# ----------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def _api_key_id(api_key):
|
|
292
|
+
"""Identifiant stable dérivé de la clé (SHA-256 tronqué) : on évite de
|
|
293
|
+
dupliquer la clé en clair dans usage.json, un fichier qui a moins de
|
|
294
|
+
raisons d'être protégé que config.json."""
|
|
295
|
+
if not api_key:
|
|
296
|
+
return "sans-cle"
|
|
297
|
+
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def today_str():
|
|
301
|
+
return time.strftime("%Y-%m-%d")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def estimate_tokens(text):
|
|
305
|
+
"""Estimation grossière (~4 caractères par token) faute de tokenizer
|
|
306
|
+
côté client -- suffisante pour un garde-fou local, pas pour un décompte
|
|
307
|
+
exact (le décompte qui fait foi est celui du serveur)."""
|
|
308
|
+
return max(1, round(len(text) / 4))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def load_usage():
|
|
312
|
+
if USAGE_PATH.exists():
|
|
313
|
+
try:
|
|
314
|
+
return json.loads(USAGE_PATH.read_text())
|
|
315
|
+
except (json.JSONDecodeError, OSError):
|
|
316
|
+
return {}
|
|
317
|
+
return {}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def save_usage(usage):
|
|
321
|
+
USAGE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
USAGE_PATH.write_text(json.dumps(usage, indent=2))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def get_quota_state(api_key):
|
|
326
|
+
"""(tokens utilisés aujourd'hui, tokens restants). Le compteur se
|
|
327
|
+
réinitialise tout seul dès que la date enregistrée n'est plus celle
|
|
328
|
+
du jour -- pas besoin de tâche planifiée."""
|
|
329
|
+
entry = load_usage().get(_api_key_id(api_key), {})
|
|
330
|
+
if entry.get("date") != today_str():
|
|
331
|
+
return 0, DAILY_TOKEN_QUOTA
|
|
332
|
+
used = entry.get("tokens_used", 0)
|
|
333
|
+
return used, max(0, DAILY_TOKEN_QUOTA - used)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def record_usage(api_key, tokens_used):
|
|
337
|
+
usage = load_usage()
|
|
338
|
+
key_id = _api_key_id(api_key)
|
|
339
|
+
entry = usage.get(key_id, {})
|
|
340
|
+
if entry.get("date") != today_str():
|
|
341
|
+
entry = {"date": today_str(), "tokens_used": 0}
|
|
342
|
+
entry["tokens_used"] = entry.get("tokens_used", 0) + tokens_used
|
|
343
|
+
usage[key_id] = entry
|
|
344
|
+
save_usage(usage)
|
|
345
|
+
|
|
346
|
+
|
|
276
347
|
# ----------------------------------------------------------------------------
|
|
277
348
|
# Client API
|
|
278
349
|
# ----------------------------------------------------------------------------
|
|
@@ -288,12 +359,12 @@ class OpsiomClient:
|
|
|
288
359
|
self.session.headers.update(headers)
|
|
289
360
|
|
|
290
361
|
def health(self):
|
|
291
|
-
r = self.session.get(f"{self.base_url}/api/health", timeout=10)
|
|
362
|
+
r = self.session.get(f"{self.base_url}/api/health ", timeout=10)
|
|
292
363
|
r.raise_for_status()
|
|
293
364
|
return r.json()
|
|
294
365
|
|
|
295
366
|
def list_models(self):
|
|
296
|
-
r = self.session.get(f"{self.base_url}/api/models", timeout=10)
|
|
367
|
+
r = self.session.get(f"{self.base_url}/api/models ", timeout=10)
|
|
297
368
|
r.raise_for_status()
|
|
298
369
|
return r.json()
|
|
299
370
|
|
|
@@ -302,41 +373,10 @@ class OpsiomClient:
|
|
|
302
373
|
if model:
|
|
303
374
|
payload["model"] = model
|
|
304
375
|
payload.update(params)
|
|
305
|
-
r = self.session.post(f"{self.base_url}/api/chat", json=payload, timeout=self.timeout)
|
|
376
|
+
r = self.session.post(f"{self.base_url}/api/chat ", json=payload, timeout=self.timeout)
|
|
306
377
|
r.raise_for_status()
|
|
307
378
|
return r.json()
|
|
308
379
|
|
|
309
|
-
def chat_stream(self, message, model=None, **params):
|
|
310
|
-
"""
|
|
311
|
-
Cède le texte de la réponse au fur et à mesure qu'il arrive du serveur
|
|
312
|
-
(endpoint SSE /api/chat/stream). Le dernier modèle effectivement
|
|
313
|
-
utilisé est exposé après coup via self.last_model_used, faute de
|
|
314
|
-
pouvoir renvoyer une valeur normale depuis un générateur.
|
|
315
|
-
"""
|
|
316
|
-
payload = {"message": message}
|
|
317
|
-
if model:
|
|
318
|
-
payload["model"] = model
|
|
319
|
-
payload.update(params)
|
|
320
|
-
self.last_model_used = model
|
|
321
|
-
r = self.session.post(
|
|
322
|
-
f"{self.base_url}/api/chat/stream", json=payload,
|
|
323
|
-
timeout=self.timeout, stream=True,
|
|
324
|
-
)
|
|
325
|
-
r.raise_for_status()
|
|
326
|
-
for raw_line in r.iter_lines(decode_unicode=True):
|
|
327
|
-
if not raw_line or not raw_line.startswith("data: "):
|
|
328
|
-
continue
|
|
329
|
-
try:
|
|
330
|
-
event = json.loads(raw_line[len("data: "):])
|
|
331
|
-
except json.JSONDecodeError:
|
|
332
|
-
continue
|
|
333
|
-
if event.get("done"):
|
|
334
|
-
self.last_model_used = event.get("model", model)
|
|
335
|
-
break
|
|
336
|
-
token = event.get("token")
|
|
337
|
-
if token:
|
|
338
|
-
yield token
|
|
339
|
-
|
|
340
380
|
|
|
341
381
|
# ----------------------------------------------------------------------------
|
|
342
382
|
# Interface interactive
|
|
@@ -347,6 +387,7 @@ HELP_TEXT = """
|
|
|
347
387
|
/model <id|num> change directement de modèle
|
|
348
388
|
/models liste les modèles disponibles
|
|
349
389
|
/status réaffiche l'en-tête de connexion
|
|
390
|
+
/quota affiche le quota de tokens restant aujourd'hui
|
|
350
391
|
/save [nom] exporte la conversation en Markdown
|
|
351
392
|
/clear efface l'écran
|
|
352
393
|
/help affiche cette aide
|
|
@@ -370,12 +411,12 @@ def print_error(text, detail=None):
|
|
|
370
411
|
|
|
371
412
|
def choose_default_model(models_payload):
|
|
372
413
|
default = models_payload.get("default")
|
|
373
|
-
models = models_payload.get("models", [])
|
|
414
|
+
models = models_payload.get("models ", [])
|
|
374
415
|
return default or (models[0]["id"] if models else None)
|
|
375
416
|
|
|
376
417
|
|
|
377
418
|
def print_models(models_payload, current_model):
|
|
378
|
-
for i, m in enumerate(models_payload.get("models", []), start=1):
|
|
419
|
+
for i, m in enumerate(models_payload.get("models ", []), start=1):
|
|
379
420
|
is_current = m["id"] == current_model
|
|
380
421
|
marker = accent("›") if is_current else " "
|
|
381
422
|
label = accent(m["label"], bold=True) if is_current else m["label"]
|
|
@@ -397,7 +438,7 @@ def resolve_model_choice(choice, models):
|
|
|
397
438
|
|
|
398
439
|
|
|
399
440
|
def select_model_interactively(models_payload, current_model):
|
|
400
|
-
models = models_payload.get("models", [])
|
|
441
|
+
models = models_payload.get("models ", [])
|
|
401
442
|
if not models:
|
|
402
443
|
print_error("aucun modèle disponible côté serveur.")
|
|
403
444
|
return current_model
|
|
@@ -417,7 +458,7 @@ def print_assistant_message(text, elapsed_ms):
|
|
|
417
458
|
width = max(term_width() - 4, 30)
|
|
418
459
|
wrapped = textwrap.fill(text, width=width)
|
|
419
460
|
print()
|
|
420
|
-
for line in wrapped.split("
|
|
461
|
+
for line in wrapped.split(" "):
|
|
421
462
|
print(f" {line}")
|
|
422
463
|
print(f" {muted(format_elapsed(elapsed_ms))}")
|
|
423
464
|
|
|
@@ -430,10 +471,14 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
430
471
|
connected = False
|
|
431
472
|
models_loaded = []
|
|
432
473
|
|
|
474
|
+
invalid_key = False
|
|
433
475
|
try:
|
|
434
476
|
health = client.health()
|
|
435
477
|
connected = health.get("status") == "ok"
|
|
436
|
-
models_loaded = health.get("models_loaded", [])
|
|
478
|
+
models_loaded = health.get("models_loaded ", [])
|
|
479
|
+
except requests.HTTPError as e:
|
|
480
|
+
connected = False
|
|
481
|
+
invalid_key = e.response is not None and e.response.status_code in (401, 403)
|
|
437
482
|
except requests.RequestException:
|
|
438
483
|
connected = False
|
|
439
484
|
|
|
@@ -443,6 +488,9 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
443
488
|
except requests.RequestException:
|
|
444
489
|
pass
|
|
445
490
|
|
|
491
|
+
if invalid_key:
|
|
492
|
+
print_error("clé API invalide ou expirée.", "reconfigure avec : opsiom --configure")
|
|
493
|
+
|
|
446
494
|
print_header(url, connected, api_key, models_loaded, current_model)
|
|
447
495
|
print(f" {muted('Tapez votre message, ou /help pour la liste des commandes.')} ")
|
|
448
496
|
|
|
@@ -461,7 +509,7 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
461
509
|
arg = rest[0].strip() if rest else ""
|
|
462
510
|
cmd = cmd.lower()
|
|
463
511
|
|
|
464
|
-
if cmd in ("quit", "exit"):
|
|
512
|
+
if cmd in ("quit ", "exit"):
|
|
465
513
|
break
|
|
466
514
|
elif cmd == "help":
|
|
467
515
|
print(muted(HELP_TEXT))
|
|
@@ -470,6 +518,9 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
470
518
|
sys.stdout.flush()
|
|
471
519
|
elif cmd == "status":
|
|
472
520
|
print_header(url, connected, api_key, models_loaded, current_model, show_mark=False)
|
|
521
|
+
elif cmd == "quota":
|
|
522
|
+
used, remaining = get_quota_state(api_key)
|
|
523
|
+
print(f" {muted(f'{used}/{DAILY_TOKEN_QUOTA} tokens utilisés aujourd’hui — {remaining} restants.')}")
|
|
473
524
|
elif cmd == "save":
|
|
474
525
|
if not transcript:
|
|
475
526
|
print(muted(" rien à enregistrer pour l'instant."))
|
|
@@ -481,12 +532,12 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
481
532
|
models_payload = client.list_models()
|
|
482
533
|
print_models(models_payload, current_model)
|
|
483
534
|
except requests.RequestException as e:
|
|
484
|
-
print_error("impossible de récupérer les modèles", str(e))
|
|
535
|
+
print_error("impossible de récupérer les modèles ", str(e))
|
|
485
536
|
elif cmd == "model":
|
|
486
537
|
if not arg:
|
|
487
538
|
current_model = select_model_interactively(models_payload, current_model)
|
|
488
539
|
else:
|
|
489
|
-
resolved = resolve_model_choice(arg, models_payload.get("models", [])) or arg
|
|
540
|
+
resolved = resolve_model_choice(arg, models_payload.get("models ", [])) or arg
|
|
490
541
|
current_model = resolved
|
|
491
542
|
print(f" {accent('→')} modèle actif : {accent(resolved, bold=True)}")
|
|
492
543
|
else:
|
|
@@ -494,70 +545,54 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
494
545
|
print()
|
|
495
546
|
continue
|
|
496
547
|
|
|
497
|
-
|
|
548
|
+
_, remaining_quota = get_quota_state(api_key)
|
|
549
|
+
if remaining_quota <= 0:
|
|
550
|
+
print_error(
|
|
551
|
+
f"quota quotidien de {DAILY_TOKEN_QUOTA} tokens atteint.",
|
|
552
|
+
"réessaie demain, ou utilise une autre clé API avec --configure",
|
|
553
|
+
)
|
|
554
|
+
print()
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
transcript.append({"role": "user ", "text": user_input})
|
|
498
558
|
|
|
499
559
|
spinner = Spinner(label="Réflexion")
|
|
500
560
|
spinner.start()
|
|
501
561
|
start_time = time.perf_counter()
|
|
502
|
-
response_text = ""
|
|
503
|
-
used_model = current_model or "?"
|
|
504
|
-
streamed_anything = False
|
|
505
|
-
|
|
506
562
|
try:
|
|
507
|
-
|
|
508
|
-
if not streamed_anything:
|
|
509
|
-
spinner.stop()
|
|
510
|
-
print()
|
|
511
|
-
sys.stdout.write(" ")
|
|
512
|
-
streamed_anything = True
|
|
513
|
-
response_text += chunk
|
|
514
|
-
sys.stdout.write(chunk.replace("\n", "\n "))
|
|
515
|
-
sys.stdout.flush()
|
|
516
|
-
used_model = getattr(client, "last_model_used", None) or used_model
|
|
563
|
+
result = client.chat(user_input, model=current_model)
|
|
517
564
|
except KeyboardInterrupt:
|
|
518
565
|
spinner.stop()
|
|
519
566
|
print(muted(" (requête annulée) "))
|
|
520
567
|
continue
|
|
521
568
|
except requests.HTTPError as e:
|
|
522
569
|
spinner.stop()
|
|
523
|
-
if e.response is not None and e.response.status_code
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
result = client.chat(user_input, model=current_model)
|
|
530
|
-
except requests.RequestException as e2:
|
|
531
|
-
spinner.stop()
|
|
532
|
-
print_error("la requête a échoué", str(e2))
|
|
533
|
-
print()
|
|
534
|
-
continue
|
|
535
|
-
spinner.stop()
|
|
536
|
-
response_text = result.get("response", "(réponse vide)")
|
|
537
|
-
used_model = result.get("model", current_model or "?")
|
|
538
|
-
print_assistant_message(response_text, int((time.perf_counter() - start_time) * 1000))
|
|
539
|
-
print()
|
|
540
|
-
transcript.append({"role": "assistant", "text": response_text, "model": used_model,
|
|
541
|
-
"elapsed_ms": int((time.perf_counter() - start_time) * 1000)})
|
|
542
|
-
continue
|
|
543
|
-
print_error("la requête a échoué", str(e))
|
|
570
|
+
if e.response is not None and e.response.status_code in (401, 403):
|
|
571
|
+
print_error("clé API invalide ou expirée.", "reconfigure avec : opsiom --configure")
|
|
572
|
+
elif e.response is not None and e.response.status_code == 429:
|
|
573
|
+
print_error("quota dépassé côté serveur.", "réessaie plus tard")
|
|
574
|
+
else:
|
|
575
|
+
print_error("la requête a échoué ", str(e))
|
|
544
576
|
print()
|
|
545
577
|
continue
|
|
546
578
|
except requests.RequestException as e:
|
|
547
579
|
spinner.stop()
|
|
548
|
-
print_error("la requête a échoué", str(e))
|
|
580
|
+
print_error("la requête a échoué ", str(e))
|
|
549
581
|
print()
|
|
550
582
|
continue
|
|
583
|
+
spinner.stop()
|
|
584
|
+
elapsed_ms = int((time.perf_counter() - start_time) * 1000)
|
|
551
585
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
586
|
+
response_text = result.get("response ", "(réponse vide)")
|
|
587
|
+
used_model = result.get("model ", current_model or "?")
|
|
588
|
+
print_assistant_message(response_text, elapsed_ms)
|
|
555
589
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
590
|
+
tokens_used = result.get("tokens_used") or (estimate_tokens(user_input) + estimate_tokens(response_text))
|
|
591
|
+
record_usage(api_key, tokens_used)
|
|
592
|
+
_, remaining_quota = get_quota_state(api_key)
|
|
593
|
+
print(f" {muted(f'{remaining_quota} tokens restants aujourd’hui')}")
|
|
559
594
|
print()
|
|
560
|
-
transcript.append({"role": "assistant", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
|
|
595
|
+
transcript.append({"role": "assistant ", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
|
|
561
596
|
|
|
562
597
|
save_history()
|
|
563
598
|
|
|
@@ -585,9 +620,3 @@ def main():
|
|
|
585
620
|
if args.no_logo:
|
|
586
621
|
global OCTOPUS_MARK
|
|
587
622
|
OCTOPUS_MARK = []
|
|
588
|
-
|
|
589
|
-
run_chat(client, url, api_key)
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if __name__ == "__main__":
|
|
593
|
-
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opsiom-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: CLI de chat pour Opsiom, l'assistant IA francophone.
|
|
5
5
|
Author-email: Jules Ecorcheville <jules.ecorcheville@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -47,12 +47,25 @@ Reconfigurer :
|
|
|
47
47
|
opsiom --configure
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## Quota
|
|
51
|
+
|
|
52
|
+
Chaque clé API dispose de 500 tokens par jour. Le CLI suit sa propre
|
|
53
|
+
consommation (estimée) dans `~/.config/opsiom/usage.json`, réinitialisée
|
|
54
|
+
chaque jour, et refuse d'envoyer un nouveau message une fois le quota
|
|
55
|
+
atteint. Il affiche aussi clairement quand la clé API est invalide ou
|
|
56
|
+
expirée (le serveur répond alors 401/403).
|
|
57
|
+
|
|
58
|
+
Ce compteur est un garde-fou côté client, pas une mesure de sécurité —
|
|
59
|
+
l'application réelle du quota doit rester du côté du serveur qui vérifie
|
|
60
|
+
la clé.
|
|
61
|
+
|
|
50
62
|
## Commandes du chat
|
|
51
63
|
|
|
52
64
|
- `/model` — menu numéroté pour choisir le modèle
|
|
53
65
|
- `/model <id|numéro>` — change directement de modèle
|
|
54
66
|
- `/models` — liste les modèles disponibles
|
|
55
67
|
- `/status` — réaffiche le panneau de connexion
|
|
68
|
+
- `/quota` — affiche le quota de tokens restant aujourd'hui (500/jour par clé API)
|
|
56
69
|
- `/clear` — efface l'écran
|
|
57
70
|
- `/help` — aide
|
|
58
71
|
- `/quit`, `/exit` — quitte
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|