Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +23,9 @@ FastAPI микросервис для LLM-чатов с управлением
| `GET` | `/api/v1/behavior/schema` | Текущая схема поведения |
| `GET` | `/health` | Health check |

Ошибки LLM-провайдера возвращаются как HTTP `502`; они не маскируются под
успешный `200` и не создают запись истории проекта.

## Запуск

### Локально (с uv)
Expand Down
23 changes: 19 additions & 4 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@
ChatResponse,
CreateProjectRequest,
ProjectInfo,
Role,
)
from .services.chat_service import ChatService

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand All @@ -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()
Expand Down
20 changes: 17 additions & 3 deletions app/core/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
unit="1"
)


class LLMProviderError(RuntimeError):
"""The configured LLM provider could not produce a valid reply."""

class OpenRouterClient:
def __init__(self):
# Из секрета
Expand Down Expand Up @@ -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:
# Восстанавливаем оригинальный ключ
Expand Down
65 changes: 64 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""API endpoint tests."""

from unittest.mock import AsyncMock

from fastapi.testclient import TestClient

from app.main import create_app
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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):
Expand Down
28 changes: 28 additions & 0 deletions tests/test_container_runtime.sh
Original file line number Diff line number Diff line change
@@ -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
Loading