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"