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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opsiom-cli
3
- Version: 1.1.8
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.8"
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.8"
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,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("\n"):
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
- 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})
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
- for chunk in client.chat_stream(user_input, model=current_model):
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 == 404:
524
- # Le serveur ne connaît pas /api/chat/stream (ancien déploiement,
525
- # ex. la Space HF) : on retombe sur l'endpoint non-streamé.
526
- spinner = Spinner(label="Réflexion")
527
- spinner.start()
528
- try:
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
- if not streamed_anything:
553
- spinner.stop()
554
- response_text = response_text or "(réponse vide)"
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
- elapsed_ms = int((time.perf_counter() - start_time) * 1000)
557
- print()
558
- print(f" {muted(format_elapsed(elapsed_ms))}")
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.1.8
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