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.
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/PKG-INFO +1 -1
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/pyproject.toml +2 -2
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/src/opsiom_cli/cli.py +108 -48
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/PKG-INFO +1 -1
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/README.md +0 -0
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/setup.cfg +0 -0
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/src/opsiom_cli/__init__.py +0 -0
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/SOURCES.txt +0 -0
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/dependency_links.txt +0 -0
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/entry_points.txt +0 -0
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/requires.txt +0 -0
- {opsiom_cli-1.2.5 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opsiom-cli"
|
|
7
|
-
version = "1.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
quota_color = error_text if remaining <= 0 else muted
|
|
146
|
-
|
|
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 —
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
# refuse
|
|
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
|
-
"""
|
|
328
|
-
réinitialise tout seul dès que la date
|
|
329
|
-
du jour
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
"tokens_used":
|
|
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
|
-
|
|
601
|
-
|
|
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
|
-
|
|
627
|
-
if
|
|
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 {
|
|
630
|
-
"réessaie demain, ou
|
|
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
|
|
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
|
|
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
|
|
692
|
-
if remaining_quota is not None:
|
|
693
|
-
|
|
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
|
-
|
|
708
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|