diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b5e0a6a..f141647 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,7 +1,13 @@ name: Python Tests on: pull_request: - paths: ['app/**', 'tests/**', 'pyproject.toml'] + paths: + - 'app/**' + - 'tests/**' + - 'Dockerfile' + - 'pyproject.toml' + - 'uv.lock' + - '.github/workflows/test.yaml' jobs: test: runs-on: ubuntu-latest @@ -21,3 +27,17 @@ jobs: run: uv run mypy app --ignore-missing-imports - name: Lint run: uv run ruff check app + + container: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build runtime image + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: app-poly-gitops-fastapi:test + - name: Verify offline runtime startup + run: tests/test_container_runtime.sh app-poly-gitops-fastapi:test diff --git a/Dockerfile b/Dockerfile index 48ca9c8..b0c2985 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ COPY ./app ./app # Expose port EXPOSE 8000 -# Run with uv -CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run the environment assembled at build time. Runtime startup must not resolve, +# download, or install dependencies. +CMD [".venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] # Trigger rebuild diff --git a/README.md b/README.md index 022f0bd..f3432f7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ FastAPI микросервис для LLM-чатов с управлением - 🗨️ **Chat API** — standalone и project-scoped чаты - 📁 **Проекты** — группировка чатов по проектам -- 📜 **История** — сохранение истории с trace_id/span_id +- 📜 **История** — сохранение успешного transcript (входные сообщения и ответ модели) с trace_id/span_id - 🔗 **Notion** — загрузка behavior-схем агентов - 📊 **Observability** — OpenTelemetry traces/logs/metrics - 🐳 **Docker** — контейнеризация с uv @@ -23,6 +23,9 @@ FastAPI микросервис для LLM-чатов с управлением | `GET` | `/api/v1/behavior/schema` | Текущая схема поведения | | `GET` | `/health` | Health check | +Ошибки LLM-провайдера возвращаются как HTTP `502`; они не маскируются под +успешный `200` и не создают запись истории проекта. + ## Запуск ### Локально (с uv) diff --git a/app/api.py b/app/api.py index 06f3df5..dd2418f 100644 --- a/app/api.py +++ b/app/api.py @@ -10,6 +10,7 @@ from app.logger import enrich_context from .core.db import get_session +from .core.llm_client import LLMProviderError from .core.project_memory import ProjectMemory from .integrations.behavior_manager import BehaviorManager from .models import ChatHistory, ChatHistoryDB, Project, StoredChatMessage @@ -18,6 +19,7 @@ ChatResponse, CreateProjectRequest, ProjectInfo, + Role, ) from .services.chat_service import ChatService @@ -64,6 +66,12 @@ async def chat_endpoint( ) return ChatResponse(reply=reply) + except LLMProviderError as e: + log.bind(event="chat_provider_error", error=str(e)).error( + "LLM provider request failed" + ) + raise HTTPException(status_code=502, detail="AI provider unavailable") + except Exception as e: log.bind(event="chat_api_error", error=str(e)).error("Standard chat request failed") traceback.print_exc() @@ -123,10 +131,15 @@ async def chat_in_project( if not project: raise ValueError(f"Проект {project_id} не найден") + reply = await chat_service.get_ai_reply( + req.messages, req.user_api_key, project_id=project_id, trace_id=trace_id + ) + chat_messages = [ StoredChatMessage(role=m.role, content=m.content) for m in req.messages ] + chat_messages.append(StoredChatMessage(role=Role.assistant, content=reply)) history_db = ChatHistoryDB( project_id=project_id, messages=json.dumps([m.model_dump() for m in chat_messages], default=json_serial), @@ -135,16 +148,18 @@ async def chat_in_project( ) session.add(history_db) session.commit() - - reply = await chat_service.get_ai_reply( - req.messages, req.user_api_key, project_id=project_id, trace_id=trace_id - ) return ChatResponse(reply=reply) except ValueError as e: log.bind(event="project_not_found").warning("Project not found") raise HTTPException(status_code=404, detail=str(e)) + except LLMProviderError as e: + log.bind(event="project_chat_provider_error", error=str(e)).error( + "LLM provider request failed" + ) + raise HTTPException(status_code=502, detail="AI provider unavailable") + except Exception as e: log.bind(event="project_chat_error", error=str(e)).error("Project chat failed") traceback.print_exc() diff --git a/app/core/llm_client.py b/app/core/llm_client.py index d93299b..3483cc0 100644 --- a/app/core/llm_client.py +++ b/app/core/llm_client.py @@ -33,6 +33,10 @@ unit="1" ) + +class LLMProviderError(RuntimeError): + """The configured LLM provider could not produce a valid reply.""" + class OpenRouterClient: def __init__(self): # Из секрета @@ -240,10 +244,20 @@ async def generate_reply( # Проверяем на ошибки if "error" in response: - return f"Ошибка API: {response['error']}" + raise LLMProviderError(str(response["error"])) + + # A transport-level success does not guarantee a valid provider + # payload. Keep malformed upstream responses on the same 502 path + # as explicit provider errors instead of leaking an internal 500. + try: + content = response["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise LLMProviderError("Malformed response from LLM provider") from exc + + if not isinstance(content, str) or not content: + raise LLMProviderError("Malformed response from LLM provider") - # Извлекаем текст ответа - return response["choices"][0]["message"]["content"] + return content finally: # Восстанавливаем оригинальный ключ diff --git a/tests/test_api.py b/tests/test_api.py index 1cb8c00..d33f843 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,7 @@ """API endpoint tests.""" +from unittest.mock import AsyncMock + from fastapi.testclient import TestClient from app.main import create_app @@ -40,6 +42,40 @@ def test_chat_endpoint(client, mock_openrouter): assert "reply" in response.json() +def test_chat_endpoint_reports_provider_failure_as_bad_gateway(client, monkeypatch): + """A provider failure must not look like a successful chat reply.""" + monkeypatch.setattr( + "app.core.llm_client.OpenRouterClient.chat_completion", + AsyncMock(return_value={"error": "provider unavailable"}), + ) + + response = client.post( + "/api/v1/chat", + json={"messages": [{"role": "user", "content": "test"}]}, + ) + + assert response.status_code == 502 + assert response.json() == {"detail": "AI provider unavailable"} + + +def test_chat_endpoint_reports_malformed_provider_response_as_bad_gateway( + client, monkeypatch +): + """A malformed successful response is still a provider failure.""" + monkeypatch.setattr( + "app.core.llm_client.OpenRouterClient.chat_completion", + AsyncMock(return_value={"choices": []}), + ) + + response = client.post( + "/api/v1/chat", + json={"messages": [{"role": "user", "content": "test"}]}, + ) + + assert response.status_code == 502 + assert response.json() == {"detail": "AI provider unavailable"} + + def test_chat_endpoint_empty_messages(client, mock_openrouter): """Test POST /api/v1/chat with empty messages list.""" response = client.post("/api/v1/chat", json={ @@ -106,7 +142,34 @@ def test_project_history(client, mock_openrouter): # Get history response = client.get(f"/api/v1/projects/{project_id}/history") assert response.status_code == 200 - assert isinstance(response.json(), list) + assert [ + (message["role"], message["content"]) + for message in response.json()[0]["messages"] + ] == [ + ("user", "test message"), + ("assistant", "Mocked response from AI"), + ] + + +def test_failed_project_chat_does_not_create_history(client, monkeypatch): + """Provider failures must not leave a request that looks completed.""" + project = client.post( + "/api/v1/projects", json={"name": "Failed chat"} + ).json() + monkeypatch.setattr( + "app.core.llm_client.OpenRouterClient.chat_completion", + AsyncMock(return_value={"error": "provider unavailable"}), + ) + + response = client.post( + f"/api/v1/projects/{project['id']}/chat", + json={"messages": [{"role": "user", "content": "test"}]}, + ) + + assert response.status_code == 502 + history = client.get(f"/api/v1/projects/{project['id']}/history") + assert history.status_code == 200 + assert history.json() == [] def test_behavior_schema_not_loaded(client): diff --git a/tests/test_container_runtime.sh b/tests/test_container_runtime.sh new file mode 100755 index 0000000..b279ede --- /dev/null +++ b/tests/test_container_runtime.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +image="${1:-app-poly-gitops-fastapi:test}" +container_id="$(docker run -d --network none "$image")" + +cleanup() { + docker logs "$container_id" 2>/dev/null || true + docker rm -f "$container_id" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +for _ in $(seq 1 30); do + if [ "$(docker inspect -f '{{.State.Running}}' "$container_id")" != true ]; then + echo "container exited before becoming healthy" >&2 + exit 1 + fi + + if docker exec "$container_id" .venv/bin/python -c \ + 'import json, urllib.request; assert json.load(urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=1)) == {"status": "ok"}' \ + >/dev/null 2>&1; then + exit 0 + fi + sleep 1 +done + +echo "container did not become healthy without runtime network access" >&2 +exit 1