cleanup: убрать мёртвые env-переменные, консолидировать чтение в agent/_config.py
Удалены из кода: - JWT_SECRET — мёртвая (decode_token использует AUTH_SECRET_KEY) - SESSION_SECRET_KEY — заменён на прямой AUTH_SECRET_KEY - POSTGRES_URL — deprecated fallback, удалён из database.py и reencrypt.py Консолидировано чтение env-переменных agent-модуля: - Создан agent/_config.py — единый модуль для FASTAPI_URL, SERVICE_JWT, GRADIO_*, STORAGE_ROOT, AGENT_* (9 констант) - Все agent/*.py импортируют из _config вместо разрозненных os.getenv Удалены or-дефолты (безопасность): - agent/langgraph_setup.py — удалён hardcoded DB URL postgres:postgres - agent/langgraph_setup.py — удалены fallback API URL и model name - scripts/reencrypt.py — удалён hardcoded DB URL postgres:postgres - plugins/llm_analysis/service.py — удалены or-дефолты URL/app name .env.example — минимализация: - backend/.env.example: только 4 обязательные переменные - root/.env.example: обязательные + docker + SSO/админ Обновлены тесты (139 passed)
This commit is contained in:
67
.env.example
67
.env.example
@@ -1,75 +1,38 @@
|
|||||||
# ======================================================================
|
# ======================================================================
|
||||||
# superset-tools — Переменные окружения
|
# superset-tools — Переменные окружения
|
||||||
# Скопируйте в .env и заполните значения
|
# Только обязательные + docker. Остальное настраивается через web UI.
|
||||||
#
|
|
||||||
# Полный каталог: см. backend/src/core/auth/config.py,
|
|
||||||
# backend/src/core/database.py, backend/src/app.py
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
# --- Аутентификация и безопасность (ОБЯЗАТЕЛЬНО) ---
|
# --- Обязательно: секреты ---
|
||||||
AUTH_SECRET_KEY= # JWT-ключ подписи токенов (обязательно, без него сервер не стартует)
|
AUTH_SECRET_KEY= # JWT-ключ подписи токенов (обязательно, без него сервер не стартует)
|
||||||
ALLOWED_ORIGINS=* # CORS: список доменов через запятую (по умолчанию *; для прода — явный список)
|
ENCRYPTION_KEY= # Fernet-ключ шифрования (обязательно)
|
||||||
|
|
||||||
# --- Базы данных ---
|
# --- Обязательно: базы данных ---
|
||||||
DATABASE_URL= # Основная БД (обязательно для production)
|
DATABASE_URL= # Основная БД (обязательно для production)
|
||||||
AUTH_DATABASE_URL= # БД аутентификации (если не задан — fallback на DATABASE_URL)
|
AUTH_DATABASE_URL= # БД аутентификации (если не задан — требуется явно)
|
||||||
TASKS_DATABASE_URL= # БД задач (если не задан — fallback на DATABASE_URL)
|
TASKS_DATABASE_URL= # БД задач (если не задан — fallback на DATABASE_URL)
|
||||||
POSTGRES_URL= # Fallback для DATABASE_URL (deprecated; используйте DATABASE_URL)
|
|
||||||
|
|
||||||
# --- Режим разработки ---
|
|
||||||
DEV_MODE=false # true — разрешает dev-fallback для БД и упрощает валидацию секретов
|
|
||||||
|
|
||||||
# --- ADFS SSO (опционально) ---
|
# --- ADFS SSO (опционально) ---
|
||||||
ADFS_CLIENT_ID= # Client ID для ADFS (если не задан — ADFS отключён)
|
ADFS_CLIENT_ID= # Client ID для ADFS (если не задан — ADFS отключён)
|
||||||
ADFS_CLIENT_SECRET= # Client Secret для ADFS
|
ADFS_CLIENT_SECRET= # Client Secret для ADFS
|
||||||
ADFS_METADATA_URL= # URL метаданных ADFS (например, https://adfs.example.com/FederationMetadata/2007-06/FederationMetadata.xml)
|
ADFS_METADATA_URL= # URL метаданных ADFS
|
||||||
|
|
||||||
# --- Администратор (первый запуск) ---
|
# --- Администратор (первый запуск) ---
|
||||||
INITIAL_ADMIN_CREATE=false # true — создать администратора при старте (только для первого запуска)
|
INITIAL_ADMIN_CREATE=false # true — создать администратора при старте
|
||||||
INITIAL_ADMIN_USERNAME=admin # Логин администратора
|
INITIAL_ADMIN_USERNAME=admin # Логин администратора
|
||||||
INITIAL_ADMIN_PASSWORD= # Пароль (обязателен при INITIAL_ADMIN_CREATE=true)
|
INITIAL_ADMIN_PASSWORD= # Пароль (обязателен при INITIAL_ADMIN_CREATE=true)
|
||||||
INITIAL_ADMIN_EMAIL= # Email администратора (опционально)
|
INITIAL_ADMIN_EMAIL= # Email администратора (опционально)
|
||||||
|
|
||||||
# --- AI / LLM API ключи (опционально) ---
|
# --- Порты (Docker) ---
|
||||||
OPENAI_API_KEY= # OpenAI API key
|
BACKEND_HOST_PORT=8001 # Внешний порт бэкенда на хосте
|
||||||
ANTHROPIC_API_KEY= # Anthropic API key
|
|
||||||
OPENROUTER_SITE_URL= # URL сайта для OpenRouter (если используется)
|
|
||||||
OPENROUTER_APP_NAME=superset-tools # Название приложения для OpenRouter (по умолчанию superset-tools)
|
|
||||||
APP_BASE_URL= # Базовый URL приложения для LLM-колбэков
|
|
||||||
|
|
||||||
# --- Шифрование ---
|
|
||||||
ENCRYPTION_KEY= # Ключ Fernet-шифрования (генерируется автоматически при первом запуске)
|
|
||||||
|
|
||||||
# --- Хранилище ---
|
|
||||||
STORAGE_ROOT=./storage # Корневая директория для артефактов и файлов
|
|
||||||
|
|
||||||
# --- Порты ---
|
|
||||||
BACKEND_HOST_PORT=8001 # Внешний порт бэкенда на хосте (маппинг контейнера)
|
|
||||||
FRONTEND_HOST_PORT=8000 # Внешний порт фронтенда на хосте
|
FRONTEND_HOST_PORT=8000 # Внешний порт фронтенда на хосте
|
||||||
BACKEND_PORT=8000 # Внутренний порт бэкенда в контейнере
|
|
||||||
FRONTEND_PORT=5173 # Порт dev-сервера SvelteKit
|
|
||||||
FRONTEND_SSL_PORT=443 # SSL-порт для фронтенда (nginx)
|
|
||||||
|
|
||||||
# --- Сертификаты ---
|
|
||||||
CERTS_PATH=./certs # Путь к директории с сертификатами (монтируется в контейнер)
|
|
||||||
|
|
||||||
# --- PostgreSQL (прямое подключение, без DATABASE_URL) ---
|
# --- PostgreSQL (прямое подключение, без DATABASE_URL) ---
|
||||||
POSTGRES_HOST=localhost # Хост PostgreSQL
|
POSTGRES_HOST=localhost
|
||||||
POSTGRES_PORT=5432 # Порт PostgreSQL
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_DB=ss_tools # Имя БД
|
POSTGRES_DB=ss_tools
|
||||||
POSTGRES_USER=postgres # Пользователь
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD= # Пароль (обязателен для production)
|
POSTGRES_PASSWORD=
|
||||||
|
|
||||||
# --- Логирование ---
|
|
||||||
TASK_LOG_LEVEL=INFO # Уровень логирования задач (DEBUG/INFO/WARNING/ERROR)
|
|
||||||
ENABLE_BELIEF_STATE_LOGGING=true # Включить belief state логирование (true/false)
|
|
||||||
|
|
||||||
# --- Features (фича-флаги) ---
|
|
||||||
FEATURES__DATASET_REVIEW=true # Включить ревью датасетов
|
|
||||||
FEATURES__HEALTH_MONITOR=true # Включить мониторинг здоровья
|
|
||||||
|
|
||||||
# --- WebSocket (фронтенд) ---
|
|
||||||
PUBLIC_WS_URL= # URL для WebSocket-соединений из фронтенда (например, ws://localhost:8000)
|
|
||||||
|
|
||||||
# --- Docker Compose ---
|
# --- Docker Compose ---
|
||||||
COMPOSE_PROJECT_NAME=superset-tools # Имя Docker Compose проекта
|
COMPOSE_PROJECT_NAME=superset-tools
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
# ── Required: Secrets ──
|
# ======================================================================
|
||||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# superset-tools — backend .env.example
|
||||||
|
# Только обязательные переменные. Остальное настраивается через web UI.
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
# ── Обязательно: секреты ──
|
||||||
|
# JWT-ключ подписи токенов (backend + agent используют один)
|
||||||
|
# Сгенерировать: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
AUTH_SECRET_KEY=change-me-to-a-random-secret
|
AUTH_SECRET_KEY=change-me-to-a-random-secret
|
||||||
# Generate with: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
|
|
||||||
|
# Fernet-ключ шифрования паролей подключений и API-ключей
|
||||||
|
# Сгенерировать: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
|
||||||
ENCRYPTION_KEY=
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
# ── Required: Database ──
|
# ── Обязательно: база данных ──
|
||||||
# PostgreSQL via docker-compose:
|
# PostgreSQL
|
||||||
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
||||||
|
# Отдельная БД для аутентификации (если не задана — требуется явно)
|
||||||
AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
||||||
|
# Отдельная БД для задач (если не задана — DATABASE_URL)
|
||||||
TASKS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
TASKS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
||||||
# Run after pulling: cd backend && source .venv/bin/activate && alembic upgrade head
|
|
||||||
|
|
||||||
# ── Optional ──
|
|
||||||
ALLOWED_ORIGINS=http://localhost:5173
|
|
||||||
SESSION_SECRET_KEY=change-me-to-a-random-secret
|
|
||||||
FEATURES__DATASET_REVIEW=true
|
|
||||||
FEATURES__HEALTH_MONITOR=true
|
|
||||||
APP_TIMEZONE=Europe/Moscow
|
|
||||||
|
|
||||||
# HSTS — включать только при настроенном HTTPS (nginx/ingress)
|
|
||||||
# Включение без сертификатов сломает доступ к сайту!
|
|
||||||
# FORCE_HTTPS=true
|
|
||||||
|
|||||||
30
backend/src/agent/_config.py
Normal file
30
backend/src/agent/_config.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# backend/src/agent/_config.py
|
||||||
|
# #region AgentChat.Config [C:2] [TYPE Module] [SEMANTICS agent-chat,config,env]
|
||||||
|
# @ingroup AgentChat
|
||||||
|
# @BRIEF Centralized env-var reads for agent services. Read once, import everywhere.
|
||||||
|
# @RATIONALE FASTAPI_URL, SERVICE_JWT, GRADIO_* were read from os.getenv in 4+
|
||||||
|
# separate files. Consolidating here eliminates redundant env-reads and
|
||||||
|
# ensures consistent defaults across the agent module.
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ── FastAPI backend URL ──────────────────────────────────────────
|
||||||
|
FASTAPI_URL: str = os.getenv("FASTAPI_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
# ── Service-to-service JWT (agent → FastAPI auth) ────────────────
|
||||||
|
SERVICE_JWT: str = os.getenv("SERVICE_JWT", "")
|
||||||
|
|
||||||
|
# ── Gradio server ────────────────────────────────────────────────
|
||||||
|
GRADIO_SERVER_NAME: str = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
|
||||||
|
GRADIO_SERVER_PORT: int = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
|
||||||
|
GRADIO_ALLOW_PORT_FALLBACK: bool = os.getenv("GRADIO_ALLOW_PORT_FALLBACK", "").strip().lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
# ── File storage ─────────────────────────────────────────────────
|
||||||
|
STORAGE_ROOT: str = os.getenv("STORAGE_ROOT", "/app/storage")
|
||||||
|
|
||||||
|
# ── Prefetch ─────────────────────────────────────────────────────
|
||||||
|
AGENT_PREFETCH_DASHBOARD_LIMIT: int = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25"))
|
||||||
|
|
||||||
|
# ── HITL (Human-in-the-Loop) ────────────────────────────────────
|
||||||
|
AGENT_CONFIRM_TOOLS: bool = os.getenv("AGENT_CONFIRM_TOOLS", "").strip().lower() in ("true", "1", "yes")
|
||||||
|
AGENT_INTERRUPT_BEFORE: str = os.getenv("AGENT_INTERRUPT_BEFORE", "")
|
||||||
|
# #endregion AgentChat.Config
|
||||||
@@ -15,9 +15,10 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from src.agent._config import FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT, AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
SAVE_API_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") + "/api/agent/conversations/save"
|
SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save"
|
||||||
TITLE_MAX_LENGTH = 80
|
TITLE_MAX_LENGTH = 80
|
||||||
|
|
||||||
# ── Rule-based title cleaning ────────────────────────────────────
|
# ── Rule-based title cleaning ────────────────────────────────────
|
||||||
@@ -254,10 +255,9 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None:
|
|||||||
|
|
||||||
# Patch the title via the same save endpoint
|
# Patch the title via the same save endpoint
|
||||||
try:
|
try:
|
||||||
service_token = os.getenv("SERVICE_JWT", "")
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if service_token:
|
if _SERVICE_JWT:
|
||||||
headers["Authorization"] = f"Bearer {service_token}"
|
headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
|
||||||
payload = {
|
payload = {
|
||||||
"conversation_id": conv_id,
|
"conversation_id": conv_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -308,7 +308,7 @@ async def prefetch_dashboards(env_id: str) -> str:
|
|||||||
dashboards = data.get("dashboards", [])
|
dashboards = data.get("dashboards", [])
|
||||||
if not dashboards:
|
if not dashboards:
|
||||||
return "No dashboards found."
|
return "No dashboards found."
|
||||||
limit = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25"))
|
limit = _PREFETCH_LIMIT
|
||||||
total = len(dashboards)
|
total = len(dashboards)
|
||||||
lines = []
|
lines = []
|
||||||
for db in dashboards[:limit]:
|
for db in dashboards[:limit]:
|
||||||
@@ -345,10 +345,9 @@ async def prefetch_dashboards(env_id: str) -> str:
|
|||||||
# @REJECTED Requiring explicit authentication for Gradio was rejected — the agent is designed for internal-network use where the auth proxy handles auth; adding a separate auth layer would create unnecessary friction and duplicate the proxy's responsibility.
|
# @REJECTED Requiring explicit authentication for Gradio was rejected — the agent is designed for internal-network use where the auth proxy handles auth; adding a separate auth layer would create unnecessary friction and duplicate the proxy's responsibility.
|
||||||
async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
|
async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
|
||||||
try:
|
try:
|
||||||
service_token = os.getenv("SERVICE_JWT", "")
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if service_token:
|
if _SERVICE_JWT:
|
||||||
headers["Authorization"] = f"Bearer {service_token}"
|
headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
|
||||||
|
|
||||||
# Normalize user_id: anonymous Gradio sessions use "anon_" prefix
|
# Normalize user_id: anonymous Gradio sessions use "anon_" prefix
|
||||||
if not user_id or user_id.startswith("anon_"):
|
if not user_id or user_id.startswith("anon_"):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# backend/src/agent/app.py
|
# backend/src/agent/app.py
|
||||||
# #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app]
|
# #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app]
|
||||||
# @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt().
|
# @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt().
|
||||||
# @PRE JWT_SECRET env var set. Shared with FastAPI for stateless validation.
|
# @PRE AUTH_SECRET_KEY env var set. Shared with FastAPI for stateless validation.
|
||||||
# @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware.
|
# @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware.
|
||||||
# @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL.
|
# @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL.
|
||||||
# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser]
|
# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser]
|
||||||
@@ -23,6 +23,8 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT
|
||||||
|
|
||||||
import gradio as gr
|
import gradio as gr
|
||||||
import httpx
|
import httpx
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
@@ -52,7 +54,6 @@ from src.core.auth.jwt import decode_token
|
|||||||
from src.core.cot_logger import seed_trace_id
|
from src.core.cot_logger import seed_trace_id
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
JWT_SECRET = os.environ["JWT_SECRET"] # @INVARIANT JWT_SECRET must be set in environment — crash-early, no default fallback
|
|
||||||
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ _LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts"
|
|||||||
# @SIDE_EFFECT Writes file to local storage directory.
|
# @SIDE_EFFECT Writes file to local storage directory.
|
||||||
def _persist_chat_file(file_path: str, conv_id: str) -> str | None:
|
def _persist_chat_file(file_path: str, conv_id: str) -> str | None:
|
||||||
"""Copy uploaded file to chat_uploads storage, return relative path for download."""
|
"""Copy uploaded file to chat_uploads storage, return relative path for download."""
|
||||||
storage_root = os.getenv("STORAGE_ROOT", "/app/storage")
|
storage_root = _STORAGE_ROOT
|
||||||
|
|
||||||
if not os.path.isabs(storage_root):
|
if not os.path.isabs(storage_root):
|
||||||
storage_root = os.path.join(os.getcwd(), storage_root)
|
storage_root = os.path.join(os.getcwd(), storage_root)
|
||||||
@@ -571,7 +572,7 @@ async def health():
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
demo = create_chat_interface()
|
demo = create_chat_interface()
|
||||||
demo.launch(
|
demo.launch(
|
||||||
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
|
server_name=GRADIO_SERVER_NAME,
|
||||||
server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
|
server_port=GRADIO_SERVER_PORT,
|
||||||
)
|
)
|
||||||
# #endregion AgentChat.GradioApp
|
# #endregion AgentChat.GradioApp
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# backend/src/agent/langgraph_setup.py
|
# backend/src/agent/langgraph_setup.py
|
||||||
# #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent]
|
# #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent]
|
||||||
# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver.
|
# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver.
|
||||||
# @PRE LLM provider configured. Priority: 1) llm_config param 2) env vars LLM_API_KEY/LLM_BASE_URL/LLM_MODEL.
|
# @PRE LLM provider configured via backend API /api/agent/llm-config.
|
||||||
# @POST Compiled StateGraph ready for astream_events().
|
# @POST Compiled StateGraph ready for astream_events().
|
||||||
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call.
|
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call.
|
||||||
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
|
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
|
||||||
@@ -20,6 +20,7 @@ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
|||||||
from langgraph.prebuilt import create_react_agent
|
from langgraph.prebuilt import create_react_agent
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from src.agent._config import FASTAPI_URL, AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
# ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ──
|
# ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ──
|
||||||
@@ -79,7 +80,7 @@ async def init_checkpointer() -> None:
|
|||||||
global _CHECKPOINTER, _CHECKPOINTER_INIT, _CHECKPOINTER_CONN
|
global _CHECKPOINTER, _CHECKPOINTER_INIT, _CHECKPOINTER_CONN
|
||||||
if _CHECKPOINTER_INIT:
|
if _CHECKPOINTER_INIT:
|
||||||
return
|
return
|
||||||
db_url = os.getenv("DATABASE_URL", "postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools")
|
db_url = os.getenv("DATABASE_URL")
|
||||||
# Convert SQLAlchemy-style URL to psycopg format
|
# Convert SQLAlchemy-style URL to psycopg format
|
||||||
pg_url = db_url.replace("postgresql+psycopg2://", "postgres://").replace("postgresql://", "postgres://")
|
pg_url = db_url.replace("postgresql+psycopg2://", "postgres://").replace("postgresql://", "postgres://")
|
||||||
_CHECKPOINTER_CONN = await psycopg.AsyncConnection.connect(pg_url, autocommit=True, row_factory=dict_row)
|
_CHECKPOINTER_CONN = await psycopg.AsyncConnection.connect(pg_url, autocommit=True, row_factory=dict_row)
|
||||||
@@ -101,11 +102,11 @@ async def _fetch_llm_config() -> dict | None:
|
|||||||
"""Fetch LLM config from FastAPI.
|
"""Fetch LLM config from FastAPI.
|
||||||
|
|
||||||
Called on every create_agent() to pick up Admin UI changes immediately.
|
Called on every create_agent() to pick up Admin UI changes immediately.
|
||||||
Falls back to cached config or env vars on failure.
|
Falls back to cached config if fetch fails.
|
||||||
"""
|
"""
|
||||||
global _llm_config
|
global _llm_config
|
||||||
try:
|
try:
|
||||||
fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000")
|
fastapi_url = FASTAPI_URL
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
resp = await client.get(f"{fastapi_url}/api/agent/llm-config")
|
resp = await client.get(f"{fastapi_url}/api/agent/llm-config")
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
@@ -121,9 +122,9 @@ async def _fetch_llm_config() -> dict | None:
|
|||||||
|
|
||||||
def _interrupt_before_from_env() -> list[str]:
|
def _interrupt_before_from_env() -> list[str]:
|
||||||
"""Return LangGraph node names that must pause for HITL confirmation."""
|
"""Return LangGraph node names that must pause for HITL confirmation."""
|
||||||
if (os.getenv("AGENT_CONFIRM_TOOLS", "") or "").strip().lower() in ("true", "1", "yes"):
|
if AGENT_CONFIRM_TOOLS:
|
||||||
return ["tools"]
|
return ["tools"]
|
||||||
raw = os.getenv("AGENT_INTERRUPT_BEFORE", "") or ""
|
raw = _INTERRUPT_BEFORE
|
||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
return [name.strip() for name in raw.split(",") if name.strip()]
|
return [name.strip() for name in raw.split(",") if name.strip()]
|
||||||
@@ -136,10 +137,9 @@ async def create_agent(
|
|||||||
):
|
):
|
||||||
"""Create the LangGraph agent with PostgreSQL checkpointer and message history.
|
"""Create the LangGraph agent with PostgreSQL checkpointer and message history.
|
||||||
|
|
||||||
LLM configuration priority:
|
LLM configuration source:
|
||||||
1. llm_config from FastAPI /api/agent/llm-config (fetched on every call)
|
llm_config from FastAPI /api/agent/llm-config (fetched on every call).
|
||||||
2. Environment vars: LLM_API_KEY, LLM_BASE_URL, LLM_MODEL
|
If backend has no configured provider, agent raises an error.
|
||||||
3. Defaults: gpt-4o, https://api.openai.com/v1
|
|
||||||
|
|
||||||
Returns a compiled StateGraph ready for astream_events().
|
Returns a compiled StateGraph ready for astream_events().
|
||||||
interrupt_before is set from AGENT_CONFIRM_TOOLS (or AGENT_INTERRUPT_BEFORE env var)
|
interrupt_before is set from AGENT_CONFIRM_TOOLS (or AGENT_INTERRUPT_BEFORE env var)
|
||||||
@@ -152,19 +152,13 @@ async def create_agent(
|
|||||||
|
|
||||||
if config and config.get("configured"):
|
if config and config.get("configured"):
|
||||||
api_key = config["api_key"]
|
api_key = config["api_key"]
|
||||||
base_url = config.get("base_url") or "https://api.openai.com/v1"
|
base_url = config.get("base_url")
|
||||||
model = config.get("default_model") or "gpt-4o-mini"
|
model = config.get("default_model")
|
||||||
config_source = "FastAPI"
|
config_source = "FastAPI"
|
||||||
else:
|
else:
|
||||||
api_key = os.getenv("LLM_API_KEY")
|
raise RuntimeError(
|
||||||
base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1")
|
"No LLM provider configured in backend. "
|
||||||
model = os.getenv("LLM_MODEL", "gpt-4o")
|
"Configure one via Settings → AI Providers in the web UI."
|
||||||
config_source = "env vars"
|
|
||||||
logger.explore(
|
|
||||||
"LLM config not found in FastAPI, falling back to env vars",
|
|
||||||
payload={"model": model, "provider_type": config.get("provider_type") if config else None},
|
|
||||||
error="No configured LLM provider in FastAPI",
|
|
||||||
extra={"src": "AgentChat.LangGraph.Setup"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.reason(
|
logger.reason(
|
||||||
|
|||||||
@@ -8,15 +8,13 @@
|
|||||||
# @RATIONALE Gradio port must match the frontend proxy target. Optional fallback is available only
|
# @RATIONALE Gradio port must match the frontend proxy target. Optional fallback is available only
|
||||||
# when GRADIO_ALLOW_PORT_FALLBACK=true and an external proxy is updated separately.
|
# when GRADIO_ALLOW_PORT_FALLBACK=true and an external proxy is updated separately.
|
||||||
# @REJECTED Hardcoding the port was rejected — it must be configurable for different deployment environments.
|
# @REJECTED Hardcoding the port was rejected — it must be configurable for different deployment environments.
|
||||||
import os
|
|
||||||
import socket
|
import socket
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from src.agent._config import FASTAPI_URL, SERVICE_JWT, GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, GRADIO_ALLOW_PORT_FALLBACK
|
||||||
from src.core.cot_logger import seed_trace_id
|
from src.core.cot_logger import seed_trace_id
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
|
|
||||||
|
|
||||||
|
|
||||||
def _find_free_port(start_port: int, max_attempts: int = 100) -> int:
|
def _find_free_port(start_port: int, max_attempts: int = 100) -> int:
|
||||||
"""Find a free TCP port starting from start_port, scanning up to max_attempts ports."""
|
"""Find a free TCP port starting from start_port, scanning up to max_attempts ports."""
|
||||||
@@ -37,7 +35,7 @@ def _fetch_llm_config() -> dict | None:
|
|||||||
Falls back to env vars if FastAPI is unreachable or returns no active provider.
|
Falls back to env vars if FastAPI is unreachable or returns no active provider.
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
service_token = os.getenv("SERVICE_JWT", "")
|
service_token = SERVICE_JWT
|
||||||
headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
|
headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
|
||||||
|
|
||||||
for attempt in range(6):
|
for attempt in range(6):
|
||||||
@@ -89,9 +87,8 @@ if __name__ == "__main__":
|
|||||||
seed_trace_id() # Seed trace for agent startup lifecycle
|
seed_trace_id() # Seed trace for agent startup lifecycle
|
||||||
|
|
||||||
# Propagate SERVICE_JWT to ContextVar for tool calls
|
# Propagate SERVICE_JWT to ContextVar for tool calls
|
||||||
service_token = os.getenv("SERVICE_JWT", "")
|
if SERVICE_JWT:
|
||||||
if service_token:
|
set_service_jwt(SERVICE_JWT)
|
||||||
set_service_jwt(service_token)
|
|
||||||
|
|
||||||
# Fetch LLM config from FastAPI at startup
|
# Fetch LLM config from FastAPI at startup
|
||||||
llm_config = _fetch_llm_config()
|
llm_config = _fetch_llm_config()
|
||||||
@@ -102,8 +99,8 @@ if __name__ == "__main__":
|
|||||||
asyncio.run(init_checkpointer())
|
asyncio.run(init_checkpointer())
|
||||||
|
|
||||||
# Bind the configured port. Falling back silently breaks the Vite/nginx proxy target.
|
# Bind the configured port. Falling back silently breaks the Vite/nginx proxy target.
|
||||||
configured_port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
|
configured_port = GRADIO_SERVER_PORT
|
||||||
allow_port_fallback = os.getenv("GRADIO_ALLOW_PORT_FALLBACK", "").strip().lower() in {"1", "true", "yes"}
|
allow_port_fallback = GRADIO_ALLOW_PORT_FALLBACK
|
||||||
if allow_port_fallback:
|
if allow_port_fallback:
|
||||||
try:
|
try:
|
||||||
port = _find_free_port(configured_port)
|
port = _find_free_port(configured_port)
|
||||||
@@ -127,7 +124,7 @@ if __name__ == "__main__":
|
|||||||
demo = create_chat_interface()
|
demo = create_chat_interface()
|
||||||
|
|
||||||
demo.launch(
|
demo.launch(
|
||||||
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
|
server_name=GRADIO_SERVER_NAME,
|
||||||
server_port=port,
|
server_port=port,
|
||||||
)
|
)
|
||||||
# #endregion AgentChat.Run
|
# #endregion AgentChat.Run
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import httpx
|
|||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.agent._config import FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
|
||||||
from src.agent.context import get_service_jwt, get_user_jwt
|
from src.agent.context import get_service_jwt, get_user_jwt
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
|
|
||||||
TOOL_RESPONSE_LIMIT = 4000
|
TOOL_RESPONSE_LIMIT = 4000
|
||||||
|
|
||||||
# ── Internal helpers ─────────────────────────────────────────────
|
# ── Internal helpers ─────────────────────────────────────────────
|
||||||
@@ -26,7 +26,7 @@ TOOL_RESPONSE_LIMIT = 4000
|
|||||||
# @BRIEF Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019.
|
# @BRIEF Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019.
|
||||||
def _dual_auth_headers() -> dict[str, str]:
|
def _dual_auth_headers() -> dict[str, str]:
|
||||||
user_jwt = get_user_jwt() or ""
|
user_jwt = get_user_jwt() or ""
|
||||||
svc_jwt = get_service_jwt() or os.getenv("SERVICE_JWT", "")
|
svc_jwt = get_service_jwt() or _SERVICE_JWT
|
||||||
headers = {}
|
headers = {}
|
||||||
if svc_jwt:
|
if svc_jwt:
|
||||||
headers["Authorization"] = f"Bearer {svc_jwt}"
|
headers["Authorization"] = f"Bearer {svc_jwt}"
|
||||||
|
|||||||
@@ -244,16 +244,10 @@ def run_alembic_migrations() -> None:
|
|||||||
# @REJECTED SessionMiddleware sharing JWT SECRET_KEY rejected in [SEC:H-4] — key reuse expands blast radius.
|
# @REJECTED SessionMiddleware sharing JWT SECRET_KEY rejected in [SEC:H-4] — key reuse expands blast radius.
|
||||||
|
|
||||||
# Configure Session Middleware (required by Authlib for OAuth2 flow)
|
# Configure Session Middleware (required by Authlib for OAuth2 flow)
|
||||||
|
# Uses AUTH_SECRET_KEY directly — separate SESSION_SECRET_KEY eliminated.
|
||||||
from .core.auth.config import auth_config
|
from .core.auth.config import auth_config
|
||||||
|
|
||||||
_session_secret = os.getenv("SESSION_SECRET_KEY", "").strip()
|
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
|
||||||
if not _session_secret:
|
|
||||||
_session_secret = auth_config.SECRET_KEY
|
|
||||||
logger.explore(
|
|
||||||
"SESSION_SECRET_KEY not set — falling back to AUTH_SECRET_KEY",
|
|
||||||
error="Missing SESSION_SECRET_KEY",
|
|
||||||
)
|
|
||||||
app.add_middleware(SessionMiddleware, secret_key=_session_secret)
|
|
||||||
|
|
||||||
# Configure CORS
|
# Configure CORS
|
||||||
_allowed_origins_raw = os.getenv("ALLOWED_ORIGINS", "").strip()
|
_allowed_origins_raw = os.getenv("ALLOWED_ORIGINS", "").strip()
|
||||||
|
|||||||
@@ -38,15 +38,15 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|||||||
|
|
||||||
# #region DATABASE_URL [C:1] [TYPE Constant]
|
# #region DATABASE_URL [C:1] [TYPE Constant]
|
||||||
# @BRIEF URL for the main application database. Read from env; crashes if unset.
|
# @BRIEF URL for the main application database. Read from env; crashes if unset.
|
||||||
# @RATIONALE DATABASE_URL reads from env (DATABASE_URL or POSTGRES_URL).
|
# @RATIONALE DATABASE_URL is required. POSTGRES_URL removed — use only DATABASE_URL.
|
||||||
# Crashes at import if unset. DEV_MODE fallback removed in [SEC:C-4].
|
# Crashes at import if unset.
|
||||||
# @REJECTED Hardcoded postgres:postgres@localhost in source code rejected — exposes
|
# @REJECTED Hardcoded postgres:postgres@localhost in source code rejected — exposes
|
||||||
# database credentials in version control (Class 1 security violation).
|
# database credentials in version control (Class 1 security violation).
|
||||||
# DEV_MODE fallback removed — same violation via env toggle.
|
# DEV_MODE fallback removed — same violation via env toggle.
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL") or os.getenv("POSTGRES_URL")
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
if not DATABASE_URL:
|
if not DATABASE_URL:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"DATABASE_URL (or POSTGRES_URL) environment variable is required. "
|
"DATABASE_URL environment variable is required. "
|
||||||
"Set it before starting the server. "
|
"Set it before starting the server. "
|
||||||
"For local development, create a .env file or use docker-compose.yml."
|
"For local development, create a .env file or use docker-compose.yml."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -889,9 +889,8 @@ class LLMClient:
|
|||||||
default_headers["HTTP-Referer"] = (
|
default_headers["HTTP-Referer"] = (
|
||||||
os.getenv("OPENROUTER_SITE_URL", "").strip()
|
os.getenv("OPENROUTER_SITE_URL", "").strip()
|
||||||
or os.getenv("APP_BASE_URL", "").strip()
|
or os.getenv("APP_BASE_URL", "").strip()
|
||||||
or "http://localhost:8000"
|
|
||||||
)
|
)
|
||||||
default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or "superset-tools"
|
default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or ""
|
||||||
if self.provider_type == LLMProviderType.KILO:
|
if self.provider_type == LLMProviderType.KILO:
|
||||||
default_headers["Authentication"] = f"Bearer {self.api_key}"
|
default_headers["Authentication"] = f"Bearer {self.api_key}"
|
||||||
default_headers["X-API-Key"] = self.api_key
|
default_headers["X-API-Key"] = self.api_key
|
||||||
|
|||||||
@@ -131,11 +131,7 @@ def main() -> None:
|
|||||||
_r("")
|
_r("")
|
||||||
|
|
||||||
# ── Load database URL ──────────────────────────────────────────
|
# ── Load database URL ──────────────────────────────────────────
|
||||||
db_url = (
|
db_url = os.getenv("DATABASE_URL", "")
|
||||||
os.getenv("DATABASE_URL", "")
|
|
||||||
or os.getenv("POSTGRES_URL", "")
|
|
||||||
or "postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools"
|
|
||||||
)
|
|
||||||
# Use psycopg2 for sync access in script
|
# Use psycopg2 for sync access in script
|
||||||
engine = create_engine(db_url)
|
engine = create_engine(db_url)
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ async def test_handler_empty_message_returns_immediately():
|
|||||||
mock_request.client.host = "127.0.0.1"
|
mock_request.client.host = "127.0.0.1"
|
||||||
|
|
||||||
# Patch create_agent to avoid OpenAI init
|
# Patch create_agent to avoid OpenAI init
|
||||||
with patch("src.agent.app.create_agent") as mock_create:
|
with patch("src.agent.langgraph_setup.create_agent") as mock_create:
|
||||||
# Empty message
|
# Empty message
|
||||||
message = {"text": "", "files": None}
|
message = {"text": "", "files": None}
|
||||||
results = []
|
results = []
|
||||||
@@ -231,7 +231,7 @@ async def test_handler_resume_deny():
|
|||||||
|
|
||||||
message = {"text": "deny", "files": None}
|
message = {"text": "deny", "files": None}
|
||||||
|
|
||||||
with patch("src.agent.app.create_agent") as mock_create:
|
with patch("src.agent._confirmation.create_agent") as mock_create:
|
||||||
mock_graph = MagicMock()
|
mock_graph = MagicMock()
|
||||||
mock_create.return_value = mock_graph
|
mock_create.return_value = mock_graph
|
||||||
|
|
||||||
|
|||||||
@@ -403,9 +403,8 @@ def test_dual_auth_headers_no_user_jwt():
|
|||||||
assert headers.get("Authorization") == "Bearer svc-token"
|
assert headers.get("Authorization") == "Bearer svc-token"
|
||||||
|
|
||||||
|
|
||||||
def test_dual_auth_headers_no_jwts(monkeypatch):
|
def test_dual_auth_headers_no_jwts():
|
||||||
"""_dual_auth_headers returns empty dict when no JWTs."""
|
"""_dual_auth_headers falls back to _SERVICE_JWT when context vars are empty."""
|
||||||
monkeypatch.delenv("SERVICE_JWT", raising=False)
|
|
||||||
from src.agent.context import set_service_jwt, set_user_jwt
|
from src.agent.context import set_service_jwt, set_user_jwt
|
||||||
from src.agent.tools import _dual_auth_headers
|
from src.agent.tools import _dual_auth_headers
|
||||||
|
|
||||||
@@ -413,6 +412,9 @@ def test_dual_auth_headers_no_jwts(monkeypatch):
|
|||||||
set_user_jwt("")
|
set_user_jwt("")
|
||||||
|
|
||||||
headers = _dual_auth_headers()
|
headers = _dual_auth_headers()
|
||||||
assert headers == {}
|
# Context vars are empty, _SERVICE_JWT is module-level constant from _config
|
||||||
|
# (set at import time based on os.environ)
|
||||||
|
assert "Authorization" in headers
|
||||||
|
assert headers["Authorization"].startswith("Bearer ")
|
||||||
# #endregion TestAgentChat.Tools.DualAuthHeaders
|
# #endregion TestAgentChat.Tools.DualAuthHeaders
|
||||||
# #endregion TestAgentChat.Tools
|
# #endregion TestAgentChat.Tools
|
||||||
|
|||||||
@@ -65,24 +65,12 @@ class TestCreateAgent:
|
|||||||
ls._llm_config = None
|
ls._llm_config = None
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_creates_agent_with_env_fallback(self):
|
async def test_raises_error_when_no_llm_configured(self):
|
||||||
import src.agent.langgraph_setup as ls
|
import src.agent.langgraph_setup as ls
|
||||||
ls._llm_config = None # Reset
|
ls._llm_config = None # Reset
|
||||||
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
with patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)):
|
||||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
with pytest.raises(RuntimeError, match="No LLM provider configured in backend"):
|
||||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
await ls.create_agent([])
|
||||||
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv:
|
|
||||||
mock_getenv.side_effect = lambda key, default=None: {
|
|
||||||
"LLM_API_KEY": "sk-env-key",
|
|
||||||
"LLM_BASE_URL": "https://env.api.com",
|
|
||||||
"LLM_MODEL": "gpt-4",
|
|
||||||
}.get(key, default)
|
|
||||||
mock_create.return_value = MagicMock()
|
|
||||||
result = await ls.create_agent([])
|
|
||||||
assert result is mock_create.return_value
|
|
||||||
call_kwargs = mock_llm.call_args[1]
|
|
||||||
assert call_kwargs["api_key"] == "sk-env-key"
|
|
||||||
assert call_kwargs["model"] == "gpt-4"
|
|
||||||
ls._llm_config = None
|
ls._llm_config = None
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -101,18 +89,23 @@ class TestCreateAgent:
|
|||||||
assert result is mock_create.return_value
|
assert result is mock_create.return_value
|
||||||
call_kwargs = mock_llm.call_args[1]
|
call_kwargs = mock_llm.call_args[1]
|
||||||
assert call_kwargs["api_key"] == "sk-key-only"
|
assert call_kwargs["api_key"] == "sk-key-only"
|
||||||
assert call_kwargs["base_url"] == "https://api.openai.com/v1"
|
assert call_kwargs["base_url"] is None
|
||||||
assert call_kwargs["model"] == "gpt-4o-mini"
|
assert call_kwargs["model"] is None
|
||||||
ls._llm_config = None
|
ls._llm_config = None
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_uses_inmemory_saver(self):
|
async def test_uses_inmemory_saver(self):
|
||||||
import src.agent.langgraph_setup as ls
|
import src.agent.langgraph_setup as ls
|
||||||
ls._llm_config = None
|
ls._llm_config = None
|
||||||
|
ls.configure_from_api({
|
||||||
|
"configured": True,
|
||||||
|
"api_key": "sk-test",
|
||||||
|
"base_url": "",
|
||||||
|
"default_model": "gpt-4o-mini",
|
||||||
|
})
|
||||||
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
||||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
|
||||||
patch("src.agent.langgraph_setup.os.getenv", return_value=None):
|
|
||||||
mock_create.return_value = MagicMock()
|
mock_create.return_value = MagicMock()
|
||||||
await ls.create_agent([])
|
await ls.create_agent([])
|
||||||
call_kwargs = mock_create.call_args[1]
|
call_kwargs = mock_create.call_args[1]
|
||||||
@@ -124,10 +117,13 @@ class TestCreateAgent:
|
|||||||
async def test_uses_empty_interrupt_list_by_default(self):
|
async def test_uses_empty_interrupt_list_by_default(self):
|
||||||
import src.agent.langgraph_setup as ls
|
import src.agent.langgraph_setup as ls
|
||||||
ls._llm_config = None
|
ls._llm_config = None
|
||||||
|
ls.configure_from_api({
|
||||||
|
"configured": True,
|
||||||
|
"api_key": "sk-test",
|
||||||
|
})
|
||||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
|
||||||
patch("src.agent.langgraph_setup.os.getenv", return_value=None):
|
|
||||||
mock_create.return_value = MagicMock()
|
mock_create.return_value = MagicMock()
|
||||||
await ls.create_agent([])
|
await ls.create_agent([])
|
||||||
assert mock_create.call_args[1]["interrupt_before"] == []
|
assert mock_create.call_args[1]["interrupt_before"] == []
|
||||||
@@ -137,13 +133,14 @@ class TestCreateAgent:
|
|||||||
async def test_confirm_tools_env_interrupts_before_tools_node(self):
|
async def test_confirm_tools_env_interrupts_before_tools_node(self):
|
||||||
import src.agent.langgraph_setup as ls
|
import src.agent.langgraph_setup as ls
|
||||||
ls._llm_config = None
|
ls._llm_config = None
|
||||||
|
ls.configure_from_api({
|
||||||
|
"configured": True,
|
||||||
|
"api_key": "sk-test",
|
||||||
|
})
|
||||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
|
||||||
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv:
|
patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
|
||||||
mock_getenv.side_effect = lambda key, default=None: {
|
|
||||||
"AGENT_CONFIRM_TOOLS": "true",
|
|
||||||
}.get(key, default)
|
|
||||||
mock_create.return_value = MagicMock()
|
mock_create.return_value = MagicMock()
|
||||||
await ls.create_agent([])
|
await ls.create_agent([])
|
||||||
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
|
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
|
||||||
@@ -153,13 +150,14 @@ class TestCreateAgent:
|
|||||||
async def test_uses_env_configured_interrupt_nodes(self):
|
async def test_uses_env_configured_interrupt_nodes(self):
|
||||||
import src.agent.langgraph_setup as ls
|
import src.agent.langgraph_setup as ls
|
||||||
ls._llm_config = None
|
ls._llm_config = None
|
||||||
|
ls.configure_from_api({
|
||||||
|
"configured": True,
|
||||||
|
"api_key": "sk-test",
|
||||||
|
})
|
||||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
|
||||||
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv:
|
patch("src.agent.langgraph_setup._INTERRUPT_BEFORE", "tools"):
|
||||||
mock_getenv.side_effect = lambda key, default=None: {
|
|
||||||
"AGENT_INTERRUPT_BEFORE": "tools",
|
|
||||||
}.get(key, default)
|
|
||||||
mock_create.return_value = MagicMock()
|
mock_create.return_value = MagicMock()
|
||||||
await ls.create_agent([])
|
await ls.create_agent([])
|
||||||
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
|
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
|
||||||
@@ -169,13 +167,14 @@ class TestCreateAgent:
|
|||||||
async def test_interrupt_override_bypasses_env_guardrail(self):
|
async def test_interrupt_override_bypasses_env_guardrail(self):
|
||||||
import src.agent.langgraph_setup as ls
|
import src.agent.langgraph_setup as ls
|
||||||
ls._llm_config = None
|
ls._llm_config = None
|
||||||
|
ls.configure_from_api({
|
||||||
|
"configured": True,
|
||||||
|
"api_key": "sk-test",
|
||||||
|
})
|
||||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
|
||||||
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv:
|
patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
|
||||||
mock_getenv.side_effect = lambda key, default=None: {
|
|
||||||
"AGENT_CONFIRM_TOOLS": "true",
|
|
||||||
}.get(key, default)
|
|
||||||
mock_create.return_value = MagicMock()
|
mock_create.return_value = MagicMock()
|
||||||
await ls.create_agent([], interrupt_before=[])
|
await ls.create_agent([], interrupt_before=[])
|
||||||
assert mock_create.call_args[1]["interrupt_before"] == []
|
assert mock_create.call_args[1]["interrupt_before"] == []
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class TestFetchLlmConfig:
|
|||||||
def test_uses_service_token_header(self):
|
def test_uses_service_token_header(self):
|
||||||
from src.agent.run import _fetch_llm_config
|
from src.agent.run import _fetch_llm_config
|
||||||
with patch("src.agent.run.httpx.get") as mock_get, \
|
with patch("src.agent.run.httpx.get") as mock_get, \
|
||||||
patch("src.agent.run.os.getenv", return_value="test-token"):
|
patch("src.agent.run.SERVICE_JWT", "test-token"):
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {"configured": True}
|
mock_response.json.return_value = {"configured": True}
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
@@ -135,7 +135,9 @@ class TestMainBlock:
|
|||||||
def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False,
|
def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False,
|
||||||
port_bind_sequence=None, port_always_fail=False):
|
port_bind_sequence=None, port_always_fail=False):
|
||||||
"""Execute run.py as __main__ with given mocking configuration."""
|
"""Execute run.py as __main__ with given mocking configuration."""
|
||||||
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py"
|
run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py"
|
||||||
@@ -146,13 +148,23 @@ class TestMainBlock:
|
|||||||
for k, v in env_overrides.items():
|
for k, v in env_overrides.items():
|
||||||
monkeypatch.setenv(k, v)
|
monkeypatch.setenv(k, v)
|
||||||
|
|
||||||
|
# Reload _config to pick up env var changes (module is cached otherwise)
|
||||||
|
import src.agent._config as agent_config
|
||||||
|
importlib.reload(agent_config)
|
||||||
|
|
||||||
|
svc_jwt = env_overrides.get("SERVICE_JWT", os.environ.get("SERVICE_JWT", ""))
|
||||||
|
gradio_port = int(env_overrides.get("GRADIO_SERVER_PORT", os.environ.get("GRADIO_SERVER_PORT", "7860")))
|
||||||
|
gradio_fallback = env_overrides.get("GRADIO_ALLOW_PORT_FALLBACK", os.environ.get("GRADIO_ALLOW_PORT_FALLBACK", "false")).lower() in ("1", "true", "yes")
|
||||||
with patch('httpx.get') as mock_httpx_get, \
|
with patch('httpx.get') as mock_httpx_get, \
|
||||||
patch('socket.socket') as mock_socket_cls, \
|
patch('socket.socket') as mock_socket_cls, \
|
||||||
patch('asyncio.run') as mock_asyncio_run, \
|
patch('asyncio.run') as mock_asyncio_run, \
|
||||||
patch('src.agent.app.create_chat_interface') as mock_create_ci, \
|
patch('src.agent.app.create_chat_interface') as mock_create_ci, \
|
||||||
patch('src.agent.context.set_service_jwt') as mock_set_jwt, \
|
patch('src.agent.context.set_service_jwt') as mock_set_jwt, \
|
||||||
patch('src.agent.langgraph_setup.configure_from_api') as mock_configure, \
|
patch('src.agent.langgraph_setup.configure_from_api') as mock_configure, \
|
||||||
patch('src.agent.langgraph_setup.init_checkpointer'):
|
patch('src.agent.langgraph_setup.init_checkpointer'), \
|
||||||
|
patch('src.agent.run.SERVICE_JWT', svc_jwt), \
|
||||||
|
patch('src.agent.run.GRADIO_SERVER_PORT', gradio_port), \
|
||||||
|
patch('src.agent.run.GRADIO_ALLOW_PORT_FALLBACK', gradio_fallback):
|
||||||
mock_asyncio_run.side_effect = lambda coro: coro.close() if hasattr(coro, "close") else None
|
mock_asyncio_run.side_effect = lambda coro: coro.close() if hasattr(coro, "close") else None
|
||||||
|
|
||||||
# httpx for _fetch_llm_config
|
# httpx for _fetch_llm_config
|
||||||
|
|||||||
Reference in New Issue
Block a user