Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions gknowledge/providers/openai_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
198 changes: 189 additions & 9 deletions gknowledge/ui/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading