"""Wybór dostawcy LLM (LOG-31) — jedyne miejsce znające konkretne implementacje. 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 — 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 import os from app.llm.base import LLMError, LLMProvider from app.llm.providers import AnthropicProvider, ChatCompletionsProvider LOCAL = "local" OPENAI = "openai" ANTHROPIC = "anthropic" PROVIDERS = (LOCAL, OPENAI, ANTHROPIC) _DEFAULT_MODEL = { LOCAL: "llama3.1:8b", OPENAI: "gpt-4o-mini", # Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne, # gdy pominąć parametr `thinking`, a jego tokeny liczą się do max_tokens — # przy ciasnym limicie cała tura wychodziła jako samo myślenie z pustym # tekstem. To była przyczyna pustych odpowiedzi na Anthropicu. ANTHROPIC: "claude-opus-4-8", } _DEFAULT_URL = { # Ollama i vLLM wystawiają zgodne API pod /v1 LOCAL: "http://localhost:11434/v1", OPENAI: "https://api.openai.com/v1", ANTHROPIC: "https://api.anthropic.com", } def default_provider_name() -> str: return os.getenv("LLM_PROVIDER", LOCAL).lower() def max_tokens() -> int: return int(os.getenv("LLM_MAX_TOKENS", "2000")) 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 resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]: """(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API. Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może zależeć od tego, czy klucz jest już skonfigurowany. """ provider = (name or default_provider_name()).lower() if provider not in PROVIDERS: provider = default_provider_name() chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider]) return provider, chosen def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider: """Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik wybiera model w UI, a konfiguracja podaje tylko wartość domyślną.""" 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 = (model or "").strip() or 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: return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(), leaves_lan=True) # lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(), leaves_lan=False)