diff --git a/services/logic/app/llm/factory.py b/services/logic/app/llm/factory.py index 47791ba..16620a6 100644 --- a/services/logic/app/llm/factory.py +++ b/services/logic/app/llm/factory.py @@ -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 — 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: - LLM_PROVIDER local (domyślnie) | openai | anthropic - LLM_MODEL nazwa modelu (domyślna zależy od dostawcy) - LLM_BASE_URL adres API (domyślnie: lokalny serwer zgodny z OpenAI) - LLM_API_KEY klucz — WYŁĄCZNIE z sekretu; niepotrzebny dla modelu lokalnego - LLM_TIMEOUT sekundy (domyślnie 120) - LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000) + LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny + LLM_TIMEOUT sekundy (domyślnie 120) + LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000) + + _MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy: + 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 @@ -49,20 +62,39 @@ def timeout() -> float: return float(os.getenv("LLM_TIMEOUT", "120")) +def setting(provider: str, suffix: str, fallback: str = "") -> str: + """Ustawienie dostawcy: _ → LLM_ → 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: name = (name or default_provider_name()).lower() if name not in PROVIDERS: raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})") - model = os.getenv("LLM_MODEL") or _DEFAULT_MODEL[name] - base_url = os.getenv("LLM_BASE_URL") or _DEFAULT_URL[name] - api_key = os.getenv("LLM_API_KEY", "") + model = setting(name, "MODEL", _DEFAULT_MODEL[name]) + base_url = setting(name, "BASE_URL", _DEFAULT_URL[name]) + 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: return AnthropicProvider(base_url, model, api_key, timeout()) 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(), leaves_lan=True) # lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci diff --git a/services/logic/tests/test_llm.py b/services/logic/tests/test_llm.py index 2726b49..b515248 100644 --- a/services/logic/tests/test_llm.py +++ b/services/logic/tests/test_llm.py @@ -133,7 +133,9 @@ def test_default_provider_is_local(monkeypatch): def test_openai_requires_key(monkeypatch): 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") @@ -147,3 +149,64 @@ def test_env_overrides_model_and_url(monkeypatch): monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1") p = factory.build_provider("local") 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 == ""