mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 11:49:43 +02:00
Add per-model GPU and context profiles
This commit is contained in:
1 parent
cdbb5f03fa
commit
e24d397d31
12 files changed
+224
-215
No files matched your search
+52
-2
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user