fix(editor+agent): multilignes conservees (cause reelle) + validation des modeles Nvidia
Editeur: - sync() lisait el.textContent qui supprime les <br> : ajout de gt() (innerText) utilise par sync(), tableaux (toggle/columns) et _resultText. - Scission sur Entree + limites haut/bas de bloc via splitCaret() (marqueur temporaire au curseur), plus de perte de fin de bloc multi-lignes. Agent: - fetch_provider_models() valide la liste nvidia : filtre des modeles non-chat (nom) puis probe reelle /chat/completions (6 paralleles, 6s), seuls les modeles 2xx restent. 429 conserve (route, donc utilisable). - Repli sur la liste filtree si toutes les validations echouent. - tests/test_llm_config.py : 5 cas (catalogue mock, 404 chat, autres providers non valides, chute de securite, offline). 276 tests passent.
This commit is contained in:
@@ -11,11 +11,32 @@ Rows are created lazily, so .env stays the default until saved from the UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.services.llm_client import PROVIDERS, PROVIDER_MODELS
|
||||
|
||||
# Providers whose /v1/models lists far more entries than /v1/chat/completions
|
||||
# actually serves. The fetched list is validated (name filter + live probe)
|
||||
# before being exposed as "usable models". NVIDIA exposes all of its catalog
|
||||
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
|
||||
# chat completions — the exact failure the user hit.
|
||||
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
|
||||
|
||||
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
|
||||
_NON_CHAT_MARKERS = (
|
||||
"embed", "bge-", "rerank", "retriev", "tts", "asr", "stt", "whisper", "speech",
|
||||
"transcrib", "translate", "image", "video", "audio", "music", "sound", "dall",
|
||||
"stable", "diffus", "flux", "sora", "veo", "midjourney", "clip", "segmentation",
|
||||
"ocr", "inpainting", "depth", "motion", "sento-",
|
||||
)
|
||||
|
||||
|
||||
def _is_likely_chat(model_id: str) -> bool:
|
||||
ml = model_id.lower()
|
||||
return not any(m in ml for m in _NON_CHAT_MARKERS)
|
||||
|
||||
__all__ = [
|
||||
"get_llm_config", "set_llm_config", "provider_info",
|
||||
"get_user_llm_key", "list_user_llm_keys", "upsert_user_llm_key",
|
||||
@@ -315,4 +336,59 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
out.append(i)
|
||||
return out[:300]
|
||||
|
||||
# Providers like NVIDIA list their whole catalog, most of which is NOT served
|
||||
# by /v1/chat/completions (404 sur « model not found »). Filter by name first,
|
||||
# then probe the survivors with a minimal chat call so only usable models stay.
|
||||
if provider in _CHAT_VALIDATED_PROVIDERS and out:
|
||||
candidates = [m for m in out if _is_likely_chat(m)] or out
|
||||
validated = await _validate_chat_models(base_url, api_key, candidates)
|
||||
# Ne vidons jamais la liste : en cas d'échec de validation (débit limité,
|
||||
# indisponibilité passagère) on garde la liste filtrée par nom.
|
||||
out = validated if validated else candidates
|
||||
|
||||
return out[:300]
|
||||
|
||||
|
||||
async def _validate_chat_models(base_url: str, api_key: str, candidates: list[str],
|
||||
*, timeout: float = 6.0, concurrency: int = 6,
|
||||
deadline: float = 55.0) -> list[str]:
|
||||
"""Probe `POST {base_url}/chat/completions` for each candidate with a minimal
|
||||
payload and keep the models that accept it. A 429 (rate limit) is treated as
|
||||
"probably fine" since it proves the model is routed, not that it's invalid."""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
valid: list[str] = []
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
start = time.monotonic()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
payload: dict = {
|
||||
"model": None,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"max_tokens": 4,
|
||||
}
|
||||
|
||||
async def probe(model: str) -> str | None:
|
||||
if time.monotonic() - start > deadline:
|
||||
return None
|
||||
payload["model"] = model
|
||||
try:
|
||||
async with sem:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
f"{base_url}/chat/completions", headers=headers, json=payload
|
||||
)
|
||||
if resp.status_code < 300 or resp.status_code == 429:
|
||||
return model
|
||||
except Exception: # noqa: BLE001 — réseau/timeout ⇒ rejeté
|
||||
return None
|
||||
return None
|
||||
|
||||
results = await asyncio.gather(*(probe(m) for m in candidates))
|
||||
for m in results:
|
||||
if m:
|
||||
valid.append(m)
|
||||
return valid
|
||||
Reference in New Issue
Block a user