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
3 changes: 0 additions & 3 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
tags=["chat"]
)

# In-memory fallback (not used when database is configured)
memory = ProjectMemory()
meter = metrics.get_meter(__name__)
chat_counter = meter.create_counter("chat_requests_total")
Expand Down Expand Up @@ -75,7 +74,6 @@ async def chat_endpoint(
except Exception as e:
log.bind(event="chat_api_error", error=str(e)).error("Standard chat request failed")
traceback.print_exc()
# raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="...")
raise HTTPException(status_code=500, detail="AI сервис недоступен")

@api_router.post("/projects", response_model=ProjectInfo)
Expand Down Expand Up @@ -126,7 +124,6 @@ async def chat_in_project(
span_id = format(ctx.span_id, "016x")

try:
# Ensure project exists
project = session.get(Project, project_id)
if not project:
raise ValueError(f"Проект {project_id} не найден")
Expand Down
7 changes: 3 additions & 4 deletions app/core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@

DATABASE_URL = get_settings().database_url

# Создаем engine с timeout настройками
engine = create_engine(
DATABASE_URL,
echo=True,
pool_timeout=10, # Timeout для получения connection из pool
pool_recycle=100, # Переподключение каждый час
pool_timeout=10, # Wait at most 10 seconds for a pooled connection.
pool_recycle=100, # Recycle pooled connections after 100 seconds.
connect_args={
"connect_timeout": 10, # Timeout подключения к PostgreSQL
"connect_timeout": 10, # Bound the initial PostgreSQL connection.
"application_name": "chat-api"
} if DATABASE_URL.startswith("postgresql") else {}
)
Expand Down
36 changes: 2 additions & 34 deletions app/core/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,11 @@
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.trace import Status, StatusCode

# Инструментируем httpx для автоматического трейсинга HTTP запросов
HTTPXClientInstrumentor().instrument()

# Получаем tracer и meter
tracer = trace.get_tracer(__name__)
meter = metrics.get_meter(__name__)

# Метрики
llm_request_counter = meter.create_counter(
name="llm_requests_total",
description="Total number of LLM requests",
Expand All @@ -39,10 +36,8 @@ class LLMProviderError(RuntimeError):

class OpenRouterClient:
def __init__(self):
# Из секрета
self.api_key = os.getenv("OPENROUTER_API_KEY")

# Из конфига
self.api_url = os.getenv("OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions")
self.default_model = os.getenv("OPENROUTER_MODEL", "anthropic/claude-opus-4")
self.http_referer = os.getenv("OPENROUTER_HTTP_REFERER", "")
Expand All @@ -59,7 +54,6 @@ async def chat_completion(
"""
model_name = model or self.default_model

# Создаем span для всей операции
with tracer.start_as_current_span(
"openrouter_chat_completion",
attributes={
Expand All @@ -71,32 +65,27 @@ async def chat_completion(
start_time = time.time()

try:
# Подготовка заголовков
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}

# Опциональные заголовки для OpenRouter
if self.http_referer:
headers["HTTP-Referer"] = self.http_referer
span.set_attribute("llm.http_referer", self.http_referer)
if self.x_title:
headers["X-Title"] = self.x_title
span.set_attribute("llm.x_title", self.x_title)

# Подготовка запроса
request_body = {
"model": model_name,
"messages": messages,
**kwargs
}

# Логируем размер запроса
request_size = len(str(messages))
span.set_attribute("llm.request_size", request_size)

# Создаем отдельный span для HTTP запроса
with tracer.start_as_current_span("http_request"):
async with httpx.AsyncClient() as client:
response = await client.post(
Expand All @@ -108,23 +97,19 @@ async def chat_completion(
response.raise_for_status()
result = response.json()

# Обработка успешного ответа
duration = time.time() - start_time

# Извлекаем метрики из ответа
usage = result.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
total_tokens = usage.get("total_tokens", 0)

# Устанавливаем атрибуты span
span.set_attribute("llm.prompt_tokens", prompt_tokens)
span.set_attribute("llm.completion_tokens", completion_tokens)
span.set_attribute("llm.total_tokens", total_tokens)
span.set_attribute("llm.duration_seconds", duration)
span.set_attribute("llm.response_id", result.get("id", ""))

# Записываем метрики
labels = {"model": model_name, "status": "success"}
llm_request_counter.add(1, labels)
llm_request_duration.record(duration, labels)
Expand All @@ -136,7 +121,6 @@ async def chat_completion(
return result

except httpx.HTTPStatusError as e:
# Обработка HTTP ошибок
duration = time.time() - start_time

span.set_attribute("http.status_code", e.response.status_code)
Expand All @@ -146,55 +130,48 @@ async def chat_completion(
Status(StatusCode.ERROR, f"HTTP {e.response.status_code}: {str(e)}")
)

# Метрики для ошибок
labels = {"model": model_name, "status": "error", "error_type": "http"}
llm_request_counter.add(1, labels)
llm_request_duration.record(duration, labels)

# Добавляем событие в span
span.add_event(
"HTTP Error",
attributes={
"status_code": e.response.status_code,
"response_body": e.response.text[:500] # Первые 500 символов
"response_body": e.response.text[:500] # Bound provider data recorded in telemetry.
}
)
# Возвращаем error response вместо raise
return {
"error": f"HTTP {e.response.status_code}: {str(e)}",
"choices": [{"message": {"content": f"HTTP ошибка: {e.response.status_code}"}}]
}

except Exception as e:
# Обработка других ошибок
duration = time.time() - start_time

span.set_attribute("error.type", type(e).__name__)
span.set_attribute("error.message", str(e))
span.set_status(Status(StatusCode.ERROR, str(e)))

# Метрики для ошибок
labels = {"model": model_name, "status": "error", "error_type": "exception"}
llm_request_counter.add(1, labels)
llm_request_duration.record(duration, labels)

# Добавляем событие в span
span.add_event(
"Exception occurred",
attributes={
"exception.type": type(e).__name__,
"exception.message": str(e)
}
)
# Возвращаем error response вместо raise
return {
"error": str(e),
"choices": [{"message": {"content": f"Ошибка: {str(e)}"}}]
}

async def generate_reply(
self,
messages: List, # List[Message] из schemas
messages: List,
user_api_key: Optional[str] = None,
project_id: Optional[str] = None,
trace_id: Optional[str] = None,
Expand All @@ -203,46 +180,38 @@ async def generate_reply(
Метод для совместимости с ChatService.
Преобразует список Message объектов в формат OpenRouter.
"""
# Добавляем атрибуты в текущий span если есть
current_span = trace.get_current_span()
if current_span.is_recording():
if project_id:
current_span.set_attribute("project.id", project_id)
if trace_id:
current_span.set_attribute("external.trace_id", trace_id)

# Преобразуем Message объекты в dict для OpenRouter
openrouter_messages = []
for msg in messages:
if hasattr(msg, 'role') and hasattr(msg, 'content'):
# Если это объект Message
openrouter_messages.append({
"role": msg.role,
"content": msg.content
})
elif isinstance(msg, dict):
# Если уже dict
openrouter_messages.append(msg)
else:
# Fallback
openrouter_messages.append({
"role": "user",
"content": str(msg)
})

# Используем переданный API ключ если есть
original_key = self.api_key
if user_api_key:
self.api_key = user_api_key

try:
# Вызываем основной метод
response = await self.chat_completion(
messages=openrouter_messages,
model=self.default_model
)

# Проверяем на ошибки
if "error" in response:
raise LLMProviderError(str(response["error"]))

Expand All @@ -260,7 +229,6 @@ async def generate_reply(
return content

finally:
# Восстанавливаем оригинальный ключ
if user_api_key:
self.api_key = original_key

Expand Down
3 changes: 1 addition & 2 deletions app/integrations/behavior_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,14 @@ async def refresh(self) -> None:
log.info("Behavior page retrieved")

try:
# Expect first child to be a code block with YAML
# Use the first YAML code block returned by Notion.
for block in data.get("results", []):
if block.get("type") == "code":
text = block["code"].get("rich_text", [])
content = "".join(t.get("plain_text", "") for t in text)
raw: Dict[str, Any] = yaml.safe_load(content) or {}
self.behavior = BehaviorDefinition.model_validate(raw)

# Правильная типизация для логирования
behavior_dict = self.behavior.model_dump()
log.bind(event="behavior_parsed").debug(f"Parsed behavior: {behavior_dict}")
log.bind(event="behavior_loaded").info("Behavior updated")
Expand Down
6 changes: 0 additions & 6 deletions app/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import structlog
from opentelemetry import trace

# Context variables для хранения дополнительных атрибутов
log_context: ContextVar[Dict[str, Any]] = ContextVar('log_context', default={})

class EnrichedLogger:
Expand All @@ -23,14 +22,12 @@ def _add_trace_context(self, event_dict: Dict[str, Any]) -> Dict[str, Any]:
event_dict['trace_id'] = format(span_context.trace_id, '032x')
event_dict['span_id'] = format(span_context.span_id, '016x')

# Добавляем контекстные переменные
event_dict.update(log_context.get())

return event_dict

def bind(self, **kwargs) -> 'EnrichedLogger':
"""Добавляет контекст к логгеру."""
# Приводим к нужному типу для MyPy
bound_logger = cast(structlog.BoundLogger, self.logger.bind(**kwargs))
return EnrichedLogger(bound_logger)

Expand All @@ -50,7 +47,6 @@ def debug(self, msg: str, **kwargs) -> None:
kwargs = self._add_trace_context(kwargs)
self.logger.debug(msg, **kwargs)

# Инициализация structlog
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
Expand All @@ -68,15 +64,13 @@ def debug(self, msg: str, **kwargs) -> None:
cache_logger_on_first_use=True,
)

# Базовый логгер
base_logger = structlog.get_logger()

def enrich_context(**kwargs) -> EnrichedLogger:
"""
Создает логгер с обогащенным контекстом.
Совместимо с существующим кодом.
"""
# Приводим к нужному типу для MyPy
bound_logger = cast(structlog.BoundLogger, base_logger.bind(**kwargs))
return EnrichedLogger(bound_logger)

Expand Down
7 changes: 1 addition & 6 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Lifespan event handler for startup and shutdown."""
# Startup: init database
enrich_context(event="startup_init_db_start").info("Starting database initialization")
try:
init_db()
Expand All @@ -26,9 +25,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:

enrich_context(event="startup").info("Application initialized")

yield # Application runs here
yield

# Shutdown
enrich_context(event="shutdown").info("Application shutting down")


Expand All @@ -43,7 +41,6 @@ def create_app() -> FastAPI:

enrich_context(event="middleware_setup_start").info("Setting up middleware")

# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
Expand All @@ -54,13 +51,11 @@ def create_app() -> FastAPI:

enrich_context(event="routes_setup_start").info("Setting up routes")

# Routes
app.include_router(api_router)
app.include_router(health_router)

enrich_context(event="tracing_setup_start").info("Setting up tracing")

# Tracing
setup_tracing(app)

@app.get("/")
Expand Down
1 change: 0 additions & 1 deletion app/models/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from pydantic import BaseModel, ConfigDict, Field

# Импортируем Role из schemas, избегаем дублирования
from ..schemas.chat import Role


Expand Down
Loading
Loading