opsiom-cli 1.2.5__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.5
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.5"
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"]
@@ -39,12 +39,18 @@ try:
39
39
  except ImportError:
40
40
  readline = None # absent par défaut sur certains Windows (pyreadline3 comble le manque)
41
41
 
42
- VERSION = "1.2.5"
42
+ VERSION = "1.3.0"
43
43
  CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
44
44
  HISTORY_PATH = Path.home() / ".local" / "share" / "opsiom" / "history"
45
45
  TRANSCRIPTS_DIR = Path.home() / "opsiom-conversations"
46
46
  USAGE_PATH = Path.home() / ".config" / "opsiom" / "usage.json"
47
- 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"}
48
54
 
49
55
 
50
56
  # ----------------------------------------------------------------------------
@@ -141,9 +147,11 @@ def print_header(url, connected, api_key, models_loaded, current_model, show_mar
141
147
  print(f" {status_symbol} {muted('connecté à')} {url}")
142
148
  print(f" {muted('auth')} {muted('clé API' if api_key else 'aucune')}")
143
149
  if api_key:
144
- used, remaining = get_quota_state(api_key)
145
- quota_color = error_text if remaining <= 0 else muted
146
- 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))}")
147
155
  if models_loaded:
148
156
  print(f" {muted('modèles')} {' · '.join(models_loaded)}")
149
157
  if current_model:
@@ -280,13 +288,13 @@ def resolve_settings(args):
280
288
 
281
289
 
282
290
  # ----------------------------------------------------------------------------
283
- # Quota — 500 tokens/jour par clé API. Le fichier usage.json ne sert plus
284
- # qu'à AFFICHER un dernier quota connu avant la toute première requête de la
285
- # session (utile pour /quota juste après le lancement) : dès qu'une réponse
286
- # arrive, c'est le "remaining_quota" renvoyé par le serveur (lui-même décompté
287
- # côté Octix) qui fait foi et remplace la valeur locale. Un utilisateur qui
288
- # modifierait usage.json à la main ne gagnerait donc plus rien : le serveur
289
- # refuse désormais les requêtes une fois son propre compteur à zéro (429).
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).
290
298
  # ----------------------------------------------------------------------------
291
299
 
292
300
  def _api_key_id(api_key):
@@ -309,6 +317,10 @@ def estimate_tokens(text):
309
317
  return max(1, round(len(text) / 4))
310
318
 
311
319
 
320
+ def plan_label(plan_id):
321
+ return PLAN_LABELS.get(plan_id, str(plan_id))
322
+
323
+
312
324
  def load_usage():
313
325
  if USAGE_PATH.exists():
314
326
  try:
@@ -324,28 +336,61 @@ def save_usage(usage):
324
336
 
325
337
 
326
338
  def get_quota_state(api_key):
327
- """(tokens utilisés aujourd'hui, tokens restants). Le compteur se
328
- réinitialise tout seul dès que la date enregistrée n'est plus celle
329
- 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."""
330
343
  entry = load_usage().get(_api_key_id(api_key), {})
331
- if entry.get("date") != today_str():
332
- return 0, DAILY_TOKEN_QUOTA
333
- used = entry.get("tokens_used", 0)
334
- return used, max(0, DAILY_TOKEN_QUOTA - used)
335
-
336
-
337
- def record_authoritative_remaining(api_key, remaining):
338
- """Remplace le compteur local par le quota restant renvoyé par le
339
- serveur (lui-même décompté côté Octix) — c'est la seule valeur qui
340
- compte désormais, le fichier local n'est qu'un cache d'affichage."""
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")
341
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:
342
383
  return
343
384
  usage = load_usage()
344
385
  key_id = _api_key_id(api_key)
345
- usage[key_id] = {
346
- "date": today_str(),
347
- "tokens_used": max(0, DAILY_TOKEN_QUOTA - remaining),
348
- }
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")}
349
394
  save_usage(usage)
350
395
 
351
396
 
@@ -597,8 +642,14 @@ def run_chat(client: OpsiomClient, url, api_key):
597
642
  elif cmd == "status":
598
643
  print_header(url, connected, api_key, models_loaded, current_model, show_mark=False)
599
644
  elif cmd == "quota":
600
- used, remaining = get_quota_state(api_key)
601
- 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.')}")
602
653
  elif cmd == "save":
603
654
  if not transcript:
604
655
  print(muted(" rien à enregistrer pour l'instant."))
@@ -623,11 +674,12 @@ def run_chat(client: OpsiomClient, url, api_key):
623
674
  print()
624
675
  continue
625
676
 
626
- _, remaining_quota = get_quota_state(api_key)
627
- 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 ""
628
680
  print_error(
629
- f"quota quotidien de {DAILY_TOKEN_QUOTA} tokens atteint.",
630
- "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",
631
683
  )
632
684
  print()
633
685
  continue
@@ -659,14 +711,18 @@ def run_chat(client: OpsiomClient, url, api_key):
659
711
  except requests.HTTPError as e:
660
712
  if spinner_running:
661
713
  spinner.stop()
662
- if e.response is not None and e.response.status_code in (401, 403):
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):
663
724
  print_error("clé API invalide ou expirée.", "reconfigure avec : opsiom --configure")
664
- elif e.response is not None and e.response.status_code == 429:
665
- detail = None
666
- try:
667
- detail = e.response.json().get("error")
668
- except (ValueError, AttributeError):
669
- pass
725
+ elif status == 429:
670
726
  print_error("quota dépassé côté serveur.", detail or "réessaie plus tard")
671
727
  else:
672
728
  print_error("la requête a échoué ", str(e))
@@ -688,9 +744,9 @@ def run_chat(client: OpsiomClient, url, api_key):
688
744
  used_model = final_event.get("model", current_model or "?")
689
745
  print(f" {muted(format_elapsed(elapsed_ms))}")
690
746
 
691
- remaining_quota = final_event.get("remaining_quota")
692
- if remaining_quota is not None:
693
- record_authoritative_remaining(api_key, remaining_quota)
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)
694
750
  else:
695
751
  # Le serveur n'a pas renvoyé de quota faisant autorité (Octix
696
752
  # injoignable côté serveur, p. ex.) : on retombe sur l'estimation
@@ -700,12 +756,16 @@ def run_chat(client: OpsiomClient, url, api_key):
700
756
  key_id = _api_key_id(api_key)
701
757
  entry = usage.get(key_id, {})
702
758
  if entry.get("date") != today_str():
703
- entry = {"date": today_str(), "tokens_used": 0}
759
+ entry = {**entry, "date": today_str(), "tokens_used": 0}
704
760
  entry["tokens_used"] = entry.get("tokens_used", 0) + estimated
705
761
  usage[key_id] = entry
706
762
  save_usage(usage)
707
- _, remaining_quota = get_quota_state(api_key)
708
- print(f" {muted(f'{remaining_quota} tokens restants aujourd’hui')}")
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')}")
709
769
  print()
710
770
  transcript.append({"role": "assistant", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
711
771
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opsiom-cli
3
- Version: 1.2.5
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