Skip to content

Commit 57e023e

Browse files
author
Zero
committed
Add deterministic Ollama model selection
1 parent 118c116 commit 57e023e

15 files changed

Lines changed: 540 additions & 87 deletions

File tree

zypheron-ai/autopent/ai_decision_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def _get_model_for_provider(self, provider: str) -> str:
199199
AIProvider.GEMINI.value: "gemini-1.5-pro",
200200
AIProvider.GROK.value: "grok-beta",
201201
AIProvider.KIMI.value: "moonshot-v1-8k",
202-
AIProvider.OLLAMA.value: config.OLLAMA_MODEL or "llama3:latest",
202+
AIProvider.OLLAMA.value: config.OLLAMA_MODEL or "llama3.2",
203203
}
204204
return model_map.get(provider, "gpt-4-turbo-preview")
205205

zypheron-ai/core/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class AIConfig(BaseSettings):
6060

6161
# Ollama Configuration
6262
OLLAMA_HOST: str = Field(default="http://localhost:11434", env="OLLAMA_HOST")
63-
OLLAMA_MODEL: str = Field(default="qwen3-coder", env="OLLAMA_MODEL")
63+
OLLAMA_MODEL: str = Field(default="llama3.2", env="OLLAMA_MODEL")
6464

6565
# Default Model Selection
6666
DEFAULT_PROVIDER: str = Field(default="anthropic", env="DEFAULT_AI_PROVIDER")

zypheron-ai/core/model_registry.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,20 +151,20 @@ class ProviderConfig:
151151
},
152152
),
153153
"ollama": ProviderConfig(
154-
default_model="qwen3-coder",
155-
fallback_model="llama3.2:3b",
154+
default_model="llama3.2",
155+
fallback_model="llama3.2",
156156
available_models={
157+
"llama3.2": ModelEntry(
158+
model_id="llama3.2",
159+
context_window=131072,
160+
max_output=4096,
161+
notes="Default local model",
162+
),
157163
"qwen3-coder": ModelEntry(
158164
model_id="qwen3-coder",
159165
context_window=131072,
160166
max_output=16384,
161-
notes="Best local coding model",
162-
),
163-
"llama3.2:3b": ModelEntry(
164-
model_id="llama3.2:3b",
165-
context_window=131072,
166-
max_output=4096,
167-
notes="Small + fast local model",
167+
notes="Local coding model",
168168
),
169169
"mistral:latest": ModelEntry(
170170
model_id="mistral:latest",
@@ -192,7 +192,8 @@ class ProviderConfig:
192192
"grok-3": "gpt-5.4", # Grok removed, redirect to OpenAI
193193
"deepseek-coder": "deepseek-r1",
194194
"moonshot-v1-8k": "kimi-k2",
195-
"llama3:latest": "llama3.2:3b",
195+
"llama3:latest": "llama3.2",
196+
"llama3.2:3b": "llama3.2",
196197
}
197198

198199
# Provider name aliases

zypheron-ai/env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ GROK_API_KEY=your_grok_api_key_here
2222
# Ollama Configuration (Local LLM)
2323
# --------------------------------
2424
OLLAMA_HOST=http://localhost:11434
25-
OLLAMA_MODEL=llama3:latest
25+
OLLAMA_MODEL=llama3.2
2626

2727
# Default AI Provider
2828
# -------------------

zypheron-ai/providers/ollama.py

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,90 @@
1010
from loguru import logger
1111

1212

13+
DEFAULT_OLLAMA_MODEL = "llama3.2"
14+
15+
16+
def normalize_ollama_host(host: Optional[str]) -> str:
17+
"""Normalize an Ollama host for discovery requests."""
18+
value = (host or "").strip() or "http://localhost:11434"
19+
if "://" not in value:
20+
value = f"http://{value}"
21+
return value.rstrip("/")
22+
23+
24+
def _model_base(model: str) -> str:
25+
return model.strip().split(":", 1)[0]
26+
27+
28+
def _is_embedding_model(model: str) -> bool:
29+
lowered = model.lower()
30+
return "embed" in lowered or "embedding" in lowered
31+
32+
33+
def select_ollama_model(preferred: Optional[str], available: List[str]) -> str:
34+
"""
35+
Pick an Ollama model deterministically:
36+
exact preferred, tag-tolerant preferred, first non-embedding, first available,
37+
preferred/default when no list is available.
38+
"""
39+
preferred_model = (preferred or "").strip() or DEFAULT_OLLAMA_MODEL
40+
models = [model.strip() for model in available if model and model.strip()]
41+
if not models:
42+
return preferred_model
43+
44+
for model in models:
45+
if model == preferred_model:
46+
return model
47+
48+
preferred_base = _model_base(preferred_model)
49+
for model in models:
50+
if _model_base(model) == preferred_base:
51+
return model
52+
53+
for model in models:
54+
if not _is_embedding_model(model):
55+
return model
56+
57+
return models[0]
58+
59+
1360
class OllamaProvider(BaseAIProvider):
1461
"""Ollama Provider for local LLM inference"""
1562

1663
def __init__(self, host: Optional[str] = None, model: Optional[str] = None, **kwargs):
1764
super().__init__(api_key=None, **kwargs)
18-
self.host = host or config.OLLAMA_HOST
65+
self.host = normalize_ollama_host(host or config.OLLAMA_HOST)
1966
self.model = model or config.OLLAMA_MODEL
2067
logger.info(f"Ollama provider initialized with host: {self.host}, model: {self.model}")
2168

2269
async def _list_models(self, session: aiohttp.ClientSession) -> List[str]:
2370
"""Return available local Ollama model tags."""
71+
native_models = await self._list_native_models(session)
72+
if native_models:
73+
return native_models
74+
return await self._list_openai_compatible_models(session)
75+
76+
async def _list_native_models(self, session: aiohttp.ClientSession) -> List[str]:
77+
"""Return models from Ollama's native tag endpoint."""
2478
try:
2579
async with session.get(f"{self.host}/api/tags", timeout=aiohttp.ClientTimeout(total=3)) as response:
2680
if response.status != 200:
2781
return []
2882
data = await response.json()
2983
models = data.get("models", [])
30-
return [m.get("name", "") for m in models if m.get("name")]
84+
return [m.get("name", "").strip() for m in models if m.get("name", "").strip()]
85+
except Exception:
86+
return []
87+
88+
async def _list_openai_compatible_models(self, session: aiohttp.ClientSession) -> List[str]:
89+
"""Return models from Ollama's OpenAI-compatible model endpoint."""
90+
try:
91+
async with session.get(f"{self.host}/v1/models", timeout=aiohttp.ClientTimeout(total=3)) as response:
92+
if response.status != 200:
93+
return []
94+
data = await response.json()
95+
models = data.get("data", [])
96+
return [m.get("id", "").strip() for m in models if m.get("id", "").strip()]
3197
except Exception:
3298
return []
3399

@@ -39,27 +105,14 @@ async def _resolve_model_fallback(
39105
"""
40106
Find a compatible fallback when requested model is missing.
41107
Preference:
42-
1) same family prefix (e.g. llama3.2:*),
43-
2) configured default model if installed,
44-
3) llama3:latest if installed,
45-
4) first available model.
108+
1) exact preferred match,
109+
2) tag-tolerant preferred match,
110+
3) first non-embedding model,
111+
4) first available model,
112+
5) requested/default model when offline.
46113
"""
47114
available = await self._list_models(session)
48-
if not available:
49-
return None
50-
if requested_model in available:
51-
return requested_model
52-
53-
family = requested_model.split(":", 1)[0]
54-
for m in available:
55-
if m.startswith(family + ":"):
56-
return m
57-
58-
if self.model in available:
59-
return self.model
60-
if "llama3:latest" in available:
61-
return "llama3:latest"
62-
return available[0]
115+
return select_ollama_model(requested_model or self.model, available)
63116

64117
async def chat(
65118
self,
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Tests for Ollama model fallback selection."""
2+
3+
import asyncio
4+
import importlib.util
5+
import sys
6+
import types
7+
from pathlib import Path
8+
from types import SimpleNamespace
9+
10+
11+
ROOT = Path(__file__).resolve().parents[1]
12+
PROVIDERS_DIR = ROOT / "providers"
13+
14+
providers_pkg = types.ModuleType("providers")
15+
providers_pkg.__path__ = [str(PROVIDERS_DIR)]
16+
sys.modules.setdefault("providers", providers_pkg)
17+
18+
logger = SimpleNamespace(
19+
info=lambda *args, **kwargs: None,
20+
debug=lambda *args, **kwargs: None,
21+
warning=lambda *args, **kwargs: None,
22+
)
23+
sys.modules.setdefault("loguru", SimpleNamespace(logger=logger))
24+
25+
core_pkg = types.ModuleType("core")
26+
core_pkg.__path__ = [str(ROOT / "core")]
27+
sys.modules.setdefault("core", core_pkg)
28+
sys.modules.setdefault(
29+
"core.config",
30+
SimpleNamespace(
31+
config=SimpleNamespace(
32+
OLLAMA_HOST="http://localhost:11434",
33+
OLLAMA_MODEL="llama3.2",
34+
)
35+
),
36+
)
37+
38+
base_spec = importlib.util.spec_from_file_location("providers.base", PROVIDERS_DIR / "base.py")
39+
base_module = importlib.util.module_from_spec(base_spec)
40+
sys.modules["providers.base"] = base_module
41+
base_spec.loader.exec_module(base_module)
42+
43+
ollama_spec = importlib.util.spec_from_file_location("providers.ollama", PROVIDERS_DIR / "ollama.py")
44+
ollama_module = importlib.util.module_from_spec(ollama_spec)
45+
sys.modules["providers.ollama"] = ollama_module
46+
ollama_spec.loader.exec_module(ollama_module)
47+
48+
DEFAULT_OLLAMA_MODEL = ollama_module.DEFAULT_OLLAMA_MODEL
49+
OllamaProvider = ollama_module.OllamaProvider
50+
normalize_ollama_host = ollama_module.normalize_ollama_host
51+
select_ollama_model = ollama_module.select_ollama_model
52+
53+
54+
def test_normalize_ollama_host():
55+
assert normalize_ollama_host("") == "http://localhost:11434"
56+
assert normalize_ollama_host("localhost:11434/") == "http://localhost:11434"
57+
assert normalize_ollama_host(" https://ollama.example.com/// ") == "https://ollama.example.com"
58+
59+
60+
def test_select_ollama_model_exact_match():
61+
assert (
62+
select_ollama_model("llama3.2", ["mistral:latest", "llama3.2"])
63+
== "llama3.2"
64+
)
65+
66+
67+
def test_select_ollama_model_tag_match():
68+
assert (
69+
select_ollama_model("llama3.2", ["mistral:latest", "llama3.2:latest"])
70+
== "llama3.2:latest"
71+
)
72+
73+
74+
def test_select_ollama_model_skips_embedding_models():
75+
assert (
76+
select_ollama_model("llama3.2", ["nomic-embed-text:latest", "mistral:latest"])
77+
== "mistral:latest"
78+
)
79+
80+
81+
def test_select_ollama_model_empty_list_keeps_preferred():
82+
assert select_ollama_model("llama3.3:70b", []) == "llama3.3:70b"
83+
84+
85+
def test_select_ollama_model_empty_list_falls_back_default():
86+
assert select_ollama_model("", []) == DEFAULT_OLLAMA_MODEL
87+
88+
89+
class FakeResponse:
90+
def __init__(self, status, payload):
91+
self.status = status
92+
self.payload = payload
93+
94+
async def __aenter__(self):
95+
return self
96+
97+
async def __aexit__(self, exc_type, exc, tb):
98+
return False
99+
100+
async def json(self):
101+
return self.payload
102+
103+
104+
class FakeSession:
105+
def __init__(self, routes):
106+
self.routes = routes
107+
self.urls = []
108+
109+
def get(self, url, timeout=None):
110+
self.urls.append(url)
111+
key = url.rsplit("/", 2)[-2] + "/" + url.rsplit("/", 1)[-1]
112+
status, payload = self.routes[key]
113+
return FakeResponse(status, payload)
114+
115+
116+
def test_list_models_uses_native_tags_first():
117+
async def run():
118+
provider = OllamaProvider(host="http://ollama.test", model="llama3.2")
119+
session = FakeSession(
120+
{
121+
"api/tags": (
122+
200,
123+
{"models": [{"name": " llama3.2:latest "}, {"name": ""}]},
124+
),
125+
}
126+
)
127+
128+
models = await provider._list_models(session)
129+
130+
assert models == ["llama3.2:latest"]
131+
assert session.urls == ["http://ollama.test/api/tags"]
132+
133+
asyncio.run(run())
134+
135+
136+
def test_list_models_falls_back_to_openai_compatible_models():
137+
async def run():
138+
provider = OllamaProvider(host="http://ollama.test", model="llama3.2")
139+
session = FakeSession(
140+
{
141+
"api/tags": (404, {"error": "missing"}),
142+
"v1/models": (
143+
200,
144+
{"data": [{"id": " qwen2.5-coder:latest "}, {"id": ""}]},
145+
),
146+
}
147+
)
148+
149+
models = await provider._list_models(session)
150+
151+
assert models == ["qwen2.5-coder:latest"]
152+
assert session.urls == [
153+
"http://ollama.test/api/tags",
154+
"http://ollama.test/v1/models",
155+
]
156+
157+
asyncio.run(run())

zypheron-go/internal/config/.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ ZYPHERON_AI_PROVIDER=ollama
3434
# Ollama server URL (default: http://localhost:11434)
3535
OLLAMA_URL=http://localhost:11434
3636

37-
# Ollama model to use (e.g., codellama, llama2, mistral)
38-
OLLAMA_MODEL=codellama
37+
# Ollama model to use (e.g., llama3.2, llama2, mistral)
38+
OLLAMA_MODEL=llama3.2
3939

4040
# =============================================================================
4141
# AI Model Configuration
@@ -89,7 +89,7 @@ ZYPHERON_AUDIT_LOGGING=true
8989
# Example 1: Use local Ollama (default, no API key needed)
9090
# ZYPHERON_AI_PROVIDER=ollama
9191
# OLLAMA_URL=http://localhost:11434
92-
# OLLAMA_MODEL=codellama
92+
# OLLAMA_MODEL=llama3.2
9393

9494
# Example 2: Use Anthropic Claude
9595
# ZYPHERON_AI_PROVIDER=anthropic

0 commit comments

Comments
 (0)