From 4a1fe8dc5feac3cfb61ecbab03872d3d5b9cc839 Mon Sep 17 00:00:00 2001 From: Matthew Wesney Date: Wed, 15 Jul 2026 15:43:05 -0400 Subject: [PATCH] Modernize cloud and Ollama model settings --- graphlink_app/api_provider.py | 80 +++- graphlink_app/graphlink_config.py | 66 ++-- graphlink_app/graphlink_licensing.py | 206 +++++++++-- graphlink_app/graphlink_model_catalog.py | 234 ++++++++++++ .../graphlink_settings_dialogs.py | 349 ++++++++++++------ graphlink_app/graphlink_window.py | 2 +- graphlink_app/tests/test_model_catalog.py | 88 +++++ .../tests/test_ollama_task_model_settings.py | 26 +- 8 files changed, 841 insertions(+), 210 deletions(-) create mode 100644 graphlink_app/graphlink_model_catalog.py create mode 100644 graphlink_app/tests/test_model_catalog.py diff --git a/graphlink_app/api_provider.py b/graphlink_app/api_provider.py index da5a166..a281bf5 100644 --- a/graphlink_app/api_provider.py +++ b/graphlink_app/api_provider.py @@ -21,6 +21,7 @@ import graphlink_config as config from graphlink_audio import guess_audio_mime_type +from graphlink_model_catalog import ModelDescriptor, ollama_descriptor, sort_descriptors USE_API_MODE = False @@ -265,23 +266,26 @@ def _collect_models_from_manifest_root(manifests_root: Path) -> list[str]: return sorted(discovered_models, key=str.lower) -def _list_models_from_running_ollama() -> list[str]: +def _list_model_descriptors_from_running_ollama() -> tuple[list[ModelDescriptor], bool, str]: + """Return installed Ollama models plus an honest server health signal.""" try: response = ollama.list() - except Exception: - return [] + except Exception as exc: + return [], False, str(exc) raw_models = _extract_response_field(response, "models", []) - discovered_models: set[str] = set() + descriptors = [] for raw_model in raw_models or []: - model_name = _extract_response_field(raw_model, "model") or _extract_response_field(raw_model, "name") - normalized = str(model_name or "").strip() - if normalized: - discovered_models.add(normalized) - return sorted(discovered_models, key=str.lower) + descriptor = ollama_descriptor(raw_model) + if descriptor.model_id: + descriptors.append(descriptor) + return sort_descriptors(descriptors), True, "" def scan_local_ollama_models(scan_path: str | None = None) -> dict: + running_descriptors: list[ModelDescriptor] = [] + server_reachable = None + server_error = "" if scan_path: manifest_roots = _discover_manifest_roots_in_folder(scan_path) scan_mode = "folder" @@ -291,7 +295,8 @@ def scan_local_ollama_models(scan_path: str | None = None) -> dict: manifest_roots = _iter_existing_ollama_manifest_roots() scan_mode = "system" scan_root = "" - running_models = _list_models_from_running_ollama() + running_descriptors, server_reachable, server_error = _list_model_descriptors_from_running_ollama() + running_models = [descriptor.model_id for descriptor in running_descriptors] discovered_models: set[str] = set(running_models) scanned_locations: list[str] = [] @@ -300,11 +305,43 @@ def scan_local_ollama_models(scan_path: str | None = None) -> dict: discovered_models.update(_collect_models_from_manifest_root(manifests_root)) scanned_locations.append(str(manifests_root.resolve())) + descriptors_by_id = { + descriptor.model_id.lower(): descriptor + for descriptor in (running_descriptors if not scan_path else []) + } + for model_name in discovered_models: + descriptors_by_id.setdefault( + model_name.lower(), + ModelDescriptor( + model_id=model_name, + provider=config.LOCAL_PROVIDER_OLLAMA, + ready=True, + available=True, + source="manifest", + ), + ) + return { "models": sorted(discovered_models, key=str.lower), + "descriptors": [ + { + "model_id": descriptor.model_id, + "provider": descriptor.provider, + "ready": descriptor.ready, + "available": descriptor.available, + "capabilities": sorted(descriptor.capabilities), + "source": descriptor.source, + "size_bytes": descriptor.size_bytes, + "context_length": descriptor.context_length, + "quantization": descriptor.quantization, + } + for descriptor in sort_descriptors(descriptors_by_id.values()) + ], "scan_mode": scan_mode, "scan_path": scan_root, "locations": sorted(set(scanned_locations), key=str.lower), + "server_reachable": server_reachable if not scan_path else None, + "server_error": server_error if not scan_path else "", } @@ -1763,8 +1800,6 @@ def generate_image(prompt: str, size: str = "1024x1024") -> bytes: ) api_model = state.api_models.get(config.TASK_IMAGE_GEN) - if not api_model and state.api_provider_type == config.API_PROVIDER_GEMINI: - api_model = "gemini-2.5-flash-image" if not api_model: raise RuntimeError( "No image generation model configured.\n" @@ -1907,10 +1942,7 @@ def chat(task: str, messages: list, **kwargs) -> dict: if not state.api_client: raise RuntimeError("API client not initialized. Configure API settings first.") - if task == config.TASK_WEB_VALIDATE and state.api_provider_type == config.API_PROVIDER_GEMINI: - api_model = state.api_models.get(task) or "gemini-3.1-flash-lite-preview" - else: - api_model = state.api_models.get(task) + api_model = state.api_models.get(task) if not api_model: raise RuntimeError( @@ -2255,6 +2287,22 @@ def get_available_models(): raise RuntimeError(f"Failed to fetch models from endpoint: {exc}") from exc +def get_available_model_descriptors() -> list[ModelDescriptor]: + """Fetch the active provider catalog with stable metadata for the UI.""" + models = get_available_models() + return sort_descriptors( + ModelDescriptor( + model_id=str(model_id).strip(), + provider=str(API_PROVIDER_TYPE or ""), + ready=True, + available=True, + source="endpoint", + ) + for model_id in models + if str(model_id).strip() + ) + + def set_mode(use_api: bool): global USE_API_MODE with _PROVIDER_STATE_LOCK: diff --git a/graphlink_app/graphlink_config.py b/graphlink_app/graphlink_config.py index 9ae009a..4fb9cbd 100644 --- a/graphlink_app/graphlink_config.py +++ b/graphlink_app/graphlink_config.py @@ -156,14 +156,16 @@ def apply_theme(app: QApplication, theme_name: str): MODE_API_ENDPOINT = "API Endpoint" OLLAMA_MODELS = { - TASK_TITLE: 'qwen3:8b', - TASK_CHAT: 'qwen3:8b', - TASK_CHART: 'deepseek-coder:6.7b', - TASK_WEB_VALIDATE: 'qwen3:8b', - TASK_WEB_SUMMARIZE: 'qwen3:8b' + # These are runtime-resolved selections, not product defaults. An empty + # value means the user has not selected a ready local model yet. + TASK_TITLE: '', + TASK_CHAT: '', + TASK_CHART: '', + TASK_WEB_VALIDATE: '', + TASK_WEB_SUMMARIZE: '' } -CURRENT_MODEL = OLLAMA_MODELS[TASK_CHAT] +CURRENT_MODEL = '' def set_current_model(model_name: str): global CURRENT_MODEL @@ -173,21 +175,39 @@ def set_current_model(model_name: str): def sync_ollama_task_models(settings_manager): - """Populate the per-task Ollama model table from the user's own persisted settings. - - api_provider.chat() resolves the Ollama model for a task with a plain - OLLAMA_MODELS.get(task) lookup - it has no per-call fallback logic. TASK_CHART, - TASK_WEB_VALIDATE, and TASK_WEB_SUMMARIZE each have their own independent settings - field now (see OllamaSettingsWidget), each with its own sensible get_ollama_*_model - fallback (chart falls back to a code-specialized default, web validate/summarize - fall back to the chat model) - so this has to read through the settings manager - rather than just copying whatever the chat model happens to be, the way - set_current_model() does for TASK_CHAT alone. - - Call this once at startup (after set_current_model) and again whenever - OllamaSettingsWidget.save_settings() persists a change, so the new selection takes - effect in the current session without a restart. + """Populate runtime selections from explicit/inherited/Auto settings. + + The compatibility table remains for callers that already pass a task to + :func:`api_provider.chat`, but it no longer contains product-authored model + IDs. Cached discovery is intentionally best-effort; an empty result leaves + an Auto task unconfigured until the provider is reachable and the user picks + or discovers a model. """ - OLLAMA_MODELS[TASK_CHART] = settings_manager.get_ollama_chart_model() - OLLAMA_MODELS[TASK_WEB_VALIDATE] = settings_manager.get_ollama_web_validate_model() - OLLAMA_MODELS[TASK_WEB_SUMMARIZE] = settings_manager.get_ollama_web_summarize_model() + from graphlink_model_catalog import ModelDescriptor, resolve_task_model + + if hasattr(settings_manager, "get_ollama_model_assignments"): + assignments = settings_manager.get_ollama_model_assignments() + else: + assignments = { + TASK_CHAT: settings_manager.get_ollama_chat_model(), + TASK_TITLE: settings_manager.get_ollama_title_model(), + TASK_CHART: settings_manager.get_ollama_chart_model(), + TASK_WEB_VALIDATE: settings_manager.get_ollama_web_validate_model(), + TASK_WEB_SUMMARIZE: settings_manager.get_ollama_web_summarize_model(), + } + + cached_models = [] + if hasattr(settings_manager, "get_ollama_scanned_models"): + cached_models = settings_manager.get_ollama_scanned_models() + catalog = [ModelDescriptor(model_id=model, provider=LOCAL_PROVIDER_OLLAMA) for model in cached_models] + chat_model = resolve_task_model(TASK_CHAT, assignments, catalog) + for task in (TASK_CHAT, TASK_TITLE, TASK_CHART, TASK_WEB_VALIDATE, TASK_WEB_SUMMARIZE): + OLLAMA_MODELS[task] = resolve_task_model( + task, + assignments, + catalog, + chat_model=chat_model, + ) + + global CURRENT_MODEL + CURRENT_MODEL = OLLAMA_MODELS[TASK_CHAT] diff --git a/graphlink_app/graphlink_licensing.py b/graphlink_app/graphlink_licensing.py index 507b402..f8e5294 100644 --- a/graphlink_app/graphlink_licensing.py +++ b/graphlink_app/graphlink_licensing.py @@ -4,6 +4,13 @@ from pathlib import Path import graphlink_secrets +from graphlink_model_catalog import ( + AUTO_MODEL, + INHERIT_MODEL, + ModelAssignment, + assignment_values, + normalize_model_id, +) def _is_llama_cpp_gguf_path(path_value) -> bool: @@ -14,10 +21,17 @@ def _is_llama_cpp_gguf_path(path_value) -> bool: class SettingsManager: NOTIFICATION_TYPES = ("info", "success", "warning", "error") # Bumped whenever session.dat's shape changes in a way future code needs to branch - # on. No migration logic reads this yet (see doc/ARCHITECTURE_REVIEW_FINDINGS.md #49) - # - it's the version marker itself, not a migration framework. Existing pre-this-field - # files backfill to 1 on next load, same as every other key here. - CURRENT_SCHEMA_VERSION = 1 + # on. Version 2 introduces provider-scoped cloud profiles and explicit local + # model assignment modes. + CURRENT_SCHEMA_VERSION = 2 + LEGACY_PRODUCT_MODEL_IDS = {"qwen3:8b", "deepseek-coder:6.7b"} + OLLAMA_MODEL_TASKS = ( + "task_title", + "task_chat", + "task_chart", + "task_web_validate", + "task_web_summarize", + ) """ Manages all persistent application state and user settings. @@ -32,7 +46,10 @@ class SettingsManager: def __init__(self, state_file: Path | str | None = None): self.state_file = Path(state_file) if state_file is not None else Path.home() / '.graphlink' / 'session.dat' self.state_file.parent.mkdir(parents=True, exist_ok=True) + self._state_needs_save = False self.state = self._load_state() + if self._state_needs_save: + self._save_state() self._migrate_plaintext_secrets() def _migrate_plaintext_secrets(self): @@ -63,8 +80,10 @@ def _load_state(self): state['theme'] = 'dark' if 'show_token_counter' not in state: state['show_token_counter'] = True + state_changed = False if 'ollama_chat_model' not in state: - state['ollama_chat_model'] = 'qwen3:8b' + state['ollama_chat_model'] = '' + state_changed = True if 'ollama_title_model' not in state: state['ollama_title_model'] = '' if 'ollama_chart_model' not in state: @@ -121,6 +140,13 @@ def _load_state(self): state['github_access_token'] = '' if 'api_models' not in state: state['api_models'] = {} + state_changed = True + if 'api_models_by_provider' not in state or not isinstance(state.get('api_models_by_provider'), dict): + state['api_models_by_provider'] = { + str(state.get('api_provider', 'OpenAI-Compatible')): dict(state.get('api_models', {}) or {}) + } + state_changed = True + state_changed = self._migrate_model_settings(state) or state_changed if 'enable_system_prompt' not in state: state['enable_system_prompt'] = True if 'update_notifications_enabled' not in state: @@ -142,6 +168,12 @@ def _load_state(self): state['update_available'] = False if 'schema_version' not in state: state['schema_version'] = self.CURRENT_SCHEMA_VERSION + state_changed = True + elif state.get('schema_version', 0) < self.CURRENT_SCHEMA_VERSION: + state['schema_version'] = self.CURRENT_SCHEMA_VERSION + state_changed = True + if state_changed: + self._state_needs_save = True return state except (json.JSONDecodeError, IOError) as e: self._backup_corrupt_state_file(e) @@ -171,11 +203,18 @@ def _create_initial_state(self): "schema_version": self.CURRENT_SCHEMA_VERSION, "theme": "dark", "show_token_counter": True, - "ollama_chat_model": "qwen3:8b", + "ollama_chat_model": "", "ollama_title_model": "", "ollama_chart_model": "", "ollama_web_validate_model": "", "ollama_web_summarize_model": "", + "ollama_model_assignments": { + "task_title": {"mode": INHERIT_MODEL, "model_id": ""}, + "task_chat": {"mode": AUTO_MODEL, "model_id": ""}, + "task_chart": {"mode": INHERIT_MODEL, "model_id": ""}, + "task_web_validate": {"mode": INHERIT_MODEL, "model_id": ""}, + "task_web_summarize": {"mode": INHERIT_MODEL, "model_id": ""}, + }, "ollama_reasoning_mode": "Thinking", "ollama_scanned_models": [], "ollama_model_scan_mode": "", @@ -200,6 +239,7 @@ def _create_initial_state(self): "gemini_api_key": "", "github_access_token": "", "api_models": {}, + "api_models_by_provider": {}, "enable_system_prompt": True, "update_notifications_enabled": False, "notification_preferences": {notification_type: True for notification_type in self.NOTIFICATION_TYPES}, @@ -212,6 +252,58 @@ def _create_initial_state(self): self._save_state(state) return state + def _migrate_model_settings(self, state: dict) -> bool: + """Migrate legacy model strings without activating product defaults.""" + changed = False + raw_assignments = state.get("ollama_model_assignments") + if not isinstance(raw_assignments, dict): + raw_assignments = {} + for task in self.OLLAMA_MODEL_TASKS: + legacy_key = { + "task_title": "ollama_title_model", + "task_chat": "ollama_chat_model", + "task_chart": "ollama_chart_model", + "task_web_validate": "ollama_web_validate_model", + "task_web_summarize": "ollama_web_summarize_model", + }[task] + legacy_value = normalize_model_id(state.get(legacy_key, "")) + if legacy_value.lower() in self.LEGACY_PRODUCT_MODEL_IDS: + mode = AUTO_MODEL if task == "task_chat" else INHERIT_MODEL + raw_assignments[task] = ModelAssignment(mode).to_dict() + elif legacy_value: + raw_assignments[task] = ModelAssignment("explicit", legacy_value).to_dict() + else: + mode = AUTO_MODEL if task == "task_chat" else INHERIT_MODEL + raw_assignments[task] = ModelAssignment(mode).to_dict() + changed = True + + normalized = assignment_values(raw_assignments) + for task, value in list(normalized.items()): + assignment = ModelAssignment.from_value(value) + if assignment.mode == "explicit" and assignment.model_id.lower() in self.LEGACY_PRODUCT_MODEL_IDS: + normalized[task] = ModelAssignment( + AUTO_MODEL if task == "task_chat" else INHERIT_MODEL + ).to_dict() + if state.get("ollama_model_assignments") != normalized: + state["ollama_model_assignments"] = normalized + changed = True + + # Keep legacy fields synchronized for older builds that may inspect the + # state file, but never write a product-authored default into them. + for task, key in { + "task_title": "ollama_title_model", + "task_chat": "ollama_chat_model", + "task_chart": "ollama_chart_model", + "task_web_validate": "ollama_web_validate_model", + "task_web_summarize": "ollama_web_summarize_model", + }.items(): + assignment = ModelAssignment.from_value(normalized.get(task)) + legacy_value = assignment.model_id if assignment.mode == "explicit" else "" + if state.get(key, "") != legacy_value: + state[key] = legacy_value + changed = True + return changed + def _save_state(self, state_data=None): # Write to a temp file and atomically rename it into place (os.replace is # atomic on both Windows and POSIX when source/dest are on the same volume, @@ -318,55 +410,73 @@ def record_update_check_result(self, result: dict): self.state["update_available"] = bool(result.get("update_available", False)) self._save_state() + def get_ollama_model_assignments(self): + assignments = self.state.get("ollama_model_assignments", {}) + if not isinstance(assignments, dict): + return {} + return assignment_values(assignments) + + def set_ollama_model_assignments(self, assignments: dict): + normalized = assignment_values(assignments) + self.state["ollama_model_assignments"] = normalized + for task, key in { + "task_title": "ollama_title_model", + "task_chat": "ollama_chat_model", + "task_chart": "ollama_chart_model", + "task_web_validate": "ollama_web_validate_model", + "task_web_summarize": "ollama_web_summarize_model", + }.items(): + assignment = ModelAssignment.from_value(normalized.get(task)) + self.state[key] = assignment.model_id if assignment.mode == "explicit" else "" + self._save_state() + + def _get_ollama_model(self, task: str) -> str: + assignment = ModelAssignment.from_value( + self.get_ollama_model_assignments().get(task, {}) + ) + return assignment.model_id if assignment.mode == "explicit" else "" + + def _set_ollama_model(self, task: str, legacy_key: str, model_name: str): + model_id = normalize_model_id(model_name) + assignments = self.get_ollama_model_assignments() + if model_id and model_id.lower() not in self.LEGACY_PRODUCT_MODEL_IDS: + assignments[task] = ModelAssignment("explicit", model_id).to_dict() + else: + mode = AUTO_MODEL if task == "task_chat" else INHERIT_MODEL + assignments[task] = ModelAssignment(mode).to_dict() + self.state[legacy_key] = model_id if model_id.lower() not in self.LEGACY_PRODUCT_MODEL_IDS else "" + self.state["ollama_model_assignments"] = assignment_values(assignments) + self._save_state() + def get_ollama_chat_model(self): - return self.state.get("ollama_chat_model", "qwen3:8b") + return self._get_ollama_model("task_chat") def set_ollama_chat_model(self, model_name: str): - self.state['ollama_chat_model'] = model_name - self._save_state() + self._set_ollama_model("task_chat", "ollama_chat_model", model_name) def get_ollama_title_model(self): - title_model = str(self.state.get("ollama_title_model", "")).strip() - if title_model: - return title_model - return self.get_ollama_chat_model() + return self._get_ollama_model("task_title") def set_ollama_title_model(self, model_name: str): - self.state["ollama_title_model"] = str(model_name or "").strip() - self._save_state() + self._set_ollama_model("task_title", "ollama_title_model", model_name) def get_ollama_chart_model(self): - # Chart generation defaults to a code-specialized model, not the chat model - - # deepseek-coder:6.7b is a deliberately different default from qwen3:8b, not an - # unwired copy of it. Still fully overridable via OllamaSettingsWidget. - chart_model = str(self.state.get("ollama_chart_model", "")).strip() - if chart_model: - return chart_model - return "deepseek-coder:6.7b" + return self._get_ollama_model("task_chart") def set_ollama_chart_model(self, model_name: str): - self.state["ollama_chart_model"] = str(model_name or "").strip() - self._save_state() + self._set_ollama_model("task_chart", "ollama_chart_model", model_name) def get_ollama_web_validate_model(self): - web_model = str(self.state.get("ollama_web_validate_model", "")).strip() - if web_model: - return web_model - return self.get_ollama_chat_model() + return self._get_ollama_model("task_web_validate") def set_ollama_web_validate_model(self, model_name: str): - self.state["ollama_web_validate_model"] = str(model_name or "").strip() - self._save_state() + self._set_ollama_model("task_web_validate", "ollama_web_validate_model", model_name) def get_ollama_web_summarize_model(self): - web_model = str(self.state.get("ollama_web_summarize_model", "")).strip() - if web_model: - return web_model - return self.get_ollama_chat_model() + return self._get_ollama_model("task_web_summarize") def set_ollama_web_summarize_model(self, model_name: str): - self.state["ollama_web_summarize_model"] = str(model_name or "").strip() - self._save_state() + self._set_ollama_model("task_web_summarize", "ollama_web_summarize_model", model_name) def get_ollama_reasoning_mode(self): return self.state.get("ollama_reasoning_mode", "Thinking") @@ -542,8 +652,12 @@ def get_gemini_key(self): def get_github_token(self): return graphlink_secrets.unprotect(self.state.get("github_access_token", "")) - def get_api_models(self): - return self.state.get("api_models", {}) + def get_api_models(self, provider: str | None = None): + provider = provider or self.get_api_provider() + profiles = self.state.get("api_models_by_provider", {}) + if isinstance(profiles, dict): + return dict(profiles.get(provider, {}) or {}) + return dict(self.state.get("api_models", {}) or {}) def set_api_settings( self, @@ -560,8 +674,19 @@ def set_api_settings( self.state["gemini_api_key"] = graphlink_secrets.protect(gemini_key) self._save_state() - def set_api_models(self, models_dict: dict): - self.state["api_models"] = models_dict + def set_api_models(self, models_dict: dict, provider: str | None = None): + provider = provider or self.get_api_provider() + normalized = { + str(task): normalize_model_id(model) + for task, model in (models_dict or {}).items() + if normalize_model_id(model) + } + profiles = self.state.get("api_models_by_provider", {}) + if not isinstance(profiles, dict): + profiles = {} + profiles[provider] = normalized + self.state["api_models_by_provider"] = profiles + self.state["api_models"] = normalized self._save_state() def set_github_token(self, token: str): @@ -575,4 +700,5 @@ def reset_api_settings(self): self.state["anthropic_api_key"] = "" self.state["gemini_api_key"] = "" self.state["api_models"] = {} + self.state["api_models_by_provider"] = {} self._save_state() diff --git a/graphlink_app/graphlink_model_catalog.py b/graphlink_app/graphlink_model_catalog.py new file mode 100644 index 0000000..fd0c5d2 --- /dev/null +++ b/graphlink_app/graphlink_model_catalog.py @@ -0,0 +1,234 @@ +"""Provider-neutral model metadata and task-routing helpers. + +The settings UI and the request runtime used to pass around model IDs as opaque +strings. This module keeps the public model ID deliberately small while giving +the rest of the application a stable place for readiness, capability, and +selection semantics. Provider adapters can add richer metadata without making +the settings layer aware of a provider SDK. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable, Mapping + + +AUTO_MODEL = "auto" +INHERIT_MODEL = "inherit" + +CAPABILITY_TEXT = "text" +CAPABILITY_CODE = "code" +CAPABILITY_VISION = "vision" +CAPABILITY_AUDIO = "audio" +CAPABILITY_TOOLS = "tools" +CAPABILITY_REASONING = "reasoning" +CAPABILITY_IMAGE = "image" + + +TASK_REQUIREMENTS = { + "task_title": frozenset({CAPABILITY_TEXT}), + "task_chat": frozenset({CAPABILITY_TEXT}), + "task_chart": frozenset({CAPABILITY_TEXT, CAPABILITY_CODE}), + "task_image_gen": frozenset({CAPABILITY_IMAGE}), + "task_web_validate": frozenset({CAPABILITY_TEXT}), + "task_web_summarize": frozenset({CAPABILITY_TEXT}), +} + + +@dataclass(frozen=True) +class ModelDescriptor: + """A display and routing description for one model ID. + + ``ready`` is intentionally separate from ``available``: cloud catalog + entries can be selectable even when their endpoint is currently offline, + while a local model must be installed before it can be used. + """ + + model_id: str + provider: str = "" + ready: bool = True + available: bool = True + capabilities: frozenset[str] = field(default_factory=frozenset) + source: str = "catalog" + size_bytes: int | None = None + context_length: int | None = None + quantization: str = "" + details: Mapping[str, object] = field(default_factory=dict) + error: str = "" + + def supports(self, required: Iterable[str]) -> bool: + required = set(required or ()) + if not required: + return True + # Unknown capability metadata should not make a model disappear from + # the picker. The runtime/provider remains the final authority. + if not self.capabilities: + return True + return required.issubset(self.capabilities) + + @property + def display_name(self) -> str: + return self.model_id + + +@dataclass(frozen=True) +class ModelAssignment: + """Persistable task assignment with explicit inheritance semantics.""" + + mode: str = AUTO_MODEL + model_id: str = "" + + @classmethod + def from_value(cls, value) -> "ModelAssignment": + if isinstance(value, Mapping): + mode = str(value.get("mode", AUTO_MODEL) or AUTO_MODEL).strip().lower() + model_id = normalize_model_id(value.get("model_id", value.get("model", ""))) + if mode == "explicit" and not model_id: + mode = AUTO_MODEL + if mode not in {AUTO_MODEL, INHERIT_MODEL, "explicit"}: + mode = AUTO_MODEL + return cls(mode, model_id) + + model_id = normalize_model_id(value) + if not model_id or model_id.lower() in {AUTO_MODEL, INHERIT_MODEL}: + return cls(AUTO_MODEL if model_id != INHERIT_MODEL else INHERIT_MODEL) + return cls("explicit", model_id) + + def to_dict(self) -> dict[str, str]: + return {"mode": self.mode, "model_id": self.model_id} + + +def normalize_model_id(value) -> str: + return str(value or "").strip() + + +def normalize_assignments(values: Mapping | None) -> dict[str, ModelAssignment]: + values = values if isinstance(values, Mapping) else {} + return {str(task): ModelAssignment.from_value(value) for task, value in values.items()} + + +def assignment_values(values: Mapping | None) -> dict[str, dict[str, str]]: + return { + task: assignment.to_dict() + for task, assignment in normalize_assignments(values).items() + } + + +def _field(value, name: str, default=None): + if isinstance(value, Mapping): + return value.get(name, default) + return getattr(value, name, default) + + +def _as_int(value) -> int | None: + try: + return int(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def ollama_descriptor(model, *, provider: str = "Ollama") -> ModelDescriptor: + """Normalize an Ollama ``list()``/``show()`` result into a descriptor.""" + + model_id = normalize_model_id(_field(model, "model") or _field(model, "name")) + details = _field(model, "details", {}) or {} + capabilities = set() + raw_capabilities = _field(model, "capabilities") or _field(details, "capabilities", []) + if isinstance(raw_capabilities, str): + raw_capabilities = [raw_capabilities] + for capability in raw_capabilities or (): + normalized = str(capability).strip().lower().replace("-", "_") + aliases = { + "embedding": CAPABILITY_TEXT, + "completion": CAPABILITY_TEXT, + "image_generation": CAPABILITY_IMAGE, + "image": CAPABILITY_VISION, + "vision": CAPABILITY_VISION, + "tool": CAPABILITY_TOOLS, + "function_calling": CAPABILITY_TOOLS, + } + capabilities.add(aliases.get(normalized, normalized)) + + family = str(_field(details, "family", "") or "").lower() + if family: + capabilities.add(CAPABILITY_TEXT) + if "code" in family or "coder" in family: + capabilities.add(CAPABILITY_CODE) + if _field(details, "parameter_size"): + # A model with Ollama details is at least a usable text model unless + # the provider explicitly reports another modality. + capabilities.add(CAPABILITY_TEXT) + + return ModelDescriptor( + model_id=model_id, + provider=provider, + ready=True, + available=True, + capabilities=frozenset(capabilities), + source="installed", + size_bytes=_as_int(_field(model, "size")), + context_length=_as_int(_field(details, "context_length")), + quantization=str(_field(details, "quantization_level", "") or ""), + details=dict(details) if isinstance(details, Mapping) else {}, + ) + + +def sort_descriptors(descriptors: Iterable[ModelDescriptor]) -> list[ModelDescriptor]: + unique: dict[tuple[str, str], ModelDescriptor] = {} + for descriptor in descriptors or (): + if not isinstance(descriptor, ModelDescriptor): + continue + key = (descriptor.provider.lower(), descriptor.model_id.lower()) + if key[1] and (key not in unique or descriptor.ready): + unique[key] = descriptor + return sorted( + unique.values(), + key=lambda item: (not item.ready, not item.available, item.model_id.lower()), + ) + + +def choose_auto_model( + task: str, + catalog: Iterable[ModelDescriptor], + *, + preferred_model: str = "", +) -> str: + """Choose a deterministic ready model without provider-specific defaults.""" + + candidates = [ + item + for item in sort_descriptors(catalog) + if item.ready and item.available and item.supports(TASK_REQUIREMENTS.get(task, ())) + ] + if not candidates: + return "" + preferred_model = normalize_model_id(preferred_model).lower() + if preferred_model: + for item in candidates: + if item.model_id.lower() == preferred_model: + return item.model_id + # Prefer a known text/capability match, then stable alphabetical order. + candidates.sort(key=lambda item: (not bool(item.capabilities), item.model_id.lower())) + return candidates[0].model_id + + +def resolve_task_model( + task: str, + assignments: Mapping | None, + catalog: Iterable[ModelDescriptor] = (), + *, + chat_model: str = "", +) -> str: + """Resolve explicit, inherited, or automatic task routing.""" + + normalized = normalize_assignments(assignments) + assignment = normalized.get(task, ModelAssignment()) + if assignment.mode == "explicit" and assignment.model_id: + return assignment.model_id + if assignment.mode == INHERIT_MODEL: + chat_assignment = normalized.get("task_chat", ModelAssignment()) + if chat_assignment.mode == "explicit" and chat_assignment.model_id: + return chat_assignment.model_id + if chat_model: + return normalize_model_id(chat_model) + return choose_auto_model(task, catalog, preferred_model=chat_model if task != "task_chat" else "") diff --git a/graphlink_app/graphlink_ui_dialogs/graphlink_settings_dialogs.py b/graphlink_app/graphlink_ui_dialogs/graphlink_settings_dialogs.py index baadeae..25d5c18 100644 --- a/graphlink_app/graphlink_ui_dialogs/graphlink_settings_dialogs.py +++ b/graphlink_app/graphlink_ui_dialogs/graphlink_settings_dialogs.py @@ -1,7 +1,7 @@ import os import webbrowser import qtawesome as qta -from PySide6.QtCore import QPoint, QSize, Qt, QThread, Signal +from PySide6.QtCore import QPoint, QSize, Qt, QThread, QTimer, Signal from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QButtonGroup, QCheckBox, QComboBox, QFileDialog, QFormLayout, @@ -17,6 +17,7 @@ from graphlink_config import apply_theme, get_current_palette, get_semantic_color, set_current_model from graphlink_update import APP_VERSION, UPDATE_REPOSITORY_URL from graphlink_paths import asset_url +from graphlink_model_catalog import AUTO_MODEL, INHERIT_MODEL, ModelAssignment class SettingsComboPopup(QFrame): @@ -52,6 +53,13 @@ def __init__(self, parent=None): shell_layout.setContentsMargins(4, 4, 4, 4) shell_layout.setSpacing(0) + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search models...") + self.search_input.setClearButtonEnabled(True) + self.search_input.textChanged.connect(self._filter_items) + self.search_input.returnPressed.connect(self._activate_current_item) + shell_layout.addWidget(self.search_input) + self.list_widget = QListWidget() self.list_widget.setObjectName("settingsComboPopupList") self.list_widget.setFrameShape(QFrame.Shape.NoFrame) @@ -102,6 +110,14 @@ def apply_style(self, accent_color): background-color: {accent_color}; color: #ffffff; }} + QLineEdit {{ + background-color: #242424; + color: #ffffff; + border: 1px solid #4a4a4a; + border-radius: 6px; + padding: 6px 8px; + margin: 2px 2px 6px 2px; + }} """) def populate_from_combo(self, combo): @@ -109,6 +125,9 @@ def populate_from_combo(self, combo): current_text = combo.currentText() self.list_widget.clear() + self.search_input.blockSignals(True) + self.search_input.clear() + self.search_input.blockSignals(False) for index in range(combo.count()): text = combo.itemText(index) item = QListWidgetItem(text) @@ -126,6 +145,22 @@ def populate_from_combo(self, combo): else: self.list_widget.clearSelection() + def _filter_items(self, query): + query = str(query or "").strip().lower() + for index in range(self.list_widget.count()): + item = self.list_widget.item(index) + item.setHidden(bool(query and query not in item.text().lower())) + for index in range(self.list_widget.count()): + item = self.list_widget.item(index) + if not item.isHidden(): + self.list_widget.setCurrentItem(item) + break + + def _activate_current_item(self): + item = self.list_widget.currentItem() + if item is not None and not item.isHidden(): + self._emit_item_selection(item) + def show_for_combo(self, combo): self.populate_from_combo(combo) if self.list_widget.count() == 0: @@ -166,7 +201,7 @@ def show_for_combo(self, combo): self.move(x, y) self.show() self.raise_() - self.list_widget.setFocus() + self.search_input.setFocus() def _emit_item_selection(self, item): if item is None: @@ -258,6 +293,41 @@ def run(self): self.error.emit(str(exc)) +class ApiModelLoadWorker(QThread): + finished = Signal(list) + error = Signal(str) + + def __init__(self, provider, api_key, base_url=None, parent=None): + super().__init__(parent) + self.provider = provider + self.api_key = api_key + self.base_url = base_url + + def run(self): + try: + # Discovery is deliberately isolated from the GUI thread. It also + # exercises the same provider initialization path used by Save, so a + # successful catalog load is a useful connection check. + api_provider.initialize_api( + self.provider, + self.api_key, + self.base_url if self.provider == config.API_PROVIDER_OPENAI else None, + ) + descriptors = api_provider.get_available_model_descriptors() + self.finished.emit([ + { + "model_id": descriptor.model_id, + "provider": descriptor.provider, + "capabilities": sorted(descriptor.capabilities), + "ready": descriptor.ready, + "available": descriptor.available, + } + for descriptor in descriptors + ]) + except Exception as exc: + self.error.emit(str(exc)) + + class LlamaCppModelScanWorker(QThread): finished = Signal(dict) error = Signal(str) @@ -329,16 +399,15 @@ def __init__(self, settings_manager, parent=None): else: self.quick_radio.setChecked(True) - self._refresh_scanned_ollama_models_cache() - saved_model = self.settings_manager.get_ollama_chat_model() - saved_title_model = self.settings_manager.get_ollama_title_model() - saved_chart_model = self.settings_manager.get_ollama_chart_model() - saved_web_validate_model = self.settings_manager.get_ollama_web_validate_model() - saved_web_summarize_model = self.settings_manager.get_ollama_web_summarize_model() + self.saved_assignments = ( + self.settings_manager.get_ollama_model_assignments() + if hasattr(self.settings_manager, "get_ollama_model_assignments") + else {} + ) self.models = self._build_model_cache() - self.current_model_label = QLabel(f"{saved_model}") + self.current_model_label = QLabel(f"{saved_model or 'Auto — no installed model selected yet'}") self.current_model_label.setStyleSheet(f"color: {get_semantic_color('status_success').name()};") form_layout.addRow("Current Active Chat Model:", self.current_model_label) @@ -358,52 +427,46 @@ def __init__(self, settings_manager, parent=None): form_layout.addRow("", self.scan_summary_label) self.model_combo = SettingsComboBox() + self.model_combo.setEditable(True) + self.model_combo.setPlaceholderText("Select an installed model or enter a model ID") self.model_combo.addItems([""] + self.models) self.model_combo.currentTextChanged.connect(self.on_combo_change) - form_layout.addRow("Scanned Model:", self.model_combo) + self.model_combo.setCurrentText(saved_model) + form_layout.addRow("Chat Model:", self.model_combo) self.model_input = QLineEdit() - self.model_input.setPlaceholderText("e.g., llama3:latest") + self.model_input.setPlaceholderText("Advanced model ID entry") self.model_input.textChanged.connect(self.on_text_change) - form_layout.addRow("Custom Model Name:", self.model_input) + self.model_input.setVisible(False) + form_layout.addRow("", self.model_input) self.title_model_combo = SettingsComboBox() - self.title_model_combo.setEditable(True) - self.title_model_combo.addItems([""] + self.models) - self.title_model_combo.setCurrentText(saved_title_model) + self._configure_assignment_combo(self.title_model_combo, "task_title") form_layout.addRow("Chat Naming Model:", self.title_model_combo) self.chart_model_combo = SettingsComboBox() - self.chart_model_combo.setEditable(True) - self.chart_model_combo.addItems([""] + self.models) - self.chart_model_combo.setCurrentText(saved_chart_model) + self._configure_assignment_combo(self.chart_model_combo, "task_chart") form_layout.addRow("Chart Generation Model:", self.chart_model_combo) self.web_validate_model_combo = SettingsComboBox() - self.web_validate_model_combo.setEditable(True) - self.web_validate_model_combo.addItems([""] + self.models) - self.web_validate_model_combo.setCurrentText(saved_web_validate_model) + self._configure_assignment_combo(self.web_validate_model_combo, "task_web_validate") form_layout.addRow("Web Content Validation Model:", self.web_validate_model_combo) self.web_summarize_model_combo = SettingsComboBox() - self.web_summarize_model_combo.setEditable(True) - self.web_summarize_model_combo.addItems([""] + self.models) - self.web_summarize_model_combo.setCurrentText(saved_web_summarize_model) + self._configure_assignment_combo(self.web_summarize_model_combo, "task_web_summarize") form_layout.addRow("Web Content Summarization Model:", self.web_summarize_model_combo) layout.addLayout(form_layout) self.model_input.setText(saved_model) - naming_help = QLabel("Used to name new chats. It starts with the active chat model, and you can override it independently.") + naming_help = QLabel("Each task can inherit the chat model, choose Auto, or use an explicit installed/custom model. Missing models stay visible as unavailable instead of silently changing routes.") naming_help.setWordWrap(True) naming_help.setStyleSheet("color: #9fa6ad; margin-top: 2px;") layout.addWidget(naming_help) task_models_help = QLabel( - "Chart Generation starts with a code-capable default (deepseek-coder:6.7b). " - "Web Content Validation and Summarization (used by Graphlink-Web) start with the active chat model. " - "All three can be overridden independently and are never silently changed when you switch chat models." + "Auto chooses from models detected on this machine. Ollama model IDs are never assumed or downloaded implicitly; use Validate and Pull for a custom ID." ) task_models_help.setWordWrap(True) task_models_help.setStyleSheet("color: #9fa6ad; margin-top: 2px;") @@ -432,6 +495,7 @@ def __init__(self, settings_manager, parent=None): layout.addLayout(button_layout) self.on_theme_changed() + QTimer.singleShot(0, self.scan_system_for_models) def on_theme_changed(self): palette = get_current_palette() @@ -460,6 +524,30 @@ def on_theme_changed(self): }} """) + def _configure_assignment_combo(self, combo, task): + combo.setEditable(True) + combo.clear() + combo.addItem("Use chat model", INHERIT_MODEL) + combo.addItem("Auto — choose a compatible installed model", AUTO_MODEL) + for model in self.models: + combo.addItem(model, model) + assignment = ModelAssignment.from_value(self.saved_assignments.get(task, {})) + if assignment.mode == "explicit" and assignment.model_id: + combo.setCurrentText(assignment.model_id) + else: + target_mode = assignment.mode if assignment.mode in {INHERIT_MODEL, AUTO_MODEL} else INHERIT_MODEL + index = combo.findData(target_mode) + combo.setCurrentIndex(index if index >= 0 else 0) + + def _assignment_from_combo(self, combo, *, default_mode=INHERIT_MODEL): + data = combo.currentData() + text = combo.currentText().strip() + if data in {INHERIT_MODEL, AUTO_MODEL}: + return ModelAssignment(str(data)) + if text and text not in {"Use chat model", "Auto — choose a compatible installed model"}: + return ModelAssignment("explicit", text) + return ModelAssignment(default_mode) + def _build_model_cache(self): cached_models = self.settings_manager.get_ollama_scanned_models() combined_models = { @@ -469,29 +557,6 @@ def _build_model_cache(self): } return sorted(combined_models, key=str.lower) - def _refresh_scanned_ollama_models_cache(self): - try: - results = api_provider.scan_local_ollama_models() - except Exception: - return - - discovered = sorted( - { - str(model).strip() - for model in (results.get("models", []) or []) - if str(model).strip() - }, - key=str.lower, - ) - scan_mode = str(results.get("scan_mode", "system")).strip() or "system" - scan_path = str(results.get("scan_path", "")).strip() - locations = [ - str(location).strip() - for location in (results.get("locations", []) or []) - if str(location).strip() - ] - self.settings_manager.set_ollama_model_scan_cache(discovered, scan_mode, scan_path, locations) - def _get_scan_summary_text(self): scan_mode = self.settings_manager.get_ollama_model_scan_mode() scan_path = self.settings_manager.get_ollama_model_scan_path() @@ -509,30 +574,30 @@ def _get_scan_summary_text(self): def _refresh_model_combos(self): current_model_text = self.model_input.text().strip() - current_title_text = self.title_model_combo.currentText().strip() - current_chart_text = self.chart_model_combo.currentText().strip() - current_web_validate_text = self.web_validate_model_combo.currentText().strip() - current_web_summarize_text = self.web_summarize_model_combo.currentText().strip() self.model_combo.blockSignals(True) self.model_combo.clear() self.model_combo.addItems([""] + self.models) + self.model_combo.setEditable(True) + self.model_combo.setPlaceholderText("Select an installed model or enter a model ID") if current_model_text in self.models: self.model_combo.setCurrentText(current_model_text) else: - self.model_combo.setCurrentIndex(0) + self.model_combo.setCurrentText(current_model_text) self.model_combo.blockSignals(False) - for combo, current_text in ( - (self.title_model_combo, current_title_text), - (self.chart_model_combo, current_chart_text), - (self.web_validate_model_combo, current_web_validate_text), - (self.web_summarize_model_combo, current_web_summarize_text), + for combo, task in ( + (self.title_model_combo, "task_title"), + (self.chart_model_combo, "task_chart"), + (self.web_validate_model_combo, "task_web_validate"), + (self.web_summarize_model_combo, "task_web_summarize"), ): + current_assignment = self._assignment_from_combo(combo) + self.saved_assignments[task] = current_assignment.to_dict() combo.blockSignals(True) - combo.clear() - combo.addItems([""] + self.models) - combo.setCurrentText(current_text) + self._configure_assignment_combo(combo, task) + if current_assignment.mode == "explicit": + combo.setCurrentText(current_assignment.model_id) combo.blockSignals(False) self.scan_summary_label.setText(self._get_scan_summary_text()) @@ -581,10 +646,17 @@ def handle_scan_finished(self, results): ) self.models = self._build_model_cache() self._refresh_model_combos() + config.sync_ollama_task_models(self.settings_manager) + resolved_chat_model = config.OLLAMA_MODELS.get(config.TASK_CHAT, "") + self.current_model_label.setText( + f"{resolved_chat_model or 'Auto — no compatible installed model found'}" + ) self._set_scan_buttons_enabled(True) if models: - self.status_label.setText(f"Found {len(models)} Ollama model(s). Saved this list for reuse until the next scan.") + self.status_label.setText( + f"Found {len(models)} Ollama model(s). Auto routing is now ready; saved selections were preserved." + ) self.status_label.setStyleSheet( f"color: {get_semantic_color('status_success').name()}; min-height: 40px;" ) @@ -603,32 +675,35 @@ def handle_scan_error(self, error_message): self.scan_worker = None def save_settings(self): - model_name = self.model_input.text().strip() - if not model_name: - QMessageBox.warning(self, "Warning", "Model name cannot be empty.") - return - + model_name = self.model_combo.currentText().strip() or self.model_input.text().strip() reasoning_mode = "Thinking" if self.thinking_radio.isChecked() else "Quick" - title_model_name = self.title_model_combo.currentText().strip() or model_name - chart_model_name = self.chart_model_combo.currentText().strip() or self.settings_manager.get_ollama_chart_model() - web_validate_model_name = self.web_validate_model_combo.currentText().strip() or model_name - web_summarize_model_name = self.web_summarize_model_combo.currentText().strip() or model_name - - self.settings_manager.set_ollama_chat_model(model_name) - self.settings_manager.set_ollama_title_model(title_model_name) - self.settings_manager.set_ollama_chart_model(chart_model_name) - self.settings_manager.set_ollama_web_validate_model(web_validate_model_name) - self.settings_manager.set_ollama_web_summarize_model(web_summarize_model_name) + assignments = { + "task_chat": ModelAssignment("explicit", model_name).to_dict() if model_name else ModelAssignment(AUTO_MODEL).to_dict(), + "task_title": self._assignment_from_combo(self.title_model_combo).to_dict(), + "task_chart": self._assignment_from_combo(self.chart_model_combo).to_dict(), + "task_web_validate": self._assignment_from_combo(self.web_validate_model_combo).to_dict(), + "task_web_summarize": self._assignment_from_combo(self.web_summarize_model_combo).to_dict(), + } + + if hasattr(self.settings_manager, "set_ollama_model_assignments"): + self.settings_manager.set_ollama_model_assignments(assignments) + else: + self.settings_manager.set_ollama_chat_model(model_name) self.settings_manager.set_ollama_reasoning_mode(reasoning_mode) - set_current_model(model_name) + if model_name: + set_current_model(model_name) config.sync_ollama_task_models(self.settings_manager) main_window = self.window().parent() if main_window and hasattr(main_window, 'reinitialize_agent'): main_window.reinitialize_agent() - self.current_model_label.setText(f"{model_name}") - QMessageBox.information(self, "Saved", "Ollama settings have been saved and applied for the current session.") + self.current_model_label.setText(f"{model_name or 'Auto — waiting for a detected model'}") + self.status_label.setText( + "Settings saved. Auto will resolve from the next successful Ollama discovery." if not model_name + else "Settings saved and applied for the current session." + ) + self.status_label.setStyleSheet(f"color: {get_semantic_color('status_success').name()}; min-height: 40px;") def on_combo_change(self, text): if not text: return @@ -1127,6 +1202,8 @@ class ApiSettingsWidget(QWidget): def __init__(self, settings_manager, parent=None): super().__init__(parent) self.settings_manager = settings_manager + self.api_worker = None + self.api_worker_provider = None layout = QVBoxLayout(self) layout.setContentsMargins(15, 15, 15, 15) @@ -1164,6 +1241,10 @@ def __init__(self, settings_manager, parent=None): self.load_btn = QPushButton("Load Available Models") self.load_btn.clicked.connect(self.load_models_from_endpoint) layout.addWidget(self.load_btn) + self.discovery_status_label = QLabel("Model catalog has not been refreshed yet.") + self.discovery_status_label.setWordWrap(True) + self.discovery_status_label.setStyleSheet("color: #9fa6ad;") + layout.addWidget(self.discovery_status_label) layout.addWidget(QLabel("Model Selection (per task):", styleSheet="color: #ffffff; font-weight: bold; margin-top: 15px;")) @@ -1198,7 +1279,7 @@ def __init__(self, settings_manager, parent=None): layout.addWidget(self.image_help_label) layout.addWidget(QLabel("Web Content Validation:", styleSheet="color: #d4d4d4; margin-top: 8px;")) - self.web_validate_combo = SettingsComboBox(placeholder_text="Default: gemini-3.1-flash-lite-preview") + self.web_validate_combo = SettingsComboBox(placeholder_text="Select a validation model...") self.web_validate_combo.setEditable(True) self.model_combos[config.TASK_WEB_VALIDATE] = self.web_validate_combo layout.addWidget(self.web_validate_combo) @@ -1228,7 +1309,7 @@ def __init__(self, settings_manager, parent=None): self.restore_saved_models() def restore_saved_models(self): - saved_models = self.settings_manager.get_api_models() + saved_models = self.settings_manager.get_api_models(self.provider_combo.currentText()) provider = self.provider_combo.currentText() for task, combo in self.model_combos.items(): if provider == config.API_PROVIDER_ANTHROPIC and task == config.TASK_IMAGE_GEN: @@ -1306,20 +1387,19 @@ def _on_provider_changed(self, provider_name): combo.clear() combo.addItems(api_provider.GEMINI_MODELS_STATIC) self.web_validate_combo.addItems(api_provider.GEMINI_MODELS_STATIC) - default_idx = self.web_validate_combo.findText("gemini-3.1-flash-lite-preview") - if default_idx >= 0: - self.web_validate_combo.setCurrentIndex(default_idx) self.image_combo.clear() self.image_combo.addItems(api_provider.GEMINI_IMAGE_MODELS_STATIC) - image_default_idx = self.image_combo.findText("gemini-2.5-flash-image") - if image_default_idx >= 0: - self.image_combo.setCurrentIndex(image_default_idx) self._configure_supported_image_state(provider_name) + self.discovery_status_label.setText( + "Choose Refresh to load the provider's current catalog. Saved IDs remain available as unverified custom selections." + ) self.restore_saved_models() def load_models_from_endpoint(self): + if self.api_worker and self.api_worker.isRunning(): + return provider = self.provider_combo.currentText() base_url = self.base_url_input.text().strip() api_key = self.api_key_input.text().strip() @@ -1331,19 +1411,53 @@ def load_models_from_endpoint(self): QMessageBox.warning(self, "Missing Information", "Please enter the API Key.") return - try: - api_provider.initialize_api(provider, api_key, base_url if provider == config.API_PROVIDER_OPENAI else None) - models = api_provider.get_available_models() - - if provider == config.API_PROVIDER_OPENAI: - self._populate_models(models) - elif provider == config.API_PROVIDER_ANTHROPIC: - self._populate_models(models, skip_tasks={config.TASK_IMAGE_GEN}) - - self.restore_saved_models() - QMessageBox.information(self, "Models Loaded", f"Successfully loaded {len(models)} models!") - except Exception as e: - QMessageBox.critical(self, "Failed to Load Models", f"Could not fetch models from API:\n\n{str(e)}") + self.load_btn.setEnabled(False) + self.load_btn.setText("Loading catalog…") + self.discovery_status_label.setText("Contacting the provider… You can keep editing other settings.") + self.discovery_status_label.setStyleSheet(f"color: {get_semantic_color('status_info').name()};") + self.api_worker = ApiModelLoadWorker( + provider, + api_key, + base_url if provider == config.API_PROVIDER_OPENAI else None, + self, + ) + self.api_worker_provider = provider + self.api_worker.finished.connect(self.handle_models_loaded) + self.api_worker.error.connect(self.handle_models_load_error) + self.api_worker.finished.connect(self._clear_api_worker) + self.api_worker.error.connect(self._clear_api_worker) + self.api_worker.start() + + def handle_models_loaded(self, descriptors): + if self.api_worker_provider != self.provider_combo.currentText(): + return + models = [item.get("model_id", "") for item in descriptors if item.get("model_id")] + provider = self.provider_combo.currentText() + if provider == config.API_PROVIDER_OPENAI: + self._populate_models(models) + elif provider == config.API_PROVIDER_ANTHROPIC: + self._populate_models(models, skip_tasks={config.TASK_IMAGE_GEN}) + else: + self._populate_models(models, skip_tasks={config.TASK_IMAGE_GEN}) + self.image_combo.clear() + self.image_combo.addItems(api_provider.GEMINI_IMAGE_MODELS_STATIC) + self.restore_saved_models() + self.discovery_status_label.setText(f"Catalog refreshed — {len(models)} model(s) available from {provider}.") + self.discovery_status_label.setStyleSheet(f"color: {get_semantic_color('status_success').name()};") + + def handle_models_load_error(self, error_message): + if self.api_worker_provider != self.provider_combo.currentText(): + return + self.discovery_status_label.setText( + f"Catalog refresh failed: {error_message}\nSaved/custom model IDs remain usable if the endpoint supports them." + ) + self.discovery_status_label.setStyleSheet(f"color: {get_semantic_color('status_warning').name()};") + + def _clear_api_worker(self, *_args): + self.load_btn.setEnabled(True) + self.load_btn.setText("Refresh Available Models") + self.api_worker = None + self.api_worker_provider = None def save_settings(self): provider = self.provider_combo.currentText() @@ -1364,9 +1478,7 @@ def save_settings(self): anthropic_key = api_key if provider == config.API_PROVIDER_ANTHROPIC else self.settings_manager.get_anthropic_key() gemini_key = api_key if provider == config.API_PROVIDER_GEMINI else self.settings_manager.get_gemini_key() - self.settings_manager.set_api_settings(provider, base_url, openai_key, anthropic_key, gemini_key) - - models_dict = dict(self.settings_manager.get_api_models()) + models_dict = dict(self.settings_manager.get_api_models(provider)) for task_key, combo in self.model_combos.items(): if provider == config.API_PROVIDER_ANTHROPIC and task_key == config.TASK_IMAGE_GEN: continue @@ -1374,7 +1486,19 @@ def save_settings(self): models_dict[task_key] = combo.currentText() api_provider.set_task_model(task_key, combo.currentText()) - self.settings_manager.set_api_models(models_dict) + try: + if provider == config.API_PROVIDER_OPENAI: + api_provider.initialize_api(provider, api_key, base_url) + else: + api_provider.initialize_api(provider, api_key) + except Exception as e: + QMessageBox.critical(self, "Initialization Error", f"Failed to initialize the API provider:\n\n{str(e)}") + return + + # Commit only after provider initialization succeeds. A rejected key or + # endpoint must not overwrite the last known-good settings profile. + self.settings_manager.set_api_settings(provider, base_url, openai_key, anthropic_key, gemini_key) + self.settings_manager.set_api_models(models_dict, provider) os.environ['GRAPHLINK_API_PROVIDER'] = provider if provider == config.API_PROVIDER_OPENAI: @@ -1384,15 +1508,6 @@ def save_settings(self): os.environ['GRAPHLINK_ANTHROPIC_API_KEY'] = api_key else: os.environ['GRAPHLINK_GEMINI_API_KEY'] = api_key - - try: - if provider == config.API_PROVIDER_OPENAI: - api_provider.initialize_api(provider, api_key, base_url) - else: - api_provider.initialize_api(provider, api_key) - except Exception as e: - QMessageBox.critical(self, "Initialization Error", f"Failed to initialize the API provider:\n\n{str(e)}") - return QMessageBox.information(self, "Configuration Saved", f"API settings for {provider} have been saved.") diff --git a/graphlink_app/graphlink_window.py b/graphlink_app/graphlink_window.py index b937e8f..9b83e98 100644 --- a/graphlink_app/graphlink_window.py +++ b/graphlink_app/graphlink_window.py @@ -858,7 +858,7 @@ def _initialize_mode(self, mode_text, *, show_dialogs): provider = self.settings_manager.get_api_provider() base_url = self.settings_manager.get_api_base_url() - saved_models = self.settings_manager.get_api_models() + saved_models = self.settings_manager.get_api_models(provider) for task, model_name in saved_models.items(): api_provider.set_task_model(task, model_name) diff --git a/graphlink_app/tests/test_model_catalog.py b/graphlink_app/tests/test_model_catalog.py new file mode 100644 index 0000000..ab48d60 --- /dev/null +++ b/graphlink_app/tests/test_model_catalog.py @@ -0,0 +1,88 @@ +"""Tests for provider-neutral model selection and legacy migration.""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from graphlink_model_catalog import ( + AUTO_MODEL, + INHERIT_MODEL, + ModelAssignment, + ModelDescriptor, + choose_auto_model, + resolve_task_model, +) +from graphlink_licensing import SettingsManager +import api_provider + + +def test_auto_selection_uses_detected_models_and_is_deterministic(): + catalog = [ + ModelDescriptor("zeta:latest", provider="Ollama"), + ModelDescriptor("alpha:latest", provider="Ollama"), + ] + assert choose_auto_model("task_chat", catalog) == "alpha:latest" + + +def test_explicit_assignment_wins_over_auto_catalog(): + assignments = {"task_chat": ModelAssignment("explicit", "user-model").to_dict()} + catalog = [ModelDescriptor("detected-model", provider="Ollama")] + assert resolve_task_model("task_chat", assignments, catalog) == "user-model" + + +def test_inherit_assignment_resolves_to_chat_model(): + assignments = { + "task_chat": ModelAssignment("explicit", "chat-model").to_dict(), + "task_chart": ModelAssignment(INHERIT_MODEL).to_dict(), + } + assert resolve_task_model("task_chart", assignments) == "chat-model" + + +def test_legacy_product_defaults_migrate_to_auto_and_inherit(tmp_path): + state_file = tmp_path / "session.dat" + state_file.write_text( + json.dumps({ + "ollama_chat_model": "qwen3:8b", + "ollama_chart_model": "deepseek-coder:6.7b", + "ollama_title_model": "", + "ollama_web_validate_model": "", + "ollama_web_summarize_model": "", + }), + encoding="utf-8", + ) + + manager = SettingsManager(state_file) + + assert manager.get_ollama_chat_model() == "" + assert manager.get_ollama_chart_model() == "" + assignments = manager.get_ollama_model_assignments() + assert assignments["task_chat"]["mode"] == AUTO_MODEL + assert assignments["task_chart"]["mode"] == INHERIT_MODEL + assert json.loads(state_file.read_text(encoding="utf-8"))["schema_version"] == 2 + + +def test_provider_model_profiles_are_isolated(tmp_path): + manager = SettingsManager(tmp_path / "session.dat") + manager.set_api_models({"task_chat": "openai-model"}, "OpenAI-Compatible") + manager.set_api_models({"task_chat": "claude-model"}, "Anthropic Claude") + + assert manager.get_api_models("OpenAI-Compatible")["task_chat"] == "openai-model" + assert manager.get_api_models("Anthropic Claude")["task_chat"] == "claude-model" + + +def test_ollama_scan_returns_health_and_normalized_descriptors(monkeypatch): + monkeypatch.setattr( + api_provider.ollama, + "list", + lambda: {"models": [{"name": "llama3:latest", "size": 1234}]}, + ) + monkeypatch.setattr(api_provider, "_iter_existing_ollama_manifest_roots", lambda: []) + + result = api_provider.scan_local_ollama_models() + + assert result["server_reachable"] is True + assert result["models"] == ["llama3:latest"] + assert result["descriptors"][0]["model_id"] == "llama3:latest" + assert result["descriptors"][0]["size_bytes"] == 1234 diff --git a/graphlink_app/tests/test_ollama_task_model_settings.py b/graphlink_app/tests/test_ollama_task_model_settings.py index 29e15ec..952791a 100644 --- a/graphlink_app/tests/test_ollama_task_model_settings.py +++ b/graphlink_app/tests/test_ollama_task_model_settings.py @@ -1,4 +1,4 @@ -"""Tests for per-task Ollama model settings (Chart, Web Validation, Web Summarization). +"""Regression coverage for Ollama task routing and no-default semantics. Earlier, config.OLLAMA_MODELS hardcoded a literal default for every Ollama task, and set_current_model() only ever updated TASK_CHAT. TASK_WEB_VALIDATE/TASK_WEB_SUMMARIZE @@ -18,8 +18,8 @@ other tasks' explicit settings. 2. sync_ollama_task_models() correctly pulls each task's model from the settings manager. -3. SettingsManager's new get/set methods round-trip correctly and fall back - sensibly when nothing has been explicitly set. +3. SettingsManager's new get/set methods round-trip correctly without hidden + product-authored fallback models. """ import sys @@ -79,37 +79,37 @@ def test_reads_each_task_from_the_settings_manager(self, monkeypatch, tmp_path): assert config.OLLAMA_MODELS[config.TASK_WEB_VALIDATE] == "phi4:14b" assert config.OLLAMA_MODELS[config.TASK_WEB_SUMMARIZE] == "mistral:7b" - def test_falls_back_when_nothing_explicitly_set(self, monkeypatch, tmp_path): + def test_auto_tasks_stay_unconfigured_until_discovery(self, monkeypatch, tmp_path): manager = _make_settings_manager(tmp_path) manager.set_ollama_chat_model("llama3.1:8b") config.sync_ollama_task_models(manager) - assert config.OLLAMA_MODELS[config.TASK_CHART] == "deepseek-coder:6.7b" - assert config.OLLAMA_MODELS[config.TASK_WEB_VALIDATE] == "llama3.1:8b" - assert config.OLLAMA_MODELS[config.TASK_WEB_SUMMARIZE] == "llama3.1:8b" + assert config.OLLAMA_MODELS[config.TASK_CHART] == "" + assert config.OLLAMA_MODELS[config.TASK_WEB_VALIDATE] == "" + assert config.OLLAMA_MODELS[config.TASK_WEB_SUMMARIZE] == "" class TestSettingsManagerOllamaTaskModelMethods: - def test_chart_model_round_trips_and_defaults_to_code_specialized_model(self, tmp_path): + def test_chart_model_round_trips_and_defaults_to_auto(self, tmp_path): manager = _make_settings_manager(tmp_path) - assert manager.get_ollama_chart_model() == "deepseek-coder:6.7b" + assert manager.get_ollama_chart_model() == "" manager.set_ollama_chart_model("qwen2.5-coder:7b") assert manager.get_ollama_chart_model() == "qwen2.5-coder:7b" - def test_web_validate_model_round_trips_and_falls_back_to_chat_model(self, tmp_path): + def test_web_validate_model_round_trips_without_implicit_chat_fallback(self, tmp_path): manager = _make_settings_manager(tmp_path) manager.set_ollama_chat_model("llama3.1:8b") - assert manager.get_ollama_web_validate_model() == "llama3.1:8b" + assert manager.get_ollama_web_validate_model() == "" manager.set_ollama_web_validate_model("phi4:14b") assert manager.get_ollama_web_validate_model() == "phi4:14b" - def test_web_summarize_model_round_trips_and_falls_back_to_chat_model(self, tmp_path): + def test_web_summarize_model_round_trips_without_implicit_chat_fallback(self, tmp_path): manager = _make_settings_manager(tmp_path) manager.set_ollama_chat_model("llama3.1:8b") - assert manager.get_ollama_web_summarize_model() == "llama3.1:8b" + assert manager.get_ollama_web_summarize_model() == "" manager.set_ollama_web_summarize_model("mistral:7b") assert manager.get_ollama_web_summarize_model() == "mistral:7b"