opsiom-cli 1.5.0__tar.gz → 1.5.2__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.5.0 → opsiom_cli-1.5.2}/CHANGELOG.md +1 -1
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/PKG-INFO +1 -1
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/pyproject.toml +1 -1
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/clients.py +74 -15
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/tests/test_core.py +12 -0
- opsiom_cli-1.5.2/tests/test_http.py +176 -0
- opsiom_cli-1.5.0/.env +0 -1
- opsiom_cli-1.5.0/tests/test_http.py +0 -130
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/README.md +0 -0
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/__init__.py +0 -0
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/__main__.py +0 -0
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/chat.py +0 -0
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/cli.py +0 -0
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/storage.py +0 -0
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/test.txt +0 -0
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/test_quoted.txt +0 -0
- {opsiom_cli-1.5.0 → opsiom_cli-1.5.2}/src/opsiom_cli/ui.py +0 -0
|
@@ -9,7 +9,9 @@ Les deux clients exposent la même interface :
|
|
|
9
9
|
|
|
10
10
|
import html
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
12
13
|
import re
|
|
14
|
+
import sys
|
|
13
15
|
|
|
14
16
|
import requests
|
|
15
17
|
|
|
@@ -62,6 +64,38 @@ def error_detail(exc):
|
|
|
62
64
|
return None
|
|
63
65
|
|
|
64
66
|
|
|
67
|
+
# --- Formulaire de connexion -------------------------------------------------
|
|
68
|
+
|
|
69
|
+
_INPUT_RE = re.compile(r"<input\b[^>]*>", re.IGNORECASE)
|
|
70
|
+
_ATTR_RE = re.compile(r"""([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_login_form(page_html):
|
|
74
|
+
"""Repère dans la page de connexion :
|
|
75
|
+
- les champs cachés (jeton CSRF, etc.), à renvoyer tels quels ;
|
|
76
|
+
- le nom du champ pseudo et celui du champ mot de passe.
|
|
77
|
+
Ne lit jamais de valeur saisie par l'utilisateur."""
|
|
78
|
+
hidden, user_field, pass_field = {}, None, None
|
|
79
|
+
for tag in _INPUT_RE.findall(page_html or ""):
|
|
80
|
+
attrs = {key.lower(): (dq or sq) for key, dq, sq in _ATTR_RE.findall(tag)}
|
|
81
|
+
name = attrs.get("name")
|
|
82
|
+
if not name:
|
|
83
|
+
continue
|
|
84
|
+
kind = attrs.get("type", "text").lower()
|
|
85
|
+
if kind == "hidden":
|
|
86
|
+
hidden[name] = attrs.get("value", "")
|
|
87
|
+
elif kind == "password":
|
|
88
|
+
pass_field = pass_field or name
|
|
89
|
+
elif kind in ("text", "email") and user_field is None:
|
|
90
|
+
user_field = name
|
|
91
|
+
return {"hidden": hidden, "username": user_field, "password": pass_field}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _debug(message):
|
|
95
|
+
if os.environ.get("OPSIOM_DEBUG"):
|
|
96
|
+
print(f"[debug] {message}", file=sys.stderr)
|
|
97
|
+
|
|
98
|
+
|
|
65
99
|
class OpsiomClient:
|
|
66
100
|
"""Serveur d'inférence classique : routes /api/*, clé API (X-API-Key)."""
|
|
67
101
|
|
|
@@ -104,8 +138,9 @@ class OpsiomClient:
|
|
|
104
138
|
|
|
105
139
|
|
|
106
140
|
class HFSpaceClient:
|
|
107
|
-
"""Space Hugging Face qui héberge Opsiom. Authentification par compte Octix
|
|
108
|
-
|
|
141
|
+
"""Space Hugging Face qui héberge Opsiom. Authentification par compte Octix :
|
|
142
|
+
le CLI se comporte comme un navigateur (page /login, puis formulaire).
|
|
143
|
+
Historique et quota vivent côté Space."""
|
|
109
144
|
|
|
110
145
|
backend = "hf"
|
|
111
146
|
tracks_quota = True
|
|
@@ -117,7 +152,7 @@ class HFSpaceClient:
|
|
|
117
152
|
self.username = username
|
|
118
153
|
self.identity = account_identity(self.base_url, username)
|
|
119
154
|
self.conversation_id = None
|
|
120
|
-
self.session = requests.Session()
|
|
155
|
+
self.session = requests.Session() # garde le cookie de session entre les requêtes
|
|
121
156
|
self.session.headers["User-Agent"] = "opsiom-cli"
|
|
122
157
|
if hf_token: # Space privé
|
|
123
158
|
self.session.headers["Authorization"] = f"Bearer {hf_token}"
|
|
@@ -132,30 +167,54 @@ class HFSpaceClient:
|
|
|
132
167
|
|
|
133
168
|
def login(self, username, password):
|
|
134
169
|
"""Ouvre la session du Space. Renvoie (True, None) ou (False, message)."""
|
|
170
|
+
# Les caractères de contrôle ne peuvent pas faire partie d'un mot de passe saisi
|
|
171
|
+
# au clavier ; sous Windows, un Ctrl+V dans getpass les insère pourtant tels quels.
|
|
172
|
+
removed = len(re.findall(r"[\x00-\x1f\x7f]", password))
|
|
173
|
+
password = re.sub(r"[\x00-\x1f\x7f]", "", password)
|
|
174
|
+
_debug(f"pseudo envoyé : {username!r} ; mot de passe : {len(password)} caractère(s)"
|
|
175
|
+
+ (f", {removed} caractère(s) de contrôle supprimé(s)" if removed else ""))
|
|
176
|
+
|
|
177
|
+
# 1. Charger la page de connexion : cookie de session + champs du formulaire.
|
|
178
|
+
try:
|
|
179
|
+
page = self.session.get(f"{self.base_url}/login", timeout=90)
|
|
180
|
+
except requests.RequestException as exc:
|
|
181
|
+
return False, f"Space injoignable ({exc.__class__.__name__})."
|
|
182
|
+
form = parse_login_form(page.text if page.ok else "")
|
|
183
|
+
_debug(f"GET /login -> HTTP {page.status_code}, champs cachés : {sorted(form['hidden'])}, "
|
|
184
|
+
f"champ pseudo : {form['username']!r}, champ mot de passe : {form['password']!r}")
|
|
185
|
+
|
|
186
|
+
# 2. Soumettre le formulaire avec les champs cachés (jeton CSRF).
|
|
187
|
+
data = dict(form["hidden"])
|
|
188
|
+
data[form["username"] or "username"] = username
|
|
189
|
+
data[form["password"] or "password"] = password
|
|
135
190
|
try:
|
|
136
|
-
r = self.session.post(
|
|
137
|
-
|
|
138
|
-
data={"username": username, "password": password},
|
|
139
|
-
timeout=90, # un Space endormi met un moment à se réveiller
|
|
140
|
-
allow_redirects=False,
|
|
141
|
-
)
|
|
191
|
+
r = self.session.post(f"{self.base_url}/login", data=data, timeout=90,
|
|
192
|
+
allow_redirects=False)
|
|
142
193
|
except requests.RequestException as exc:
|
|
143
194
|
return False, f"Space injoignable ({exc.__class__.__name__})."
|
|
144
195
|
location = r.headers.get("Location", "")
|
|
145
|
-
|
|
146
|
-
|
|
196
|
+
_debug(f"POST /login -> HTTP {r.status_code}, Location : {location or '—'}")
|
|
197
|
+
|
|
198
|
+
# 3. Interpréter la réponse.
|
|
147
199
|
if r.status_code in (301, 302, 303, 307, 308) and "/login" not in location:
|
|
148
|
-
self.username
|
|
149
|
-
self.identity = account_identity(self.base_url, username)
|
|
150
|
-
return True, None
|
|
200
|
+
return self._accept(username)
|
|
151
201
|
flash = re.search(r'<li class="flash[^"]*">(.*?)</li>', r.text or "", re.S)
|
|
152
202
|
if flash:
|
|
153
203
|
return False, html.unescape(re.sub(r"<[^>]+>", "", flash.group(1))).strip()
|
|
204
|
+
if r.status_code == 200 and parse_login_form(r.text)["password"] is None:
|
|
205
|
+
return self._accept(username) # connecté sans redirection
|
|
154
206
|
if r.status_code in (400, 401, 403):
|
|
155
|
-
return False, "pseudo ou mot de passe
|
|
207
|
+
return False, (f"pseudo ou mot de passe refusé (HTTP {r.status_code}). "
|
|
208
|
+
"Si le mot de passe est bon, lancez « OPSIOM_DEBUG=1 opsiom » "
|
|
209
|
+
"pour voir ce que demande le formulaire.")
|
|
156
210
|
return False, (f"réponse inattendue (HTTP {r.status_code}). Le Space démarre peut-être : "
|
|
157
211
|
"réessayez dans une minute.")
|
|
158
212
|
|
|
213
|
+
def _accept(self, username):
|
|
214
|
+
self.username = username
|
|
215
|
+
self.identity = account_identity(self.base_url, username)
|
|
216
|
+
return True, None
|
|
217
|
+
|
|
159
218
|
def health(self):
|
|
160
219
|
r = self.session.get(f"{self.base_url}/status", timeout=30)
|
|
161
220
|
r.raise_for_status()
|
|
@@ -164,3 +164,15 @@ def test_stream_printer_keeps_indentation(capsys):
|
|
|
164
164
|
assert "\n" + " " * 6 + "def f():" in out
|
|
165
165
|
assert "\n" + " " * 10 + "pass" in out
|
|
166
166
|
assert text == "Voici :\n def f():\n pass"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_parse_login_form_finds_hidden_fields_and_names():
|
|
170
|
+
from opsiom_cli.clients import parse_login_form
|
|
171
|
+
|
|
172
|
+
page = (
|
|
173
|
+
'<form><input type="hidden" name="csrf_token" value="abc123">'
|
|
174
|
+
"<input name='pseudo' type='text'>"
|
|
175
|
+
'<input type="password" name="mdp"></form>'
|
|
176
|
+
)
|
|
177
|
+
form = parse_login_form(page)
|
|
178
|
+
assert form == {"hidden": {"csrf_token": "abc123"}, "username": "pseudo", "password": "mdp"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Tests de bout en bout des clients contre des faux serveurs HTTP locaux."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from urllib.parse import parse_qs
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from opsiom_cli.chat import explain_error
|
|
12
|
+
from opsiom_cli.clients import HFSpaceClient, OpsiomClient
|
|
13
|
+
|
|
14
|
+
API_KEY = "secret"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def no_proxy(monkeypatch):
|
|
19
|
+
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
|
|
20
|
+
monkeypatch.setenv("no_proxy", "127.0.0.1,localhost")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _serve(handler):
|
|
24
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
25
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
26
|
+
return httpd
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- Faux serveur Opsiom (API) ----------------------------------------------
|
|
30
|
+
|
|
31
|
+
class FakeOpsiom(BaseHTTPRequestHandler):
|
|
32
|
+
def log_message(self, *args):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def _json(self, status, body):
|
|
36
|
+
data = json.dumps(body).encode("utf-8")
|
|
37
|
+
self.send_response(status)
|
|
38
|
+
self.send_header("Content-Type", "application/json")
|
|
39
|
+
self.send_header("Content-Length", str(len(data)))
|
|
40
|
+
self.end_headers()
|
|
41
|
+
self.wfile.write(data)
|
|
42
|
+
|
|
43
|
+
def do_GET(self):
|
|
44
|
+
if self.path == "/api/health":
|
|
45
|
+
self._json(200, {"status": "ok", "models_loaded": ["m1"], "quota": {"remaining": 490}})
|
|
46
|
+
elif self.path == "/api/models":
|
|
47
|
+
self._json(200, {"models": [{"id": "m1", "label": "Modèle 1", "params": "7B"}],
|
|
48
|
+
"default": "m1"})
|
|
49
|
+
else:
|
|
50
|
+
self._json(404, {"error": "introuvable"})
|
|
51
|
+
|
|
52
|
+
def do_POST(self):
|
|
53
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
54
|
+
self.rfile.read(length)
|
|
55
|
+
if self.headers.get("X-API-Key") != API_KEY:
|
|
56
|
+
self._json(401, {"error": "clé invalide"})
|
|
57
|
+
return
|
|
58
|
+
if self.path != "/api/chat/stream":
|
|
59
|
+
self._json(404, {"error": "introuvable"})
|
|
60
|
+
return
|
|
61
|
+
self.send_response(200)
|
|
62
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
63
|
+
self.end_headers()
|
|
64
|
+
events = [{"token": "Bon"}, {"token": "jour"},
|
|
65
|
+
{"done": True, "model": "m1", "remaining_quota": 489}]
|
|
66
|
+
for event in events:
|
|
67
|
+
self.wfile.write(f"data: {json.dumps(event)}\n\n".encode("utf-8"))
|
|
68
|
+
self.wfile.flush()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture
|
|
72
|
+
def opsiom_server():
|
|
73
|
+
httpd = _serve(FakeOpsiom)
|
|
74
|
+
yield f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
75
|
+
httpd.shutdown()
|
|
76
|
+
httpd.server_close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_health_models_and_stream(opsiom_server):
|
|
80
|
+
client = OpsiomClient(opsiom_server, api_key=API_KEY)
|
|
81
|
+
assert client.health()["status"] == "ok"
|
|
82
|
+
assert client.list_models()["default"] == "m1"
|
|
83
|
+
pieces = list(client.chat_stream("Bonjour ?", model="m1"))
|
|
84
|
+
assert "".join(p for p in pieces if isinstance(p, str)) == "Bonjour"
|
|
85
|
+
assert pieces[-1]["remaining_quota"] == 489
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_wrong_key_is_explained(opsiom_server):
|
|
89
|
+
client = OpsiomClient(opsiom_server, api_key="mauvaise")
|
|
90
|
+
with pytest.raises(requests.HTTPError) as info:
|
|
91
|
+
list(client.chat_stream("x"))
|
|
92
|
+
msg, _hint = explain_error(info.value, client)
|
|
93
|
+
assert "clé API" in msg
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- Faux Space Hugging Face : formulaire avec cookie + jeton CSRF -----------
|
|
97
|
+
|
|
98
|
+
LOGIN_PAGE = (
|
|
99
|
+
'<html><body><form method="post" action="/login">'
|
|
100
|
+
'<input type="hidden" name="csrf_token" value="tok123">'
|
|
101
|
+
'<input type="text" name="identifiant">'
|
|
102
|
+
'<input type="password" name="secret">'
|
|
103
|
+
'<button>Connexion</button></form></body></html>'
|
|
104
|
+
).encode("utf-8")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class FakeSpace(BaseHTTPRequestHandler):
|
|
108
|
+
"""Comme un Flask-WTF : le POST n'est accepté qu'avec le cookie de session
|
|
109
|
+
obtenu sur GET /login et le bon jeton CSRF. Champs : identifiant / secret."""
|
|
110
|
+
|
|
111
|
+
def log_message(self, *args):
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def _send(self, status, body=b"", headers=()):
|
|
115
|
+
self.send_response(status)
|
|
116
|
+
for key, value in headers:
|
|
117
|
+
self.send_header(key, value)
|
|
118
|
+
self.send_header("Content-Length", str(len(body)))
|
|
119
|
+
self.end_headers()
|
|
120
|
+
self.wfile.write(body)
|
|
121
|
+
|
|
122
|
+
def do_GET(self):
|
|
123
|
+
if self.path == "/login":
|
|
124
|
+
self._send(200, LOGIN_PAGE, [("Content-Type", "text/html; charset=utf-8"),
|
|
125
|
+
("Set-Cookie", "session=abc; Path=/")])
|
|
126
|
+
else:
|
|
127
|
+
self._send(404)
|
|
128
|
+
|
|
129
|
+
def do_POST(self):
|
|
130
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
131
|
+
form = parse_qs(self.rfile.read(length).decode("utf-8"))
|
|
132
|
+
if "session=abc" not in self.headers.get("Cookie", "") or form.get("csrf_token") != ["tok123"]:
|
|
133
|
+
self._send(400, b"The CSRF token is missing.")
|
|
134
|
+
return
|
|
135
|
+
if form.get("identifiant") == ["alice"] and form.get("secret") == ["bon"]:
|
|
136
|
+
self._send(302, headers=[("Location", "/")])
|
|
137
|
+
else:
|
|
138
|
+
page = LOGIN_PAGE.replace(b"<button>", b'<ul><li class="flash error">Mot de passe incorrect</li></ul><button>')
|
|
139
|
+
self._send(200, page, [("Content-Type", "text/html; charset=utf-8")])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.fixture
|
|
143
|
+
def space_server():
|
|
144
|
+
httpd = _serve(FakeSpace)
|
|
145
|
+
yield f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
146
|
+
httpd.shutdown()
|
|
147
|
+
httpd.server_close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_direct_post_without_page_is_refused(space_server):
|
|
151
|
+
"""Reproduit l'ancien comportement : POST sans passer par /login."""
|
|
152
|
+
r = requests.post(f"{space_server}/login", data={"identifiant": "alice", "secret": "bon"},
|
|
153
|
+
allow_redirects=False)
|
|
154
|
+
assert r.status_code == 400
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_octix_login_with_csrf_and_custom_field_names(space_server):
|
|
158
|
+
client = HFSpaceClient(space_server)
|
|
159
|
+
ok, message = client.login("alice", "bon")
|
|
160
|
+
assert ok is True and message is None
|
|
161
|
+
assert client.username == "alice"
|
|
162
|
+
assert client.identity.startswith("hf-")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_octix_wrong_password_shows_site_message(space_server):
|
|
166
|
+
client = HFSpaceClient(space_server)
|
|
167
|
+
ok, message = client.login("alice", "mauvais")
|
|
168
|
+
assert ok is False
|
|
169
|
+
assert "incorrect" in message
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_control_characters_are_removed_from_password(space_server):
|
|
173
|
+
"""Un Ctrl+V sous Windows peut insérer \x16 dans le mot de passe."""
|
|
174
|
+
client = HFSpaceClient(space_server)
|
|
175
|
+
ok, _ = client.login("alice", "bon\x16")
|
|
176
|
+
assert ok is True
|
opsiom_cli-1.5.0/.env
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
TOKEN_PYPI=pypi-AgEIcHlwaS5vcmcCJDI3YTY0YTk5LTExZDktNDFlOS04ZGE4LTU1MWFmNDcwZTg1MAACKlszLCJjYzExY2QyYy00ODg4LTRlOTItOTNmYy00NTdhMDhlMWQ5NjQiXQAABiDBNDAJrhWz9Rt11yQPXO1klHHZCcHTEsImiD4G6FOTWA
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
"""Tests de bout en bout des clients contre un faux serveur HTTP local."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import threading
|
|
5
|
-
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
import requests
|
|
9
|
-
|
|
10
|
-
from opsiom_cli.chat import explain_error
|
|
11
|
-
from opsiom_cli.clients import OpsiomClient
|
|
12
|
-
|
|
13
|
-
API_KEY = "secret"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class FakeOpsiom(BaseHTTPRequestHandler):
|
|
17
|
-
def log_message(self, *args): # silence les logs du serveur de test
|
|
18
|
-
pass
|
|
19
|
-
|
|
20
|
-
def _json(self, status, body):
|
|
21
|
-
data = json.dumps(body).encode("utf-8")
|
|
22
|
-
self.send_response(status)
|
|
23
|
-
self.send_header("Content-Type", "application/json")
|
|
24
|
-
self.send_header("Content-Length", str(len(data)))
|
|
25
|
-
self.end_headers()
|
|
26
|
-
self.wfile.write(data)
|
|
27
|
-
|
|
28
|
-
def do_GET(self):
|
|
29
|
-
if self.path == "/api/health":
|
|
30
|
-
self._json(200, {"status": "ok", "models_loaded": ["m1"],
|
|
31
|
-
"quota": {"remaining": 490}})
|
|
32
|
-
elif self.path == "/api/models":
|
|
33
|
-
self._json(200, {"models": [{"id": "m1", "label": "Modèle 1", "params": "7B"}],
|
|
34
|
-
"default": "m1"})
|
|
35
|
-
else:
|
|
36
|
-
self._json(404, {"error": "introuvable"})
|
|
37
|
-
|
|
38
|
-
def do_POST(self):
|
|
39
|
-
length = int(self.headers.get("Content-Length", 0))
|
|
40
|
-
self.rfile.read(length)
|
|
41
|
-
if self.headers.get("X-API-Key") != API_KEY:
|
|
42
|
-
self._json(401, {"error": "clé invalide"})
|
|
43
|
-
return
|
|
44
|
-
if self.path != "/api/chat/stream":
|
|
45
|
-
self._json(404, {"error": "introuvable"})
|
|
46
|
-
return
|
|
47
|
-
self.send_response(200)
|
|
48
|
-
self.send_header("Content-Type", "text/event-stream")
|
|
49
|
-
self.end_headers()
|
|
50
|
-
events = [
|
|
51
|
-
{"token": "Bon"},
|
|
52
|
-
{"token": "jour"},
|
|
53
|
-
{"done": True, "model": "m1", "remaining_quota": 489},
|
|
54
|
-
]
|
|
55
|
-
for event in events:
|
|
56
|
-
self.wfile.write(f"data: {json.dumps(event)}\n\n".encode("utf-8"))
|
|
57
|
-
self.wfile.flush()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@pytest.fixture
|
|
61
|
-
def server(monkeypatch):
|
|
62
|
-
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
|
|
63
|
-
monkeypatch.setenv("no_proxy", "127.0.0.1,localhost")
|
|
64
|
-
httpd = ThreadingHTTPServer(("127.0.0.1", 0), FakeOpsiom)
|
|
65
|
-
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
66
|
-
thread.start()
|
|
67
|
-
yield f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
68
|
-
httpd.shutdown()
|
|
69
|
-
httpd.server_close()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def test_health_models_and_stream(server):
|
|
73
|
-
client = OpsiomClient(server, api_key=API_KEY)
|
|
74
|
-
assert client.health()["status"] == "ok"
|
|
75
|
-
assert client.list_models()["default"] == "m1"
|
|
76
|
-
pieces = list(client.chat_stream("Bonjour ?", model="m1"))
|
|
77
|
-
text = "".join(p for p in pieces if isinstance(p, str))
|
|
78
|
-
assert text == "Bonjour"
|
|
79
|
-
assert pieces[-1]["remaining_quota"] == 489
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_wrong_key_is_explained(server):
|
|
83
|
-
client = OpsiomClient(server, api_key="mauvaise")
|
|
84
|
-
with pytest.raises(requests.HTTPError) as info:
|
|
85
|
-
list(client.chat_stream("x"))
|
|
86
|
-
msg, _hint = explain_error(info.value, client)
|
|
87
|
-
assert "clé API" in msg
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
class FakeSpace(BaseHTTPRequestHandler):
|
|
91
|
-
"""Space Hugging Face minimal : /login redirige vers / si le mot de passe est bon,
|
|
92
|
-
sinon vers /login (comportement courant d'un formulaire Flask/Gradio)."""
|
|
93
|
-
|
|
94
|
-
def log_message(self, *args):
|
|
95
|
-
pass
|
|
96
|
-
|
|
97
|
-
def do_POST(self):
|
|
98
|
-
length = int(self.headers.get("Content-Length", 0))
|
|
99
|
-
body = self.rfile.read(length).decode("utf-8")
|
|
100
|
-
if self.path == "/login" and "password=bon" in body:
|
|
101
|
-
self.send_response(302)
|
|
102
|
-
self.send_header("Location", "/")
|
|
103
|
-
else:
|
|
104
|
-
self.send_response(302)
|
|
105
|
-
self.send_header("Location", "/login")
|
|
106
|
-
self.send_header("Content-Length", "0")
|
|
107
|
-
self.end_headers()
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@pytest.fixture
|
|
111
|
-
def space(monkeypatch):
|
|
112
|
-
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
|
|
113
|
-
monkeypatch.setenv("no_proxy", "127.0.0.1,localhost")
|
|
114
|
-
httpd = ThreadingHTTPServer(("127.0.0.1", 0), FakeSpace)
|
|
115
|
-
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
116
|
-
yield f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
117
|
-
httpd.shutdown()
|
|
118
|
-
httpd.server_close()
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def test_octix_login_success_and_failure(space):
|
|
122
|
-
from opsiom_cli.clients import HFSpaceClient
|
|
123
|
-
|
|
124
|
-
client = HFSpaceClient(space, username=None)
|
|
125
|
-
ok, _ = client.login("alice", "mauvais")
|
|
126
|
-
assert ok is False # une redirection vers /login n'est pas une réussite
|
|
127
|
-
ok, _ = client.login("alice", "bon")
|
|
128
|
-
assert ok is True
|
|
129
|
-
assert client.username == "alice"
|
|
130
|
-
assert client.identity.startswith("hf-")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|