opsiom-cli 1.2.4__tar.gz → 1.3.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.2.4
3
+ Version: 1.3.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opsiom-cli"
7
- version = "1.2.4"
7
+ version = "1.3.0"
8
8
  description = "CLI de chat pour Opsiom, l'assistant IA francophone."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -20,4 +20,4 @@ Homepage = "https://github.com/ton-compte/opsiom-cli"
20
20
  opsiom = "opsiom_cli.cli:main"
21
21
 
22
22
  [tool.setuptools.packages.find]
23
- where = ["src"]
23
+ where = ["src"]
@@ -23,7 +23,6 @@ import json
23
23
  import time
24
24
  import shutil
25
25
  import hashlib
26
- import textwrap
27
26
  import argparse
28
27
  import threading
29
28
  import itertools
@@ -40,12 +39,18 @@ try:
40
39
  except ImportError:
41
40
  readline = None # absent par défaut sur certains Windows (pyreadline3 comble le manque)
42
41
 
43
- VERSION = "1.2.4"
42
+ VERSION = "1.3.0"
44
43
  CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
45
44
  HISTORY_PATH = Path.home() / ".local" / "share" / "opsiom" / "history"
46
45
  TRANSCRIPTS_DIR = Path.home() / "opsiom-conversations"
47
46
  USAGE_PATH = Path.home() / ".config" / "opsiom" / "usage.json"
48
- DAILY_TOKEN_QUOTA = 500
47
+ # Quota de repli (= forfait Free, voir plans.py côté front) : utilisé UNIQUEMENT
48
+ # tant que le serveur n'a pas encore renvoyé la vraie limite du compte. La
49
+ # limite réelle dépend du forfait (Free 2000 / Plus 10 000 / Pro 50 000 tokens
50
+ # par jour) et fait foi dès la première réponse du serveur.
51
+ DAILY_TOKEN_QUOTA = 2000
52
+ UNLIMITED_THRESHOLD = 10**9 # même valeur que UNLIMITED_REMAINING côté front
53
+ PLAN_LABELS = {"free": "Opsiom Free", "plus": "Opsiom Plus", "pro": "Opsiom Pro"}
49
54
 
50
55
 
51
56
  # ----------------------------------------------------------------------------
@@ -142,9 +147,11 @@ def print_header(url, connected, api_key, models_loaded, current_model, show_mar
142
147
  print(f" {status_symbol} {muted('connecté à')} {url}")
143
148
  print(f" {muted('auth')} {muted('clé API' if api_key else 'aucune')}")
144
149
  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)')}")
150
+ state = get_quota_state(api_key)
151
+ quota_color = error_text if (not state["unlimited"] and state["remaining"] <= 0) else muted
152
+ if state["plan"]:
153
+ print(f" {muted('forfait')} {accent(plan_label(state['plan']))}")
154
+ print(f" {muted('quota')} {quota_color(format_quota(state))}")
148
155
  if models_loaded:
149
156
  print(f" {muted('modèles')} {' · '.join(models_loaded)}")
150
157
  if current_model:
@@ -281,11 +288,13 @@ def resolve_settings(args):
281
288
 
282
289
 
283
290
  # ----------------------------------------------------------------------------
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.
291
+ # Quota — dépend du FORFAIT du compte (Free 2000 / Plus 10 000 / Pro 50 000
292
+ # tokens par jour ; les comptes administrateurs sont illimités). La limite
293
+ # n'est plus codée en dur : le serveur renvoie la limite et le quota restant
294
+ # avec chaque réponse, et c'est cette valeur qui fait foi. Le fichier
295
+ # usage.json ne sert que de cache d'affichage (par exemple pour /quota juste
296
+ # après le lancement, avant la toute première requête de la session). Le
297
+ # serveur refuse de toute façon les requêtes une fois son compteur à zéro (429).
289
298
  # ----------------------------------------------------------------------------
290
299
 
291
300
  def _api_key_id(api_key):
@@ -308,6 +317,10 @@ def estimate_tokens(text):
308
317
  return max(1, round(len(text) / 4))
309
318
 
310
319
 
320
+ def plan_label(plan_id):
321
+ return PLAN_LABELS.get(plan_id, str(plan_id))
322
+
323
+
311
324
  def load_usage():
312
325
  if USAGE_PATH.exists():
313
326
  try:
@@ -323,24 +336,61 @@ def save_usage(usage):
323
336
 
324
337
 
325
338
  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."""
339
+ """État du quota sous forme de dict : used, remaining, limit, plan,
340
+ unlimited. Le compteur d'usage se réinitialise tout seul dès que la date
341
+ enregistrée n'est plus celle du jour ; la limite et le forfait, eux,
342
+ restent ceux du dernier échange avec le serveur."""
329
343
  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):
344
+ limit = entry.get("limit") or DAILY_TOKEN_QUOTA
345
+ unlimited = bool(entry.get("unlimited"))
346
+ plan = entry.get("plan")
347
+ used = entry.get("tokens_used", 0) if entry.get("date") == today_str() else 0
348
+ remaining = UNLIMITED_THRESHOLD if unlimited else max(0, limit - used)
349
+ return {"used": used, "remaining": remaining, "limit": limit, "plan": plan, "unlimited": unlimited}
350
+
351
+
352
+ def format_quota(state):
353
+ if state["unlimited"]:
354
+ return "illimité"
355
+ return f"{state['used']}/{state['limit']} tokens aujourd’hui ({state['remaining']} restants)"
356
+
357
+
358
+ def parse_quota_event(event):
359
+ """Extrait (remaining, limit, plan, unlimited) de l'événement final du
360
+ flux. Accepte à la fois les champs à plat (remaining_quota, quota_limit,
361
+ plan) et un objet `quota` au format du front : {used, limit, remaining,
362
+ plan, unlimited}. Les champs absents valent None."""
363
+ q = event.get("quota") if isinstance(event.get("quota"), dict) else {}
364
+ remaining = event.get("remaining_quota")
365
+ if remaining is None:
366
+ remaining = q.get("remaining")
367
+ limit = event.get("quota_limit", event.get("limit"))
368
+ if limit is None:
369
+ limit = q.get("limit")
370
+ plan = event.get("plan") or q.get("plan")
371
+ unlimited = bool(event.get("unlimited") or q.get("unlimited"))
372
+ if remaining is not None and remaining >= UNLIMITED_THRESHOLD:
373
+ unlimited = True
374
+ return remaining, limit, plan, unlimited
375
+
376
+
377
+ def record_authoritative_quota(api_key, remaining, limit=None, plan=None, unlimited=False):
378
+ """Enregistre ce que le serveur vient de dire (quota restant, et si
379
+ fournis : limite du forfait et nom du forfait). Si la limite n'est pas
380
+ renvoyée, on garde la dernière connue plutôt que de retomber sur la
381
+ valeur de repli."""
382
+ if remaining is None and not unlimited:
383
+ return
337
384
  usage = load_usage()
338
385
  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
386
+ previous = usage.get(key_id, {})
387
+ if unlimited:
388
+ usage[key_id] = {"date": today_str(), "tokens_used": 0, "unlimited": True,
389
+ "limit": previous.get("limit") or DAILY_TOKEN_QUOTA, "plan": plan or previous.get("plan")}
390
+ else:
391
+ eff_limit = limit or previous.get("limit") or DAILY_TOKEN_QUOTA
392
+ usage[key_id] = {"date": today_str(), "tokens_used": max(0, eff_limit - remaining),
393
+ "limit": eff_limit, "unlimited": False, "plan": plan or previous.get("plan")}
344
394
  save_usage(usage)
345
395
 
346
396
 
@@ -377,6 +427,33 @@ class OpsiomClient:
377
427
  r.raise_for_status()
378
428
  return r.json()
379
429
 
430
+ def chat_stream(self, message, model=None, **params):
431
+ """Générateur : cède chaque fragment de texte au fur et à mesure,
432
+ puis le dict final {'done', 'model', 'tokens_used', 'remaining_quota'}.
433
+ Lève requests.HTTPError si le serveur refuse la requête (401/429/...) —
434
+ dans ce cas rien n'a encore été cédé, l'appelant peut afficher l'erreur
435
+ comme pour un appel non-streamé.
436
+ """
437
+ payload = {"message": message}
438
+ if model:
439
+ payload["model"] = model
440
+ payload.update(params)
441
+ r = self.session.post(
442
+ f"{self.base_url}/api/chat/stream", json=payload,
443
+ timeout=self.timeout, stream=True,
444
+ )
445
+ r.raise_for_status()
446
+ for line in r.iter_lines(decode_unicode=True):
447
+ if not line or not line.startswith("data: "):
448
+ continue
449
+ event = json.loads(line[len("data: "):])
450
+ if event.get("done"):
451
+ yield event
452
+ return
453
+ token = event.get("token")
454
+ if token:
455
+ yield token
456
+
380
457
 
381
458
  # ----------------------------------------------------------------------------
382
459
  # Interface interactive
@@ -454,13 +531,59 @@ def select_model_interactively(models_payload, current_model):
454
531
  return resolved
455
532
 
456
533
 
457
- def print_assistant_message(text, elapsed_ms):
458
- width = max(term_width() - 4, 30)
459
- wrapped = textwrap.fill(text, width=width)
460
- print()
461
- for line in wrapped.split("\n"):
462
- print(f" {line}")
463
- print(f" {muted(format_elapsed(elapsed_ms))}")
534
+ class StreamPrinter:
535
+ """Affiche la réponse au fur et à mesure qu'elle arrive (streaming),
536
+ avec un retour à la ligne automatique à la largeur du terminal.
537
+
538
+ Contrairement à un `textwrap.fill` relancé à chaque fragment reçu (qui
539
+ réimprimerait tout depuis le début et ferait scintiller le terminal),
540
+ on ne pousse en sortie que du texte jamais encore affiché, mot par mot.
541
+ """
542
+
543
+ def __init__(self, indent=" "):
544
+ self.indent = indent
545
+ self.width = max(term_width() - len(indent), 30)
546
+ self.line_len = 0
547
+ self.buffer = ""
548
+ self.started = False
549
+
550
+ def _flush_word(self, word):
551
+ if not self.started:
552
+ sys.stdout.write("\n" + self.indent)
553
+ self.started = True
554
+ if self.line_len > 0 and self.line_len + 1 + len(word) > self.width:
555
+ sys.stdout.write("\n" + self.indent)
556
+ self.line_len = 0
557
+ elif self.line_len > 0:
558
+ sys.stdout.write(" ")
559
+ self.line_len += 1
560
+ sys.stdout.write(word)
561
+ self.line_len += len(word)
562
+ sys.stdout.flush()
563
+
564
+ def feed(self, chunk):
565
+ self.buffer += chunk
566
+ while True:
567
+ idx = next((i for i, c in enumerate(self.buffer) if c in " \n"), None)
568
+ if idx is None:
569
+ break
570
+ word, sep = self.buffer[:idx], self.buffer[idx]
571
+ if word:
572
+ self._flush_word(word)
573
+ if sep == "\n":
574
+ if not self.started:
575
+ sys.stdout.write("\n" + self.indent)
576
+ self.started = True
577
+ sys.stdout.write("\n" + self.indent)
578
+ self.line_len = 0
579
+ sys.stdout.flush()
580
+ self.buffer = self.buffer[idx + 1:]
581
+
582
+ def close(self):
583
+ if self.buffer:
584
+ self._flush_word(self.buffer)
585
+ self.buffer = ""
586
+ print()
464
587
 
465
588
 
466
589
  def run_chat(client: OpsiomClient, url, api_key):
@@ -519,8 +642,14 @@ def run_chat(client: OpsiomClient, url, api_key):
519
642
  elif cmd == "status":
520
643
  print_header(url, connected, api_key, models_loaded, current_model, show_mark=False)
521
644
  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.')}")
645
+ state = get_quota_state(api_key)
646
+ if state["plan"]:
647
+ print(f" {muted('forfait :')} {accent(plan_label(state['plan']))}")
648
+ if state["unlimited"]:
649
+ print(f" {muted('quota illimité.')}")
650
+ else:
651
+ u, lim, rem = state["used"], state["limit"], state["remaining"]
652
+ print(f" {muted(f'{u}/{lim} tokens utilisés aujourd’hui — {rem} restants.')}")
524
653
  elif cmd == "save":
525
654
  if not transcript:
526
655
  print(muted(" rien à enregistrer pour l'instant."))
@@ -545,11 +674,12 @@ def run_chat(client: OpsiomClient, url, api_key):
545
674
  print()
546
675
  continue
547
676
 
548
- _, remaining_quota = get_quota_state(api_key)
549
- if remaining_quota <= 0:
677
+ state = get_quota_state(api_key)
678
+ if not state["unlimited"] and state["remaining"] <= 0:
679
+ plan_txt = f" ({plan_label(state['plan'])})" if state["plan"] else ""
550
680
  print_error(
551
- f"quota quotidien de {DAILY_TOKEN_QUOTA} tokens atteint.",
552
- "réessaie demain, ou utilise une autre clé API avec --configure",
681
+ f"quota quotidien de {state['limit']} tokens atteint{plan_txt}.",
682
+ "réessaie demain, ou débloque un forfait supérieur via les missions Opsiom",
553
683
  )
554
684
  print()
555
685
  continue
@@ -559,38 +689,83 @@ def run_chat(client: OpsiomClient, url, api_key):
559
689
  spinner = Spinner(label="Réflexion")
560
690
  spinner.start()
561
691
  start_time = time.perf_counter()
692
+ printer = StreamPrinter()
693
+ response_parts = []
694
+ final_event = {}
695
+ spinner_running = True
562
696
  try:
563
- result = client.chat(user_input, model=current_model)
697
+ for piece in client.chat_stream(user_input, model=current_model):
698
+ if isinstance(piece, dict):
699
+ final_event = piece
700
+ break
701
+ if spinner_running:
702
+ spinner.stop()
703
+ spinner_running = False
704
+ response_parts.append(piece)
705
+ printer.feed(piece)
564
706
  except KeyboardInterrupt:
565
- spinner.stop()
566
- print(muted(" (requête annulée) "))
707
+ if spinner_running:
708
+ spinner.stop()
709
+ print(muted("\n (requête annulée) "))
567
710
  continue
568
711
  except requests.HTTPError as e:
569
- spinner.stop()
570
- if e.response is not None and e.response.status_code in (401, 403):
712
+ if spinner_running:
713
+ spinner.stop()
714
+ status = e.response.status_code if e.response is not None else None
715
+ detail = None
716
+ try:
717
+ detail = e.response.json().get("error")
718
+ except (ValueError, AttributeError):
719
+ pass
720
+ if status == 403 and detail and "modèle" in detail.lower():
721
+ # Le forfait du compte n'inclut pas ce modèle (ex. Free : pas de Large).
722
+ print_error(detail, "choisis un autre modèle avec /model, ou débloque un forfait supérieur")
723
+ elif status in (401, 403):
571
724
  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")
725
+ elif status == 429:
726
+ print_error("quota dépassé côté serveur.", detail or "réessaie plus tard")
574
727
  else:
575
728
  print_error("la requête a échoué ", str(e))
576
729
  print()
577
730
  continue
578
731
  except requests.RequestException as e:
579
- spinner.stop()
732
+ if spinner_running:
733
+ spinner.stop()
580
734
  print_error("la requête a échoué ", str(e))
581
735
  print()
582
736
  continue
583
- spinner.stop()
737
+
738
+ if spinner_running:
739
+ spinner.stop() # réponse vide reçue sans le moindre fragment
740
+ printer.close()
584
741
  elapsed_ms = int((time.perf_counter() - start_time) * 1000)
585
742
 
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)
743
+ response_text = "".join(response_parts) or "(réponse vide)"
744
+ used_model = final_event.get("model", current_model or "?")
745
+ print(f" {muted(format_elapsed(elapsed_ms))}")
589
746
 
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')}")
747
+ remaining_quota, limit, plan, unlimited = parse_quota_event(final_event)
748
+ if remaining_quota is not None or unlimited:
749
+ record_authoritative_quota(api_key, remaining_quota, limit, plan, unlimited)
750
+ else:
751
+ # Le serveur n'a pas renvoyé de quota faisant autorité (Octix
752
+ # injoignable côté serveur, p. ex.) : on retombe sur l'estimation
753
+ # locale plutôt que de ne rien afficher.
754
+ estimated = estimate_tokens(user_input) + estimate_tokens(response_text)
755
+ usage = load_usage()
756
+ key_id = _api_key_id(api_key)
757
+ entry = usage.get(key_id, {})
758
+ if entry.get("date") != today_str():
759
+ entry = {**entry, "date": today_str(), "tokens_used": 0}
760
+ entry["tokens_used"] = entry.get("tokens_used", 0) + estimated
761
+ usage[key_id] = entry
762
+ save_usage(usage)
763
+ state = get_quota_state(api_key)
764
+ if state["unlimited"]:
765
+ print(f" {muted('quota illimité')}")
766
+ else:
767
+ rem = state["remaining"]
768
+ print(f" {muted(f'{rem} tokens restants aujourd’hui')}")
594
769
  print()
595
770
  transcript.append({"role": "assistant", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
596
771
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opsiom-cli
3
- Version: 1.2.4
3
+ Version: 1.3.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
File without changes
File without changes