Compare commits

..

3 Commits

Author SHA1 Message Date
gitea 5203ba9e76 fix(llm): konfiguracja per dostawca — przelacznik w UI byl iluzja
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Waiting to run
Testy / Build obrazu silnika B (swisseph) (pull_request) Waiting to run
Testy / Kontrola składni wszystkich warstw (pull_request) Waiting to run
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m29s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 25s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
Testy / Testy warstwy logicznej (silnik) (pull_request) Has started running
UI pozwala wybrac dostawce przy KAZDYM zadaniu, ale factory czytalo jedna
wspolna trojke LLM_MODEL / LLM_BASE_URL / LLM_API_KEY dla wszystkich. Na
klastrze LLM_BASE_URL trzeba ustawic na lokalny model (localhost:11434 w podzie
nie istnieje) — i wtedy:
  - wybor „OpenAI" wysylal zadanie do Ollamy,
  - LLM_MODEL=llama3.1:8b kazal Anthropic uzyc modelu llama,
  - jeden LLM_API_KEY nie moze byc kluczem OpenAI i Anthropic naraz.
Czyli nie bylo miejsca, w ktore dalo sie sensownie wpisac klucze do chmury.

- konfiguracja per dostawca: <DOSTAWCA>_MODEL / _BASE_URL / _API_KEY
  (LOCAL_*, OPENAI_*, ANTHROPIC_*),
- zgodnosc wstecz: wspolne LLM_* dziala nadal, ale stosuje sie WYLACZNIE do
  dostawcy domyslnego (LLM_PROVIDER) — instalacja jednodostawcowa bez zmian,
- Anthropic dostal brakujaca walidacje klucza (mial ja tylko OpenAI),
- komunikat bledu wskazuje konkretna zmienna do ustawienia.

Testy regresyjne pilnuja, ze ustawienia jednego dostawcy NIE przeciekaja na
pozostalych. Calosc: 143 passed / 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:32:04 +02:00
gitea 64d1afc76d fix(presentation): brakujacy token przy /chart/prompt i /chart/horoscope
build / build (push) Successful in 59s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 30s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
Blad z mergu: _auth_headers() dodano na galezi hardeningu, ktora odbila sie
od mastera ZANIM powstaly metody prompt() i horoscope() (LOG-29/30, LOG-31).
Git zmergowal obie zmiany czysto — byly w roznych liniach — ale semantycznie
nowe metody wyszly bez tokenu i dostawaly 401 przy wlaczonej ochronie.

Skutek dla uzytkownika: przyciski „Generuj prompt (AI)" i „Napisz horoskop"
nie dzialaly po wdrozeniu INTERNAL_TOKEN, mimo ze reszta aplikacji dzialala.

- naprawione oba wywolania,
- nowy test strukturalny (AST): KAZDE wyjscie HTTP w dol musi niesc headers=.
  Test jednej metody by tego nie zlapal — regula musi byc pilnowana calosciowo.

Straznik zweryfikowany sabotazem: po usunieciu naglowka test pada ze
wskazaniem konkretnej linii; po przywroceniu 15/15 przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:02:40 +00:00
gitea 4da5f5fe7e docs: braki bezpieczenstwa jako wymagania (PRE-16/17, DAN-25/26, LOG-33)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m53s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 44s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 34s
build / build (push) Successful in 52s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m51s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 30s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s
Obecny poziom ochrony wystarcza do developmentu, ale luki musza byc zapisane,
zeby nie wyparowaly przed produkcja.

- PRE-16 (Must) HTTPS/TLS — dzis Basic Auth leci po http, czyli haslo da sie
  podsluchac. Odblokowuje przy okazji DWIE funkcje zepsute z tego samego
  powodu: geolokalizacje (Tu i teraz) i kopiowanie do schowka — oba wymagaja
  secure context.
- PRE-17 (Should) konta imienne + slad audytowy zamiast jednego wspolnego
  hasla; bez tego nie wiadomo, kto pobieral dane, ani jak odciac jedna osobe.
- DAN-25 (Must) ograniczenie udzialu NFS — kto ma do niego dostep, bierze
  komplet baz z pominieciem aplikacji. Dzis najkrotsza droga do wycieku.
- DAN-26 (Should) znakowanie baz rekordami-pulapkami — zabezpieczenie
  detekcyjne: pozwala udowodnic zrodlo wycieku.
- LOG-33 (Should) sekrety w spoczynku (etcd to tylko base64) + rotacja.

Q-12 odnotowane jako rozstrzygniete: bazy zostaly KUPIONE, wiec zgoda jest —
ale to nie zwalnia z ochrony. LOG-32 przestawione na "W trakcie" z wykazem,
co juz wdrozone, a co zostaje.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:47:21 +02:00
5 changed files with 171 additions and 14 deletions
Binary file not shown.
+43 -11
View File
@@ -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
+64 -1
View File
@@ -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 == ""
@@ -78,7 +78,7 @@ class LogicClient:
if from_date and to_date: if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date payload["from_date"], payload["to_date"] = from_date, to_date
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/prompt", json=payload) r = client.post(f"{self.base_url}/chart/prompt", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -100,7 +100,7 @@ class LogicClient:
if from_date and to_date: if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date payload["from_date"], payload["to_date"] = from_date, to_date
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client: with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
r = client.post(f"{self.base_url}/chart/horoscope", json=payload) r = client.post(f"{self.base_url}/chart/horoscope", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -0,0 +1,62 @@
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32).
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
czysto (różne linie), ale nowe metody wyszły BEZ tokenu — i dostawały 401 dopiero na
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
strukturalnie: nie ma wywołania bez `headers=`.
"""
import ast
import pathlib
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool]]:
"""(nazwa_metody_http, linia, czy_ma_headers) dla każdego client.post/get."""
tree = ast.parse(path.read_text(encoding="utf-8"))
out = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
if node.func.attr not in ("post", "get", "put", "patch", "delete"):
continue
if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "client"):
continue
has_headers = any(kw.arg == "headers" for kw in node.keywords)
out.append((node.func.attr, node.lineno, has_headers))
return out
def test_client_module_exists():
assert CLIENT.is_file()
def test_every_outbound_call_sends_auth_header():
calls = _http_calls(CLIENT)
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
missing = [f"{CLIENT.name}:{line} client.{verb}()" for verb, line, ok in calls if not ok]
assert not missing, (
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
"ochronie): " + ", ".join(missing)
)
def test_auth_headers_helper_is_lazy():
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
z pustym tokenem, gdyby zmienna pojawiła się później."""
import os
from app.clients.logic_client import _auth_headers
old = os.environ.get("INTERNAL_TOKEN")
try:
os.environ["INTERNAL_TOKEN"] = "abc"
assert _auth_headers() == {"X-Astrololo-Token": "abc"}
os.environ.pop("INTERNAL_TOKEN")
assert _auth_headers() == {} # ochrona wyłączona = brak nagłówka
finally:
if old is not None:
os.environ["INTERNAL_TOKEN"] = old
else:
os.environ.pop("INTERNAL_TOKEN", None)