fix(llm): konfiguracja per dostawca — przelacznik w UI byl iluzja #15
@@ -4,13 +4,26 @@ Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
|
|||||||
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
|
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
|
||||||
przez konfigurację albo pojedyncze żądanie.
|
przez konfigurację albo pojedyncze żądanie.
|
||||||
|
|
||||||
|
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
|
||||||
|
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
|
||||||
|
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
|
||||||
|
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
|
||||||
|
|
||||||
Zmienne środowiskowe:
|
Zmienne środowiskowe:
|
||||||
LLM_PROVIDER local (domyślnie) | openai | anthropic
|
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
|
||||||
LLM_MODEL nazwa modelu (domyślna zależy od dostawcy)
|
LLM_TIMEOUT sekundy (domyślnie 120)
|
||||||
LLM_BASE_URL adres API (domyślnie: lokalny serwer zgodny z OpenAI)
|
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
|
||||||
LLM_API_KEY klucz — WYŁĄCZNIE z sekretu; niepotrzebny dla modelu lokalnego
|
|
||||||
LLM_TIMEOUT sekundy (domyślnie 120)
|
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
|
||||||
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
|
LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
|
||||||
|
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
|
||||||
|
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
|
||||||
|
|
||||||
|
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
|
||||||
|
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
|
||||||
|
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -49,20 +62,39 @@ def timeout() -> float:
|
|||||||
return float(os.getenv("LLM_TIMEOUT", "120"))
|
return float(os.getenv("LLM_TIMEOUT", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
def setting(provider: str, suffix: str, fallback: str = "") -> str:
|
||||||
|
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
|
||||||
|
|
||||||
|
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
|
||||||
|
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
|
||||||
|
"""
|
||||||
|
specific = os.getenv(f"{provider.upper()}_{suffix}")
|
||||||
|
if specific:
|
||||||
|
return specific
|
||||||
|
if provider == default_provider_name():
|
||||||
|
generic = os.getenv(f"LLM_{suffix}")
|
||||||
|
if generic:
|
||||||
|
return generic
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def build_provider(name: str | None = None) -> LLMProvider:
|
def build_provider(name: str | None = None) -> LLMProvider:
|
||||||
name = (name or default_provider_name()).lower()
|
name = (name or default_provider_name()).lower()
|
||||||
if name not in PROVIDERS:
|
if name not in PROVIDERS:
|
||||||
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
||||||
|
|
||||||
model = os.getenv("LLM_MODEL") or _DEFAULT_MODEL[name]
|
model = setting(name, "MODEL", _DEFAULT_MODEL[name])
|
||||||
base_url = os.getenv("LLM_BASE_URL") or _DEFAULT_URL[name]
|
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
|
||||||
api_key = os.getenv("LLM_API_KEY", "")
|
api_key = setting(name, "API_KEY")
|
||||||
|
|
||||||
|
if name in (OPENAI, ANTHROPIC) and not api_key:
|
||||||
|
raise LLMError(
|
||||||
|
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
|
||||||
|
f"(z sekretu). Model lokalny klucza nie wymaga."
|
||||||
|
)
|
||||||
if name == ANTHROPIC:
|
if name == ANTHROPIC:
|
||||||
return AnthropicProvider(base_url, model, api_key, timeout())
|
return AnthropicProvider(base_url, model, api_key, timeout())
|
||||||
if name == OPENAI:
|
if name == OPENAI:
|
||||||
if not api_key:
|
|
||||||
raise LLMError("Brak LLM_API_KEY — dostawca openai wymaga klucza.")
|
|
||||||
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
||||||
leaves_lan=True)
|
leaves_lan=True)
|
||||||
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ def test_default_provider_is_local(monkeypatch):
|
|||||||
|
|
||||||
def test_openai_requires_key(monkeypatch):
|
def test_openai_requires_key(monkeypatch):
|
||||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||||
with pytest.raises(LLMError, match="LLM_API_KEY"):
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
|
||||||
|
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
|
||||||
factory.build_provider("openai")
|
factory.build_provider("openai")
|
||||||
|
|
||||||
|
|
||||||
@@ -147,3 +149,64 @@ def test_env_overrides_model_and_url(monkeypatch):
|
|||||||
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
||||||
p = factory.build_provider("local")
|
p = factory.build_provider("local")
|
||||||
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
|
||||||
|
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
|
||||||
|
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
|
||||||
|
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
|
||||||
|
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
|
||||||
|
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
|
||||||
|
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
|
||||||
|
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
|
||||||
|
monkeypatch.delenv(v, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_config_does_not_leak_to_cloud(monkeypatch):
|
||||||
|
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||||
|
|
||||||
|
local = factory.build_provider("local")
|
||||||
|
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
|
||||||
|
|
||||||
|
openai = factory.build_provider("openai")
|
||||||
|
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
|
||||||
|
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_specific_settings_win(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
|
||||||
|
p = factory.build_provider("anthropic")
|
||||||
|
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
|
||||||
|
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
||||||
|
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
|
||||||
|
assert factory.build_provider("openai").model == "gpt-4o"
|
||||||
|
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
for name in ("openai", "anthropic"):
|
||||||
|
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
|
||||||
|
factory.build_provider(name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_needs_no_key(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
assert factory.build_provider("local").api_key == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user