diff --git a/gknowledge/providers/openai_compatible.py b/gknowledge/providers/openai_compatible.py index 6deafe8..aa67c4f 100644 --- a/gknowledge/providers/openai_compatible.py +++ b/gknowledge/providers/openai_compatible.py @@ -83,3 +83,103 @@ def complete( completion_tokens=usage.get("completion_tokens", 0), raw=data, ) + + + +DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1" +DEFAULT_OLLAMA_MODELS_URL = f"{DEFAULT_OLLAMA_BASE_URL}/models" +_OLLAMA_TIMEOUT = 2.0 + + +class OllamaUnavailable(ProviderError): + """Nothing answered at the Ollama endpoint (refused, unreachable or timed out).""" + + +def discover_ollama_models( + endpoint: str = DEFAULT_OLLAMA_MODELS_URL, + *, + timeout: float = _OLLAMA_TIMEOUT, +) -> list[str]: + """Return the model IDs a local Ollama server offers. + + One GET to the OpenAI-compatible ``/v1/models`` endpoint with a short + timeout and no request body, so no document content can leave the app. + + Raises: + OllamaUnavailable: nothing is listening, or it did not answer in time. + ProviderError: the server answered with an HTTP error or an + unexpected payload. + """ + req = urllib.request.Request(endpoint, method="GET") + req.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + raise ProviderError(f"HTTP {exc.code}: {body[:200]}") from exc + except urllib.error.URLError as exc: + raise OllamaUnavailable(f"Connection failed: {exc.reason}") from exc + except OSError as exc: # TimeoutError, ConnectionResetError, ... + raise OllamaUnavailable(f"Connection failed: {exc}") from exc + + try: + data = json.loads(raw) + except ValueError as exc: + raise ProviderError(f"Invalid JSON response: {exc}") from exc + + items = data.get("data") if isinstance(data, dict) else None + if not isinstance(items, list): + raise ProviderError(f"Unexpected response shape: {raw[:200]}") + models: list[str] = [] + for item in items: + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + raise ProviderError(f"Unexpected model entry: {str(item)[:200]}") + models.append(item["id"]) + return models + + +def should_prefill_ollama_base_url(current_base_url: str | None) -> bool: + """True when the Base URL is empty or still the OpenAI default. + + Anything else was entered by the user and must not be overwritten. + """ + cleaned = (current_base_url or "").strip().rstrip("/") + return not cleaned or cleaned == "https://api.openai.com/v1" + + +def resolve_ollama_model_selection( + current_model: str, + discovered_models: list[str], +) -> tuple[int, str, bool]: + """Pick the dropdown entry for ``current_model`` among ``discovered_models``. + + The dropdown lists the discovered models followed by a "Custom…" entry at + index ``len(discovered_models)``. Returns ``(index, model, is_custom)``: + a known model selects itself, an unknown one selects "Custom…" and keeps + its name, and no model at all selects the first discovered one. + """ + if not discovered_models: + return 0, current_model, True + if current_model in discovered_models: + return discovered_models.index(current_model), current_model, False + if current_model: + return len(discovered_models), current_model, True + return 0, discovered_models[0], False + + +def get_ollama_selection_state( + selected_idx: int, + models: list[str], +) -> tuple[str | None, bool]: + """Map a dropdown index to ``(model, is_custom)``. + + ``models`` excludes the trailing "Custom…" entry. A discovered model gives + ``(name, False)``, the "Custom…" entry ``(None, True)``, and anything else + (including GTK's invalid position) ``(None, False)``. + """ + if 0 <= selected_idx < len(models): + return models[selected_idx], False + if selected_idx == len(models): + return None, True + return None, False diff --git a/gknowledge/ui/preferences.py b/gknowledge/ui/preferences.py index 9fc49a3..906cfbc 100644 --- a/gknowledge/ui/preferences.py +++ b/gknowledge/ui/preferences.py @@ -10,6 +10,14 @@ import threading from ..providers.chatgpt import ChatGPTAuth, SignInError +from ..providers.openai_compatible import ( + DEFAULT_OLLAMA_BASE_URL, + OllamaUnavailable, + discover_ollama_models, + get_ollama_selection_state, + resolve_ollama_model_selection, + should_prefill_ollama_base_url, +) from ..core.project import Project, Settings @@ -70,6 +78,21 @@ def __init__(self, parent: Gtk.Window, project: Project, on_saved): self._provider_row.connect("notify::selected", self._on_provider_changed) group.add(self._provider_row) + # Ollama: list local models, probed only on user request. + self._saved_ollama_model = s.model if s.provider == "ollama" else "" + self._last_selected_provider = s.provider + self._ollama_models: list[str] = [] + self._ollama_request_id = 0 + self._updating_ollama_models = False + self._ollama_probe_in_flight = False + + self._ollama_row = Adw.ActionRow(title="Ollama server", + subtitle="Local model discovery") + self._detect_btn = Gtk.Button(label="Detect", valign=Gtk.Align.CENTER) + self._detect_btn.connect("clicked", self._on_detect_clicked) + self._ollama_row.add_suffix(self._detect_btn) + group.add(self._ollama_row) + # ChatGPT account: sign in like linking a cloud drive. self._auth = ChatGPTAuth() self._cancel = threading.Event() @@ -104,6 +127,15 @@ def __init__(self, parent: Gtk.Window, project: Project, on_saved): group.add(self._model_combo) self._saved_model = s.model if s.provider == "chatgpt" else "" + # For Ollama the model list comes from local server discovery. + self._ollama_model_list = Gtk.StringList.new([]) + self._ollama_model_combo = Adw.ComboRow(title="Model", model=self._ollama_model_list, + subtitle="Discovered from local Ollama server") + self._ollama_model_combo.set_factory(_full_width_factory()) + self._ollama_model_combo.set_list_factory(_full_width_factory()) + self._ollama_model_combo.connect("notify::selected", self._on_ollama_model_changed) + group.add(self._ollama_model_combo) + lang_group = Adw.PreferencesGroup(title="Generation") page.add(lang_group) self._lang = Adw.ComboRow(title="Language") @@ -131,21 +163,50 @@ def _provider_index(value: str) -> int: return 0 def _on_provider_changed(self, *_a): + kind = _PROVIDERS[self._provider_row.get_selected()][0] + if kind == "ollama" and self._last_selected_provider != "ollama": + if not self._ollama_models and not self._ollama_probe_in_flight: + self._load_ollama_models() + self._last_selected_provider = kind self._sync_sensitivity() def _sync_sensitivity(self): kind = _PROVIDERS[self._provider_row.get_selected()][0] - is_offline, is_chatgpt = kind == "offline", kind == "chatgpt" + is_offline = (kind == "offline") + is_chatgpt = (kind == "chatgpt") + is_ollama = (kind == "ollama") + self._account_row.set_visible(is_chatgpt) + self._ollama_row.set_visible(is_ollama) self._base_url.set_visible(not is_chatgpt) self._api_key.set_visible(not is_chatgpt) self._base_url.set_sensitive(not is_offline) self._api_key.set_sensitive(not is_offline) self._model.set_sensitive(not is_offline) - self._model.set_visible(not is_chatgpt) + self._model_combo.set_visible(is_chatgpt) - if is_chatgpt and len(self._model_slugs) == 1: - self._load_chatgpt_models() + + if is_chatgpt: + self._model.set_visible(False) + self._ollama_model_combo.set_visible(False) + if len(self._model_slugs) == 1: + self._load_chatgpt_models() + elif is_ollama: + has_models = bool(self._ollama_models) + self._ollama_model_combo.set_visible(has_models) + if has_models: + idx = self._ollama_model_combo.get_selected() + _, is_custom = get_ollama_selection_state(idx, self._ollama_models) + self._model.set_visible(is_custom) + if is_custom: + self._model.set_title("Custom Model") + else: + self._model.set_visible(True) + self._model.set_title("Model") + else: + self._ollama_model_combo.set_visible(False) + self._model.set_visible(True) + self._model.set_title("Model") def _load_chatgpt_models(self): if not self._auth.store.load().sharing: @@ -176,6 +237,109 @@ def _fill_models(self, models, err): self._model_combo.set_subtitle("Models available to your ChatGPT account") return False + # -- Ollama local discovery ---------------------------------------------- + def _on_detect_clicked(self, *_a): + self._load_ollama_models() + + def _on_ollama_model_changed(self, *_a): + # "Custom…" reveals the free-text Model row; a discovered model hides it. + if self._updating_ollama_models or not self._ollama_models: + return + _, is_custom = get_ollama_selection_state( + self._ollama_model_combo.get_selected(), self._ollama_models) + self._model.set_title("Custom Model") + self._model.set_visible(is_custom) + + def _get_current_ollama_model(self) -> str: + """Return the currently selected or entered Ollama model name.""" + if self._ollama_model_combo.get_visible() and self._ollama_models: + idx = self._ollama_model_combo.get_selected() + model_name, is_custom = get_ollama_selection_state(idx, self._ollama_models) + if model_name is not None: + return model_name + if is_custom: + return self._model.get_text().strip() + + text = self._model.get_text().strip() + if text: + if self._project.settings.provider == "ollama" or text != "gpt-4o-mini": + return text + + return self._saved_ollama_model + + def _load_ollama_models(self): + self._ollama_request_id += 1 + req_id = self._ollama_request_id + self._ollama_probe_in_flight = True + self._detect_btn.set_sensitive(False) + self._ollama_row.set_subtitle("Connecting to Ollama…") + + current_model = self._get_current_ollama_model() + + def work(): + # Always the local default: a URL left over from another provider + # must never receive an unrequested probe. + try: + models = discover_ollama_models() + err = None + except OllamaUnavailable: + models, err = [], "No Ollama server found at localhost:11434" + except Exception as exc: # noqa: BLE001 + models, err = [], f"Could not list Ollama models: {str(exc)[:80]}" + GLib.idle_add(self._fill_ollama_models, req_id, current_model, models, err) + + threading.Thread(target=work, daemon=True).start() + + def _fill_ollama_models(self, req_id: int, current_model: str, models: list[str], err: str | None): + if req_id != self._ollama_request_id: + return False + + self._ollama_probe_in_flight = False + self._detect_btn.set_sensitive(True) + + current_kind = _PROVIDERS[self._provider_row.get_selected()][0] + if current_kind != "ollama": + return False + + if err: + self._ollama_row.set_subtitle(err) + self._ollama_models = [] + if current_model: + self._model.set_text(current_model) + self._sync_sensitivity() + return False + + if not models: + self._ollama_row.set_subtitle("Ollama is running, but no models are installed") + self._ollama_models = [] + if current_model: + self._model.set_text(current_model) + self._sync_sensitivity() + return False + + if should_prefill_ollama_base_url(self._base_url.get_text()): + self._base_url.set_text(DEFAULT_OLLAMA_BASE_URL) + + n = len(models) + self._ollama_row.set_subtitle( + f"Ollama running at localhost ({n} model{'s' if n != 1 else ''} found)" + ) + self._ollama_models = list(models) + + self._updating_ollama_models = True + try: + items = list(models) + ["Custom…"] + self._ollama_model_list.splice(0, self._ollama_model_list.get_n_items(), items) + + idx, chosen_model, _ = resolve_ollama_model_selection(current_model, models) + self._ollama_model_combo.set_selected(idx) + self._model.set_text(chosen_model) + finally: + self._updating_ollama_models = False + + self._sync_sensitivity() + return False + # -- ChatGPT sign-in ----------------------------------------------------- def _refresh_account_row(self, message: str | None = None): acct = self._auth.store.load() @@ -234,14 +398,30 @@ def work(): def _on_save(self, *_a): provider = _PROVIDERS[self._provider_row.get_selected()][0] + if provider == "chatgpt": + model = (self._model_slugs[self._model_combo.get_selected()] + if self._model_combo.get_selected() < len(self._model_slugs) + else "") + elif provider == "ollama": + if self._ollama_model_combo.get_visible() and self._ollama_models: + idx = self._ollama_model_combo.get_selected() + model_name, _ = get_ollama_selection_state(idx, self._ollama_models) + if model_name is not None: + model = model_name + else: + model = self._model.get_text().strip() or "llama3.2" + else: + model = self._model.get_text().strip() or "llama3.2" + else: + model = self._model.get_text().strip() or "gpt-4o-mini" + settings = Settings( provider=provider, - base_url=self._base_url.get_text().strip() or "https://api.openai.com/v1", + base_url=self._base_url.get_text().strip() or ( + DEFAULT_OLLAMA_BASE_URL if provider == "ollama" else "https://api.openai.com/v1" + ), api_key=self._api_key.get_text().strip(), - model=(self._model_slugs[self._model_combo.get_selected()] - if provider == "chatgpt" and self._model_combo.get_selected() < len(self._model_slugs) - else "" if provider == "chatgpt" - else self._model.get_text().strip() or "gpt-4o-mini"), + model=model, language=_LANGS[self._lang.get_selected()], ) self._cancel.set() diff --git a/tests/test_openai_compatible.py b/tests/test_openai_compatible.py new file mode 100644 index 0000000..847fb73 --- /dev/null +++ b/tests/test_openai_compatible.py @@ -0,0 +1,179 @@ +"""Ollama model discovery against a fake local server (no network, no Ollama).""" + +from __future__ import annotations + +import http.server +import json +import socket +import sys +import threading +import time +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from gknowledge.providers.base import ProviderError +from gknowledge.providers.openai_compatible import ( + DEFAULT_OLLAMA_MODELS_URL, + OllamaUnavailable, + discover_ollama_models, + get_ollama_selection_state, + resolve_ollama_model_selection, + should_prefill_ollama_base_url, +) + + +class FakeOllama: + """Serves one canned answer on /v1/models and records every request.""" + + def __init__(self, body: str | bytes = "", status: int = 200, delay: float = 0.0): + self.requests: list[tuple[str, str, int]] = [] + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + def _record(self): + length = int(self.headers.get("Content-Length") or 0) + outer.requests.append((self.command, self.path, length)) + + def do_GET(self): # noqa: N802 + self._record() + time.sleep(delay) + data = body.encode() if isinstance(body, str) else body + try: + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + except OSError: # client already gave up (timeout test) + pass + + def do_POST(self): # noqa: N802 + self._record() + self.send_response(405) + self.end_headers() + + def log_message(self, *_a): + pass + + self.httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}/v1/models" + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + + def close(self): + self.httpd.shutdown() + self.httpd.server_close() + + +@pytest.fixture +def ollama(): + servers: list[FakeOllama] = [] + + def start(*a, **kw) -> FakeOllama: + servers.append(FakeOllama(*a, **kw)) + return servers[-1] + + yield start + for s in servers: + s.close() + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def test_default_endpoint_is_local_ollama(): + assert DEFAULT_OLLAMA_MODELS_URL == "http://localhost:11434/v1/models" + + +def test_discovers_model_ids_with_a_single_bodyless_get(ollama): + server = ollama(json.dumps({"object": "list", "data": [ + {"id": "llama3.2:latest", "object": "model", "owned_by": "library"}, + {"id": "qwen2.5:7b", "object": "model"}, + ]})) + assert discover_ollama_models(server.url) == ["llama3.2:latest", "qwen2.5:7b"] + assert server.requests == [("GET", "/v1/models", 0)] + + +def test_server_without_models_returns_empty_list(ollama): + server = ollama(json.dumps({"object": "list", "data": []})) + assert discover_ollama_models(server.url) == [] + + +def test_refused_connection_means_no_server(): + url = f"http://127.0.0.1:{_free_port()}/v1/models" + with pytest.raises(OllamaUnavailable): + discover_ollama_models(url) + + +def test_slow_server_times_out_as_unavailable(ollama): + server = ollama('{"data": []}', delay=1.0) + start = time.monotonic() + with pytest.raises(OllamaUnavailable): + discover_ollama_models(server.url, timeout=0.2) + assert time.monotonic() - start < 1.0 + + +def test_default_timeout_is_short(): + assert discover_ollama_models.__kwdefaults__["timeout"] <= 2.0 + + +@pytest.mark.parametrize("body, message", [ + ("502 Bad Gateway", "Invalid JSON"), + (json.dumps(["not", "a", "dict"]), "Unexpected response shape"), + (json.dumps({"error": "unsupported"}), "Unexpected response shape"), + (json.dumps({"data": "llama3.2"}), "Unexpected response shape"), + (json.dumps({"data": [{"name": "llama3.2"}]}), "Unexpected model entry"), +]) +def test_malformed_answers_raise_a_clear_error(ollama, body, message): + server = ollama(body) + with pytest.raises(ProviderError) as exc: + discover_ollama_models(server.url) + assert message in str(exc.value) + assert not isinstance(exc.value, OllamaUnavailable) + + +def test_http_error_is_reported_with_status(ollama): + server = ollama('{"error": "boom"}', status=500) + with pytest.raises(ProviderError, match="HTTP 500"): + discover_ollama_models(server.url) + + +def test_base_url_prefilled_only_when_empty_or_openai_default(): + assert should_prefill_ollama_base_url("") + assert should_prefill_ollama_base_url(" ") + assert should_prefill_ollama_base_url(None) + assert should_prefill_ollama_base_url("https://api.openai.com/v1/") + assert not should_prefill_ollama_base_url("http://localhost:11434/v1") + assert not should_prefill_ollama_base_url("http://192.168.1.50:11434/v1") + assert not should_prefill_ollama_base_url("https://proxy.internal/v1") + + +def test_saved_model_is_preselected_when_discovered(): + models = ["llama3.2:latest", "mistral:latest"] + assert resolve_ollama_model_selection("mistral:latest", models) == (1, "mistral:latest", False) + + +def test_unknown_saved_model_selects_custom_and_keeps_its_name(): + models = ["llama3.2:latest", "mistral:latest"] + assert resolve_ollama_model_selection("my-lora", models) == (2, "my-lora", True) + + +def test_no_saved_model_selects_first_discovered(): + assert resolve_ollama_model_selection("", ["llama3.2:latest"]) == (0, "llama3.2:latest", False) + + +def test_nothing_discovered_keeps_free_text_model(): + assert resolve_ollama_model_selection("my-model", []) == (0, "my-model", True) + + +def test_dropdown_index_maps_to_model_or_custom(): + models = ["llama3.2:latest", "qwen2.5:7b"] + assert get_ollama_selection_state(1, models) == ("qwen2.5:7b", False) + assert get_ollama_selection_state(2, models) == (None, True) # "Custom…" + assert get_ollama_selection_state(-1, models) == (None, False) + assert get_ollama_selection_state(0xFFFFFFFF, models) == (None, False) # GTK_INVALID_LIST_POSITION