Add per-model GPU and context profiles

This commit is contained in:
Michael committed 2026-06-30 11:58:39 +02:00
1 parent cdbb5f03fa
commit e24d397d31
12 files changed
+224 -215

No files matched your search

+52 -2
View File
@@ -22,6 +22,7 @@ from .ollama_client import OllamaError, ollama
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
MAX_ITERATIONS = 6
MAX_TOOL_REPAIR_ATTEMPTS = 2
def _tools_not_supported(exc: OllamaError) -> bool:
@@ -30,6 +31,15 @@ def _tools_not_supported(exc: OllamaError) -> bool:
return "does not support tools" in message or "does not support tool" in message
def _invalid_tool_arguments(exc: OllamaError) -> bool:
message = str(exc).lower()
return (
"invalid tool call arguments" in message
or "unexpected end of json" in message
or "failed to parse tool" in message
)
def _parse_args(raw) -> dict:
if isinstance(raw, dict):
return raw
@@ -62,6 +72,8 @@ async def run_agent(
text_parts: list[str] = []
active_tools = tools
tool_fallback_used = False
tool_repair_attempts = 0
request_options = dict(options or {})
try:
for _ in range(MAX_ITERATIONS):
@@ -75,7 +87,11 @@ async def run_agent(
while True:
try:
async for chunk in ollama.chat(
model, convo, tools=active_tools, options=options, stream=True
model,
convo,
tools=active_tools,
options=request_options,
stream=True,
):
msg = chunk.get("message", {})
token = msg.get("content", "")
@@ -94,6 +110,38 @@ async def run_agent(
break
break
except OllamaError as exc:
if (
active_tools
and tool_repair_attempts < MAX_TOOL_REPAIR_ATTEMPTS
and not content_buf
and not tool_calls
and _invalid_tool_arguments(exc)
):
tool_repair_attempts += 1
request_options["num_predict"] = max(
int(request_options.get("num_predict", 0)), 4096
)
thinking_buf = ""
convo.append(
{
"role": "system",
"content": (
"L'appel d'outil précédent contenait un JSON "
"tronqué. Réessaie immédiatement avec des arguments "
"JSON valides. Pour un fichier long, utilise write_file "
"en plusieurs appels : overwrite puis append, avec des "
"morceaux courts et complets."
),
}
)
yield {
"type": "notice",
"message": (
"Appel d'outil tronqué : nouvelle tentative "
f"{tool_repair_attempts}/{MAX_TOOL_REPAIR_ATTEMPTS}."
),
}
continue
if (
active_tools
and not tool_fallback_used
@@ -188,7 +236,9 @@ async def run_agent(
if awaiting_confirmation:
# Laisse le modèle conclure son tour (message d'attente).
final_chunk = ""
async for chunk in ollama.chat(model, convo, options=options, stream=True):
async for chunk in ollama.chat(
model, convo, options=request_options, stream=True
):
tok = chunk.get("message", {}).get("content", "")
if tok:
final_chunk += tok
+77 -13
View File
@@ -8,12 +8,17 @@ from __future__ import annotations
from . import db
CONFIG_KEY = "agent"
MODEL_PROFILES_KEY = "model_profiles"
PROFILE_STATE_KEY = "model_profiles_state"
PROFILE_VERSION = 3
DEFAULT_SYSTEM_PROMPT = (
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
"d'outils pour lire, écrire et lister des fichiers dans le workspace. "
"Utilise-les pour accomplir les tâches concrètement, puis réponds de façon "
"concise en français. Après avoir écrit un fichier, propose un aperçu."
"concise en français. Pour un fichier long, appelle write_file en plusieurs "
"morceaux (overwrite puis append) afin de toujours produire un JSON valide. "
"Après avoir écrit un fichier, propose un aperçu."
)
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
@@ -28,24 +33,73 @@ DEFAULT_TOOL_STATE = {
"run_shell": False,
}
DEFAULT_CONFIG: dict = {
"system_prompt": DEFAULT_SYSTEM_PROMPT,
GENERATION_FIELDS = {
"temperature",
"top_p",
"top_k",
"max_tokens",
"num_ctx",
"num_gpu",
"num_batch",
}
DEFAULT_GENERATION: dict = {
"temperature": 0.7,
"top_p": 0.9,
"top_k": 40,
"max_tokens": 2048,
# Fenêtre de contexte envoyée à Ollama (0 = laisser le défaut du modèle).
"num_ctx": 0,
"num_ctx": 4096,
"num_gpu": -1,
"num_batch": 256,
}
RTX_3060_GEMMA4_PROFILE: dict = {
**DEFAULT_GENERATION,
"max_tokens": 4096,
"num_ctx": 8192,
"num_gpu": 49,
}
DEFAULT_CONFIG: dict = {
"system_prompt": DEFAULT_SYSTEM_PROMPT,
**DEFAULT_GENERATION,
"tools": dict(DEFAULT_TOOL_STATE),
# Demander une validation utilisateur avant toute commande shell.
"confirm_shell": True,
}
def get_config() -> dict:
"""Config courante = défauts fusionnés avec le stockage."""
def _default_generation(model: str | None) -> dict:
if model and model.split(":", 1)[0].lower() == "gemma4":
return dict(RTX_3060_GEMMA4_PROFILE)
return dict(DEFAULT_GENERATION)
def _migrate_profiles() -> None:
state = db.get_config_value(PROFILE_STATE_KEY) or {}
if state.get("version", 0) >= PROFILE_VERSION:
return
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
gemma_profile = {
**RTX_3060_GEMMA4_PROFILE,
**profiles.get("gemma4:12b", {}),
}
if gemma_profile.get("max_tokens", 0) <= 2048:
gemma_profile["max_tokens"] = 4096
profiles["gemma4:12b"] = gemma_profile
db.set_config_value(MODEL_PROFILES_KEY, profiles)
db.set_config_value(PROFILE_STATE_KEY, {"version": PROFILE_VERSION})
def get_config(model: str | None = None) -> dict:
"""Configuration globale complétée par le profil du modèle demandé."""
_migrate_profiles()
stored = db.get_config_value(CONFIG_KEY) or {}
cfg = {**DEFAULT_CONFIG, **stored}
global_stored = {k: v for k, v in stored.items() if k not in GENERATION_FIELDS}
cfg = {**DEFAULT_CONFIG, **global_stored}
if model:
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
cfg.update({**_default_generation(model), **profiles.get(model, {})})
cfg["tools"] = {
name: bool(stored.get("tools", {}).get(name, DEFAULT_TOOL_STATE[name]))
for name in AVAILABLE_TOOLS
@@ -53,9 +107,10 @@ def get_config() -> dict:
return cfg
def save_config(patch: dict) -> dict:
"""Applique une mise à jour partielle et renvoie la config complète."""
cfg = {**get_config(), **{k: v for k, v in patch.items() if v is not None}}
def save_config(patch: dict, model: str | None = None) -> dict:
"""Sauvegarde le comportement global et la génération par modèle."""
clean = {k: v for k, v in patch.items() if v is not None}
cfg = {**get_config(model), **clean}
if "tools" in patch and patch["tools"]:
cfg["tools"] = {
name: bool(
@@ -65,8 +120,14 @@ def save_config(patch: dict) -> dict:
)
for name in AVAILABLE_TOOLS
}
db.set_config_value(CONFIG_KEY, cfg)
return get_config()
global_cfg = {k: v for k, v in cfg.items() if k not in GENERATION_FIELDS}
db.set_config_value(CONFIG_KEY, global_cfg)
if model:
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
profiles[model] = {field: cfg[field] for field in GENERATION_FIELDS}
db.set_config_value(MODEL_PROFILES_KEY, profiles)
return get_config(model)
def ollama_options(cfg: dict) -> dict:
@@ -76,6 +137,9 @@ def ollama_options(cfg: dict) -> dict:
"top_p": cfg["top_p"],
"top_k": cfg["top_k"],
"num_predict": cfg["max_tokens"],
"num_gpu": cfg["num_gpu"],
"main_gpu": 0,
"num_batch": cfg["num_batch"],
}
# num_ctx n'est envoyé que s'il est défini (> 0), sinon défaut du modèle.
if cfg.get("num_ctx"):
+1 -6
View File
@@ -6,16 +6,11 @@ class Settings(BaseSettings):
"""Réglages globaux de Loki (surchargés par variables d'environnement)."""
ollama_host: str = "http://host.docker.internal:11434"
default_model: str = "llama3.1:8b"
default_model: str = "gemma4:12b"
workspace_dir: str = "/workspace"
data_dir: str = "/data"
port: int = 8080
# Override manuel de la VRAM (Mo) si la détection GPU échoue dans le
# conteneur (utile sur Unraid où le GPU est sur l'hôte / un autre conteneur).
gpu_vram_mb: int = 0
gpu_name: str = ""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+1 -1
View File
@@ -42,7 +42,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
raise HTTPException(404, "session introuvable")
model = req.model or session.get("model") or settings.default_model
cfg = agent_config.get_config()
cfg = agent_config.get_config(model)
# Premier message : titre la session avec un extrait.
if not db.list_messages(req.session_id):
+8 -36
View File
@@ -1,11 +1,10 @@
"""Routes de configuration de l'agent (lecture / mise à jour / auto-réglage)."""
"""Routes de lecture et mise à jour de la configuration de l'agent."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from .. import agent_config, autotune
from ..config import settings
from .. import agent_config
router = APIRouter(prefix="/api/config", tags=["config"])
@@ -17,48 +16,21 @@ class ConfigPatch(BaseModel):
top_k: int | None = None
max_tokens: int | None = None
num_ctx: int | None = None
num_gpu: int | None = None
num_batch: int | None = None
tools: dict[str, bool] | None = None
confirm_shell: bool | None = None
class AutoTuneRequest(BaseModel):
model: str
apply: bool = True
@router.get("")
async def get_config() -> dict:
async def get_config(model: str | None = None) -> dict:
return {
"config": agent_config.get_config(),
"config": agent_config.get_config(model),
"available_tools": agent_config.AVAILABLE_TOOLS,
}
@router.put("")
async def put_config(patch: ConfigPatch) -> dict:
cfg = agent_config.save_config(patch.model_dump(exclude_none=True))
async def put_config(patch: ConfigPatch, model: str | None = None) -> dict:
cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model)
return {"config": cfg}
@router.post("/auto")
async def auto_tune(req: AutoTuneRequest) -> dict:
"""Détecte le GPU + le modèle, calcule les réglages optimaux, et (par
défaut) les applique. Renvoie le détail de la détection pour l'UI."""
reco = await autotune.recommend(req.model)
placement = await autotune.placement(req.model)
applied = None
if req.apply:
applied = agent_config.save_config(
{
"num_ctx": reco["recommended"]["num_ctx"],
"max_tokens": reco["recommended"]["max_tokens"],
}
)
return {
"detection": reco,
"placement": placement,
"config": applied or agent_config.get_config(),
"vram_override": settings.gpu_vram_mb,
}
+16 -4
View File
@@ -50,14 +50,16 @@ def read_file(path: str) -> dict:
return {"ok": True, "content": content, "summary": summary}
def write_file(path: str, content: str) -> dict:
def write_file(path: str, content: str, mode: str = "overwrite") -> dict:
target = _safe_path(path)
if mode not in {"overwrite", "append"}:
raise ToolError("mode write_file invalide : utilise overwrite ou append")
os.makedirs(os.path.dirname(target) or _workspace_root(), exist_ok=True)
existed = os.path.isfile(target)
with open(target, "w", encoding="utf-8") as f:
with open(target, "a" if mode == "append" else "w", encoding="utf-8") as f:
f.write(content)
lines = len(content.splitlines())
verb = "modifié" if existed else "écrit"
verb = "complété" if mode == "append" else "modifié" if existed else "écrit"
return {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
@@ -210,7 +212,17 @@ TOOL_DEFINITIONS = [
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"},
"content": {"type": "string", "description": "Contenu complet du fichier"},
"content": {
"type": "string",
"description": "Contenu complet ou morceau court du fichier",
},
"mode": {
"type": "string",
"enum": ["overwrite", "append"],
"description": (
"overwrite pour le premier morceau, append pour les suivants"
),
},
},
"required": ["path", "content"],
},