diff --git a/README.md b/README.md index 6f20bbe..bbf1c89 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Copiez `config.json.template` vers `config.json` puis adaptez : "model": "llama3.1", "api_key": "", "server_url": "http://localhost:11434", - "hotkey": "ctrl+alt+a" + "hotkey": "ctrl+alt+a", + "request_timeout_seconds": 300 } ``` @@ -63,6 +64,10 @@ Copiez `config.json.template` vers `config.json` puis adaptez : } ``` +### Timeout IA + +`request_timeout_seconds` vaut `300` par défaut. Si Ollama charge un gros modèle ou répond lentement, augmentez cette valeur. Mettez `0` pour désactiver le timeout côté application. + ## Compilation ### Windows diff --git a/config.json.template b/config.json.template index 9f0fb76..fcea5aa 100644 --- a/config.json.template +++ b/config.json.template @@ -4,7 +4,7 @@ "api_key": "", "server_url": "http://localhost:11434", "hotkey": "ctrl+alt+a", - "request_timeout_seconds": 120, + "request_timeout_seconds": 300, "type_delay_seconds": 0, "system_prompt": "Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute." } diff --git a/src/ai_typewriter/ai_client.py b/src/ai_typewriter/ai_client.py index 2783fd2..584aac8 100644 --- a/src/ai_typewriter/ai_client.py +++ b/src/ai_typewriter/ai_client.py @@ -34,6 +34,13 @@ def _ask_ollama(prompt: str, config: AppConfig) -> str: response = requests.post(url, json=payload, timeout=config.request_timeout_seconds) response.raise_for_status() data = response.json() + except requests.Timeout as exc: + timeout_label = "désactivé" if config.request_timeout_seconds is None else f"{config.request_timeout_seconds:.0f} s" + raise AIClientError( + "Ollama n'a pas répondu avant le délai configuré " + f"({timeout_label}). Le modèle est peut-être en chargement ou trop lent; " + "augmentez request_timeout_seconds dans config.json, ou mettez 0 pour désactiver le timeout." + ) from exc except requests.RequestException as exc: raise AIClientError(f"Erreur Ollama: {exc}") from exc except ValueError as exc: @@ -65,6 +72,13 @@ def _ask_gemini(prompt: str, config: AppConfig) -> str: ) response.raise_for_status() data = response.json() + except requests.Timeout as exc: + timeout_label = "désactivé" if config.request_timeout_seconds is None else f"{config.request_timeout_seconds:.0f} s" + raise AIClientError( + "Gemini n'a pas répondu avant le délai configuré " + f"({timeout_label}). Augmentez request_timeout_seconds dans config.json, " + "ou mettez 0 pour désactiver le timeout." + ) from exc except requests.RequestException as exc: raise AIClientError(f"Erreur Gemini: {exc}") from exc except ValueError as exc: diff --git a/src/ai_typewriter/config.py b/src/ai_typewriter/config.py index cef092b..f661699 100644 --- a/src/ai_typewriter/config.py +++ b/src/ai_typewriter/config.py @@ -15,7 +15,7 @@ class AppConfig: api_key: str = "" server_url: str = "http://localhost:11434" hotkey: str = "ctrl+alt+a" - request_timeout_seconds: float = 120.0 + request_timeout_seconds: float | None = 300.0 copy_wait_seconds: float = 1.0 type_delay_seconds: float = 0.0 restore_clipboard: bool = True @@ -33,6 +33,13 @@ def _coerce_provider(value: Any) -> Provider: return provider # type: ignore[return-value] +def _coerce_timeout(value: Any) -> float | None: + timeout = float(value) + if timeout <= 0: + return None + return timeout + + def load_config(path: str | Path = "config.json") -> AppConfig: cfg_path = Path(path) if not cfg_path.exists(): @@ -46,7 +53,7 @@ def load_config(path: str | Path = "config.json") -> AppConfig: api_key=str(data.get("api_key", "")), server_url=str(data.get("server_url", "http://localhost:11434")).rstrip("/"), hotkey=str(data.get("hotkey", "ctrl+alt+a")).lower(), - request_timeout_seconds=float(data.get("request_timeout_seconds", 120)), + request_timeout_seconds=_coerce_timeout(data.get("request_timeout_seconds", 300)), copy_wait_seconds=float(data.get("copy_wait_seconds", 1)), type_delay_seconds=float(data.get("type_delay_seconds", 0)), restore_clipboard=bool(data.get("restore_clipboard", True)), diff --git a/tests/test_ai_client.py b/tests/test_ai_client.py index dc8cbf7..3e78a78 100644 --- a/tests/test_ai_client.py +++ b/tests/test_ai_client.py @@ -1,4 +1,7 @@ -from ai_typewriter.ai_client import ask_ai +import pytest +import requests + +from ai_typewriter.ai_client import AIClientError, ask_ai from ai_typewriter.config import AppConfig @@ -27,6 +30,19 @@ def test_ollama_payload_contains_strict_system_prompt(monkeypatch): assert "Uniquement la réponse brute" in seen["json"]["messages"][0]["content"] +def test_ollama_timeout_message_suggests_config_change(monkeypatch): + def fake_post(url, json, timeout, **kwargs): + raise requests.Timeout("too slow") + monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post) + + with pytest.raises(AIClientError) as exc: + ask_ai("texte", AppConfig(provider="ollama", model="m", request_timeout_seconds=300)) + + assert "300 s" in str(exc.value) + assert "request_timeout_seconds" in str(exc.value) + assert "0 pour désactiver" in str(exc.value) + + def test_gemini_payload(monkeypatch): seen = {} def fake_post(url, params, json, timeout): diff --git a/tests/test_config.py b/tests/test_config.py index 78c58c9..4dcf7b4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,15 @@ def test_load_default_config(tmp_path): assert "Réponds directement" in cfg.system_prompt +def test_timeout_zero_disables_timeout(tmp_path): + path = tmp_path / "config.json" + path.write_text(json.dumps({"provider": "ollama", "request_timeout_seconds": 0}), encoding="utf-8") + + cfg = load_config(path) + + assert cfg.request_timeout_seconds is None + + def test_reject_invalid_provider(tmp_path): path = tmp_path / "config.json" path.write_text(json.dumps({"provider": "bad"}), encoding="utf-8")