diff --git a/README.md b/README.md index ced6f45..3839220 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,16 @@ Exports are mapped to target formats without lock-in. ### Local-first execution Your hardware and selected profile determine latency/quality. +Automatic selection uses the smallest effective local profile: + +- Fast: `gemma4:e2b-it-qat` for unknown or under-16-GB hardware and Intel Macs +- Balanced: `gemma4:e4b-it-qat` for Apple Silicon with 16 GB or more +- Advanced: explicit opt-in only; it is never selected automatically + +An explicit `CHROMEAI_OLLAMA_MODEL` override remains authoritative. If that +model is unavailable locally, SelectPilot reports the missing model instead of +silently substituting another model. + --- ## Tiers diff --git a/scripts/install-macos-local.sh b/scripts/install-macos-local.sh index 3e74fbb..a3d1317 100755 --- a/scripts/install-macos-local.sh +++ b/scripts/install-macos-local.sh @@ -6,7 +6,7 @@ TEMPLATE="$ROOT/launchd/com.chromeai.nano.plist" DEST="${HOME}/Library/LaunchAgents/com.chromeai.nano.plist" HASH="$(shasum -a 256 "$ROOT/server/nano_server.py" | awk '{print $1}')" OLLAMA_BASE_URL="${CHROMEAI_OLLAMA_BASE_URL:-http://127.0.0.1:11434}" -OLLAMA_MODEL="${CHROMEAI_OLLAMA_MODEL:-qwen2.5:0.5b}" +OLLAMA_MODEL="${CHROMEAI_OLLAMA_MODEL:-gemma4:e2b-it-qat}" OLLAMA_EMBED_MODEL="${CHROMEAI_OLLAMA_EMBED_MODEL:-nomic-embed-text-v2-moe:latest}" RUN_DIR="${CHROMEAI_RUN_DIR:-${HOME}/Library/Application Support/SelectPilot/run}" LOG_DIR="${CHROMEAI_LOG_DIR:-${HOME}/Library/Logs/SelectPilot}" diff --git a/server/ollama_client.py b/server/ollama_client.py index ffc4acd..9d62762 100644 --- a/server/ollama_client.py +++ b/server/ollama_client.py @@ -65,29 +65,6 @@ def _build_markdown(summary: str, bullets: list[str], action_items: list[str] | return "\n".join(lines).strip() + "\n" -GENERATION_MODEL_PREFERENCES = [ - "llama3.2", - "llama3.1", - "qwen2.5", - "mistral", - "phi4", - "gemma3", - "glm-5-extended:latest", - "glm-5:cloud", - "gpt-oss:20b-cloud", - "qwen3.5:cloud", - "kimi-k2.5:cloud", - "minimax-m2.5:cloud", - "deepseek-v3.2:cloud", -] - -EMBED_MODEL_PREFERENCES = [ - "nomic-embed-text-v2-moe:latest", - "nomic-embed-text", - "mxbai-embed-large", -] - - @dataclass(frozen=True) class OllamaConfig: base_url: str @@ -103,7 +80,7 @@ class OllamaError(RuntimeError): class OllamaClient: def __init__(self, config: OllamaConfig | None = None): if config is None: - default_generation_model = "llama3.2" + default_generation_model = "gemma4:e2b-it-qat" default_embed_model = "nomic-embed-text-v2-moe:latest" runtime_profile = os.environ.get("CHROMEAI_RUNTIME_PROFILE", "auto") @@ -209,17 +186,6 @@ def _model_names(self, local_only: bool = False) -> list[str]: models.append(str(name)) return models - def _resolve_model(self, requested: str, preferences: list[str], models: list[str]) -> str: - if requested in models: - return requested - for candidate in preferences: - for model in models: - if model == candidate or model.startswith(f"{candidate}:"): - return model - if models: - return models[0] - return requested - def _request_json(self, path: str, payload: dict[str, Any] | None = None) -> Any: url = urljoin(self.config.base_url + "/", path.lstrip("/")) data = None if payload is None else json.dumps(payload).encode("utf-8") @@ -243,12 +209,12 @@ def tags(self) -> dict[str, Any]: return self._request_json("/api/tags", None) def active_generation_model(self, models: list[str] | None = None) -> str: - models = models if models is not None else self._model_names() - return self._resolve_model(self.config.model, GENERATION_MODEL_PREFERENCES, models) + # The configured hardware profile is authoritative. A different local + # model must never be selected implicitly when the requested one is absent. + return self.config.model def active_embedding_model(self, models: list[str] | None = None) -> str: - models = models if models is not None else self._model_names() - return self._resolve_model(self.config.embed_model, EMBED_MODEL_PREFERENCES, models) + return self.config.embed_model def health(self) -> dict[str, Any]: try: diff --git a/server/runtime_profiles.py b/server/runtime_profiles.py index 8080709..eb45d02 100644 --- a/server/runtime_profiles.py +++ b/server/runtime_profiles.py @@ -24,8 +24,8 @@ class RuntimeProfile: "fast": RuntimeProfile( key="fast", label="Fast", - description="Smallest viable local profile for structured extraction and low-latency summaries.", - generation_model="qwen2.5:0.5b", + description="Memory-safe local profile for structured extraction and low-latency summaries.", + generation_model="gemma4:e2b-it-qat", embedding_model="nomic-embed-text-v2-moe:latest", target_latency="1-4s", intended_for="Selected-text extraction, action briefs, and quick summaries.", @@ -34,8 +34,8 @@ class RuntimeProfile: "balanced": RuntimeProfile( key="balanced", label="Balanced", - description="Higher quality local profile for rewrite and general-purpose browser transforms.", - generation_model="qwen2.5:3b", + description="Efficient local profile for higher-quality browser transforms on 16 GB or more.", + generation_model="gemma4:e4b-it-qat", embedding_model="nomic-embed-text-v2-moe:latest", target_latency="2-6s", intended_for="Daily use when you want better quality without drifting into heavy models.", @@ -132,4 +132,3 @@ def build_bootstrap_commands(profile_key: str, project_root: str | Path) -> dict "generation_model": profile.generation_model, "embedding_model": profile.embedding_model, } - diff --git a/tests/server/test_runtime_profiles.py b/tests/server/test_runtime_profiles.py index 114b317..d1a61ec 100644 --- a/tests/server/test_runtime_profiles.py +++ b/tests/server/test_runtime_profiles.py @@ -2,6 +2,7 @@ import sys import unittest +from unittest.mock import patch from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -10,13 +11,36 @@ sys.path.insert(0, str(SERVER_DIR)) from runtime_profiles import build_bootstrap_commands, get_runtime_profile, recommend_runtime_profile # noqa: E402 +from ollama_client import OllamaClient # noqa: E402 class RuntimeProfileTests(unittest.TestCase): def test_unknown_profile_falls_back_to_fast(self) -> None: profile = get_runtime_profile("missing") self.assertEqual(profile.key, "fast") - self.assertEqual(profile.generation_model, "qwen2.5:0.5b") + self.assertEqual(profile.generation_model, "gemma4:e2b-it-qat") + + def test_low_memory_hardware_uses_memory_safe_gemma_profile(self) -> None: + recommendation = recommend_runtime_profile( + {"machine": "arm64", "memory_gb": 8, "platform": "darwin", "cpu_count": 8} + ) + profile = get_runtime_profile(recommendation["recommended_profile"]) + self.assertEqual(profile.key, "fast") + self.assertEqual(profile.generation_model, "gemma4:e2b-it-qat") + + def test_16_gb_apple_silicon_uses_balanced_gemma_profile(self) -> None: + recommendation = recommend_runtime_profile( + {"machine": "arm64", "memory_gb": 16, "platform": "darwin", "cpu_count": 10} + ) + profile = get_runtime_profile(recommendation["recommended_profile"]) + self.assertEqual(profile.key, "balanced") + self.assertEqual(profile.generation_model, "gemma4:e4b-it-qat") + + def test_intel_hardware_remains_on_memory_safe_profile(self) -> None: + recommendation = recommend_runtime_profile( + {"machine": "x86_64", "memory_gb": 64, "platform": "darwin", "cpu_count": 12} + ) + self.assertEqual(recommendation["recommended_profile"], "fast") def test_auto_recommendation_prefers_balanced_on_large_machines(self) -> None: recommendation = recommend_runtime_profile( @@ -28,6 +52,26 @@ def test_bootstrap_command_contains_profile(self) -> None: command = build_bootstrap_commands("balanced", ROOT) self.assertIn("--profile balanced", command["command"]) + def test_ollama_client_uses_explicit_profile_without_model_fallback(self) -> None: + with patch.dict("os.environ", {"CHROMEAI_RUNTIME_PROFILE": "balanced"}, clear=True): + client = OllamaClient() + + self.assertEqual(client.config.model, "gemma4:e4b-it-qat") + self.assertEqual(client.active_generation_model(["qwen2.5:0.5b"]), "gemma4:e4b-it-qat") + + def test_explicit_model_override_remains_authoritative(self) -> None: + with patch.dict( + "os.environ", + { + "CHROMEAI_RUNTIME_PROFILE": "balanced", + "CHROMEAI_OLLAMA_MODEL": "custom-local:model", + }, + clear=True, + ): + client = OllamaClient() + + self.assertEqual(client.config.model, "custom-local:model") + if __name__ == "__main__": unittest.main()