diff --git a/.env.example b/.env.example index 7d5265b..6e6affc 100644 --- a/.env.example +++ b/.env.example @@ -30,11 +30,11 @@ GEMINI_MODEL=gemini-2.5-flash # LITELLM_MODEL=gemini/gemini-2.5-flash # ANTHROPIC_API_KEY=sk-ant-... -# ── Langfuse observability ──────────────────────────────────────────────────── -# Get keys from http://localhost:3000 after running docker compose up +# ── Langfuse observability (v3) ─────────────────────────────────────────────── +# Get keys from http://localhost:3010 after running docker compose up LANGFUSE_PUBLIC_KEY=pk-lf-your-public-key LANGFUSE_SECRET_KEY=sk-lf-your-secret-key -LANGFUSE_HOST=http://localhost:3000 +LANGFUSE_HOST=http://localhost:3010 # ── Infrastructure ──────────────────────────────────────────────────────────── REDIS_URL=redis://localhost:6379/0 diff --git a/README.md b/README.md index 2d7abcb..4c6e0fe 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,18 @@ ## Features - **Multi-provider LLM support** via LiteLLM — swap between Gemini, DeepSeek, Claude, GPT-4o, Groq, Ollama, and 100+ more with a single env var change, zero code changes +- **Split planner model** — route the one-token routing decision to a cheap/fast model (`PLANNER_MODEL`) while keeping a capable model for synthesis; falls back to the same model if unset +- **LLM retry with jitter** — all LLM calls automatically retry up to 3× with exponential backoff and random jitter, handling transient rate-limit and network errors - **FastAPI** production API with structured JSON logging and request-ID tracing - **LangGraph** workflow: `planner → tool → synthesizer` state machine with persistent memory across turns +- **History truncation** — only the most recent 10 turns are sent to the LLM; full history is preserved in the Postgres checkpoint - **DuckDuckGo search** — real web search with no API key required -- **Redis** session memory — conversation context survives API restarts -- **Postgres** run history — every agent run persisted for audit and replay -- **Langfuse** LLM tracing — full trace context, per-request handlers, generation-level spans -- **Prometheus + Grafana** — request rate, latency histograms (p50/p95/p99), agent run duration, error rate -- **Nuxt 3 frontend** — dark-mode chat UI + live status dashboard -- **Docker Compose** 8-container stack — API, frontend, Redis, Postgres, Langfuse, Prometheus, Grafana +- **Postgres** run history and LangGraph checkpoints — every agent run persisted for audit and replay; persistent multi-turn memory per session +- **Alembic** database migrations — schema changes are versioned and applied automatically at startup +- **Langfuse** LLM tracing — full trace context, node-level spans nested under a root trace per request +- **Prometheus + Grafana** — request rate, latency histograms (p50/p95/p99), agent run duration, tool execution counts +- **Nuxt 3 frontend** — dark-mode chat UI + live status dashboard with 120s request timeout and clear error messages +- **Docker Compose** 11-container stack — API, frontend, Redis, Postgres, Langfuse (web + worker + ClickHouse + MinIO), Prometheus, Grafana - **uv** — fast, reproducible Python dependency management - **Ruff + Pyright** — lint, format, and strict type checking - **pytest** — unit, integration, and eval suites with automatic LLM mocking @@ -243,8 +246,12 @@ agent-platform/ │ ├── tools/ # DuckDuckGo search tool │ └── llm.py # Unified LLM factory (all providers) ├── infra/ -│ └── docker/ -│ └── Dockerfile # Multistage Python build +│ ├── docker/ +│ │ └── Dockerfile # Multistage Python build +│ ├── migrations/ # Alembic migration scripts +│ │ ├── env.py # Migration env (excludes LangGraph tables) +│ │ └── versions/ # Versioned schema migrations +│ └── clickhouse/ # ClickHouse config (used by Langfuse) ├── monitoring/ │ ├── prometheus/ # prometheus.yml scrape config │ └── grafana/ # Provisioned datasources + dashboards @@ -291,6 +298,7 @@ just ci # full local CI gate | `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama server URL | | `OLLAMA_MODEL` | `llama3.2` | Ollama model name | | `LITELLM_MODEL` | `gemini/gemini-2.5-flash` | Any LiteLLM model string | +| `PLANNER_MODEL` | *(same as LITELLM_MODEL)* | Cheaper model for the one-token routing step; falls back to `LITELLM_MODEL` if blank | Any API key supported by LiteLLM (`ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, etc.) is passed through automatically. @@ -344,8 +352,8 @@ Full LLM traces with per-request handlers. Open http://localhost:3010. Create a | Orchestration | LangGraph, LangChain | | LLM | LiteLLM (100+ providers) · Gemini 2.5 Flash default | | Search | DuckDuckGo (no API key) | -| Memory | Redis + LangGraph MemorySaver | -| History | Postgres + SQLAlchemy async + asyncpg | +| Memory | LangGraph AsyncPostgresSaver (falls back to MemorySaver) | +| History | Postgres + SQLAlchemy async + asyncpg + Alembic | | Tracing | Langfuse | | Metrics | Prometheus, Grafana | | Logging | structlog (JSON in production) | diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..15d5479 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/infra/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/api/agent.py b/apps/api/agent.py index 65a518a..34d7a4b 100644 --- a/apps/api/agent.py +++ b/apps/api/agent.py @@ -1,6 +1,7 @@ +import contextlib import time import uuid -from typing import cast +from typing import Any, cast import structlog from fastapi import APIRouter, BackgroundTasks, Request @@ -66,29 +67,48 @@ async def run_agent( invoke_config: RunnableConfig = {"configurable": {"thread_id": thread_id}} - langfuse_handler = None - if settings.langfuse_public_key and settings.langfuse_secret_key: - try: - from langfuse.callback import CallbackHandler # type: ignore[import-untyped] - - lf_kwargs: dict[str, object] = { - "public_key": settings.langfuse_public_key, - "secret_key": settings.langfuse_secret_key, - "host": settings.langfuse_host, - "user_id": body.user_id, - "session_id": thread_id, - "trace_name": _LANGFUSE_TRACE_NAME, - "tags": [settings.environment, settings.llm_provider], - } - langfuse_handler = CallbackHandler(**lf_kwargs) # type: ignore[arg-type] - invoke_config["callbacks"] = [langfuse_handler] - except Exception as exc: - log.warning("langfuse.callback.unavailable", error=str(exc)) + # ── Run agent ──────────────────────────────────────────────────────────── + # Use the single Langfuse client initialised at startup (app.state.langfuse). + # CallbackHandler is created INSIDE start_as_current_observation so that the + # active OTel span is already set when the handler builds its child spans — + # this is what makes node/LLM observations appear nested under the root trace. + lf_client: Any = getattr(request.app.state, "langfuse", None) start = time.perf_counter() - result = await request.app.state.agent.ainvoke(cast(AgentState, state), config=invoke_config) - duration = time.perf_counter() - start + result: dict[str, Any] = {} + + if lf_client is not None: + with lf_client.start_as_current_observation( + name=_LANGFUSE_TRACE_NAME, + as_type="span", + input={"user_message": body.input}, + ) as root_obs: + with contextlib.suppress(Exception): + lf_client.update_current_trace( + name=_LANGFUSE_TRACE_NAME, + user_id=body.user_id, + session_id=thread_id, + tags=[settings.environment, settings.llm_provider], + metadata={"thread_id": thread_id, "llm_provider": settings.llm_provider}, + ) + # Create CallbackHandler here, after the parent span is active, + # so LangGraph node spans are captured as children of root_obs. + with contextlib.suppress(Exception): + from langfuse.langchain import CallbackHandler # type: ignore[import-untyped] + + invoke_config["callbacks"] = [CallbackHandler()] + + result = await request.app.state.agent.ainvoke( + cast(AgentState, state), config=invoke_config + ) + with contextlib.suppress(Exception): + root_obs.update(output=result.get("final_output")) + else: + result = await request.app.state.agent.ainvoke( + cast(AgentState, state), config=invoke_config + ) + duration = time.perf_counter() - start agent_run_duration_seconds.observe(duration) duration_ms = round(duration * 1000, 2) @@ -101,8 +121,8 @@ async def run_agent( thread_id=thread_id, ) - if langfuse_handler is not None: - background_tasks.add_task(langfuse_handler.langfuse.flush) + if lf_client is not None: + background_tasks.add_task(lf_client.flush) background_tasks.add_task( _persist_run, diff --git a/apps/api/config.py b/apps/api/config.py index 4609566..7f5c109 100644 --- a/apps/api/config.py +++ b/apps/api/config.py @@ -2,7 +2,7 @@ class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") app_name: str = "Agent Platform" environment: str = "development" @@ -31,6 +31,13 @@ class Settings(BaseSettings): # gemini/gemini-2.5-flash, anthropic/claude-opus-4-7, openai/gpt-4o # groq/llama-3.1-8b-instant, bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 litellm_model: str = "gemini/gemini-2.5-flash" + # Cheap fast model used only for planner routing (outputs one token). + # Defaults to same as litellm_model so it works out-of-the-box without config. + planner_model: str = "" + + @property + def resolved_planner_model(self) -> str: + return self.planner_model or self.litellm_model # ── Langfuse observability ──────────────────────────────────────────────── langfuse_public_key: str | None = None diff --git a/apps/api/main.py b/apps/api/main.py index 83692ba..6fb6c0b 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -1,3 +1,4 @@ +import contextlib from contextlib import asynccontextmanager import structlog @@ -26,11 +27,31 @@ async def lifespan(app: FastAPI): log.info("startup", app=settings.app_name, env=settings.environment, llm=settings.llm_provider) try: - from apps.api.db.session import create_tables + import asyncio - await create_tables() + from alembic import command as alembic_command + from alembic.config import Config as AlembicConfig + + alembic_cfg = AlembicConfig("alembic.ini") + await asyncio.to_thread(alembic_command.upgrade, alembic_cfg, "head") + log.info("db.migrations.applied") except Exception as exc: - log.warning("db.init.skipped", reason=str(exc)) + log.warning("db.migrations.skipped", reason=str(exc)) + + # Initialize Langfuse once at startup so the OTel tracer provider is set up + # exactly once. Re-creating Langfuse() per request resets the OTel context + # and breaks parent-span propagation for child observations. + app.state.langfuse = None + if settings.langfuse_public_key and settings.langfuse_secret_key: + with contextlib.suppress(Exception): + from langfuse import Langfuse # type: ignore[import-untyped] + + app.state.langfuse = Langfuse( + public_key=settings.langfuse_public_key, + secret_key=settings.langfuse_secret_key, + host=settings.langfuse_host, + ) + log.info("tracing.backend", backend="langfuse", host=settings.langfuse_host) # Build agent graph with Postgres checkpointer for persistent memory. # Uses the same agentdb already running; falls back to MemorySaver if unavailable. @@ -54,6 +75,11 @@ async def lifespan(app: FastAPI): yield + lf = getattr(app.state, "langfuse", None) + if lf is not None: + with contextlib.suppress(Exception): + lf.flush() + ctx = getattr(app.state, "checkpointer_ctx", None) if ctx is not None: try: # noqa: SIM105 diff --git a/apps/web/app/composables/useAgent.ts b/apps/web/app/composables/useAgent.ts index 8fdfb67..7443480 100644 --- a/apps/web/app/composables/useAgent.ts +++ b/apps/web/app/composables/useAgent.ts @@ -16,15 +16,27 @@ export function useAgent() { const apiUrl = config.public.apiUrl async function runAgent(payload: AgentRequest): Promise { - const response = await fetch(`${apiUrl}/agent/run`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) + const url = `${apiUrl}/agent/run` + let response: Response + + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(120_000), // 2-min hard timeout + }) + } catch (e: unknown) { + // Network-level failure (no response received) + const reason = e instanceof Error ? e.message : String(e) + const label = reason === 'The operation was aborted.' ? 'Request timed out (>2 min)' : reason + throw new Error(`Cannot reach API at ${url} — ${label}`) + } if (!response.ok) { - const error = await response.text() - throw new Error(`Agent error (${response.status}): ${error}`) + let body = '' + try { body = await response.text() } catch { /* ignore */ } + throw new Error(`API error ${response.status} — ${body || response.statusText}`) } return response.json() as Promise diff --git a/apps/web/app/composables/useHealth.ts b/apps/web/app/composables/useHealth.ts index f3b17eb..5533774 100644 --- a/apps/web/app/composables/useHealth.ts +++ b/apps/web/app/composables/useHealth.ts @@ -13,21 +13,23 @@ export function useHealth() { async function checkHealth(): Promise { const start = Date.now() + const url = `${apiUrl}/health` try { - const response = await fetch(`${apiUrl}/health`) + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }) const latency_ms = Date.now() - start if (!response.ok) { - health.value = { status: 'error', error: `HTTP ${response.status}`, latency_ms } + health.value = { status: 'error', error: `HTTP ${response.status} from ${url}`, latency_ms } return } const data = await response.json() health.value = { status: 'ok', ...data, latency_ms } } catch (e: unknown) { + const reason = e instanceof Error ? e.message : 'Connection failed' health.value = { status: 'error', - error: e instanceof Error ? e.message : 'Connection failed', + error: `${url} — ${reason}`, latency_ms: Date.now() - start, } } diff --git a/apps/web/app/pages/index.vue b/apps/web/app/pages/index.vue index 375bcf4..54d0818 100644 --- a/apps/web/app/pages/index.vue +++ b/apps/web/app/pages/index.vue @@ -1,5 +1,18 @@ diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 2a634a3..0cb5170 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -7,6 +7,7 @@ "name": "web", "hasInstallScript": true, "dependencies": { + "marked": "^15.0.12", "nuxt": "^4.4.2", "vue": "^3.5.32", "vue-router": "^5.0.4" @@ -7200,6 +7201,18 @@ "source-map-js": "^1.2.1" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 997ca22..92e70e8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "postinstall": "nuxt prepare" }, "dependencies": { + "marked": "^15.0.12", "nuxt": "^4.4.2", "vue": "^3.5.32", "vue-router": "^5.0.4" diff --git a/docker-compose.yml b/docker-compose.yml index c7ddb58..dc0229a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - "8000:8000" env_file: .env environment: - LANGFUSE_HOST: http://langfuse:3000 + LANGFUSE_HOST: http://langfuse-web:3000 ENVIRONMENT: production REDIS_URL: redis://redis:6379/0 DATABASE_URL: postgresql+asyncpg://agent:agent@agentdb:5432/agent @@ -20,7 +20,7 @@ services: condition: service_healthy redis: condition: service_healthy - langfuse: + langfuse-web: condition: service_started restart: unless-stopped networks: @@ -49,7 +49,7 @@ services: networks: - agent-net - # ── Redis (session memory + caching) ───────────────────────────────────────── + # ── Redis (shared — used by agent platform AND Langfuse worker queue) ──────── redis: image: redis:7-alpine command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru @@ -66,13 +66,15 @@ services: timeout: 5s retries: 5 - # ── Agent Postgres (run history) ───────────────────────────────────────────── + # ── Agent Postgres (run history + LangGraph checkpoints) ───────────────────── agentdb: image: postgres:16-alpine environment: POSTGRES_USER: agent POSTGRES_PASSWORD: agent POSTGRES_DB: agent + ports: + - "5433:5432" volumes: - agentdb_data:/var/lib/postgresql/data restart: unless-stopped @@ -84,41 +86,160 @@ services: timeout: 5s retries: 5 - # ── Langfuse LLM observability ─────────────────────────────────────────────── - langfuse: - image: langfuse/langfuse:2 + # ── Langfuse Postgres (user accounts, API keys, project config) ────────────── + langfusedb: + image: postgres:16-alpine + environment: + POSTGRES_USER: langfuse + POSTGRES_PASSWORD: langfuse + POSTGRES_DB: langfuse + volumes: + - langfusedb_data:/var/lib/postgresql/data + restart: unless-stopped + networks: + - agent-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U langfuse"] + interval: 10s + timeout: 5s + retries: 5 + + # ── ClickHouse (Langfuse v3 — stores traces, spans, generations) ───────────── + clickhouse: + image: clickhouse/clickhouse-server:24.12-alpine + environment: + CLICKHOUSE_USER: clickhouse + CLICKHOUSE_PASSWORD: clickhouse + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" + volumes: + - clickhouse_data:/var/lib/clickhouse + - ./infra/clickhouse/config.d:/etc/clickhouse-server/config.d:ro + restart: unless-stopped + networks: + - agent-net + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + + # ── MinIO (Langfuse v3 — S3-compatible blob storage for media/exports) ──────── + minio: + image: minio/minio + command: server /data --console-address ":9001" + ports: + - "9092:9000" + - "9093:9001" + environment: + MINIO_ROOT_USER: minio + MINIO_ROOT_PASSWORD: miniosecret + volumes: + - minio_data:/data + restart: unless-stopped + networks: + - agent-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + + # ── MinIO bucket init (creates the 'langfuse' bucket on first run) ──────────── + minio-init: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 minio miniosecret && + mc mb local/langfuse --ignore-existing + " + networks: + - agent-net + restart: on-failure + + # ── Langfuse Web (v3) ──────────────────────────────────────────────────────── + langfuse-web: + image: langfuse/langfuse:3 ports: - "3010:3000" environment: DATABASE_URL: postgresql://langfuse:langfuse@langfusedb:5432/langfuse + CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:clickhouse@clickhouse:9000/default + CLICKHOUSE_URL: http://clickhouse:8123 + CLICKHOUSE_USER: clickhouse + CLICKHOUSE_PASSWORD: clickhouse + LANGFUSE_S3_MEDIA_UPLOAD_ENABLED: "true" + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://minio:9000 + LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://minio:9000 + LANGFUSE_S3_EVENT_UPLOAD_REGION: auto + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true" + REDIS_HOST: redis + REDIS_PORT: "6379" NEXTAUTH_SECRET: changethis_dev_secret_32chars_min SALT: changethis_dev_salt NEXTAUTH_URL: http://localhost:3010 + # Generate with: openssl rand -hex 32 + ENCRYPTION_KEY: bc7e26109507c8497949ad8762321373f38ea92cd64c0703ee6e1b4ccf6f4c44 TELEMETRY_ENABLED: "false" depends_on: langfusedb: condition: service_healthy + clickhouse: + condition: service_healthy + redis: + condition: service_healthy + minio-init: + condition: service_completed_successfully restart: unless-stopped networks: - agent-net - # ── Langfuse Postgres ──────────────────────────────────────────────────────── - langfusedb: - image: postgres:16-alpine + # ── Langfuse Worker (v3) — processes async jobs (scoring, exports, evals) ──── + langfuse-worker: + image: langfuse/langfuse-worker:3 environment: - POSTGRES_USER: langfuse - POSTGRES_PASSWORD: langfuse - POSTGRES_DB: langfuse - volumes: - - langfusedb_data:/var/lib/postgresql/data + DATABASE_URL: postgresql://langfuse:langfuse@langfusedb:5432/langfuse + CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:clickhouse@clickhouse:9000/default + CLICKHOUSE_URL: http://clickhouse:8123 + CLICKHOUSE_USER: clickhouse + CLICKHOUSE_PASSWORD: clickhouse + LANGFUSE_S3_MEDIA_UPLOAD_ENABLED: "true" + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://minio:9000 + LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://minio:9000 + LANGFUSE_S3_EVENT_UPLOAD_REGION: auto + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true" + REDIS_HOST: redis + REDIS_PORT: "6379" + ENCRYPTION_KEY: bc7e26109507c8497949ad8762321373f38ea92cd64c0703ee6e1b4ccf6f4c44 + TELEMETRY_ENABLED: "false" + depends_on: + langfusedb: + condition: service_healthy + clickhouse: + condition: service_healthy + redis: + condition: service_healthy restart: unless-stopped networks: - agent-net - healthcheck: - test: ["CMD-SHELL", "pg_isready -U langfuse"] - interval: 10s - timeout: 5s - retries: 5 # ── Prometheus metrics ─────────────────────────────────────────────────────── prometheus: @@ -163,4 +284,6 @@ volumes: redis_data: agentdb_data: langfusedb_data: + clickhouse_data: + minio_data: grafana_data: diff --git a/infra/clickhouse/config.d/cluster.xml b/infra/clickhouse/config.d/cluster.xml new file mode 100644 index 0000000..ed386e3 --- /dev/null +++ b/infra/clickhouse/config.d/cluster.xml @@ -0,0 +1,51 @@ + + + 0.0.0.0 + + + + 9181 + 1 + /var/lib/clickhouse/coordination/log + /var/lib/clickhouse/coordination/snapshots + + 10000 + 30000 + warning + + + + 1 + 127.0.0.1 + 9444 + + + + + + + + 127.0.0.1 + 9181 + + + + + + + + + 127.0.0.1 + 9000 + + + + + + + + default + 1 + clickhouse-01 + + diff --git a/infra/migrations/README b/infra/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/infra/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/infra/migrations/env.py b/infra/migrations/env.py new file mode 100644 index 0000000..dec3bab --- /dev/null +++ b/infra/migrations/env.py @@ -0,0 +1,68 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Wire to the app's ORM models so autogenerate can diff against them. +from apps.api.config import settings # noqa: E402 +from apps.api.db.models import Base # noqa: E402 + +target_metadata = Base.metadata + +# Override the URL from settings so we never need it hardcoded in alembic.ini. +# Strip +asyncpg — Alembic uses the sync psycopg driver. +_sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql://") +config.set_main_option("sqlalchemy.url", _sync_url) + +# LangGraph manages its own tables (checkpoints, checkpoint_blobs, etc.). +# Exclude them from autogenerate so Alembic never generates drop/create for them. +_LANGGRAPH_TABLES = { + "checkpoints", + "checkpoint_blobs", + "checkpoint_writes", + "checkpoint_migrations", +} + + +def include_object(object, name, type_, reflected, compare_to) -> bool: # noqa: A002 + return not (type_ == "table" and name in _LANGGRAPH_TABLES) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + include_object=include_object, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + include_object=include_object, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/infra/migrations/script.py.mako b/infra/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/infra/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/infra/migrations/versions/4cbb88e1252b_initial_agent_runs.py b/infra/migrations/versions/4cbb88e1252b_initial_agent_runs.py new file mode 100644 index 0000000..b6fafa3 --- /dev/null +++ b/infra/migrations/versions/4cbb88e1252b_initial_agent_runs.py @@ -0,0 +1,48 @@ +"""initial_agent_runs + +Revision ID: 4cbb88e1252b +Revises: +Create Date: 2026-04-18 16:43:40.358527 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "4cbb88e1252b" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "agent_runs", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("session_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.String(length=64), nullable=False), + sa.Column("input", sa.Text(), nullable=False), + sa.Column("output", sa.Text(), nullable=True), + sa.Column("tool_result", sa.Text(), nullable=True), + sa.Column("tool_used", sa.Boolean(), nullable=False), + sa.Column("duration_ms", sa.Float(), nullable=False), + sa.Column("llm_provider", sa.String(length=32), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_agent_runs_session_id", "agent_runs", ["session_id"]) + op.create_index("ix_agent_runs_user_id", "agent_runs", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_agent_runs_user_id", table_name="agent_runs") + op.drop_index("ix_agent_runs_session_id", table_name="agent_runs") + op.drop_table("agent_runs") diff --git a/pyproject.toml b/pyproject.toml index 76a129e..da2e840 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "httpx>=0.28.0", "langchain>=0.3.0", "langchain-google-genai>=2.0.0", - "langfuse>=2.0.0,<3.0.0", + "langfuse>=3.14.6,<4.0.0", "langgraph>=0.2.0", "prometheus-client>=0.21.0", "pydantic-settings>=2.6.0", @@ -26,6 +26,7 @@ dependencies = [ "langchain-community>=0.3.0", "litellm>=1.83.7", "langgraph-checkpoint-postgres>=3.0.5", + "ddgs>=9.14.0", ] [project.scripts] @@ -38,6 +39,7 @@ build-backend = "uv_build" [dependency-groups] dev = [ "pre-commit>=4.0.0", + "psycopg2-binary>=2.9.11", "pyright>=1.1.390", "pytest>=8.3.0", "pytest-asyncio>=0.24.0", diff --git a/services/agent/graph/state.py b/services/agent/graph/state.py index e253857..794641d 100644 --- a/services/agent/graph/state.py +++ b/services/agent/graph/state.py @@ -1,6 +1,12 @@ import operator from typing import Annotated, TypedDict +# Each turn appends 2 strings ("User: ..." + "Assistant: ..."). +# Sending the full history to every LLM call grows the prompt unboundedly. +# We keep only the most recent N turns in the prompt while the full history +# stays safely stored in the Postgres checkpoint. +_MAX_HISTORY_TURNS = 10 + class AgentState(TypedDict): input: str @@ -11,4 +17,8 @@ class AgentState(TypedDict): def format_history(state: AgentState) -> str: - return "\n".join(state["messages"]) if state["messages"] else "(no prior messages)" + msgs = state["messages"] + if not msgs: + return "(no prior messages)" + recent = msgs[-(_MAX_HISTORY_TURNS * 2) :] + return "\n".join(recent) diff --git a/services/agent/graph/workflow.py b/services/agent/graph/workflow.py index 29178ae..524745c 100644 --- a/services/agent/graph/workflow.py +++ b/services/agent/graph/workflow.py @@ -10,8 +10,8 @@ def _route(state: AgentState) -> str: - next_step = state.get("next_step") or "" - return "tool" if "tool" in next_step else "synth" + next_step = (state.get("next_step") or "").strip().lower() + return "tool" if next_step.startswith("tool:") else "synth" def build_graph(checkpointer: Any = None) -> Any: diff --git a/services/agent/llm.py b/services/agent/llm.py index 4a04723..3368c68 100644 --- a/services/agent/llm.py +++ b/services/agent/llm.py @@ -15,9 +15,29 @@ def _setenv(key: str, value: str | None) -> None: os.environ[key] = value -@lru_cache(maxsize=4) -def get_llm(temperature: float = 0.0): # noqa: ANN201 - """Return a configured LangChain chat model based on LLM_PROVIDER. Cached per temperature.""" +def _with_retry(llm): # noqa: ANN001, ANN201 + """Wrap a LangChain chat model with 3-attempt exponential-backoff retry. + + Handles transient provider errors (5xx, network timeouts, brief rate limits) + without surfacing them to the user. If all 3 attempts fail, the exception + propagates normally. + """ + return llm.with_retry( + retry_if_exception_type=(Exception,), + wait_exponential_jitter=True, + stop_after_attempt=3, + ) + + +@lru_cache(maxsize=8) +def get_llm(temperature: float = 0.0, task: str = "default"): # noqa: ANN201 + """Return a configured LangChain chat model based on LLM_PROVIDER. + + Args: + temperature: Sampling temperature. 0 = deterministic (used by planner). + task: "planner" uses a cheap fast model; "default" uses the main model. + Cached per (temperature, task) pair. + """ from apps.api.config import settings provider = settings.llm_provider.lower() @@ -26,21 +46,24 @@ def get_llm(temperature: float = 0.0): # noqa: ANN201 from langchain_google_genai import ChatGoogleGenerativeAI _setenv("GOOGLE_API_KEY", settings.google_api_key) - return ChatGoogleGenerativeAI(model=settings.gemini_model, temperature=temperature) + model = settings.gemini_model + return _with_retry(ChatGoogleGenerativeAI(model=model, temperature=temperature)) if provider == "openai": from langchain_openai import ChatOpenAI _setenv("OPENAI_API_KEY", settings.openai_api_key) - return ChatOpenAI(model=settings.openai_model, temperature=temperature) + return _with_retry(ChatOpenAI(model=settings.openai_model, temperature=temperature)) if provider == "ollama": from langchain_community.chat_models import ChatOllama # type: ignore[import-untyped] - return ChatOllama( - base_url=settings.ollama_base_url, - model=settings.ollama_model, - temperature=temperature, + return _with_retry( + ChatOllama( + base_url=settings.ollama_base_url, + model=settings.ollama_model, + temperature=temperature, + ) ) if provider == "groq": @@ -51,12 +74,13 @@ def get_llm(temperature: float = 0.0): # noqa: ANN201 "langchain-groq is not installed. Run: uv add langchain-groq" ) from err _setenv("GROQ_API_KEY", settings.groq_api_key) - return ChatGroq(model=settings.groq_model, temperature=temperature) + return _with_retry(ChatGroq(model=settings.groq_model, temperature=temperature)) if provider == "litellm": from langchain_community.chat_models import ChatLiteLLM # type: ignore[import-untyped] - return ChatLiteLLM(model=settings.litellm_model, temperature=temperature) + model = settings.resolved_planner_model if task == "planner" else settings.litellm_model + return _with_retry(ChatLiteLLM(model=model, temperature=temperature)) raise ValueError( f"Unknown LLM_PROVIDER '{provider}'. Choose one of: gemini, openai, ollama, groq, litellm" diff --git a/services/agent/nodes/planner.py b/services/agent/nodes/planner.py index 7b2a7bd..66342be 100644 --- a/services/agent/nodes/planner.py +++ b/services/agent/nodes/planner.py @@ -1,6 +1,7 @@ from __future__ import annotations import structlog +from langchain_core.runnables import RunnableConfig from services.agent.graph.state import AgentState, format_history from services.agent.llm import get_llm @@ -8,30 +9,38 @@ log = structlog.get_logger(__name__) _SYSTEM_PROMPT = """\ -You are a planner agent. You have a conversation history and a new user message. +You are a routing agent. Read the user message and decide how to answer. Conversation history: {history} -New user message: {input} +User message: {input} -Decide whether the user's request requires an external search or can be answered directly. -Respond with EXACTLY one of: -- "tool:search" — if the user needs up-to-date or external information -- "final" — if you can answer from knowledge or conversation history -""" +Output rules — one line only, no explanation: +- When the user asks about current events, live data, news, prices, sports scores, or any fact that may have changed recently: output tool:search (e.g. "tool:search bitcoin price today") +- When you can answer from training knowledge or the conversation history: output final +Your output:""" -def planner_node(state: AgentState) -> dict[str, str | list[str] | None]: + +def planner_node(state: AgentState, config: RunnableConfig) -> dict[str, str | None]: history = format_history(state) log.debug( "planner.start", input_length=len(state["input"]), history_turns=len(state["messages"]) ) - llm = get_llm(temperature=0) + llm = get_llm(temperature=0, task="planner") prompt = _SYSTEM_PROMPT.format(history=history, input=state["input"]) - raw = llm.invoke(prompt).content - response = (raw if isinstance(raw, str) else str(raw)).strip().lower() - - log.debug("planner.decision", next_step=response) - return {"next_step": response, "messages": [f"User: {state['input']}"]} + raw = llm.invoke(prompt, config=config).content + # Take only the first non-empty line to avoid verbose LLM output + first_line = next( + ( + ln.strip() + for ln in (raw if isinstance(raw, str) else str(raw)).splitlines() + if ln.strip() + ), + "final", + ).lower() + + log.debug("planner.decision", next_step=first_line) + return {"next_step": first_line} diff --git a/services/agent/nodes/synthesizer.py b/services/agent/nodes/synthesizer.py index e1c6b41..ced69dd 100644 --- a/services/agent/nodes/synthesizer.py +++ b/services/agent/nodes/synthesizer.py @@ -1,6 +1,7 @@ from __future__ import annotations import structlog +from langchain_core.runnables import RunnableConfig from services.agent.graph.state import AgentState, format_history from services.agent.llm import get_llm @@ -21,7 +22,7 @@ """ -def synth_node(state: AgentState) -> dict[str, str | list[str] | None]: +def synth_node(state: AgentState, config: RunnableConfig) -> dict[str, str | list[str] | None]: history = format_history(state) log.debug("synth.start", has_tool_result=state.get("tool_result") is not None) @@ -31,8 +32,11 @@ def synth_node(state: AgentState) -> dict[str, str | list[str] | None]: tool_result=state.get("tool_result") or "N/A", input=state["input"], ) - raw = llm.invoke(prompt).content + raw = llm.invoke(prompt, config=config).content response = raw if isinstance(raw, str) else str(raw) log.debug("synth.complete", output_length=len(response)) - return {"final_output": response, "messages": [f"Assistant: {response}"]} + return { + "final_output": response, + "messages": [f"User: {state['input']}", f"Assistant: {response}"], + } diff --git a/services/agent/nodes/tools.py b/services/agent/nodes/tools.py index 02d0a5d..4226108 100644 --- a/services/agent/nodes/tools.py +++ b/services/agent/nodes/tools.py @@ -1,6 +1,7 @@ from __future__ import annotations import structlog +from langchain_core.runnables import RunnableConfig from apps.api.metrics import TOOL_EXECUTIONS from services.agent.graph.state import AgentState @@ -9,10 +10,18 @@ log = structlog.get_logger(__name__) -def tool_node(state: AgentState) -> dict[str, str | None]: - query = state["input"] +def tool_node(state: AgentState, config: RunnableConfig) -> dict[str, str | None]: + # Planner may embed a refined query after the colon: "tool:search bitcoin price" + # Fall back to the raw user input when no query is specified. + next_step = state.get("next_step") or "" + suffix = ( + next_step[len("tool:search") :].strip() + if next_step.lower().startswith("tool:search") + else "" + ) + query = suffix if suffix else state["input"] log.info("tool.execute", tool="search", query=query) TOOL_EXECUTIONS.labels(tool_name="search").inc() - result = search_tool.invoke({"query": query}) + result = search_tool.invoke({"query": query}, config=config) log.debug("tool.result", tool="search", result_length=len(result)) return {"tool_result": result} diff --git a/services/agent/tools/search.py b/services/agent/tools/search.py index 0b7aec9..244f746 100644 --- a/services/agent/tools/search.py +++ b/services/agent/tools/search.py @@ -1,7 +1,7 @@ from __future__ import annotations import structlog -from duckduckgo_search import DDGS +from ddgs import DDGS from langchain_core.tools import tool log = structlog.get_logger(__name__) diff --git a/tests/conftest.py b/tests/conftest.py index 862e32b..c240115 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ class _MockLLM: """Deterministic LLM stub — returns predictable content based on prompt context.""" - def invoke(self, prompt: str) -> MagicMock: + def invoke(self, prompt: str, **_kwargs) -> MagicMock: resp = MagicMock() # Planner prompts contain "tool:search" or "final" decision cues if ( diff --git a/tests/integration/test_agent_run.py b/tests/integration/test_agent_run.py index c6929f6..e2ab891 100644 --- a/tests/integration/test_agent_run.py +++ b/tests/integration/test_agent_run.py @@ -14,8 +14,6 @@ from apps.api.db.session import get_db from apps.api.main import app -client = TestClient(app) - async def _fake_db(): """Fake DB session — no real Postgres needed.""" @@ -33,7 +31,14 @@ def override_db(): app.dependency_overrides.pop(get_db, None) -def test_agent_run_basic() -> None: +@pytest.fixture() +def client(): + """TestClient used as context manager so the FastAPI lifespan runs.""" + with TestClient(app, raise_server_exceptions=True) as c: + yield c + + +def test_agent_run_basic(client: TestClient) -> None: response = client.post("/agent/run", json={"input": "Hello, what is 2+2?"}) assert response.status_code == 200 data = response.json() @@ -42,7 +47,7 @@ def test_agent_run_basic() -> None: assert "session_id" in data -def test_agent_run_with_user_id() -> None: +def test_agent_run_with_user_id(client: TestClient) -> None: response = client.post( "/agent/run", json={"input": "test query", "user_id": "user-123"}, @@ -50,7 +55,7 @@ def test_agent_run_with_user_id() -> None: assert response.status_code == 200 -def test_agent_run_with_session_id() -> None: +def test_agent_run_with_session_id(client: TestClient) -> None: """Session ID enables thread-based memory via LangGraph checkpointer.""" response = client.post( "/agent/run", @@ -62,12 +67,12 @@ def test_agent_run_with_session_id() -> None: assert data["duration_ms"] >= 0 -def test_agent_run_missing_input_returns_422() -> None: +def test_agent_run_missing_input_returns_422(client: TestClient) -> None: response = client.post("/agent/run", json={}) assert response.status_code == 422 -def test_agent_run_increments_metrics() -> None: +def test_agent_run_increments_metrics(client: TestClient) -> None: """Verify that calling /agent/run updates the agent_runs_total counter.""" client.post("/agent/run", json={"input": "metrics test"}) metrics_after = client.get("/metrics").text diff --git a/tests/unit/test_fixes.py b/tests/unit/test_fixes.py new file mode 100644 index 0000000..b9ed007 --- /dev/null +++ b/tests/unit/test_fixes.py @@ -0,0 +1,242 @@ +"""Tests for the four production fixes: +1. Unbounded messages truncation +2. LLM retry wrapping +3. Planner model splitting +4. Alembic migration is at head +""" + +from __future__ import annotations + +import contextlib +from unittest.mock import MagicMock + +import pytest + +# ── Fix 1: Unbounded messages ──────────────────────────────────────────────── + + +class TestHistoryTruncation: + def _make_state(self, n_turns: int): + from services.agent.graph.state import AgentState + + msgs = [] + for i in range(n_turns): + msgs.append(f"User: question {i}") + msgs.append(f"Assistant: answer {i}") + return AgentState( + input="latest question", + messages=msgs, + next_step=None, + tool_result=None, + final_output=None, + ) + + def test_short_history_returned_in_full(self): + from services.agent.graph.state import format_history # noqa: PLC0415 + + state = self._make_state(3) + result = format_history(state) + assert "question 0" in result + assert "question 2" in result + + def test_long_history_is_truncated(self): + from services.agent.graph.state import _MAX_HISTORY_TURNS, format_history + + n_turns = _MAX_HISTORY_TURNS + 5 + state = self._make_state(n_turns) + result = format_history(state) + # Oldest turns must be gone + assert "question 0" not in result + # Most recent turns must be present + assert f"question {n_turns - 1}" in result + + def test_truncation_keeps_exactly_max_turns(self): + from services.agent.graph.state import _MAX_HISTORY_TURNS, format_history + + n_turns = _MAX_HISTORY_TURNS + 3 + state = self._make_state(n_turns) + result = format_history(state) + lines = [ln for ln in result.splitlines() if ln.strip()] + assert len(lines) == _MAX_HISTORY_TURNS * 2 + + def test_empty_history_returns_placeholder(self): + from services.agent.graph.state import AgentState, format_history + + state = AgentState( + input="hi", messages=[], next_step=None, tool_result=None, final_output=None + ) + assert format_history(state) == "(no prior messages)" + + def test_stored_messages_unaffected(self): + """format_history truncates the view; it must NOT mutate state['messages'].""" + from services.agent.graph.state import _MAX_HISTORY_TURNS, format_history + + n_turns = _MAX_HISTORY_TURNS + 5 + state = self._make_state(n_turns) + format_history(state) + assert len(state["messages"]) == n_turns * 2 + + +# ── Fix 2: LLM retry ──────────────────────────────────────────────────────── + + +class TestLLMRetry: + def test_get_llm_returns_runnable_with_retry(self): + """get_llm() result must expose .invoke() (i.e. it's a valid Runnable).""" + from services.agent.llm import get_llm + + llm = get_llm(temperature=0, task="default") + assert hasattr(llm, "invoke") + + def test_llm_retries_on_transient_error(self): + """A model that fails twice then succeeds should return the success result.""" + call_count = 0 + + class FlakyLLM: + def invoke(self, prompt, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError("transient") + resp = MagicMock() + resp.content = "success" + return resp + + def with_retry(self, **kwargs): + # Return a wrapper that calls invoke up to stop_after_attempt times + stop = kwargs.get("stop_after_attempt", 3) + + class Retried: + def invoke(self, prompt, **kw): # noqa: N805 + last_exc: Exception = RuntimeError("no attempts") + for _ in range(stop): + try: + return FlakyLLM().invoke(prompt) + except Exception as e: + last_exc = e + raise last_exc + + return Retried() + + flaky = FlakyLLM() + wrapped = flaky.with_retry(stop_after_attempt=3) + result = wrapped.invoke("test") + assert result.content == "success" + + def test_llm_raises_after_all_retries_exhausted(self): + """If every attempt fails, the final exception must propagate.""" + + class AlwaysFailLLM: + def invoke(self, prompt, **kwargs): + raise RuntimeError("always fails") + + def with_retry(self, stop_after_attempt=3, **kwargs): + stop = stop_after_attempt + + class Retried: + def invoke(self, prompt, **kw): # noqa: N805 + for _ in range(stop): + with contextlib.suppress(RuntimeError): + AlwaysFailLLM().invoke(prompt) + raise RuntimeError("always fails") + + return Retried() + + wrapped = AlwaysFailLLM().with_retry(stop_after_attempt=3) + with pytest.raises(RuntimeError, match="always fails"): + wrapped.invoke("test") + + +# ── Fix 3: Planner model split ─────────────────────────────────────────────── + + +class TestPlannerModelSplit: + def test_resolved_planner_model_uses_custom_when_set(self, monkeypatch): + from apps.api.config import Settings + + monkeypatch.setenv("LLM_PROVIDER", "litellm") + monkeypatch.setenv("LITELLM_MODEL", "deepseek/deepseek-chat") + monkeypatch.setenv("PLANNER_MODEL", "gemini/gemini-2.0-flash-lite") + s = Settings() + assert s.resolved_planner_model == "gemini/gemini-2.0-flash-lite" + + def test_resolved_planner_model_falls_back_to_litellm(self, monkeypatch): + from apps.api.config import Settings + + monkeypatch.setenv("LLM_PROVIDER", "litellm") + monkeypatch.setenv("LITELLM_MODEL", "deepseek/deepseek-chat") + monkeypatch.setenv("PLANNER_MODEL", "") + s = Settings() + assert s.resolved_planner_model == "deepseek/deepseek-chat" + + def test_planner_node_calls_get_llm_with_planner_task(self, monkeypatch): + """Planner node must request task='planner' from the factory.""" + captured = {} + + def mock_get_llm(temperature=0.0, task="default"): + captured["task"] = task + captured["temperature"] = temperature + m = MagicMock() + m.invoke.return_value = MagicMock(content="final") + return m + + monkeypatch.setattr("services.agent.nodes.planner.get_llm", mock_get_llm) + + from langchain_core.runnables import RunnableConfig + + from services.agent.graph.state import AgentState + from services.agent.nodes.planner import planner_node + + state = AgentState( + input="What is LangGraph?", + messages=[], + next_step=None, + tool_result=None, + final_output=None, + ) + planner_node(state, RunnableConfig()) + + assert captured["task"] == "planner" + assert captured["temperature"] == 0 + + +# ── Fix 4: Alembic at head ─────────────────────────────────────────────────── + + +class TestAlembicMigration: + def test_migration_script_exists(self): + from pathlib import Path + + versions = list(Path("infra/migrations/versions").glob("*.py")) + assert len(versions) >= 1, "No migration scripts found" + + def test_alembic_is_at_head(self): + """The live database must be at the latest migration revision. + Skipped automatically when no Postgres is available (e.g. in unit CI).""" + import pytest + from alembic.config import Config as AlembicConfig + from alembic.runtime.migration import MigrationContext + from alembic.script import ScriptDirectory + from sqlalchemy import create_engine + from sqlalchemy.exc import OperationalError + + from apps.api.config import settings + + sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql://") + engine = create_engine( + sync_url, poolclass=__import__("sqlalchemy.pool", fromlist=["NullPool"]).NullPool + ) + try: + engine.connect().close() + except OperationalError: + pytest.skip("Postgres not available — skipping DB migration check") + + alembic_cfg = AlembicConfig("alembic.ini") + script = ScriptDirectory.from_config(alembic_cfg) + + with engine.connect() as conn: + ctx = MigrationContext.configure(conn) + current = ctx.get_current_revision() + + head = script.get_current_head() + assert current == head, f"DB is at {current!r}, expected head {head!r}" diff --git a/uv.lock b/uv.lock index 25e8e0e..321e0b4 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "asyncpg" }, + { name = "ddgs" }, { name = "duckduckgo-search" }, { name = "fastapi" }, { name = "httpx" }, @@ -36,6 +37,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pre-commit" }, + { name = "psycopg2-binary" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -47,6 +49,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "ddgs", specifier = ">=9.14.0" }, { name = "duckduckgo-search", specifier = ">=7.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, @@ -54,7 +57,7 @@ requires-dist = [ { name = "langchain-community", specifier = ">=0.3.0" }, { name = "langchain-google-genai", specifier = ">=2.0.0" }, { name = "langchain-openai", specifier = ">=0.3.0" }, - { name = "langfuse", specifier = ">=2.0.0,<3.0.0" }, + { name = "langfuse", specifier = ">=3.14.6,<4.0.0" }, { name = "langgraph", specifier = ">=0.2.0" }, { name = "langgraph-checkpoint-postgres", specifier = ">=3.0.5" }, { name = "litellm", specifier = ">=1.83.7" }, @@ -70,6 +73,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "pyright", specifier = ">=1.1.390" }, { name = "pytest", specifier = ">=8.3.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, @@ -606,6 +610,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, ] +[[package]] +name = "ddgs" +version = "9.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "lxml" }, + { name = "primp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/82/84a183483843a9e9113296628c25443aaf409a7b30cdeb6d97c7cd28782d/ddgs-9.14.0.tar.gz", hash = "sha256:e7767fca7b070c0ba680c1069a551fc611296ec46b67ac3d5144656745a7ec5f", size = 57148, upload-time = "2026-04-17T07:49:22.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/0b/3de3bc74df2a94f83a610cf5b0e79ee6a85ed9472cd6d6c02c0e45255398/ddgs-9.14.0-py3-none-any.whl", hash = "sha256:e542def6e9767f2e687dfe1d12905d84ba339c22d9ccfe21b15ef310dea76276", size = 67002, upload-time = "2026-04-17T07:49:21.739Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -862,6 +880,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/af/508e0528015240d710c6763f7c89ff44fab9a94a80b4377e265d692cbfd6/google_genai-1.73.1-py3-none-any.whl", hash = "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", size = 783595, upload-time = "2026-04-14T21:06:17.464Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + [[package]] name = "greenlet" version = "3.4.0" @@ -1324,21 +1354,22 @@ wheels = [ [[package]] name = "langfuse" -version = "2.60.10" +version = "3.14.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, { name = "backoff" }, { name = "httpx" }, - { name = "idna" }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/45/77fdf53c9e9f49bb78f72eba3f992f2f3d8343e05976aabfe1fca276a640/langfuse-2.60.10.tar.gz", hash = "sha256:a26d0d927a28ee01b2d12bb5b862590b643cc4e60a28de6e2b0c2cfff5dbfc6a", size = 152648, upload-time = "2025-09-16T15:08:12.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/e4/5bb96bc5f95f476c8f7c4e654a6420250938cc21645b914a1309c2dc3d49/langfuse-3.14.6.tar.gz", hash = "sha256:058f7b7caa9284b964feaee81c223542d78e9fb4daabf312269b9f903471b9c4", size = 236530, upload-time = "2026-04-01T17:27:55.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/69/08584fbd69e14398d3932a77d0c8d7e20389da3e6470210d6719afba2801/langfuse-2.60.10-py3-none-any.whl", hash = "sha256:815c6369194aa5b2a24f88eb9952f7c3fc863272c41e90642a71f3bc76f4a11f", size = 275568, upload-time = "2025-09-16T15:08:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9a/f2d82cdb7fb71d89de2e693b317a8f2c3d3da44db8a1533b96cf0fbe362f/langfuse-3.14.6-py3-none-any.whl", hash = "sha256:5eb22a5ea579ac10f050513e3fedccc3c9a981eeefe44f9014273bc229a56f88", size = 421426, upload-time = "2026-04-01T17:27:53.487Z" }, ] [[package]] @@ -1854,6 +1885,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.62b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" }, +] + [[package]] name = "orjson" version = "3.11.8" @@ -2120,6 +2233,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "psycopg" version = "3.3.3" @@ -2145,6 +2273,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, ] +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + [[package]] name = "pyasn1" version = "0.6.3"