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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 64 additions & 16 deletions graphlink_app/api_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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] = []
Expand All @@ -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 "",
}


Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
66 changes: 43 additions & 23 deletions graphlink_app/graphlink_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Loading