Skip to content
Closed
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +101 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let Quick Start use automatic profile selection

For users following the README Quick Start, pnpm setup:local still invokes bootstrap-macos-local.sh --profile fast in package.json, bypassing the hardware recommendation entirely. Consequently, Apple Silicon machines with 16 GB or more install E2B rather than the newly documented automatic E4B profile; remove the forced profile from the standard setup path or avoid claiming that path selects automatically.

Useful? React with 👍 / 👎.

- Advanced: explicit opt-in only; it is never selected automatically
Comment on lines +103 to +105

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
Expand Down
2 changes: 1 addition & 1 deletion scripts/install-macos-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Comment on lines 8 to 10
RUN_DIR="${CHROMEAI_RUN_DIR:-${HOME}/Library/Application Support/SelectPilot/run}"
LOG_DIR="${CHROMEAI_LOG_DIR:-${HOME}/Library/Logs/SelectPilot}"
Expand Down
44 changes: 5 additions & 39 deletions server/ollama_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand Down Expand Up @@ -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")
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject configured models that are not local

When the configured model exists only as an Ollama remote model, active_generation_model() now ignores the supplied local-only model list and returns that remote name. The /summarize, /extract, and /agent handlers do not gate execution on health, so direct requests pass the remote model to /api/generate and can send selected content off-device instead of reporting the model as missing. Reject generation before the request whenever _model_available_locally() is false.

Useful? React with 👍 / 👎.

Comment on lines 211 to +214

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:
Expand Down
9 changes: 4 additions & 5 deletions server/runtime_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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.",
Expand Down Expand Up @@ -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,
}

46 changes: 45 additions & 1 deletion tests/server/test_runtime_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import sys
import unittest
from unittest.mock import patch
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
Expand All @@ -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(
Expand All @@ -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()