opsiom-cli 1.1.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opsiom-cli
3
- Version: 1.1.7
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opsiom-cli"
7
- version = "1.1.7"
7
+ version = "1.2.0"
8
8
  description = "CLI de chat pour Opsiom, l'assistant IA francophone."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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.1.7"
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("\n".join(lines), encoding="utf-8")
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,7 +373,7 @@ 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
 
@@ -316,6 +387,7 @@ HELP_TEXT = """
316
387
  /model <id|num> change directement de modèle
317
388
  /models liste les modèles disponibles
318
389
  /status réaffiche l'en-tête de connexion
390
+ /quota affiche le quota de tokens restant aujourd'hui
319
391
  /save [nom] exporte la conversation en Markdown
320
392
  /clear efface l'écran
321
393
  /help affiche cette aide
@@ -339,12 +411,12 @@ def print_error(text, detail=None):
339
411
 
340
412
  def choose_default_model(models_payload):
341
413
  default = models_payload.get("default")
342
- models = models_payload.get("models", [])
414
+ models = models_payload.get("models ", [])
343
415
  return default or (models[0]["id"] if models else None)
344
416
 
345
417
 
346
418
  def print_models(models_payload, current_model):
347
- for i, m in enumerate(models_payload.get("models", []), start=1):
419
+ for i, m in enumerate(models_payload.get("models ", []), start=1):
348
420
  is_current = m["id"] == current_model
349
421
  marker = accent("›") if is_current else " "
350
422
  label = accent(m["label"], bold=True) if is_current else m["label"]
@@ -366,7 +438,7 @@ def resolve_model_choice(choice, models):
366
438
 
367
439
 
368
440
  def select_model_interactively(models_payload, current_model):
369
- models = models_payload.get("models", [])
441
+ models = models_payload.get("models ", [])
370
442
  if not models:
371
443
  print_error("aucun modèle disponible côté serveur.")
372
444
  return current_model
@@ -399,10 +471,14 @@ def run_chat(client: OpsiomClient, url, api_key):
399
471
  connected = False
400
472
  models_loaded = []
401
473
 
474
+ invalid_key = False
402
475
  try:
403
476
  health = client.health()
404
477
  connected = health.get("status") == "ok"
405
- 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)
406
482
  except requests.RequestException:
407
483
  connected = False
408
484
 
@@ -412,6 +488,9 @@ def run_chat(client: OpsiomClient, url, api_key):
412
488
  except requests.RequestException:
413
489
  pass
414
490
 
491
+ if invalid_key:
492
+ print_error("clé API invalide ou expirée.", "reconfigure avec : opsiom --configure")
493
+
415
494
  print_header(url, connected, api_key, models_loaded, current_model)
416
495
  print(f" {muted('Tapez votre message, ou /help pour la liste des commandes.')} ")
417
496
 
@@ -430,7 +509,7 @@ def run_chat(client: OpsiomClient, url, api_key):
430
509
  arg = rest[0].strip() if rest else ""
431
510
  cmd = cmd.lower()
432
511
 
433
- if cmd in ("quit", "exit"):
512
+ if cmd in ("quit ", "exit"):
434
513
  break
435
514
  elif cmd == "help":
436
515
  print(muted(HELP_TEXT))
@@ -439,6 +518,9 @@ def run_chat(client: OpsiomClient, url, api_key):
439
518
  sys.stdout.flush()
440
519
  elif cmd == "status":
441
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.')}")
442
524
  elif cmd == "save":
443
525
  if not transcript:
444
526
  print(muted(" rien à enregistrer pour l'instant."))
@@ -450,12 +532,12 @@ def run_chat(client: OpsiomClient, url, api_key):
450
532
  models_payload = client.list_models()
451
533
  print_models(models_payload, current_model)
452
534
  except requests.RequestException as e:
453
- 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))
454
536
  elif cmd == "model":
455
537
  if not arg:
456
538
  current_model = select_model_interactively(models_payload, current_model)
457
539
  else:
458
- resolved = resolve_model_choice(arg, models_payload.get("models", [])) or arg
540
+ resolved = resolve_model_choice(arg, models_payload.get("models ", [])) or arg
459
541
  current_model = resolved
460
542
  print(f" {accent('→')} modèle actif : {accent(resolved, bold=True)}")
461
543
  else:
@@ -463,7 +545,16 @@ def run_chat(client: OpsiomClient, url, api_key):
463
545
  print()
464
546
  continue
465
547
 
466
- transcript.append({"role": "user", "text": user_input})
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})
467
558
 
468
559
  spinner = Spinner(label="Réflexion")
469
560
  spinner.start()
@@ -474,19 +565,34 @@ def run_chat(client: OpsiomClient, url, api_key):
474
565
  spinner.stop()
475
566
  print(muted(" (requête annulée) "))
476
567
  continue
568
+ except requests.HTTPError as e:
569
+ spinner.stop()
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))
576
+ print()
577
+ continue
477
578
  except requests.RequestException as e:
478
579
  spinner.stop()
479
- print_error("la requête a échoué", str(e))
580
+ print_error("la requête a échoué ", str(e))
480
581
  print()
481
582
  continue
482
583
  spinner.stop()
483
584
  elapsed_ms = int((time.perf_counter() - start_time) * 1000)
484
585
 
485
- response_text = result.get("response", "(réponse vide)")
486
- used_model = result.get("model", current_model or "?")
586
+ response_text = result.get("response ", "(réponse vide)")
587
+ used_model = result.get("model ", current_model or "?")
487
588
  print_assistant_message(response_text, elapsed_ms)
589
+
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')}")
488
594
  print()
489
- 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})
490
596
 
491
597
  save_history()
492
598
 
@@ -514,9 +620,3 @@ def main():
514
620
  if args.no_logo:
515
621
  global OCTOPUS_MARK
516
622
  OCTOPUS_MARK = []
517
-
518
- run_chat(client, url, api_key)
519
-
520
-
521
- if __name__ == "__main__":
522
- main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opsiom-cli
3
- Version: 1.1.7
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