From bd8397575d0bde8b190a0efa811dfc50da460a29 Mon Sep 17 00:00:00 2001 From: justgithubaccount <86586195+justgithubaccount@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:19:41 +0000 Subject: [PATCH 1/2] fix: preserve successful chat transcripts --- .github/workflows/test.yaml | 22 +++++++++++++++++++++- Dockerfile | 5 +++-- README.md | 5 ++++- app/api.py | 23 +++++++++++++++++++---- app/core/llm_client.py | 6 +++++- tests/test_api.py | 26 +++++++++++++++++++++++++- tests/test_container_runtime.sh | 28 ++++++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 10 deletions(-) create mode 100755 tests/test_container_runtime.sh 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..0e53040 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,7 +244,7 @@ async def generate_reply( # Проверяем на ошибки if "error" in response: - return f"Ошибка API: {response['error']}" + raise LLMProviderError(str(response["error"])) # Извлекаем текст ответа return response["choices"][0]["message"]["content"] diff --git a/tests/test_api.py b/tests/test_api.py index 1cb8c00..4965b5b 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,22 @@ 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_empty_messages(client, mock_openrouter): """Test POST /api/v1/chat with empty messages list.""" response = client.post("/api/v1/chat", json={ @@ -106,7 +124,13 @@ 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_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 From 370a5a9f82e03a5ddc9b79d26b303a8cb1173044 Mon Sep 17 00:00:00 2001 From: justgithubaccount <86586195+justgithubaccount@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:23:19 +0000 Subject: [PATCH 2/2] test: handle malformed provider responses --- app/core/llm_client.py | 14 ++++++++++++-- tests/test_api.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/core/llm_client.py b/app/core/llm_client.py index 0e53040..3483cc0 100644 --- a/app/core/llm_client.py +++ b/app/core/llm_client.py @@ -246,8 +246,18 @@ async def generate_reply( if "error" in response: raise LLMProviderError(str(response["error"])) - # Извлекаем текст ответа - return response["choices"][0]["message"]["content"] + # 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 content finally: # Восстанавливаем оригинальный ключ diff --git a/tests/test_api.py b/tests/test_api.py index 4965b5b..d33f843 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -58,6 +58,24 @@ def test_chat_endpoint_reports_provider_failure_as_bad_gateway(client, monkeypat 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={ @@ -133,6 +151,27 @@ def test_project_history(client, mock_openrouter): ] +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): """Test GET /api/v1/behavior/schema when behavior not loaded.""" response = client.get("/api/v1/behavior/schema")