Nouvelle couche app/ai/ (provider OpenAI-compatible via urllib, redaction de secrets, detection de commandes dangereuses, parametres, worker QThread) et son integration dans chaque onglet terminal via un bouton "Assistant IA" (memes conventions que le bouton Snippets existant) : - Expliquer cette sortie / Diagnostiquer cette erreur / Proposer une solution / Poser une question... : utilisent la selection courante du terminal, affichees dans un panneau lateral (memoire uniquement, jamais journalise) reutilisant le splitter comme le panneau SFTP. - Generer une commande : ne s'execute jamais automatiquement (Copier / Inserer dans le terminal sans Entree / Annuler), avec avertissement si la commande proposee correspond a un motif destructeur connu. - Avant tout envoi : avis unique (persist) puis detection de secrets evidents (mot de passe, cle, jeton) avec choix de masquer, envoyer tel quel ou annuler. Desactive par defaut, sans liste de fournisseur figee (URL + modele libres, compatible OpenAI/OpenRouter/LLM local). La cle API passe exclusivement par CredentialStore (jamais dans settings.json ni journalisee). Parametrable via le nouveau menu "Assistant IA > Parametres..." (bouton "Tester la connexion"). Bug trouve par les tests avant commit : un panneau nouvellement insere dans un QSplitter deja visible est affiche automatiquement par Qt, ce qui inversait le premier clic sur "Afficher le panneau IA" ; corrige en le masquant explicitement a sa creation. 9 nouveaux fichiers de test (provider, redaction, dangers, parametres, worker, confirmations de securite, dialogue de commande, panneau, integration onglet) : 39 verifications, toutes au vert. Suite complete (31 fichiers) executee sans regression. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
158 lines
5.5 KiB
Python
158 lines
5.5 KiB
Python
"""OpenAICompatibleProvider : requete HTTP construite correctement, et gestion
|
|
des erreurs (401, 429, timeout, reseau, reponse invalide) - urllib.request.urlopen
|
|
est entierement mocke, aucun appel reseau reel."""
|
|
import sys, pathlib
|
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
|
|
|
import io
|
|
import json
|
|
import urllib.error
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from app.ai.provider import AIProvider, AIError, OpenAICompatibleProvider
|
|
|
|
|
|
def fake_response(payload: dict):
|
|
m = MagicMock()
|
|
m.read.return_value = json.dumps(payload).encode("utf-8")
|
|
m.__enter__.return_value = m
|
|
m.__exit__.return_value = False
|
|
return m
|
|
|
|
|
|
def make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError:
|
|
return urllib.error.HTTPError(url="http://x/chat/completions", code=code, msg="err",
|
|
hdrs=None, fp=io.BytesIO(body))
|
|
|
|
|
|
provider = OpenAICompatibleProvider(
|
|
base_url="https://api.example.com/v1", api_key="sk-test", model="gpt-test", timeout=5, temperature=0.2,
|
|
)
|
|
|
|
print("=== 1. Requete construite correctement (URL, methode, en-tetes, corps) ===")
|
|
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
|
|
mocked.return_value = fake_response({"choices": [{"message": {"content": "bonjour"}}]})
|
|
result = provider.chat([{"role": "user", "content": "salut"}])
|
|
|
|
request = mocked.call_args.args[0]
|
|
print(" URL:", request.full_url)
|
|
print(" methode:", request.get_method())
|
|
print(" Authorization:", request.get_header("Authorization"))
|
|
body = json.loads(request.data.decode("utf-8"))
|
|
print(" corps:", body)
|
|
assert request.full_url == "https://api.example.com/v1/chat/completions"
|
|
assert request.get_method() == "POST"
|
|
assert request.get_header("Authorization") == "Bearer sk-test"
|
|
assert body["model"] == "gpt-test"
|
|
assert body["temperature"] == 0.2
|
|
assert body["messages"] == [{"role": "user", "content": "salut"}]
|
|
assert result == "bonjour"
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 2. Erreur 401 -> message clair mentionnant la cle API ===")
|
|
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(401)):
|
|
try:
|
|
provider.chat([{"role": "user", "content": "x"}])
|
|
ok = False
|
|
except AIError as exc:
|
|
ok = True
|
|
print(" message:", exc)
|
|
assert "401" in str(exc) and "cle" in str(exc).lower()
|
|
assert ok
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 3. Erreur 429 -> message clair mentionnant la limitation ===")
|
|
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(429)):
|
|
try:
|
|
provider.chat([{"role": "user", "content": "x"}])
|
|
ok = False
|
|
except AIError as exc:
|
|
ok = True
|
|
print(" message:", exc)
|
|
assert "429" in str(exc)
|
|
assert ok
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 4. Timeout -> message clair, pas une exception brute ===")
|
|
timeout_error = urllib.error.URLError(reason=TimeoutError("timed out"))
|
|
with patch("app.ai.provider.urllib.request.urlopen", side_effect=timeout_error):
|
|
try:
|
|
provider.chat([{"role": "user", "content": "x"}])
|
|
ok = False
|
|
except AIError as exc:
|
|
ok = True
|
|
print(" message:", exc)
|
|
assert "delai" in str(exc).lower() or "temps" in str(exc).lower()
|
|
assert ok
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 5. Erreur reseau (DNS, connexion refusee) -> message clair ===")
|
|
network_error = urllib.error.URLError(reason="nom ou service inconnu")
|
|
with patch("app.ai.provider.urllib.request.urlopen", side_effect=network_error):
|
|
try:
|
|
provider.chat([{"role": "user", "content": "x"}])
|
|
ok = False
|
|
except AIError as exc:
|
|
ok = True
|
|
print(" message:", exc)
|
|
assert ok
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 6. Reponse invalide (JSON incoherent) -> message clair, pas de KeyError brut ===")
|
|
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
|
|
mocked.return_value = fake_response({"unexpected": "shape"})
|
|
try:
|
|
provider.chat([{"role": "user", "content": "x"}])
|
|
ok = False
|
|
except AIError as exc:
|
|
ok = True
|
|
print(" message:", exc)
|
|
assert ok
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 7. Sans cle API : pas d'en-tete Authorization ===")
|
|
no_key_provider = OpenAICompatibleProvider(base_url="http://localhost:8080/v1", api_key="", model="local-model")
|
|
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
|
|
mocked.return_value = fake_response({"choices": [{"message": {"content": "ok"}}]})
|
|
no_key_provider.chat([{"role": "user", "content": "x"}])
|
|
request2 = mocked.call_args.args[0]
|
|
assert request2.get_header("Authorization") is None
|
|
print(" OK (utile pour un LLM local sans authentification)")
|
|
|
|
print()
|
|
print("=== 8. Les 4 usages metier construisent les bons messages (via chat()) ===")
|
|
captured = []
|
|
|
|
|
|
class SpyProvider(AIProvider):
|
|
def chat(self, messages, *, temperature=None):
|
|
captured.append(messages)
|
|
return "reponse"
|
|
|
|
|
|
spy = SpyProvider()
|
|
spy.explain_output("erreur X")
|
|
spy.diagnose("erreur Y")
|
|
spy.propose_solution("erreur Z")
|
|
spy.generate_command("trouver les gros fichiers")
|
|
spy.ask("pourquoi ?", context="ligne de contexte")
|
|
|
|
print(" nombre d'appels a chat():", len(captured))
|
|
assert len(captured) == 5
|
|
assert "erreur X" in captured[0][-1]["content"]
|
|
assert "Cause probable" in captured[1][0]["content"] # structure imposee au system prompt de diagnose
|
|
assert "erreur Z" in captured[2][-1]["content"]
|
|
assert captured[3][-1]["content"] == "trouver les gros fichiers"
|
|
assert "ligne de contexte" in captured[4][1]["content"]
|
|
assert captured[4][-1]["content"] == "pourquoi ?"
|
|
print(" OK")
|
|
|
|
print()
|
|
print("AI PROVIDER : TOUS LES TESTS OK")
|