diff --git a/.github/workflows/am-mcp-gateway.yml b/.github/workflows/am-mcp-gateway.yml new file mode 100644 index 0000000..e666bbb --- /dev/null +++ b/.github/workflows/am-mcp-gateway.yml @@ -0,0 +1,40 @@ +name: AM MCP Gateway Publish + +on: + push: + branches: ["main", "develop", "test/**", "feature/**", "hotfix/**", "fix/**"] + paths: + - "am-mcp-gateway/**" + - "libraries/**" + - "requirements.txt" + - "requirements-dev.txt" + - ".dockerignore" + - "docker/**" + pull_request: + branches: ["main", "develop", "test/**", "feature/**", "hotfix/**", "fix/**"] + paths: + - "am-mcp-gateway/**" + - "libraries/**" + - "requirements.txt" + - "requirements-dev.txt" + - ".dockerignore" + - "docker/**" + workflow_dispatch: + +jobs: + publish: + name: Publish AM MCP Gateway + permissions: + contents: read + packages: write + uses: AM-Portfolio/am-pipelines/.github/workflows/central-build-publish.yml@main + with: + language: "python" + working_directory: "am-mcp-gateway" + image_name: "am-mcp-gateway" + build_context: ".." + preprod_namespace: "am-apps-preprod" + prod_namespace: "am-apps-prod" + deploy_dev: true + deploy_prod: true + secrets: inherit diff --git a/.github/workflows/deploy-am-mcp-gateway.yml b/.github/workflows/deploy-am-mcp-gateway.yml new file mode 100644 index 0000000..c1ae95a --- /dev/null +++ b/.github/workflows/deploy-am-mcp-gateway.yml @@ -0,0 +1,38 @@ +name: Manual Deploy (AM MCP Gateway) + +on: + workflow_dispatch: + inputs: + image_tag: + description: "Select Image Tag" + required: true + default: "latest" + type: choice + options: + - latest + - custom + custom_tag: + description: "If 'custom' selected, enter tag here" + required: false + type: string + environment: + description: "Target Environment" + required: true + default: "preprod" + type: choice + options: + - preprod + - prod + +jobs: + deploy: + name: Deploy AM MCP Gateway to ${{ github.event.inputs.environment }} + uses: AM-Portfolio/am-pipelines/.github/workflows/central-deploy.yml@main + with: + service_name: "am-mcp-gateway" + working_directory: "am-mcp-gateway" + language: "python" + environment: ${{ github.event.inputs.environment }} + image_tag: ${{ github.event.inputs.image_tag == 'custom' && github.event.inputs.custom_tag || github.event.inputs.image_tag }} + namespace: ${{ github.event.inputs.environment == 'prod' && 'am-apps-prod' || 'am-apps-preprod' }} + secrets: inherit diff --git a/am-identity/am_identity/api/user_router.py b/am-identity/am_identity/api/user_router.py index c8a018d..10ea2ed 100644 --- a/am-identity/am_identity/api/user_router.py +++ b/am-identity/am_identity/api/user_router.py @@ -13,7 +13,7 @@ def _profile_from_claims(claims: dict[str, Any]) -> dict[str, Any]: """Build profile from validated JWT when Keycloak userinfo is unavailable.""" return { - "sub": claims.get("sub", ""), + "sub": claims.get("userId") or claims.get("sub", ""), "email": claims.get("email"), "preferred_username": claims.get("preferred_username"), "given_name": claims.get("given_name"), diff --git a/am-mcp-gateway/.env.preprod b/am-mcp-gateway/.env.preprod new file mode 100644 index 0000000..1ff2772 --- /dev/null +++ b/am-mcp-gateway/.env.preprod @@ -0,0 +1,31 @@ +# ── App Environment ──────────────────────────────────────────────────────── +APP_ENV=preprod +LOG_LEVEL=DEBUG +LOG_FORMAT=text + +# ── OIDC / Keycloak (am-preprod-realm) ──────────────────────────────────── +OIDC_JWKS_URL=http://auth.munish.org/auth/realms/am-preprod-realm/protocol/openid-connect/certs +OIDC_ISSUER=http://auth.munish.org/auth/realms/am-preprod-realm + +# ── MCP Service Client Credentials ──────────────────────────────────────── +AM_MCP_CLIENT_ID=am-mcp-service +AM_MCP_CLIENT_SECRET=hkk4698D7xZ8m2VpPL3zNfepAoTwRN8r + +# ── LiteLLM (local port-forward to cluster) ─────────────────────────────── +LITELLM_BASE_URL=http://localhost:4000 +LITELLM_MASTER_KEY=sk-27ad0c81915a946bfcf010e9b28a777c1ddc1a42f6640a6d +LLM_MODEL=together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite +LLM_FALLBACK_CHAIN=litellm +LLM_CB_ENABLED=false + +# ── Langfuse Observability ──────────────────────────────────────────────── +LANGFUSE_ENABLED=true +LANGFUSE_HOST=https://langfuse.munish.org +LANGFUSE_PUBLIC_KEY=pk-lf-cc35cb35-f20e-463d-90df-b41caec0a962 +LANGFUSE_SECRET_KEY=sk-lf-f64795da-863f-4a83-8f47-5b43a1bd0472 + +# ── UI test agent (MCP tool proxy) ──────────────────────────── +UI_TEST_AGENT_BASE_URL=http://localhost:8130 +UI_TEST_MANIFEST_PATH=../../am-modern-ui/testing/manifest.json +MCP_GATEWAY_PUBLIC_URL=http://localhost:8120 +LITELLM_MCP_SERVER_ALIAS=am_mcp_gateway diff --git a/am-mcp-gateway/.gitignore b/am-mcp-gateway/.gitignore new file mode 100644 index 0000000..5f8b3a6 --- /dev/null +++ b/am-mcp-gateway/.gitignore @@ -0,0 +1,7 @@ +.env +.env.* +!.env.example +.pytest_cache/ +__pycache__/ +*.pyc +.venv/ diff --git a/am-mcp-gateway/Dockerfile b/am-mcp-gateway/Dockerfile new file mode 100644 index 0000000..02a4fe5 --- /dev/null +++ b/am-mcp-gateway/Dockerfile @@ -0,0 +1,44 @@ +# AM MCP Gateway — build context: am-platform root (build_context: ..) +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy root requirements first to cache base platform dependencies +COPY requirements.txt ./requirements-platform.txt +RUN pip install --no-cache-dir -r requirements-platform.txt + +# Copy gateway-specific requirements and install them +COPY am-mcp-gateway/requirements.txt ./am-mcp-gateway/requirements.txt +RUN pip install --no-cache-dir -r am-mcp-gateway/requirements.txt + +# Copy shared libraries +COPY libraries/am-platform-common/ libraries/am-platform-common/ +COPY libraries/am-platform-security/ libraries/am-platform-security/ +RUN pip install --no-cache-dir ./libraries/am-platform-common ./libraries/am-platform-security + +# Copy service code +COPY am-mcp-gateway/ am-mcp-gateway/ + +# Set environment +ENV PYTHONPATH=/app/libraries/am-platform-common:/app/libraries/am-platform-security:/app/am-mcp-gateway +ENV APP_ENV=production +ENV TZ=Asia/Kolkata +ENV APP_PORT=8120 + +EXPOSE 8120 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=45s --retries=3 \ + CMD curl -f http://localhost:8120/health || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8120"] + diff --git a/am-mcp-gateway/Makefile b/am-mcp-gateway/Makefile new file mode 100644 index 0000000..71cefd0 --- /dev/null +++ b/am-mcp-gateway/Makefile @@ -0,0 +1,19 @@ +.PHONY: run test lint format clean + +run: + uvicorn app.main:app --host 0.0.0.0 --port 8120 --reload + +test: + pytest tests/ + +lint: + black --check app/ tests/ + isort --check-only app/ tests/ + +format: + black app/ tests/ + isort app/ tests/ + +clean: + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type d -name ".pytest_cache" -exec rm -rf {} + diff --git a/am-mcp-gateway/README.md b/am-mcp-gateway/README.md new file mode 100644 index 0000000..82387fa --- /dev/null +++ b/am-mcp-gateway/README.md @@ -0,0 +1,31 @@ +# AM MCP Gateway + +The Gateway routes MCP and LLM queries through local and remote models, ensuring secure, fast, and structured execution. + +## 🔒 Secret Management & Security Guidelines + +To prevent sensitive credentials and API keys from leaking, the gateway strictly decouples credentials from code and configuration: + +### 1. In-Cluster (Preprod / Production) +In cluster environments, secrets are injected dynamically using **HashiCorp Vault Agent Sidecars**. +- Secrets are mapped via [vault-mappings.yaml](file:///a:/InfraCode/AM-Portfolio-grp/am-platform/am-mcp-gateway/helm/vault-mappings.yaml). +- The Vault agent writes secrets to memory-mounted files under `/vault/secrets/`. +- Sourced files automatically set environment variables (such as `TOGETHER_API_KEY`, `LITELLM_MASTER_KEY`, etc.) inside the shell before running the Gateway process: + ```bash + . /vault/secrets/identity-oidc 2>/dev/null || true + . /vault/secrets/llm-api-keys 2>/dev/null || true + . /vault/secrets/observability 2>/dev/null || true + . /vault/secrets/redis 2>/dev/null || true + exec uvicorn app.main:app + ``` + +### 2. Local Development +For local testing: +- Create a `.env` file in the root of `am-mcp-gateway` (or use the global `am-platform/.secrets.env`). +- Secrets will be parsed automatically at startup by Pydantic settings. +- **Never commit `.env` or `.secrets.env` files.** The global and local `.gitignore` rules are configured to prevent these files from being tracked by Git. + +### 3. Golden Rule +> [!IMPORTANT] +> **Never hardcode or place real API keys/credentials inside values files (like `values.yaml` or `values.preprod.yaml`).** +> All credential integrations must be mapped via Vault paths and key bindings. diff --git a/am-mcp-gateway/app/api/agent_llm.py b/am-mcp-gateway/app/api/agent_llm.py new file mode 100644 index 0000000..e1a1135 --- /dev/null +++ b/am-mcp-gateway/app/api/agent_llm.py @@ -0,0 +1,122 @@ +"""Service-to-service LLM endpoint for internal agents (e.g. ui-test-agent). + +Routes through LiteLLM so all agent LLM/vision calls appear in LiteLLM logs + Langfuse. +""" +from __future__ import annotations + +import logging +import time +import uuid +from typing import Any, Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from am_platform_security.dependencies import require_auth_context +from am_platform_security.models import AuthContext + +from app.api.chat import _litellm_metadata, _log_chat_trace +from app.llm.router import llm_router + +logger = logging.getLogger(__name__) + +router = APIRouter(include_in_schema=False) + + +class AgentLLMRequest(BaseModel): + messages: list[dict[str, Any]] = Field(..., description="OpenAI-style messages (text or multimodal)") + model: str = Field(..., description="LiteLLM model name") + temperature: float = Field(default=0.2, ge=0.0, le=2.0) + max_tokens: int = Field(default=4096, ge=1, le=8192) + sessionId: Optional[str] = None + testId: Optional[str] = Field(default=None, description="UI test run id for trace correlation") + source: str = Field(default="ui-test-agent", description="Caller service name") + + +class AgentLLMResponse(BaseModel): + content: str + model: str + sessionId: str + traceId: str + usage: dict[str, int] | None = None + + +def _prompt_summary(messages: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for msg in messages: + content = msg.get("content") + if isinstance(content, str): + parts.append(content[:500]) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text", ""))[:500]) + return "\n".join(parts)[:2000] or "(multimodal request)" + + +@router.post("/agent/llm/completions", response_model=AgentLLMResponse) +async def agent_llm_completions( + request: AgentLLMRequest, + auth_context: AuthContext = Depends(require_auth_context()), +): + """Proxy agent LLM/vision calls to LiteLLM with gateway + Langfuse tracing.""" + user_id = auth_context.subject + session_id = request.sessionId or request.testId or str(uuid.uuid4()) + trace_id = str(uuid.uuid4()) + start_time = time.time() + + metadata = _litellm_metadata(user_id=user_id, session_id=session_id, trace_id=trace_id) + metadata["source"] = request.source + if request.testId: + metadata["test_id"] = request.testId + + response_text, provider, usage = await llm_router.generate_chat_messages( + request.messages, + model=request.model, + temperature=request.temperature, + metadata=metadata, + max_tokens=request.max_tokens, + ) + latency = time.time() - start_time + + await _log_chat_trace( + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + response=response_text, + model=request.model, + latency=latency, + provider=provider, + usage=usage, + request=_AgentTraceRequest( + message=_prompt_summary(request.messages), + model=request.model, + temperature=request.temperature, + stream=False, + ), + ) + + logger.info( + "Agent LLM completion source=%s test_id=%s model=%s latency=%.2fs", + request.source, + request.testId, + request.model, + latency, + ) + return AgentLLMResponse( + content=response_text, + model=request.model, + sessionId=session_id, + traceId=trace_id, + usage=usage, + ) + + +class _AgentTraceRequest: + """Minimal adapter so agent calls reuse chat trace logging.""" + + def __init__(self, *, message: str, model: str, temperature: float, stream: bool): + self.message = message + self.model = model + self.temperature = temperature + self.stream = stream diff --git a/am-mcp-gateway/app/api/chat.py b/am-mcp-gateway/app/api/chat.py new file mode 100644 index 0000000..6b839d6 --- /dev/null +++ b/am-mcp-gateway/app/api/chat.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import json +import logging +import time +import uuid +from typing import Optional, Dict, Any +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +# Shared security library imports +from am_platform_security.dependencies import require_auth_context +from am_platform_security.models import AuthContext + +from app.config import settings +from app.llm.router import llm_router +from app.session.cache import response_cache +from app.tools.fin_agent_client import fin_agent_client +from app.observability.tracer import observability_tracer + +logger = logging.getLogger(__name__) + +router = APIRouter(include_in_schema=False) + + +def _litellm_metadata(*, user_id: str, session_id: str, trace_id: str) -> dict: + return { + "trace_user_id": user_id, + "session_id": session_id, + "gateway_trace_id": trace_id, + } + + +async def _log_chat_trace( + *, + request: ChatRequest, + user_id: str, + session_id: str, + trace_id: str, + response: str, + model: str, + latency: float, + cached: bool = False, + tools_used: list[str] | None = None, + provider: str = "litellm", + usage: dict[str, int] | None = None, +) -> None: + await observability_tracer.log_trace( + user_id=user_id, + prompt=request.message, + response=response, + model=model, + latency=latency, + trace_id=trace_id, + session_id=session_id, + cached=cached, + temperature=request.temperature, + stream=request.stream, + tools_used=tools_used, + provider=provider, + usage=usage, + ) + + +class ChatRequest(BaseModel): + message: str = Field(..., description="Message input from user") + model: str = Field(default="together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite") + temperature: float = Field(default=0.2, ge=0.0, le=2.0) + stream: bool = Field(default=True) + sessionId: Optional[str] = None + +class ChatResponse(BaseModel): + message: str + widgetId: str = "TEXT_RESPONSE" + widgetParams: Dict[str, Any] = Field(default_factory=dict) + sessionId: str + toolsUsed: list[str] = Field(default_factory=list) + traceId: str + cached: bool = False + +@router.post("/chat") +async def chat_stream( + request: ChatRequest, + auth_context: AuthContext = Depends(require_auth_context()) +): + """ + Main SSE streaming chat endpoint. + Routes to am-fin-agent if financial keywords are detected, otherwise calls direct LLM. + """ + user_id = auth_context.subject + session_id = request.sessionId or str(uuid.uuid4()) + trace_id = str(uuid.uuid4()) + start_time = time.time() + + # 1. Check cache first + cached_val = await response_cache.get(user_id, request.message, request.model) + if cached_val: + logger.info(f"Cache hit for user: {user_id}") + async def cached_stream(): + yield f"data: {cached_val}\n\n" + yield "data: [DONE]\n\n" + + # Async tracer logging for cached hit + await _log_chat_trace( + request=request, + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + response=json.loads(cached_val).get("message", cached_val) if cached_val.startswith("{") else cached_val, + model=request.model, + latency=0.0, + cached=True, + ) + return StreamingResponse(cached_stream(), media_type="text/event-stream") + + # 2. Check intent routing + is_financial = await fin_agent_client.check_financial_intent(request.message) + + if is_financial and settings.MCP_SERVER_ENABLED: + async def financial_stream_gen(): + try: + res = await fin_agent_client.query_agent(request.message, user_id, session_id) + res_payload = { + "message": res.get("message", ""), + "widgetId": res.get("widgetId", "TEXT_RESPONSE"), + "widgetParams": res.get("widgetParams", {}), + "sessionId": res.get("sessionId", session_id), + "toolsUsed": res.get("toolsUsed", []), + "traceId": res.get("traceId", trace_id), + "cached": False + } + serialized = json.dumps(res_payload) + # Cache full response + await response_cache.set(user_id, request.message, request.model, serialized) + + # Yield SSE chunk + yield f"data: {serialized}\n\n" + yield "data: [DONE]\n\n" + + latency = time.time() - start_time + await _log_chat_trace( + request=request, + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + response=res.get("message", ""), + model="am-fin-agent", + latency=latency, + tools_used=res.get("toolsUsed", []), + provider="am-fin-agent", + ) + except Exception as e: + logger.error(f"Failed to query financial agent: {e}. Falling back to general LLM.") + # Fallback to general LLM on failure + async for chunk in general_llm_stream(): + yield chunk + + return StreamingResponse(financial_stream_gen(), media_type="text/event-stream") + + # 3. Direct general LLM streaming + async def general_llm_stream(): + full_text = [] + actual_model = request.model + try: + async for chunk, _provider in llm_router.generate_chat_stream( + request.message, + model=request.model, + temperature=request.temperature, + metadata=_litellm_metadata( + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + ), + ): + full_text.append(chunk) + yield f"data: {json.dumps({'chunk': chunk, 'model': request.model})}\n\n" + + combined_text = "".join(full_text) + res_payload = { + "message": combined_text, + "widgetId": "TEXT_RESPONSE", + "widgetParams": {}, + "sessionId": session_id, + "toolsUsed": [], + "traceId": trace_id, + "cached": False + } + # Cache full response + await response_cache.set(user_id, request.message, request.model, json.dumps(res_payload)) + + yield "data: [DONE]\n\n" + + latency = time.time() - start_time + await _log_chat_trace( + request=request, + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + response=combined_text, + model=actual_model, + latency=latency, + usage=llm_router.last_usage, + ) + except Exception as exc: + logger.error(f"General LLM streaming failed: {exc}") + yield f"data: {json.dumps({'error': str(exc)})}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(general_llm_stream(), media_type="text/event-stream") + + +@router.post("/chat/sync", response_model=ChatResponse) +async def chat_sync( + request: ChatRequest, + auth_context: AuthContext = Depends(require_auth_context()) +): + """ + Synchronous chat endpoint (returns full JSON immediately). + """ + user_id = auth_context.subject + session_id = request.sessionId or str(uuid.uuid4()) + trace_id = str(uuid.uuid4()) + start_time = time.time() + + # 1. Cache lookup + cached_val = await response_cache.get(user_id, request.message, request.model) + if cached_val: + logger.info(f"Cache hit (sync) for user: {user_id}") + data = json.loads(cached_val) + data["cached"] = True + + await _log_chat_trace( + request=request, + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + response=data.get("message", ""), + model=request.model, + latency=0.0, + cached=True, + ) + return ChatResponse(**data) + + # 2. Check intent routing + is_financial = await fin_agent_client.check_financial_intent(request.message) + + if is_financial and settings.MCP_SERVER_ENABLED: + try: + res = await fin_agent_client.query_agent(request.message, user_id, session_id) + latency = time.time() - start_time + + res_obj = ChatResponse( + message=res.get("message", ""), + widgetId=res.get("widgetId", "TEXT_RESPONSE"), + widgetParams=res.get("widgetParams", {}), + sessionId=res.get("sessionId", session_id), + toolsUsed=res.get("toolsUsed", []), + traceId=res.get("traceId", trace_id), + cached=False + ) + + # Write to cache + await response_cache.set(user_id, request.message, request.model, json.dumps(res_obj.model_dump())) + + await _log_chat_trace( + request=request, + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + response=res_obj.message, + model="am-fin-agent", + latency=latency, + tools_used=res_obj.toolsUsed, + provider="am-fin-agent", + ) + return res_obj + except Exception as e: + logger.error(f"Synchronous financial agent call failed: {e}. Falling back to general LLM.") + + # 3. Direct general LLM call + try: + response_text, _provider, usage = await llm_router.generate_chat( + request.message, + model=request.model, + temperature=request.temperature, + metadata=_litellm_metadata( + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + ), + ) + latency = time.time() - start_time + + res_obj = ChatResponse( + message=response_text, + widgetId="TEXT_RESPONSE", + widgetParams={}, + sessionId=session_id, + toolsUsed=[], + traceId=trace_id, + cached=False + ) + + # Write to cache + await response_cache.set(user_id, request.message, request.model, json.dumps(res_obj.model_dump())) + + await _log_chat_trace( + request=request, + user_id=user_id, + session_id=session_id, + trace_id=trace_id, + response=response_text, + model=request.model, + latency=latency, + usage=usage, + ) + return res_obj + except Exception as exc: + logger.error(f"Synchronous general LLM failed: {exc}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"General LLM call failed: {str(exc)}" + ) diff --git a/am-mcp-gateway/app/api/health.py b/am-mcp-gateway/app/api/health.py new file mode 100644 index 0000000..58b44bc --- /dev/null +++ b/am-mcp-gateway/app/api/health.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, status +from app.session.cache import response_cache + +router = APIRouter() + +@router.get("/health", status_code=status.HTTP_200_OK) +async def health(): + """Liveness probe.""" + return {"status": "ok", "service": "am-mcp-gateway"} + +@router.get("/ready", status_code=status.HTTP_200_OK) +async def ready(): + """Readiness probe.""" + cache_status = "disabled" + if response_cache.enabled: + if response_cache.redis_client: + try: + response_cache.redis_client.ping() + cache_status = "connected" + except Exception: + cache_status = "disconnected" + else: + cache_status = "in_memory" + + return { + "status": "ready", + "cache_backend": cache_status + } diff --git a/am-mcp-gateway/app/api/ui_test_tools.py b/am-mcp-gateway/app/api/ui_test_tools.py new file mode 100644 index 0000000..59f904e --- /dev/null +++ b/am-mcp-gateway/app/api/ui_test_tools.py @@ -0,0 +1,154 @@ +"""MCP-oriented HTTP tools — thin proxy to am-ui-test-agent using wrapper manifests.""" +from __future__ import annotations + +import asyncio +import logging +import time +from pathlib import Path +from typing import Any, Optional + +import httpx +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, Field + +from app.config import settings +from app.tools.ui_test_resolver import load_manifest, resolve_module_target + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tools/ui-test", tags=["ui-test-tools"]) + + +class UiTestAuthRunRequest(BaseModel): + module: str = Field(default="modern-ui", description="Wrapper module key from manifest.json") + environment: str = Field(default="preprod", description="Target environment (local, preprod, ...)") + target: str = Field(default="main", description="Named target inside targets.{env}.json") + wait: bool = Field(default=True, description="Poll until test completes") + timeout_seconds: int = Field(default=180, ge=10, le=600) + + +class UiTestAuthRunResponse(BaseModel): + testId: str + status: str + module: str + environment: str + target: str + targetUrl: str + uiMode: str + profile: Optional[str] = None + report: Optional[str] = None + reportUrl: Optional[str] = None + error: Optional[str] = None + + +@router.get( + "/manifest", + operation_id="get_ui_test_manifest", + summary="UI test wrapper manifest for MCP tool registration", +) +async def get_ui_test_manifest(): + """Return wrapper manifest for MCP tool registration (e.g. am-mcp-server @Tool metadata).""" + path = Path(settings.UI_TEST_MANIFEST_PATH) + if not path.is_file(): + raise HTTPException(status_code=404, detail=f"Manifest not found: {path}") + return load_manifest(path) + + +@router.post( + "/run-auth", + response_model=UiTestAuthRunResponse, + operation_id="run_modern_ui_auth_test", + summary="Run am-modern-ui Demo Login auth E2E via ui-test-agent", +) +async def run_ui_test_auth(request: UiTestAuthRunRequest): + """ + MCP tool entrypoint: resolve wrapper targets → queue auth test on ui-test-agent → optional poll. + + Designed for loose coupling: logic stays in ui-test-agent; URLs stay in am-modern-ui/testing/. + """ + manifest_path = Path(settings.UI_TEST_MANIFEST_PATH) + if not manifest_path.is_file(): + raise HTTPException(status_code=503, detail=f"UI test manifest missing: {manifest_path}") + + try: + resolved = resolve_module_target( + manifest_path, + module=request.module, + environment=request.environment, + target_name=request.target, + ) + except KeyError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + agent_base = settings.UI_TEST_AGENT_BASE_URL.rstrip("/") + payload = { + "targetUrl": resolved["target_url"], + "uiMode": resolved["ui_mode"], + } + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + resp = await client.post(f"{agent_base}/api/v1/test/run/auth", json=payload) + resp.raise_for_status() + except httpx.ConnectError as exc: + raise HTTPException( + status_code=503, + detail=f"ui-test-agent unavailable at {agent_base}. Start: cd am-ui-test-agent && npm run preprod", + ) from exc + except httpx.HTTPStatusError as exc: + raise HTTPException(status_code=exc.response.status_code, detail=exc.response.text) from exc + + body = resp.json() + test_id = body["testId"] + + if not request.wait: + return UiTestAuthRunResponse( + testId=test_id, + status="QUEUED", + module=resolved["module"], + environment=resolved["environment"], + target=resolved["target"], + targetUrl=resolved["target_url"], + uiMode=resolved["ui_mode"], + profile=resolved.get("profile"), + reportUrl=f"{agent_base}/api/v1/test/report/{test_id}", + ) + + deadline = time.monotonic() + request.timeout_seconds + last: dict[str, Any] = {"status": "QUEUED"} + while time.monotonic() < deadline: + status_resp = await client.get(f"{agent_base}/api/v1/test/status/{test_id}") + status_resp.raise_for_status() + last = status_resp.json() + st = last.get("status", "UNKNOWN") + if st == "COMPLETED": + report = last.get("report") + return UiTestAuthRunResponse( + testId=test_id, + status=st, + module=resolved["module"], + environment=resolved["environment"], + target=resolved["target"], + targetUrl=resolved["target_url"], + uiMode=resolved["ui_mode"], + profile=resolved.get("profile"), + report=report, + reportUrl=f"{agent_base}/api/v1/test/report/{test_id}", + ) + if st == "FAILED": + return UiTestAuthRunResponse( + testId=test_id, + status=st, + module=resolved["module"], + environment=resolved["environment"], + target=resolved["target"], + targetUrl=resolved["target_url"], + uiMode=resolved["ui_mode"], + profile=resolved.get("profile"), + report=last.get("report"), + reportUrl=f"{agent_base}/api/v1/test/report/{test_id}", + error=last.get("error"), + ) + await asyncio.sleep(3) + + raise HTTPException(status_code=504, detail=f"Timeout waiting for test {test_id}") diff --git a/am-mcp-gateway/app/config.py b/am-mcp-gateway/app/config.py new file mode 100644 index 0000000..37a057e --- /dev/null +++ b/am-mcp-gateway/app/config.py @@ -0,0 +1,107 @@ +import os +from typing import List, Optional +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +class Settings(BaseSettings): + # Pydantic v2 configuration + model_config = SettingsConfigDict( + env_file=os.getenv("ENV_FILE_PATH", ".env"), + env_file_encoding="utf-8", + extra="ignore" + ) + + # ── Service ──────────────────────────────────────────────── + APP_PORT: int = Field(default=8120) + APP_ENV: str = Field(default="development") + LOG_LEVEL: str = Field(default="INFO") + LOG_FORMAT: str = Field(default="text") # text | json + + # ── Security ─────────────────────────────────────────────── + OIDC_JWKS_URL: str = Field(default="http://auth.munish.org/auth/realms/am-realm/protocol/openid-connect/certs") + OIDC_ISSUER: str = Field(default="http://auth.munish.org/auth/realms/am-realm") + OIDC_JWKS_CACHE_TTL_SECONDS: int = Field(default=300) + AM_MCP_CLIENT_ID: str = Field(default="am-mcp-service") + AM_MCP_CLIENT_SECRET: Optional[str] = Field(default=None) + + # ── LLM Provider ─────────────────────────────────────────── + LLM_PROVIDER: str = Field(default="litellm") # litellm | deepseek | gemini | openai + LLM_FALLBACK_CHAIN: str = Field(default="litellm,deepseek,gemini") + LLM_MODEL: str = Field(default="deepseek/deepseek-chat") # LiteLLM model name format + LLM_TEMPERATURE: float = Field(default=0.2) + LLM_MAX_TOKENS: int = Field(default=4096) + LLM_TIMEOUT_SECONDS: int = Field(default=60) + LLM_STREAM: bool = Field(default=True) + + # LiteLLM Proxy (internal cluster URL) + LITELLM_BASE_URL: str = Field(default="http://litellm.am-ai.svc.cluster.local:4000") + LITELLM_MASTER_KEY: Optional[str] = Field(default=None) # sk-... key injected via secret + + # Circuit Breaker Configuration + LLM_CB_ENABLED: bool = Field(default=True) + LLM_CB_FAILURE_THRESHOLD: int = Field(default=5) + LLM_CB_RECOVERY_TIMEOUT_SECONDS: int = Field(default=30) + + # API Keys + DEEPSEEK_API_KEY: Optional[str] = Field(default=None) + GOOGLE_API_KEY: Optional[str] = Field(default=None) + OPENAI_API_KEY: Optional[str] = Field(default=None) + + # ── Caching ──────────────────────────────────────────────── + CACHE_ENABLED: bool = Field(default=True) + CACHE_BACKEND: str = Field(default="redis") # redis | memory + CACHE_TTL_SECONDS: int = Field(default=300) + REDIS_URL: str = Field(default="redis://localhost:6379/4") + + # ── am-mcp-server (tool execution) ───────────────────────── + MCP_SERVER_URL: str = Field(default="http://localhost:8080") + MCP_SERVER_TIMEOUT_SECONDS: int = Field(default=20) + MCP_SERVER_ENABLED: bool = Field(default=True) + + # ── ui-test-agent (MCP tool proxy) ─────────────────────────── + UI_TEST_AGENT_BASE_URL: str = Field(default="http://localhost:8130") + UI_TEST_MANIFEST_PATH: str = Field( + default="../../am-modern-ui/testing/manifest.json", + description="Path to wrapper manifest (module → targets + env files)", + ) + + # ── LiteLLM MCP tool sync ────────────────────────────────────── + MCP_GATEWAY_PUBLIC_URL: str = Field( + default="http://localhost:8120", + description="Base URL LiteLLM uses for OpenAPI spec_path (local or ingress)", + ) + LITELLM_MCP_SERVER_ALIAS: str = Field(default="am_mcp_gateway") + LITELLM_SYNC_MCP_TOOLS: bool = Field( + default=False, + description="On startup, sync manifest tools to LiteLLM MCP server registry", + ) + + # ── Observability ────────────────────────────────────────── + LANGFUSE_ENABLED: bool = Field(default=False) + LANGFUSE_HOST: str = Field(default="https://langfuse.munish.org") + LANGFUSE_PUBLIC_KEY: Optional[str] = Field(default=None) + LANGFUSE_SECRET_KEY: Optional[str] = Field(default=None) + LANGFUSE_FLUSH_INTERVAL_SECONDS: int = Field(default=5) + + MLFLOW_ENABLED: bool = Field(default=False) + MLFLOW_TRACKING_URI: Optional[str] = Field(default=None) + MLFLOW_EXPERIMENT_NAME: str = Field(default="am-mcp-gateway") + MLFLOW_ASYNC: bool = Field(default=True) + + # ── Rate Limiting ────────────────────────────────────────── + RATE_LIMIT_ENABLED: bool = Field(default=True) + RATE_LIMIT_REQUESTS_PER_MINUTE: int = Field(default=60) + RATE_LIMIT_BURST: int = Field(default=10) + + # ── Session ──────────────────────────────────────────────── + SESSION_BACKEND: str = Field(default="redis") + SESSION_TTL_SECONDS: int = Field(default=3600) + + # ── CORS ────────────────────────────────────────────────── + CORS_ORIGINS: str = Field(default="*") + + @property + def fallback_chain_list(self) -> List[str]: + return [p.strip() for p in self.LLM_FALLBACK_CHAIN.split(",") if p.strip()] + +settings = Settings() diff --git a/am-mcp-gateway/app/llm/base.py b/am-mcp-gateway/app/llm/base.py new file mode 100644 index 0000000..61055ab --- /dev/null +++ b/am-mcp-gateway/app/llm/base.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod +from typing import AsyncIterator, Union + +from app.llm.types import LLMChatResult + + +class BaseLLMProvider(ABC): + last_usage: dict[str, int] | None = None + + @abstractmethod + async def generate_chat_stream( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> AsyncIterator[str]: + """Generate streamed tokens from the LLM.""" + pass + + @abstractmethod + async def generate_chat( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> Union[str, LLMChatResult]: + """Generate full response from the LLM synchronously.""" + pass diff --git a/am-mcp-gateway/app/llm/circuit_breaker.py b/am-mcp-gateway/app/llm/circuit_breaker.py new file mode 100644 index 0000000..d47c1ab --- /dev/null +++ b/am-mcp-gateway/app/llm/circuit_breaker.py @@ -0,0 +1,63 @@ +import time +import logging +from enum import Enum +from app.config import settings + +logger = logging.getLogger(__name__) + +class CircuitState(Enum): + CLOSED = "CLOSED" + OPEN = "OPEN" + HALF_OPEN = "HALF_OPEN" + +class CircuitBreaker: + def __init__(self, provider: str, failure_threshold: int = None, recovery_timeout: int = None): + self.provider = provider + self.failure_threshold = failure_threshold or settings.LLM_CB_FAILURE_THRESHOLD + self.recovery_timeout = recovery_timeout or settings.LLM_CB_RECOVERY_TIMEOUT_SECONDS + self.state = CircuitState.CLOSED + self.failures = 0 + self.last_state_change = time.time() + + def record_success(self): + """Records a successful call and closes the circuit if it was open or half-open.""" + if not settings.LLM_CB_ENABLED: + return + if self.state != CircuitState.CLOSED: + logger.info(f"Circuit breaker for provider '{self.provider}' changed state from {self.state.name} to CLOSED.") + self.failures = 0 + self.state = CircuitState.CLOSED + + def record_failure(self): + """Records a failure and opens the circuit if failure threshold is reached.""" + if not settings.LLM_CB_ENABLED: + return + self.failures += 1 + logger.warning(f"Recorded failure for provider '{self.provider}'. Total failures: {self.failures}/{self.failure_threshold}") + if self.failures >= self.failure_threshold and self.state != CircuitState.OPEN: + logger.error(f"Circuit breaker for provider '{self.provider}' tripped! State changed to OPEN for {self.recovery_timeout} seconds.") + self.state = CircuitState.OPEN + self.last_state_change = time.time() + + def allow_request(self) -> bool: + """Determines if a request to the provider is allowed based on the circuit state.""" + if not settings.LLM_CB_ENABLED: + return True + if self.state == CircuitState.CLOSED: + return True + + now = time.time() + if self.state == CircuitState.OPEN: + # Check if recovery timeout has passed + if now - self.last_state_change > self.recovery_timeout: + logger.info(f"Recovery timeout passed. Circuit breaker for provider '{self.provider}' set to HALF_OPEN.") + self.state = CircuitState.HALF_OPEN + self.last_state_change = now + return True + return False + + if self.state == CircuitState.HALF_OPEN: + # In half-open state, we allow one trial request + return True + + return True diff --git a/am-mcp-gateway/app/llm/deepseek.py b/am-mcp-gateway/app/llm/deepseek.py new file mode 100644 index 0000000..7e385d9 --- /dev/null +++ b/am-mcp-gateway/app/llm/deepseek.py @@ -0,0 +1,114 @@ +import json +import logging +from typing import AsyncIterator +import httpx +from app.config import settings +from app.llm.base import BaseLLMProvider + +logger = logging.getLogger(__name__) + +class DeepSeekProvider(BaseLLMProvider): + def __init__(self): + self.api_key = settings.DEEPSEEK_API_KEY + self.model = settings.LLM_MODEL or "deepseek-chat" + self.temperature = settings.LLM_TEMPERATURE + self.max_tokens = settings.LLM_MAX_TOKENS + self.timeout = settings.LLM_TIMEOUT_SECONDS + + async def generate_chat_stream( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> AsyncIterator[str]: + if not self.api_key: + raise ValueError("DEEPSEEK_API_KEY is not configured") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": self.model, + "messages": messages, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "stream": True, + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + async with client.stream( + "POST", + "https://api.deepseek.com/chat/completions", + headers=headers, + json=payload, + ) as response: + if response.status_code != 200: + error_text = await response.aread() + logger.error(f"DeepSeek stream error response: {error_text.decode()}") + response.raise_for_status() + + async for line in response.iter_lines(): + if line.startswith("data:"): + data_str = line[5:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + chunk = data["choices"][0]["delta"].get("content", "") + if chunk: + yield chunk + except Exception as e: + logger.error(f"Error parsing DeepSeek stream chunk: {e}") + + async def generate_chat( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> str: + if not self.api_key: + raise ValueError("DEEPSEEK_API_KEY is not configured") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": self.model, + "messages": messages, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "stream": False, + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + "https://api.deepseek.com/chat/completions", + headers=headers, + json=payload, + ) + if response.status_code != 200: + logger.error(f"DeepSeek response error: {response.text}") + response.raise_for_status() + + data = response.json() + return data["choices"][0]["message"]["content"] diff --git a/am-mcp-gateway/app/llm/factory.py b/am-mcp-gateway/app/llm/factory.py new file mode 100644 index 0000000..8dc47c9 --- /dev/null +++ b/am-mcp-gateway/app/llm/factory.py @@ -0,0 +1,39 @@ +import logging +from typing import Dict, List +from app.config import settings +from app.llm.base import BaseLLMProvider +from app.llm.deepseek import DeepSeekProvider +from app.llm.gemini import GeminiProvider +from app.llm.openai import OpenAIProvider +from app.llm.litellm_provider import LiteLLMProvider + +logger = logging.getLogger(__name__) + +class LLMProviderFactory: + _providers: Dict[str, BaseLLMProvider] = {} + + @classmethod + def get_provider(cls, name: str) -> BaseLLMProvider: + name_lower = name.lower().strip() + if name_lower not in cls._providers: + if name_lower == "deepseek": + cls._providers[name_lower] = DeepSeekProvider() + elif name_lower == "gemini": + cls._providers[name_lower] = GeminiProvider() + elif name_lower == "openai": + cls._providers[name_lower] = OpenAIProvider() + elif name_lower == "litellm": + cls._providers[name_lower] = LiteLLMProvider() + else: + raise ValueError(f"Unknown LLM provider: {name}") + return cls._providers[name_lower] + + @classmethod + def get_fallback_chain(cls) -> List[BaseLLMProvider]: + chain = [] + for provider_name in settings.fallback_chain_list: + try: + chain.append(cls.get_provider(provider_name)) + except Exception as e: + logger.error(f"Failed to initialize LLM provider '{provider_name}': {e}") + return chain diff --git a/am-mcp-gateway/app/llm/gemini.py b/am-mcp-gateway/app/llm/gemini.py new file mode 100644 index 0000000..ec91c1d --- /dev/null +++ b/am-mcp-gateway/app/llm/gemini.py @@ -0,0 +1,151 @@ +import json +import logging +from typing import AsyncIterator +import httpx +from app.config import settings +from app.llm.base import BaseLLMProvider + +logger = logging.getLogger(__name__) + +class GeminiProvider(BaseLLMProvider): + def __init__(self): + self.api_key = settings.GOOGLE_API_KEY + self.model = "gemini-1.5-flash" + self.temperature = settings.LLM_TEMPERATURE + self.max_tokens = settings.LLM_MAX_TOKENS + self.timeout = settings.LLM_TIMEOUT_SECONDS + + async def generate_chat_stream( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> AsyncIterator[str]: + if not self.api_key: + raise ValueError("GOOGLE_API_KEY is not configured") + + url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:streamGenerateContent?key={self.api_key}" + + contents = [] + if system_prompt: + contents.append({ + "role": "user", + "parts": [{"text": f"System Instruction: {system_prompt}"}] + }) + contents.append({ + "role": "user", + "parts": [{"text": prompt}] + }) + + payload = { + "contents": contents, + "generationConfig": { + "temperature": self.temperature, + "maxOutputTokens": self.max_tokens, + } + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + async with client.stream("POST", url, json=payload) as response: + if response.status_code != 200: + error_text = await response.aread() + logger.error(f"Gemini stream error response: {error_text.decode()}") + response.raise_for_status() + + # Gemini streams a JSON array of responses, or Server-Sent Events sometimes. + # Actually, streamGenerateContent returns a JSON array of objects or streaming chunks. + # Let's read buffer and process chunks of JSON objects. + buffer = "" + async for chunk in response.iter_text(): + buffer += chunk + # Try to parse individual objects from the streaming JSON array. + # It usually comes as `[ { ... }, { ... } ]`. + # We can do basic extraction of text. + # A robust way is to yield anything that looks like "text": "..." + # Or extract contents from json objects: + # Let's do simple cleaning or line/bracket based parsing: + while True: + buffer = buffer.strip() + if not buffer: + break + + # Strip starting array brackets if present + if buffer.startswith("["): + buffer = buffer[1:].strip() + continue + if buffer.startswith(","): + buffer = buffer[1:].strip() + continue + + # Find the first complete JSON object in the buffer + try: + # Let's parse JSON objects by finding matching braces + brace_count = 0 + end_idx = -1 + for idx, char in enumerate(buffer): + if char == "{": + brace_count += 1 + elif char == "}": + brace_count -= 1 + if brace_count == 0: + end_idx = idx + 1 + break + + if end_idx != -1: + obj_str = buffer[:end_idx] + buffer = buffer[end_idx:].strip() + + data = json.loads(obj_str) + text = data["candidates"][0]["content"]["parts"][0]["text"] + if text: + yield text + else: + break # incomplete JSON object, wait for more data + except Exception as e: + # If parsing fails, it might be partial. Just wait. + break + + async def generate_chat( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> str: + if not self.api_key: + raise ValueError("GOOGLE_API_KEY is not configured") + + url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent?key={self.api_key}" + + contents = [] + if system_prompt: + contents.append({ + "role": "user", + "parts": [{"text": f"System Instruction: {system_prompt}"}] + }) + contents.append({ + "role": "user", + "parts": [{"text": prompt}] + }) + + payload = { + "contents": contents, + "generationConfig": { + "temperature": self.temperature, + "maxOutputTokens": self.max_tokens, + } + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post(url, json=payload) + if response.status_code != 200: + logger.error(f"Gemini response error: {response.text}") + response.raise_for_status() + + data = response.json() + return data["candidates"][0]["content"]["parts"][0]["text"] diff --git a/am-mcp-gateway/app/llm/litellm_provider.py b/am-mcp-gateway/app/llm/litellm_provider.py new file mode 100644 index 0000000..79b9c73 --- /dev/null +++ b/am-mcp-gateway/app/llm/litellm_provider.py @@ -0,0 +1,216 @@ +import json +import logging +from typing import AsyncIterator, Union +import httpx +from app.config import settings +from app.llm.base import BaseLLMProvider +from app.llm.types import LLMChatResult, normalize_usage + +logger = logging.getLogger(__name__) + + +class LiteLLMProvider(BaseLLMProvider): + """ + Routes all LLM calls through the internal LiteLLM proxy. + LiteLLM handles model selection, API key management, retries, + and forwards traces to Langfuse natively via its callback config. + """ + + def __init__(self): + self.base_url = settings.LITELLM_BASE_URL.rstrip("/") + self.api_key = settings.LITELLM_MASTER_KEY + self.model = settings.LLM_MODEL + self.temperature = settings.LLM_TEMPERATURE + self.max_tokens = settings.LLM_MAX_TOKENS + self.timeout = settings.LLM_TIMEOUT_SECONDS + self.last_usage = None + + def _headers(self) -> dict: + if not self.api_key: + raise ValueError("LITELLM_MASTER_KEY is not configured") + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + def _resolve_model(self, model: str | None) -> str: + return model or self.model + + def _resolve_temperature(self, temperature: float | None) -> float: + return self.temperature if temperature is None else temperature + + def _payload( + self, + prompt: str, + system_prompt: str | None, + *, + model: str | None, + temperature: float | None, + stream: bool, + metadata: dict | None = None, + ) -> dict: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + resolved_model = self._resolve_model(model) + body = { + "model": resolved_model, + "messages": messages, + "temperature": self._resolve_temperature(temperature), + "max_tokens": self.max_tokens, + "stream": stream, + } + if stream: + body["stream_options"] = {"include_usage": True} + if metadata: + body["metadata"] = metadata + return body + + def _raise_litellm_error(self, response: httpx.Response, *, model: str, stream: bool) -> None: + body = response.text[:1000] + detail = body + try: + detail = response.json().get("error", {}).get("message", body) + except Exception: + pass + mode = "stream" if stream else "chat" + raise RuntimeError( + f"LiteLLM {mode} failed [{response.status_code}] model={model}: {detail}" + ) + + async def generate_chat_stream( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> AsyncIterator[str]: + url = f"{self.base_url}/chat/completions" + payload = self._payload( + prompt, + system_prompt, + model=model, + temperature=temperature, + stream=True, + metadata=metadata, + ) + resolved_model = payload["model"] + self.last_usage = None + logger.info("LiteLLM stream request model=%s url=%s", resolved_model, url) + + async with httpx.AsyncClient(timeout=self.timeout) as client: + async with client.stream( + "POST", + url, + headers=self._headers(), + json=payload, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + logger.error( + "LiteLLM stream error [%s] model=%s: %s", + response.status_code, + resolved_model, + error_body.decode()[:1000], + ) + response._content = error_body + self._raise_litellm_error(response, model=resolved_model, stream=True) + + async for line in response.iter_lines(): + if not line or not line.startswith("data:"): + continue + data_str = line[5:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + if data.get("usage"): + self.last_usage = normalize_usage(data["usage"]) + chunk = data["choices"][0]["delta"].get("content", "") + if chunk: + yield chunk + except Exception as e: + logger.error(f"Error parsing LiteLLM stream chunk: {e}") + + async def generate_chat( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> LLMChatResult: + url = f"{self.base_url}/chat/completions" + payload = self._payload( + prompt, + system_prompt, + model=model, + temperature=temperature, + stream=False, + metadata=metadata, + ) + resolved_model = payload["model"] + self.last_usage = None + logger.info("LiteLLM chat request model=%s url=%s", resolved_model, url) + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post(url, headers=self._headers(), json=payload) + if response.status_code != 200: + logger.error( + "LiteLLM error [%s] model=%s: %s", + response.status_code, + resolved_model, + response.text[:1000], + ) + self._raise_litellm_error(response, model=resolved_model, stream=False) + + data = response.json() + usage = normalize_usage(data.get("usage")) + self.last_usage = usage + text = data["choices"][0]["message"]["content"] + return LLMChatResult(text=text, usage=usage, model=resolved_model) + + async def generate_chat_messages( + self, + messages: list[dict], + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + max_tokens: int | None = None, + ) -> LLMChatResult: + """Send a pre-built messages array (supports multimodal content).""" + url = f"{self.base_url}/chat/completions" + resolved_model = self._resolve_model(model) + body: dict = { + "model": resolved_model, + "messages": messages, + "temperature": self._resolve_temperature(temperature), + "max_tokens": max_tokens or self.max_tokens, + "stream": False, + } + if metadata: + body["metadata"] = metadata + self.last_usage = None + logger.info("LiteLLM messages request model=%s url=%s", resolved_model, url) + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post(url, headers=self._headers(), json=body) + if response.status_code != 200: + logger.error( + "LiteLLM error [%s] model=%s: %s", + response.status_code, + resolved_model, + response.text[:1000], + ) + self._raise_litellm_error(response, model=resolved_model, stream=False) + + data = response.json() + usage = normalize_usage(data.get("usage")) + self.last_usage = usage + text = data["choices"][0]["message"]["content"] + return LLMChatResult(text=text, usage=usage, model=resolved_model) diff --git a/am-mcp-gateway/app/llm/openai.py b/am-mcp-gateway/app/llm/openai.py new file mode 100644 index 0000000..ac28c1a --- /dev/null +++ b/am-mcp-gateway/app/llm/openai.py @@ -0,0 +1,114 @@ +import json +import logging +from typing import AsyncIterator +import httpx +from app.config import settings +from app.llm.base import BaseLLMProvider + +logger = logging.getLogger(__name__) + +class OpenAIProvider(BaseLLMProvider): + def __init__(self): + self.api_key = settings.OPENAI_API_KEY + self.model = "gpt-4o-mini" + self.temperature = settings.LLM_TEMPERATURE + self.max_tokens = settings.LLM_MAX_TOKENS + self.timeout = settings.LLM_TIMEOUT_SECONDS + + async def generate_chat_stream( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> AsyncIterator[str]: + if not self.api_key: + raise ValueError("OPENAI_API_KEY is not configured") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": self.model, + "messages": messages, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "stream": True, + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + async with client.stream( + "POST", + "https://api.openai.com/v1/chat/completions", + headers=headers, + json=payload, + ) as response: + if response.status_code != 200: + error_text = await response.aread() + logger.error(f"OpenAI stream error response: {error_text.decode()}") + response.raise_for_status() + + async for line in response.iter_lines(): + if line.startswith("data:"): + data_str = line[5:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + chunk = data["choices"][0]["delta"].get("content", "") + if chunk: + yield chunk + except Exception as e: + logger.error(f"Error parsing OpenAI stream chunk: {e}") + + async def generate_chat( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> str: + if not self.api_key: + raise ValueError("OPENAI_API_KEY is not configured") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": self.model, + "messages": messages, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "stream": False, + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers=headers, + json=payload, + ) + if response.status_code != 200: + logger.error(f"OpenAI response error: {response.text}") + response.raise_for_status() + + data = response.json() + return data["choices"][0]["message"]["content"] diff --git a/am-mcp-gateway/app/llm/router.py b/am-mcp-gateway/app/llm/router.py new file mode 100644 index 0000000..f0bebd9 --- /dev/null +++ b/am-mcp-gateway/app/llm/router.py @@ -0,0 +1,195 @@ +import logging +from typing import AsyncIterator, Tuple + +from app.llm.base import BaseLLMProvider +from app.llm.circuit_breaker import CircuitBreaker +from app.llm.factory import LLMProviderFactory +from app.llm.types import LLMChatResult + +logger = logging.getLogger(__name__) + + +def _provider_key(provider: BaseLLMProvider) -> str: + name = provider.__class__.__name__ + if name == "LiteLLMProvider": + return "litellm" + return name.replace("Provider", "").lower() + + +class LLMRouter: + def __init__(self): + self.providers_chain = LLMProviderFactory.get_fallback_chain() + self.breakers = { + "litellm": CircuitBreaker("litellm"), + "deepseek": CircuitBreaker("deepseek"), + "gemini": CircuitBreaker("gemini"), + "openai": CircuitBreaker("openai"), + } + self.last_usage: dict[str, int] | None = None + + async def generate_chat_stream( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> AsyncIterator[Tuple[str, str]]: + last_error = None + skipped: list[str] = [] + self.last_usage = None + + if not self.providers_chain: + raise RuntimeError( + "No LLM providers configured. Check LLM_FALLBACK_CHAIN and provider API keys." + ) + + for provider in self.providers_chain: + provider_name = _provider_key(provider) + breaker = self.breakers.get(provider_name) + + if breaker and not breaker.allow_request(): + logger.warning(f"Circuit breaker for {provider_name} is OPEN. Skipping...") + skipped.append(f"{provider_name} (circuit OPEN)") + continue + + logger.info(f"Attempting to stream chat via provider: {provider_name}") + try: + async for chunk in provider.generate_chat_stream( + prompt, + system_prompt, + model=model, + temperature=temperature, + metadata=metadata, + ): + yield chunk, provider_name + + self.last_usage = getattr(provider, "last_usage", None) + if breaker: + breaker.record_success() + return + except Exception as e: + logger.error(f"Streaming failed with provider {provider_name}: {e}") + last_error = e + if breaker: + breaker.record_failure() + + if last_error: + raise last_error + if skipped: + raise RuntimeError( + f"All LLM providers unavailable: {', '.join(skipped)}. " + "Restart the gateway or wait for circuit breaker recovery." + ) + raise RuntimeError("All LLM providers skipped by circuit breakers or failed to initialize.") + + async def generate_chat( + self, + prompt: str, + system_prompt: str = None, + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + ) -> Tuple[str, str, dict[str, int] | None]: + last_error = None + skipped: list[str] = [] + self.last_usage = None + + if not self.providers_chain: + raise RuntimeError( + "No LLM providers configured. Check LLM_FALLBACK_CHAIN and provider API keys." + ) + + for provider in self.providers_chain: + provider_name = _provider_key(provider) + breaker = self.breakers.get(provider_name) + + if breaker and not breaker.allow_request(): + logger.warning(f"Circuit breaker for {provider_name} is OPEN. Skipping...") + skipped.append(f"{provider_name} (circuit OPEN)") + continue + + logger.info(f"Attempting chat via provider: {provider_name}") + try: + result = await provider.generate_chat( + prompt, + system_prompt, + model=model, + temperature=temperature, + metadata=metadata, + ) + if isinstance(result, LLMChatResult): + self.last_usage = result.usage + text = result.text + else: + self.last_usage = getattr(provider, "last_usage", None) + text = result + + if breaker: + breaker.record_success() + return text, provider_name, self.last_usage + except Exception as e: + logger.error(f"Chat execution failed with provider {provider_name}: {e}") + last_error = e + if breaker: + breaker.record_failure() + + if last_error: + raise last_error + if skipped: + raise RuntimeError( + f"All LLM providers unavailable: {', '.join(skipped)}. " + "Restart the gateway or wait for circuit breaker recovery." + ) + raise RuntimeError("All LLM providers skipped by circuit breakers or failed to initialize.") + + async def generate_chat_messages( + self, + messages: list[dict], + *, + model: str | None = None, + temperature: float | None = None, + metadata: dict | None = None, + max_tokens: int | None = None, + ) -> Tuple[str, str, dict[str, int] | None]: + """Route a raw messages payload (multimodal) through the LiteLLM provider chain.""" + last_error = None + skipped: list[str] = [] + self.last_usage = None + + for provider in self.providers_chain: + provider_name = _provider_key(provider) + if provider_name != "litellm": + continue + breaker = self.breakers.get(provider_name) + if breaker and not breaker.allow_request(): + skipped.append(f"{provider_name} (circuit OPEN)") + continue + try: + result = await provider.generate_chat_messages( + messages, + model=model, + temperature=temperature, + metadata=metadata, + max_tokens=max_tokens, + ) + self.last_usage = result.usage + if breaker: + breaker.record_success() + return result.text, provider_name, self.last_usage + except Exception as e: + last_error = e + if breaker: + breaker.record_failure() + + if last_error: + raise last_error + raise RuntimeError( + "Multimodal LLM requests require LiteLLM provider. " + f"Skipped/unavailable: {', '.join(skipped) or 'none configured'}" + ) + + +llm_router = LLMRouter() diff --git a/am-mcp-gateway/app/llm/types.py b/am-mcp-gateway/app/llm/types.py new file mode 100644 index 0000000..0d2d546 --- /dev/null +++ b/am-mcp-gateway/app/llm/types.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class LLMChatResult: + text: str + usage: dict[str, int] | None = None + model: str | None = None + + +def normalize_usage(raw: dict[str, Any] | None) -> dict[str, int] | None: + if not raw: + return None + mapping = { + "prompt_tokens": raw.get("prompt_tokens"), + "completion_tokens": raw.get("completion_tokens"), + "total_tokens": raw.get("total_tokens"), + } + cleaned = {k: int(v) for k, v in mapping.items() if v is not None} + return cleaned or None diff --git a/am-mcp-gateway/app/main.py b/am-mcp-gateway/app/main.py new file mode 100644 index 0000000..9bd77db --- /dev/null +++ b/am-mcp-gateway/app/main.py @@ -0,0 +1,65 @@ +import asyncio +import logging +import os +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.api.chat import router as chat_router +from app.api.agent_llm import router as agent_llm_router +from app.api.ui_test_tools import router as ui_test_tools_router +from app.api.health import router as health_router +from app.observability.tracer import observability_tracer + +# Configure logging +logging.basicConfig( + level=logging.getLevelName(settings.LOG_LEVEL), + format='%(asctime)s | %(levelname)-8s | %(name)s | %(message)s' if settings.LOG_FORMAT == 'text' else '{"time":"%(asctime)s", "level":"%(levelname)s", "name":"%(name)s", "message":"%(message)s"}', +) +logger = logging.getLogger("am-mcp-gateway") + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info( + "LLM config: base_url=%s model=%s fallback=%s auth_disabled=%s", + settings.LITELLM_BASE_URL, + settings.LLM_MODEL, + settings.LLM_FALLBACK_CHAIN, + os.environ.get("AUTH_DISABLED", "false"), + ) + # Startup: Launch background worker for observability + worker_task = asyncio.create_task(observability_tracer.worker()) + logger.info("Observability background tracing worker started.") + yield + # Shutdown: Cancel worker and allow cleanup + logger.info("Shutting down observability background tracing worker...") + worker_task.cancel() + try: + await worker_task + except asyncio.CancelledError: + pass + logger.info("Gateway service stopped.") + +app = FastAPI( + title="AM MCP Gateway", + description="Intelligent AI Routing layer with SSO, Caching, and Tracing", + version="2.0.0", + lifespan=lifespan +) + +# Wire CORS middleware based on settings config +cors_origins = [origin.strip() for origin in settings.CORS_ORIGINS.split(",") if origin.strip()] +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins or ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount Routers +app.include_router(chat_router, prefix="/api/v1") +app.include_router(agent_llm_router, prefix="/api/v1") +app.include_router(ui_test_tools_router, prefix="/api/v1") +app.include_router(health_router) diff --git a/am-mcp-gateway/app/observability/tracer.py b/am-mcp-gateway/app/observability/tracer.py new file mode 100644 index 0000000..de3aad0 --- /dev/null +++ b/am-mcp-gateway/app/observability/tracer.py @@ -0,0 +1,259 @@ +import asyncio +import base64 +import logging +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +import httpx + +from app.config import settings + +logger = logging.getLogger(__name__) + +observability_queue = asyncio.Queue(maxsize=1000) + + +class ObservabilityTracer: + def __init__(self): + self.langfuse_enabled = settings.LANGFUSE_ENABLED + self.mlflow_enabled = settings.MLFLOW_ENABLED + self._mlflow_initialized = False + + def _langfuse_auth_header(self) -> str | None: + if not settings.LANGFUSE_PUBLIC_KEY or not settings.LANGFUSE_SECRET_KEY: + return None + token = f"{settings.LANGFUSE_PUBLIC_KEY}:{settings.LANGFUSE_SECRET_KEY}" + return base64.b64encode(token.encode()).decode() + + async def log_trace( + self, + user_id: str, + prompt: str, + response: str, + model: str, + latency: float, + trace_id: str, + session_id: Optional[str] = None, + cached: bool = False, + *, + temperature: float | None = None, + stream: bool | None = None, + tools_used: list[str] | None = None, + provider: str = "litellm", + usage: dict[str, int] | None = None, + ): + """Asynchronously queues trace data to prevent blocking requests.""" + if not (self.langfuse_enabled or self.mlflow_enabled): + return + + trace_data = { + "user_id": user_id, + "prompt": prompt, + "response": response, + "model": model, + "latency": latency, + "trace_id": trace_id, + "session_id": session_id, + "cached": cached, + "temperature": temperature, + "stream": stream, + "tools_used": tools_used or [], + "provider": provider, + "usage": usage, + "timestamp": time.time(), + } + + try: + observability_queue.put_nowait(trace_data) + except asyncio.QueueFull: + logger.warning("Observability queue is full. Dropping trace to prevent memory leak.") + + async def _send_to_langfuse(self, data: Dict[str, Any]): + """Log trace via Langfuse public ingestion API (SDK-version agnostic).""" + auth = self._langfuse_auth_header() + if not auth: + logger.warning("Langfuse keys missing — skipping trace") + return + + host = settings.LANGFUSE_HOST.rstrip("/") + url = f"{host}/api/public/ingestion" + now = datetime.now(timezone.utc).isoformat() + generation_id = str(uuid.uuid4()) + + request_input = { + "message": data["prompt"], + "model": data["model"], + "temperature": data.get("temperature"), + "stream": data.get("stream"), + } + trace_metadata = { + "provider": data.get("provider", "litellm"), + "cached": data["cached"], + "latency_seconds": round(data["latency"], 3), + "tools_used": data.get("tools_used", []), + "gateway": "am-mcp-gateway", + } + generation_metadata = { + "latency_seconds": round(data["latency"], 3), + "temperature": data.get("temperature"), + "stream": data.get("stream"), + "cached": data["cached"], + } + + generation_body: dict[str, Any] = { + "id": generation_id, + "traceId": data["trace_id"], + "name": "llm-call", + "model": data["model"], + "input": request_input, + "output": data["response"], + "metadata": generation_metadata, + } + usage = data.get("usage") + if usage: + generation_body["usageDetails"] = { + "input": usage.get("prompt_tokens"), + "output": usage.get("completion_tokens"), + "total": usage.get("total_tokens"), + } + + batch = [ + { + "id": str(uuid.uuid4()), + "type": "trace-create", + "timestamp": now, + "body": { + "id": data["trace_id"], + "name": "am-mcp-gateway-chat", + "userId": data["user_id"], + "sessionId": data.get("session_id"), + "input": request_input, + "output": data["response"], + "metadata": trace_metadata, + }, + }, + { + "id": str(uuid.uuid4()), + "type": "generation-create", + "timestamp": now, + "body": generation_body, + }, + ] + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + url, + headers={ + "Authorization": f"Basic {auth}", + "Content-Type": "application/json", + }, + json={"batch": batch}, + ) + if resp.status_code not in (200, 207): + logger.error( + "Langfuse ingestion failed [%s]: %s", + resp.status_code, + resp.text[:500], + ) + return + payload = resp.json() + errors = payload.get("errors") or [] + if errors: + logger.error("Langfuse ingestion batch errors: %s", errors[:3]) + return + logger.debug("Langfuse trace sent: %s", data["trace_id"]) + except Exception as e: + logger.error(f"Langfuse trace logging failed: {e}") + + def _send_to_mlflow_sync(self, data: Dict[str, Any]) -> None: + """Log metrics and params to MLflow as a run (blocking — run via thread pool).""" + if not self._mlflow_initialized and self.mlflow_enabled: + try: + import mlflow + mlflow.set_tracking_uri(settings.MLFLOW_TRACKING_URI) + mlflow.set_experiment(settings.MLFLOW_EXPERIMENT_NAME) + self._mlflow_initialized = True + logger.info( + "MLflow tracking initialized → %s / %s", + settings.MLFLOW_TRACKING_URI, + settings.MLFLOW_EXPERIMENT_NAME, + ) + except Exception as e: + logger.error(f"Failed to initialize MLflow: {e}") + return + + if not self._mlflow_initialized: + return + + try: + import mlflow + with mlflow.start_run(run_name=f"chat-{data['trace_id'][:8]}"): + mlflow.set_tags({ + "user_id": data["user_id"], + "model": data["model"], + "session_id": data.get("session_id", ""), + "cached": str(data["cached"]), + "gateway": "am-mcp-gateway", + "provider": data.get("provider", "litellm"), + }) + mlflow.log_params({ + "model": data["model"], + "prompt_length": len(data["prompt"]), + "response_length": len(data["response"]), + "temperature": data.get("temperature"), + "stream": data.get("stream"), + }) + metrics = { + "latency_seconds": round(data["latency"], 3), + } + usage = data.get("usage") or {} + if usage.get("prompt_tokens") is not None: + metrics["prompt_tokens"] = usage["prompt_tokens"] + if usage.get("completion_tokens") is not None: + metrics["completion_tokens"] = usage["completion_tokens"] + mlflow.log_metrics(metrics) + mlflow.log_text(data["prompt"], "prompt.txt") + mlflow.log_text(data["response"], "response.txt") + logger.debug(f"MLflow run logged for trace: {data['trace_id']}") + except Exception as e: + logger.error(f"MLflow tracking failed: {e}") + + async def _send_to_mlflow(self, data: Dict[str, Any]) -> None: + await asyncio.to_thread(self._send_to_mlflow_sync, data) + + async def worker(self): + """Background worker loop to process tracing tasks.""" + logger.info("Starting ObservabilityTracer background worker...") + while True: + try: + data = await observability_queue.get() + + # Langfuse first — must not be blocked by sync MLflow I/O on the event loop. + if self.langfuse_enabled: + try: + await asyncio.wait_for(self._send_to_langfuse(data), timeout=15.0) + except asyncio.TimeoutError: + logger.warning("Langfuse logging timed out.") + except Exception as e: + logger.error(f"Langfuse logging failed: {e}") + + if self.mlflow_enabled: + try: + await asyncio.wait_for(self._send_to_mlflow(data), timeout=15.0) + except asyncio.TimeoutError: + logger.warning("MLflow logging timed out.") + except Exception as e: + logger.error(f"MLflow logging failed: {e}") + + observability_queue.task_done() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in observability worker: {e}") + await asyncio.sleep(1.0) + + +observability_tracer = ObservabilityTracer() diff --git a/am-mcp-gateway/app/security/jwks_cache.py b/am-mcp-gateway/app/security/jwks_cache.py new file mode 100644 index 0000000..561400a --- /dev/null +++ b/am-mcp-gateway/app/security/jwks_cache.py @@ -0,0 +1,64 @@ +import time +import logging +from typing import Dict, Any, Optional +import httpx +from jwt.algorithms import RSAAlgorithm +from app.config import settings + +logger = logging.getLogger(__name__) + +class JWKSCache: + def __init__(self, jwks_url: str, cache_ttl: int = 300): + self.jwks_url = jwks_url + self.cache_ttl = cache_ttl + self._keys: Dict[str, Any] = {} + self._last_fetched: float = 0.0 + + async def _fetch_jwks(self) -> None: + """Fetch JWKS keys from Keycloak certificates URL and parse them.""" + now = time.time() + # If cache is still valid and we have keys, skip fetching + if self._keys and (now - self._last_fetched) < self.cache_ttl: + return + + logger.info(f"Fetching JWKS from Keycloak: {self.jwks_url}") + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(self.jwks_url) + response.raise_for_status() + jwks = response.json() + + new_keys = {} + for key_data in jwks.get("keys", []): + kid = key_data.get("kid") + if kid: + # Convert JWK to a PEM public key object (or PyJWT compatible public key) + public_key = RSAAlgorithm.from_jwk(key_data) + new_keys[kid] = public_key + + self._keys = new_keys + self._last_fetched = now + logger.info(f"Successfully loaded {len(self._keys)} public keys from JWKS.") + except Exception as e: + logger.error(f"Failed to fetch JWKS keys from Keycloak: {str(e)}") + # If fetch fails, keep using old keys if we have them + if not self._keys: + raise e + + async def get_public_key(self, kid: str) -> Any: + """Retrieves matching PEM key for the given key ID (kid).""" + await self._fetch_jwks() + + if kid not in self._keys: + # Force refresh cache once if kid not found, just in case Keycloak rolled keys + logger.warning(f"Key ID {kid} not found in cache. Refreshing JWKS...") + self._last_fetched = 0.0 # bypass TTL check + await self._fetch_jwks() + + if kid not in self._keys: + raise KeyError(f"Key ID {kid} not found in Keycloak JWKS") + + return self._keys[kid] + +# Global singleton JWKS Cache +jwks_cache = JWKSCache(jwks_url=settings.OIDC_JWKS_URL, cache_ttl=settings.OIDC_JWKS_CACHE_TTL_SECONDS) diff --git a/am-mcp-gateway/app/security/jwt_bearer.py b/am-mcp-gateway/app/security/jwt_bearer.py new file mode 100644 index 0000000..37fd226 --- /dev/null +++ b/am-mcp-gateway/app/security/jwt_bearer.py @@ -0,0 +1,128 @@ +import logging +from fastapi import Request, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import jwt +from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, InvalidTokenError + +from app.config import settings +from app.security.jwks_cache import jwks_cache +from app.security.models import TokenPayload + +logger = logging.getLogger(__name__) + +class JWTBearer(HTTPBearer): + def __init__(self, auto_error: bool = True): + super().__init__(auto_error=auto_error) + + async def __call__(self, request: Request) -> TokenPayload: + credentials: HTTPAuthorizationCredentials = await super().__call__(request) + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization credentials", + ) + + token = credentials.credentials + try: + # 1. Decode header to extract kid (Key ID) + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + if not kid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token header missing 'kid'", + ) + + # 2. Retrieve the public key matching kid + public_key = await jwks_cache.get_public_key(kid) + + # 3. Verify signature and standard claims + # Keycloak tokens may or may not specify client_id in the aud claim. + # Usually Keycloak token aud is the client_id or account. + # We can verify the issuer, and verify signature. We can pass options to verify audience if needed. + options = { + "verify_aud": False, # Aud verification can sometimes fail if audience is custom in Keycloak, but let's make it robust + } + + payload = jwt.decode( + token, + public_key, + algorithms=["RS256"], + issuer=settings.OIDC_ISSUER, + options=options + ) + + # Optional: Manually check client audience if settings.AM_MCP_CLIENT_ID is specified and verify_aud is disabled + aud = payload.get("aud") + # If audience is a list or single string, check if client_id is present + if aud and settings.AM_MCP_CLIENT_ID: + audiences = aud if isinstance(aud, list) else [aud] + # Keycloak client ID might also be mapped in 'azp' (Authorized party) + azp = payload.get("azp") + if settings.AM_MCP_CLIENT_ID not in audiences and azp != settings.AM_MCP_CLIENT_ID: + logger.warning(f"Audience/AZP mismatch. Token aud: {aud}, azp: {azp}. Expected client: {settings.AM_MCP_CLIENT_ID}") + # In some environments, we might want to warn or raise. Let's warn but allow to pass if signature is valid, + # or restrict based on requirements. Let's make it raise an error if client ID doesn't match either aud or azp. + # Wait, let's make it check if azp == client_id or client_id in aud. + if azp != settings.AM_MCP_CLIENT_ID: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token audience", + ) + + # 4. Extract Keycloak roles (realm and client level) + roles = [] + realm_access = payload.get("realm_access", {}) + if "roles" in realm_access: + roles.extend(realm_access["roles"]) + + resource_access = payload.get("resource_access", {}) + client_access = resource_access.get(settings.AM_MCP_CLIENT_ID, {}) + if "roles" in client_access: + roles.extend(client_access["roles"]) + + return TokenPayload( + sub=payload.get("sub", ""), + email=payload.get("email"), + preferred_username=payload.get("preferred_username"), + roles=roles, + client_id=payload.get("azp"), + iss=payload.get("iss", ""), + exp=payload.get("exp", 0) + ) + + except ExpiredSignatureError as e: + logger.warning("JWT token signature has expired") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="token_expired", + ) + except InvalidSignatureError as e: + logger.warning("JWT token signature verification failed") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="token_invalid_signature", + ) + except KeyError as e: + logger.warning(f"Signing key not found in JWKS: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="signing_key_not_found", + ) + except InvalidTokenError as e: + logger.warning(f"Invalid token format or claims: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="token_invalid", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Unexpected error validating token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="token_validation_error", + ) + +# Global Dependency +get_current_user = JWTBearer() diff --git a/am-mcp-gateway/app/security/models.py b/am-mcp-gateway/app/security/models.py new file mode 100644 index 0000000..cbbe3c3 --- /dev/null +++ b/am-mcp-gateway/app/security/models.py @@ -0,0 +1,11 @@ +from typing import List, Optional +from pydantic import BaseModel, Field + +class TokenPayload(BaseModel): + sub: str + email: Optional[str] = None + preferred_username: Optional[str] = None + roles: List[str] = Field(default_factory=list) + client_id: Optional[str] = None + iss: str + exp: int diff --git a/am-mcp-gateway/app/session/cache.py b/am-mcp-gateway/app/session/cache.py new file mode 100644 index 0000000..d9dc7ef --- /dev/null +++ b/am-mcp-gateway/app/session/cache.py @@ -0,0 +1,85 @@ +import hashlib +import logging +from typing import Optional +import redis +from app.config import settings + +logger = logging.getLogger(__name__) + +# List of keywords indicating dynamic data that should NOT be cached +DYNAMIC_KEYWORDS = {"now", "today", "current", "latest", "realtime", "live", "price", "valuation"} + +class ResponseCache: + def __init__(self): + self.enabled = settings.CACHE_ENABLED + self.redis_client = None + self.in_memory_cache = {} + + if self.enabled and settings.CACHE_BACKEND == "redis": + try: + logger.info(f"Initializing Redis Cache with URL: {settings.REDIS_URL}") + self.redis_client = redis.from_url( + settings.REDIS_URL, + decode_responses=True, + socket_timeout=2.0, + socket_connect_timeout=2.0 + ) + # Test connection + self.redis_client.ping() + logger.info("Successfully connected to Redis cache backend.") + except Exception as e: + logger.warning(f"Failed to connect to Redis, falling back to in-memory cache: {e}") + self.redis_client = None + + def _should_cache(self, prompt: str) -> bool: + """Determines if a prompt should be cached based on the presence of dynamic keywords.""" + sanitized = prompt.lower().split() + for word in sanitized: + # Check prefix/substring match for keywords like 'today', 'latest' + for kw in DYNAMIC_KEYWORDS: + if kw in word: + logger.info(f"Prompt matches dynamic keyword '{kw}'. Skipping caching.") + return False + return True + + def build_key(self, user_id: str, prompt: str, model: str) -> str: + sanitized = prompt.strip().lower() + h = hashlib.sha256(f"{user_id}:{sanitized}:{model}".encode()).hexdigest() + return f"mcp:cache:{h}" + + async def get(self, user_id: str, prompt: str, model: str) -> Optional[str]: + if not self.enabled: + return None + + if not self._should_cache(prompt): + return None + + key = self.build_key(user_id, prompt, model) + try: + if self.redis_client: + return self.redis_client.get(key) + else: + return self.in_memory_cache.get(key) + except Exception as e: + logger.error(f"Error reading from response cache: {e}") + return None + + async def set(self, user_id: str, prompt: str, model: str, value: str, ttl: Optional[int] = None) -> None: + if not self.enabled: + return + + if not self._should_cache(prompt): + return + + key = self.build_key(user_id, prompt, model) + ttl = ttl or settings.CACHE_TTL_SECONDS + try: + if self.redis_client: + self.redis_client.set(key, value, ex=ttl) + else: + self.in_memory_cache[key] = value + # Note: Simple in-memory cache does not enforce TTL in this mockup but is fine for fallback + except Exception as e: + logger.error(f"Error writing to response cache: {e}") + +response_cache = ResponseCache() diff --git a/am-mcp-gateway/app/tools/__init__.py b/am-mcp-gateway/app/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/am-mcp-gateway/app/tools/fin_agent_client.py b/am-mcp-gateway/app/tools/fin_agent_client.py new file mode 100644 index 0000000..68596c9 --- /dev/null +++ b/am-mcp-gateway/app/tools/fin_agent_client.py @@ -0,0 +1,55 @@ +import logging +from typing import Dict, Any, Optional +import httpx +from app.config import settings + +logger = logging.getLogger(__name__) + +class FinAgentClient: + def __init__(self): + self.base_url = settings.MCP_SERVER_URL # points to http://localhost:8100 or am-fin-agent service + self.timeout = settings.MCP_SERVER_TIMEOUT_SECONDS + + async def check_financial_intent(self, message: str) -> bool: + """ + Uses a lightweight check to determine if the message requires financial tools. + For example, questions about valuation, portfolio, holdings, stocks, trades, allocation. + """ + keywords = { + "portfolio", "valuation", "holdings", "stock", "trade", "buy", "sell", "etf", + "allocation", "benchmark", "mover", "activity", "balance", "shares", "invest" + } + sanitized = message.lower().split() + for word in sanitized: + # Check prefix/substring match for keywords + for kw in keywords: + if kw in word: + return True + return False + + async def query_agent( + self, + message: str, + user_id: str, + session_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Proxies request to am-fin-agent chat endpoint POST /api/v1/ai/chat. + """ + url = f"{self.base_url.rstrip('/')}/api/v1/ai/chat" + payload = { + "message": message, + "userId": user_id, + "sessionId": session_id + } + + logger.info(f"Routing financial request to am-fin-agent: {url} (User: {user_id})") + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post(url, json=payload) + if response.status_code != 200: + logger.error(f"am-fin-agent returned error status {response.status_code}: {response.text}") + response.raise_for_status() + + return response.json() + +fin_agent_client = FinAgentClient() diff --git a/am-mcp-gateway/app/tools/litellm_mcp_sync.py b/am-mcp-gateway/app/tools/litellm_mcp_sync.py new file mode 100644 index 0000000..220c860 --- /dev/null +++ b/am-mcp-gateway/app/tools/litellm_mcp_sync.py @@ -0,0 +1,87 @@ +"""Build LiteLLM MCP server payload from am-*/testing/manifest.json files.""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +MANIFEST_SCHEMA = "am-ui-test-manifest/v1" +DEFAULT_SERVER_ALIAS = "am_mcp_gateway" +TOOL_PREFIX_SEPARATOR = "-" + + +def repo_root_from_gateway(gateway_root: Path) -> Path: + """am-platform/am-mcp-gateway → AM-Portfolio-grp root.""" + return gateway_root.resolve().parents[1] + + +def discover_manifests(repo_root: Path) -> list[Path]: + paths: list[Path] = [] + for candidate in sorted(repo_root.glob("am-*/testing/manifest.json")): + if candidate.is_file(): + paths.append(candidate) + return paths + + +def load_manifest(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema") != MANIFEST_SCHEMA: + raise ValueError(f"Unsupported manifest schema in {path}") + return data + + +def prefixed_tool_name(server_alias: str, operation_id: str) -> str: + safe = re.sub(r"[^a-zA-Z0-9_-]", "_", operation_id).lower() + alias = server_alias.replace(" ", "_") + return f"{alias}{TOOL_PREFIX_SEPARATOR}{safe}" + + +def build_allowed_tools_from_manifests( + manifest_paths: list[Path], + *, + server_alias: str = DEFAULT_SERVER_ALIAS, +) -> tuple[list[str], dict[str, str]]: + """Return (allowed_tools, tool_name_to_description) for LiteLLM MCP server.""" + allowed: list[str] = [] + descriptions: dict[str, str] = {} + + for path in manifest_paths: + manifest = load_manifest(path) + for tool in manifest.get("tools") or []: + litellm_meta = tool.get("litellm") or {} + operation_id = litellm_meta.get("operation_id") or tool.get("name") + if not operation_id: + continue + prefixed = prefixed_tool_name(server_alias, operation_id) + allowed.append(prefixed) + desc = tool.get("description") or f"UI test tool from {path.parent.parent.name}" + descriptions[prefixed] = desc + + # Stable order for config diffs + allowed = sorted(set(allowed)) + return allowed, descriptions + + +def build_mcp_server_payload( + *, + gateway_base_url: str, + manifest_paths: list[Path], + server_alias: str = DEFAULT_SERVER_ALIAS, + allow_all_keys: bool = True, +) -> dict[str, Any]: + base = gateway_base_url.rstrip("/") + allowed_tools, tool_name_to_description = build_allowed_tools_from_manifests( + manifest_paths, server_alias=server_alias + ) + return { + "server_name": server_alias, + "alias": server_alias, + "description": "AM MCP Gateway — UI test tools from module testing manifests", + "transport": "http", + "url": base, + "spec_path": f"{base}/openapi.json", + "allow_all_keys": allow_all_keys, + "allowed_tools": allowed_tools, + "tool_name_to_description": tool_name_to_description, + } diff --git a/am-mcp-gateway/app/tools/ui_test_resolver.py b/am-mcp-gateway/app/tools/ui_test_resolver.py new file mode 100644 index 0000000..ea4e439 --- /dev/null +++ b/am-mcp-gateway/app/tools/ui_test_resolver.py @@ -0,0 +1,86 @@ +"""Resolve ui-test wrapper targets for MCP gateway tool proxy.""" +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import Any + +def load_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.is_file(): + return values + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, val = line.split("=", 1) + values[key.strip()] = val.strip() + return values + + +def resolve_env_refs(value: str, env: dict[str, str]) -> str: + pattern = re.compile(r"\$\{([^}]+)\}") + + def repl(match: re.Match[str]) -> str: + key = match.group(1) + if key not in env: + raise KeyError(f"Missing env var for target ref: {key}") + return env[key] + + return pattern.sub(repl, value) + + +def _resolve_obj(obj: Any, env: dict[str, str]) -> Any: + if isinstance(obj, str): + return resolve_env_refs(obj, env) if "${" in obj else obj + if isinstance(obj, dict): + return {k: _resolve_obj(v, env) for k, v in obj.items()} + if isinstance(obj, list): + return [_resolve_obj(v, env) for v in obj] + return obj + + +def load_manifest(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def resolve_module_target( + manifest_path: Path, + *, + module: str, + environment: str, + target_name: str | None = None, +) -> dict[str, Any]: + manifest = load_manifest(manifest_path) + modules = manifest.get("modules") or {} + if module not in modules: + raise KeyError(f"Unknown module {module!r} in manifest") + + mod = modules[module] + repo_root = (manifest_path.parent / mod.get("repo_root", "..")).resolve() + targets_path = repo_root / mod["targets_file"].format(environment=environment) + env_path = repo_root / mod["env_file"].format(environment=environment) + + merged_env = dict(os.environ) + merged_env.update(load_env_file(env_path)) + data = _resolve_obj(json.loads(targets_path.read_text(encoding="utf-8")), merged_env) + + name = target_name or data.get("default_target") or mod.get("default_target") or "main" + targets = data.get("targets") or {} + if name not in targets: + raise KeyError(f"Target {name!r} not found in {targets_path}") + + entry = targets[name] + return { + "module": module, + "environment": environment, + "target": name, + "target_url": entry["base_url"], + "ui_mode": entry.get("ui_mode", name), + "profile": entry.get("profile"), + "auth_login_mode": entry.get("auth_login_mode", "demo"), + "targets_file": str(targets_path), + "env_file": str(env_path), + } diff --git a/am-mcp-gateway/helm/values.dev.yaml b/am-mcp-gateway/helm/values.dev.yaml new file mode 100644 index 0000000..3aa949f --- /dev/null +++ b/am-mcp-gateway/helm/values.dev.yaml @@ -0,0 +1,42 @@ +# Dev Environment overrides for am-mcp-gateway +environment: dev +replicaCount: 1 + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +vault: + enabled: true + secretPaths: + redis: + path: "apps/data/dev/infra/redis" + llm-api-keys: + path: "apps/data/dev/services/am-mcp-gateway" + identity-oidc: + path: "apps/data/dev/services/am-identity" + observability: + path: "apps/data/dev/services/am-mcp-gateway" + +ingress: + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: >- + am-apps-dev-dev-global-cors@kubernetescrd, + am-apps-dev-dev-strip-prefix@kubernetescrd + hosts: + - host: am-dev.asrax.in + paths: + - path: /mcp + pathType: Prefix + +env: + APP_ENV: dev + LOG_LEVEL: DEBUG + DB_AGENT_BASE_URL: "http://am-db-agent.am-apps-dev.svc.cluster.local:8140" + TOOL_AGENT_BASE_URL: "http://am-tool-agent.am-apps-dev.svc.cluster.local:8141" diff --git a/am-mcp-gateway/helm/values.preprod.yaml b/am-mcp-gateway/helm/values.preprod.yaml new file mode 100644 index 0000000..a8d33cd --- /dev/null +++ b/am-mcp-gateway/helm/values.preprod.yaml @@ -0,0 +1,39 @@ +# Preprod Environment overrides for am-mcp-gateway +environment: preprod +replicaCount: 1 + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +vault: + enabled: true + secretPaths: + redis: + path: "apps/data/preprod/infra/redis" + llm-api-keys: + path: "apps/data/preprod/services/am-mcp-gateway" + identity-oidc: + path: "apps/data/preprod/services/am-identity" + observability: + path: "apps/data/preprod/services/am-mcp-gateway" + +env: + APP_ENV: preprod + LOG_LEVEL: INFO + LLM_MODEL: together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite + LLM_FALLBACK_CHAIN: litellm + UI_TEST_AGENT_BASE_URL: "http://am-ui-test-agent.am-apps-preprod.svc.cluster.local:8130" + DB_AGENT_BASE_URL: "http://am-db-agent.am-apps-preprod.svc.cluster.local:8140" + TOOL_AGENT_BASE_URL: "http://am-tool-agent.am-apps-preprod.svc.cluster.local:8141" + MCP_GATEWAY_PUBLIC_URL: "https://am.asrax.in/mcp" + # Match local .env.preprod — internal langfuse.am-ai URL was failing ingestion silently + LANGFUSE_ENABLED: "true" + LANGFUSE_HOST: "https://langfuse.munish.org" + LANGFUSE_FLUSH_INTERVAL_SECONDS: "5" + # MLflow sync logging blocks the event loop and caused Langfuse 5s gather timeouts + MLFLOW_ENABLED: "false" diff --git a/am-mcp-gateway/helm/values.prod.yaml b/am-mcp-gateway/helm/values.prod.yaml new file mode 100644 index 0000000..87c9ab6 --- /dev/null +++ b/am-mcp-gateway/helm/values.prod.yaml @@ -0,0 +1,29 @@ +# Production Environment overrides for am-mcp-gateway +environment: production +replicaCount: 2 + +resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 200m + memory: 512Mi + +vault: + enabled: true + secretPaths: + redis: + path: "apps/data/prod/infra/redis" + llm-api-keys: + path: "apps/data/prod/services/am-mcp-gateway" + identity-oidc: + path: "apps/data/prod/services/am-identity" + observability: + path: "apps/data/prod/services/am-mcp-gateway" + +env: + APP_ENV: production + LOG_LEVEL: INFO + DB_AGENT_BASE_URL: "http://am-db-agent.am-apps-prod.svc.cluster.local:8140" + TOOL_AGENT_BASE_URL: "http://am-tool-agent.am-apps-prod.svc.cluster.local:8141" diff --git a/am-mcp-gateway/helm/values.yaml b/am-mcp-gateway/helm/values.yaml new file mode 100644 index 0000000..5478607 --- /dev/null +++ b/am-mcp-gateway/helm/values.yaml @@ -0,0 +1,102 @@ +global: + image: + registry: ghcr.io/am-portfolio + pullPolicy: Always + imagePullSecrets: + - name: regcred + vault: + enabled: true + role: "am-backend-role" + authPath: "auth/kubernetes" + serviceAccountName: "am-backend-sa" + +image: + repository: am-mcp-gateway + +replicaCount: 1 +language: python +port: 8120 + +service: + port: 8120 + +entrypoint: "exec uvicorn app.main:app --host 0.0.0.0 --port 8120" + +security: + automountServiceAccountToken: true + +config: {} + +env: + APP_ENV: production + APP_NAME: am-mcp-gateway + APP_PORT: "8120" + LOG_FORMAT: json + LOG_LEVEL: INFO + TZ: Asia/Kolkata + LLM_PROVIDER: litellm + LLM_FALLBACK_CHAIN: litellm + LLM_MODEL: together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite + LLM_TEMPERATURE: "0.2" + LLM_MAX_TOKENS: "4096" + LLM_TIMEOUT_SECONDS: "60" + LLM_STREAM: "true" + LLM_CB_FAILURE_THRESHOLD: "5" + LLM_CB_RECOVERY_TIMEOUT_SECONDS: "30" + LITELLM_BASE_URL: "http://litellm.am-ai.svc.cluster.local:4000" + CACHE_ENABLED: "true" + CACHE_BACKEND: redis + CACHE_TTL_SECONDS: "300" + MCP_SERVER_URL: "http://am-fin-agent.am-apps-preprod.svc.cluster.local:8100" + MCP_SERVER_TIMEOUT_SECONDS: "20" + MCP_SERVER_ENABLED: "true" + LANGFUSE_ENABLED: "true" + LANGFUSE_HOST: "http://langfuse.am-ai.svc.cluster.local:3000" # internal cluster URL (faster) + LANGFUSE_FLUSH_INTERVAL_SECONDS: "5" + MLFLOW_ENABLED: "true" + MLFLOW_TRACKING_URI: "http://mlflow.am-ai.svc.cluster.local:5000" + MLFLOW_EXPERIMENT_NAME: am-mcp-gateway + MLFLOW_ASYNC: "true" + RATE_LIMIT_ENABLED: "true" + RATE_LIMIT_REQUESTS_PER_MINUTE: "60" + RATE_LIMIT_BURST: "10" + SESSION_BACKEND: redis + SESSION_TTL_SECONDS: "3600" + UI_TEST_AGENT_BASE_URL: "http://am-ui-test-agent.am-apps-preprod.svc.cluster.local:8130" + UI_TEST_MANIFEST_PATH: "/config/testing/manifest.json" + MCP_GATEWAY_PUBLIC_URL: "http://am-mcp-gateway.am-apps-preprod.svc.cluster.local:8120" + LITELLM_MCP_SERVER_ALIAS: "am_mcp_gateway" + +probes: + port: 8120 + startup: + path: /health + failureThreshold: 30 + periodSeconds: 10 + liveness: + path: /health + readiness: + path: /health + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: >- + am-apps-preprod-global-cors@kubernetescrd, + am-apps-preprod-strip-prefix-apps@kubernetescrd + hosts: + - host: am.asrax.in + paths: + - path: /mcp + pathType: Prefix diff --git a/am-mcp-gateway/helm/vault-mappings.yaml b/am-mcp-gateway/helm/vault-mappings.yaml new file mode 100644 index 0000000..935e9ee --- /dev/null +++ b/am-mcp-gateway/helm/vault-mappings.yaml @@ -0,0 +1,21 @@ +vault: + secretPaths: + redis: + template: | + export REDIS_URL="redis://:{{ .Data.data.password }}@{{ .Data.data.host }}:{{ .Data.data.port }}/4" + llm-api-keys: + mappings: + DEEPSEEK_API_KEY: "DEEPSEEK_API_KEY" + GOOGLE_API_KEY: "GOOGLE_API_KEY" + OPENAI_API_KEY: "OPENAI_API_KEY" + TOGETHER_API_KEY: "TOGETHER_API_KEY" + identity-oidc: + mappings: + OIDC_JWKS_URL: "OIDC_JWKS_URL" + OIDC_ISSUER: "OIDC_ISSUER" + AM_MCP_CLIENT_SECRET: "AM_MCP_CLIENT_SECRET" + observability: + mappings: + LANGFUSE_PUBLIC_KEY: "LANGFUSE_PUBLIC_KEY" + LANGFUSE_SECRET_KEY: "LANGFUSE_SECRET_KEY" + LITELLM_MASTER_KEY: "LITELLM_MASTER_KEY" diff --git a/am-mcp-gateway/package.json b/am-mcp-gateway/package.json new file mode 100644 index 0000000..91928b7 --- /dev/null +++ b/am-mcp-gateway/package.json @@ -0,0 +1,20 @@ +{ + "name": "@am-platform/mcp-gateway", + "version": "1.0.0", + "private": true, + "description": "AM MCP Gateway service (FastAPI routing, Observability, Session management)", + "scripts": { + "dev": "node ../automation/scripts/run-with-logs.js python ../automation/scripts/run_service.py mcp-gateway dev", + "start": "npm run dev", + "preprod": "node ../automation/scripts/run-with-logs.js python ../automation/scripts/run_service.py mcp-gateway preprod", + "prod": "node ../automation/scripts/run-with-logs.js python ../automation/scripts/run_service.py mcp-gateway prod", + "lint": "node ../automation/scripts/run-with-logs.js python -m black --check app tests", + "format": "node ../automation/scripts/run-with-logs.js python -m black app tests", + "test": "node ../automation/scripts/run-with-logs.js pytest tests/", + "test:litellm-langfuse": "node ../automation/scripts/run-with-logs.js python scripts/test_litellm_langfuse.py", + "test:ui-test-auth": "node ../automation/scripts/run-with-logs.js python -c \"import httpx; r=httpx.post('http://localhost:8120/api/v1/tools/ui-test/run-auth', json={'module':'modern-ui','environment':'preprod','target':'main','wait':false}, timeout=30); print(r.status_code, r.text)\"", + "sync:litellm-mcp-tools": "node ../automation/scripts/run-with-logs.js python scripts/sync_litellm_mcp_tools.py", + "sync:litellm-mcp-tools:dry": "node ../automation/scripts/run-with-logs.js python scripts/sync_litellm_mcp_tools.py --dry-run", + "compile": "node ../automation/scripts/run-with-logs.js python -m compileall app" + } +} diff --git a/am-mcp-gateway/postman/AM-MCP-Gateway.local.postman_environment.json b/am-mcp-gateway/postman/AM-MCP-Gateway.local.postman_environment.json new file mode 100644 index 0000000..bc65c8b --- /dev/null +++ b/am-mcp-gateway/postman/AM-MCP-Gateway.local.postman_environment.json @@ -0,0 +1,33 @@ +{ + "id": "am-mcp-gateway-local-env", + "name": "AM MCP Gateway — Local", + "values": [ + { + "key": "base_url", + "value": "http://localhost:8120", + "type": "default", + "enabled": true + }, + { + "key": "mcp_client_id", + "value": "am-mcp-service", + "type": "default", + "enabled": true + }, + { + "key": "mcp_client_secret", + "value": "", + "type": "secret", + "enabled": true + }, + { + "key": "access_token", + "value": "", + "type": "secret", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-06-13T00:00:00.000Z", + "_postman_exported_using": "Antigravity" +} diff --git a/am-mcp-gateway/postman/AM-MCP-Gateway.postman_collection.json b/am-mcp-gateway/postman/AM-MCP-Gateway.postman_collection.json new file mode 100644 index 0000000..7132e96 --- /dev/null +++ b/am-mcp-gateway/postman/AM-MCP-Gateway.postman_collection.json @@ -0,0 +1,105 @@ +{ + "info": { + "_postman_id": "am-mcp-gateway-collection-v1", + "name": "AM MCP Gateway Service", + "description": "API collection for **am-mcp-gateway** (port 8120).\n\n## Quick start\n1. Run **Health → Health Check**\n2. Get an `access_token` from Identity login or client credentials grant.\n3. Run **Chat → Chat Sync** or **Chat → Chat Stream**.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get('base_url')) {", + " pm.environment.set('base_url', 'http://localhost:8120');", + "}" + ] + } + } + ], + "variable": [ + { "key": "base_url", "value": "http://localhost:8120" }, + { "key": "access_token", "value": "" } + ], + "item": [ + { + "name": "00 Health", + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": "{{base_url}}/health", + "description": "Liveness check for the gateway." + }, + "response": [] + } + ] + }, + { + "name": "01 Chat", + "item": [ + { + "name": "Chat Sync", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"message\": \"What is 2+2?\",\n \"model\": \"together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite\",\n \"temperature\": 0.2,\n \"stream\": false\n}" + }, + "url": "{{base_url}}/api/v1/chat/sync", + "description": "Sends a synchronous chat request to the LLM via LiteLLM." + }, + "response": [] + }, + { + "name": "Chat Stream", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"message\": \"Explain quantum computing in one sentence.\",\n \"model\": \"together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite\",\n \"temperature\": 0.2,\n \"stream\": true\n}" + }, + "url": "{{base_url}}/api/v1/chat", + "description": "Sends a streaming chat request to the LLM via LiteLLM (returns Server-Sent Events)." + }, + "response": [] + } + ] + } + ] +} diff --git a/am-mcp-gateway/pyproject.toml b/am-mcp-gateway/pyproject.toml new file mode 100644 index 0000000..ff464ad --- /dev/null +++ b/am-mcp-gateway/pyproject.toml @@ -0,0 +1,13 @@ +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' + +[tool.isort] +profile = "black" +line_length = 88 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true diff --git a/am-mcp-gateway/requirements-dev.txt b/am-mcp-gateway/requirements-dev.txt new file mode 100644 index 0000000..9610c8b --- /dev/null +++ b/am-mcp-gateway/requirements-dev.txt @@ -0,0 +1,7 @@ +pytest>=7.3.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 +httpx>=0.24.0 +black>=23.3.0 +isort>=5.12.0 +flake8>=6.0.0 diff --git a/am-mcp-gateway/requirements.txt b/am-mcp-gateway/requirements.txt new file mode 100644 index 0000000..65a28c1 --- /dev/null +++ b/am-mcp-gateway/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.100.0 +uvicorn>=0.22.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +httpx>=0.24.0 +redis>=5.0.0 +pyjwt[crypto]>=2.8.0 +cryptography>=41.0.0 +langfuse>=2.0.0 +mlflow>=3.0.0 +python-multipart>=0.0.6 diff --git a/am-mcp-gateway/scripts/sync_litellm_mcp_tools.py b/am-mcp-gateway/scripts/sync_litellm_mcp_tools.py new file mode 100644 index 0000000..af1f91c --- /dev/null +++ b/am-mcp-gateway/scripts/sync_litellm_mcp_tools.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Register / update AM MCP Gateway UI test tools in LiteLLM via Management API. + +Reads am-*/testing/manifest.json → sets allowed_tools on mcp_servers.am_mcp_gateway. + +Usage: + python scripts/sync_litellm_mcp_tools.py + python scripts/sync_litellm_mcp_tools.py --dry-run + python scripts/sync_litellm_mcp_tools.py --gateway-url http://localhost:8120 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +import httpx + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.tools.litellm_mcp_sync import ( # noqa: E402 + DEFAULT_SERVER_ALIAS, + build_mcp_server_payload, + discover_manifests, + repo_root_from_gateway, +) + +DEFAULT_LITELLM = "http://localhost:4000" + + +def _auth_headers(master_key: str) -> dict[str, str]: + return {"Authorization": f"Bearer {master_key}", "Content-Type": "application/json"} + + +def _find_server(servers: list[dict], alias: str) -> dict | None: + for row in servers: + if row.get("alias") == alias or row.get("server_name") == alias: + return row + return None + + +def sync_to_litellm( + *, + litellm_base: str, + master_key: str, + gateway_base_url: str, + server_alias: str, + manifest_paths: list[Path], + dry_run: bool, +) -> int: + payload = build_mcp_server_payload( + gateway_base_url=gateway_base_url, + manifest_paths=manifest_paths, + server_alias=server_alias, + ) + + if dry_run: + print(json.dumps(payload, indent=2)) + return 0 + + base = litellm_base.rstrip("/") + headers = _auth_headers(master_key) + + with httpx.Client(timeout=60.0) as client: + list_resp = client.get(f"{base}/v1/mcp/server", headers=headers) + list_resp.raise_for_status() + existing = _find_server(list_resp.json(), server_alias) + + if existing: + update = {**payload, "server_id": existing["server_id"]} + resp = client.put(f"{base}/v1/mcp/server", headers=headers, json=update) + action = "updated" + else: + resp = client.post(f"{base}/v1/mcp/server", headers=headers, json=payload) + action = "created" + + if resp.status_code >= 400: + print(f"LiteLLM MCP sync failed [{resp.status_code}]: {resp.text[:800]}", file=sys.stderr) + return 1 + + body = resp.json() + print(f"LiteLLM MCP server {action}: alias={server_alias} id={body.get('server_id', '?')}") + print(f" allowed_tools={payload['allowed_tools']}") + print(f" spec_path={payload['spec_path']}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Sync UI test MCP tools to LiteLLM") + parser.add_argument("--litellm-url", default=os.getenv("LITELLM_BASE_URL", DEFAULT_LITELLM)) + parser.add_argument( + "--master-key", + default=os.getenv("LITELLM_MASTER_KEY"), + help="LiteLLM PROXY_ADMIN key (required unless --dry-run)", + ) + parser.add_argument( + "--gateway-url", + default=os.getenv("MCP_GATEWAY_PUBLIC_URL", "http://localhost:8120"), + ) + parser.add_argument("--server-alias", default=os.getenv("LITELLM_MCP_SERVER_ALIAS", DEFAULT_SERVER_ALIAS)) + parser.add_argument("--repo-root", type=Path, default=None, help="Monorepo root (auto-detected)") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + repo_root = args.repo_root or repo_root_from_gateway(ROOT) + manifests = discover_manifests(repo_root) + if not manifests: + print(f"No manifests under {repo_root}/am-*/testing/manifest.json", file=sys.stderr) + return 1 + + print(f"Repo root: {repo_root}") + print(f"Manifests: {[str(p.relative_to(repo_root)) for p in manifests]}") + + if not args.dry_run and not args.master_key: + print("LITELLM_MASTER_KEY required (or use --dry-run)", file=sys.stderr) + return 1 + + return sync_to_litellm( + litellm_base=args.litellm_url, + master_key=args.master_key or "", + gateway_base_url=args.gateway_url, + server_alias=args.server_alias, + manifest_paths=manifests, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/am-mcp-gateway/scripts/test_litellm_langfuse.py b/am-mcp-gateway/scripts/test_litellm_langfuse.py new file mode 100644 index 0000000..0e26c62 --- /dev/null +++ b/am-mcp-gateway/scripts/test_litellm_langfuse.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Direct integration check for MCP gateway dependencies — no gateway, no auth. + + 1. LiteLLM → POST {LITELLM_BASE_URL}/chat/completions + 2. Langfuse → SDK trace + optional read-back via public API + +Loads am-mcp-gateway/.env.preprod by default. + +Usage: + python scripts/test_litellm_langfuse.py + python scripts/test_litellm_langfuse.py --prompt "What is 2+2?" + python scripts/test_litellm_langfuse.py --env-file .env.preprod --skip-langfuse-verify +""" +from __future__ import annotations + +import argparse +import base64 +import sys +import time +import uuid +from pathlib import Path + +import httpx +import requests + +ROOT = Path(__file__).resolve().parents[1] + + +def load_env_file(path: Path) -> dict[str, str]: + env: dict[str, str] = {} + if not path.is_file(): + return env + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip() + return env + + +def test_litellm( + *, + base_url: str, + api_key: str, + model: str, + prompt: str, + timeout: float, +) -> tuple[str, float]: + url = f"{base_url.rstrip('/')}/chat/completions" + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.2, + "max_tokens": 64, + "stream": False, + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + print(f"\n[LiteLLM] POST {url}") + print(f"[LiteLLM] model={model}") + + started = time.perf_counter() + with httpx.Client(timeout=timeout) as client: + response = client.post(url, headers=headers, json=payload) + latency = time.perf_counter() - started + + if response.status_code != 200: + raise RuntimeError(f"LiteLLM failed [{response.status_code}]: {response.text[:500]}") + + data = response.json() + answer = data["choices"][0]["message"]["content"] + usage = data.get("usage", {}) + print(f"[LiteLLM] OK ({latency:.2f}s) tokens={usage}") + print(f"[LiteLLM] response: {answer[:300]}") + return answer, latency, usage + + +def send_langfuse_trace( + *, + host: str, + public_key: str, + secret_key: str, + trace_id: str, + prompt: str, + response: str, + model: str, + latency: float, + usage: dict[str, int] | None = None, +) -> None: + """Send trace via Langfuse public ingestion API (no SDK version coupling).""" + from datetime import datetime, timezone + + url = f"{host.rstrip('/')}/api/public/ingestion" + auth = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode() + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": "application/json", + } + now = datetime.now(timezone.utc).isoformat() + generation_id = str(uuid.uuid4()) + generation_body: dict = { + "id": generation_id, + "traceId": trace_id, + "name": "llm-call", + "model": model, + "input": {"message": prompt, "model": model}, + "output": response, + "metadata": {"latency_seconds": round(latency, 3), "source": "direct-litellm-test"}, + } + if usage: + generation_body["usageDetails"] = { + "input": usage.get("prompt_tokens"), + "output": usage.get("completion_tokens"), + "total": usage.get("total_tokens"), + } + + trace_body: dict = { + "id": trace_id, + "name": "mcp-gateway-direct-test", + "userId": "local-test-user", + "sessionId": f"test-{trace_id[:8]}", + "input": {"message": prompt, "model": model}, + "output": response, + "metadata": { + "source": "scripts/test_litellm_langfuse.py", + "model": model, + "latency_seconds": round(latency, 3), + }, + } + + batch = [ + { + "id": str(uuid.uuid4()), + "type": "trace-create", + "timestamp": now, + "body": trace_body, + }, + { + "id": str(uuid.uuid4()), + "type": "generation-create", + "timestamp": now, + "body": generation_body, + }, + ] + + print(f"\n[Langfuse] POST {url}") + print(f"[Langfuse] trace_id={trace_id}") + + resp = requests.post(url, headers=headers, json={"batch": batch}, timeout=30) + if resp.status_code not in (200, 207): + raise RuntimeError(f"Langfuse ingestion failed [{resp.status_code}]: {resp.text[:500]}") + + body = resp.json() + errors = body.get("errors") or [] + if errors: + raise RuntimeError(f"Langfuse ingestion batch errors: {errors[:3]}") + + print(f"[Langfuse] ingestion OK ({resp.status_code}, events={len(body.get('successes', []))})") + + +def verify_langfuse_trace( + *, + host: str, + public_key: str, + secret_key: str, + trace_id: str, + retries: int = 6, + delay_seconds: float = 2.0, +) -> bool: + url = f"{host.rstrip('/')}/api/public/traces/{trace_id}" + auth = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode() + headers = {"Authorization": f"Basic {auth}"} + + print(f"\n[Langfuse] verify GET {url}") + + for attempt in range(1, retries + 1): + resp = requests.get(url, headers=headers, timeout=15) + if resp.status_code == 200: + body = resp.json() + obs_count = len(body.get("observations") or []) + print( + f"[Langfuse] trace found (attempt {attempt}) " + f"name={body.get('name')} observations={obs_count}" + ) + if obs_count == 0: + print("[Langfuse] WARN: trace has no generations yet — wait and retry") + time.sleep(delay_seconds) + continue + return True + if resp.status_code == 404: + print(f"[Langfuse] not indexed yet (attempt {attempt}/{retries})") + time.sleep(delay_seconds) + continue + raise RuntimeError(f"Langfuse verify failed [{resp.status_code}]: {resp.text[:300]}") + + print("[Langfuse] trace not visible yet — check UI manually") + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description="Direct LiteLLM + Langfuse smoke test") + parser.add_argument("--env-file", default=".env.preprod", help="Env file (default: .env.preprod)") + parser.add_argument("--prompt", default="Reply with only the number 4.", help="Prompt sent to LiteLLM") + parser.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout seconds") + parser.add_argument("--skip-langfuse", action="store_true", help="Skip Langfuse logging") + parser.add_argument("--skip-langfuse-verify", action="store_true", help="Skip Langfuse API read-back") + args = parser.parse_args() + + env_path = ROOT / args.env_file + cfg = load_env_file(env_path) + if not cfg: + print(f"ERROR: env file not found or empty: {env_path}", file=sys.stderr) + return 1 + + litellm_url = cfg.get("LITELLM_BASE_URL", "http://localhost:4000") + litellm_key = cfg.get("LITELLM_MASTER_KEY", "") + model = cfg.get("LLM_MODEL", "together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite") + + langfuse_enabled = cfg.get("LANGFUSE_ENABLED", "true").lower() == "true" + langfuse_host = cfg.get("LANGFUSE_HOST", "https://langfuse.munish.org") + langfuse_public = cfg.get("LANGFUSE_PUBLIC_KEY", "") + langfuse_secret = cfg.get("LANGFUSE_SECRET_KEY", "") + + if not litellm_key: + print("ERROR: LITELLM_MASTER_KEY missing in env file", file=sys.stderr) + return 1 + + trace_id = str(uuid.uuid4()) + prompt = f"{args.prompt} [trace={trace_id}]" + + print("=== MCP direct integration test ===", flush=True) + print(f"env: {env_path.name}", flush=True) + print(f"trace_id: {trace_id}", flush=True) + + litellm_ok = False + langfuse_ok = True + + try: + answer, latency, usage = test_litellm( + base_url=litellm_url, + api_key=litellm_key, + model=model, + prompt=prompt, + timeout=args.timeout, + ) + litellm_ok = True + except Exception as exc: + print(f"\nFAIL LiteLLM: {exc}", file=sys.stderr) + print("Tip: port-forward LiteLLM → kubectl -n am-ai port-forward svc/litellm 4000:4000", file=sys.stderr) + return 1 + + if not args.skip_langfuse and langfuse_enabled: + if not langfuse_public or not langfuse_secret: + print("\nWARN Langfuse keys missing — skipping trace") + else: + try: + send_langfuse_trace( + host=langfuse_host, + public_key=langfuse_public, + secret_key=langfuse_secret, + trace_id=trace_id, + prompt=prompt, + response=answer, + model=model, + latency=latency, + usage=usage, + ) + if not args.skip_langfuse_verify: + langfuse_ok = verify_langfuse_trace( + host=langfuse_host, + public_key=langfuse_public, + secret_key=langfuse_secret, + trace_id=trace_id, + ) + except Exception as exc: + print(f"\nFAIL Langfuse: {exc}", file=sys.stderr) + print( + "Tip: confirm LANGFUSE_HOST and project API keys in Langfuse → Settings → API Keys", + file=sys.stderr, + ) + langfuse_ok = False + + print("\n=== Summary ===") + print(f"LiteLLM : {'OK' if litellm_ok else 'FAIL'}") + if not args.skip_langfuse and langfuse_enabled and langfuse_public: + print(f"Langfuse: {'OK' if langfuse_ok else 'FAIL'}") + print(f"UI : {langfuse_host}/trace/{trace_id}") + print(f"trace_id: {trace_id}") + + if not litellm_ok: + return 1 + if not args.skip_langfuse and langfuse_enabled and langfuse_public and not langfuse_ok: + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/am-mcp-gateway/test_auth_flow.py b/am-mcp-gateway/test_auth_flow.py new file mode 100644 index 0000000..b6e52ac --- /dev/null +++ b/am-mcp-gateway/test_auth_flow.py @@ -0,0 +1,54 @@ +import requests +import json + +# 1. Verify JWKS endpoint +jwks_url = "http://auth.munish.org/auth/realms/am-preprod-realm/protocol/openid-connect/certs" +resp = requests.get(jwks_url, headers={"User-Agent": "am-platform-security/1.0", "Accept": "application/json"}, timeout=10) +print(f"JWKS status: {resp.status_code}") +if resp.status_code == 200: + keys = resp.json().get("keys", []) + print(f"Keys found: {len(keys)}") + for k in keys: + print(f" kid={k.get('kid')}, alg={k.get('alg')}, use={k.get('use')}") +else: + print(resp.text[:200]) + +# 2. Get token via client credentials +print("\n--- Client credentials token ---") +token_url = "http://auth.munish.org/auth/realms/am-preprod-realm/protocol/openid-connect/token" +resp2 = requests.post(token_url, data={ + "grant_type": "client_credentials", + "client_id": "am-mcp-service", + "client_secret": "hkk4698D7xZ8m2VpPL3zNfepAoTwRN8r", +}, timeout=10) +print(f"Token status: {resp2.status_code}") +if resp2.status_code == 200: + token_data = resp2.json() + access_token = token_data["access_token"] + # Decode header to see kid + header_b64 = access_token.split(".")[0] + padding = 4 - len(header_b64) % 4 + header = json.loads(__import__("base64").urlsafe_b64decode(header_b64 + "=" * padding)) + print(f"Token kid: {header.get('kid')}") + print(f"Token alg: {header.get('alg')}") + + # 3. Call the MCP gateway + print("\n--- MCP Gateway call ---") + chat_resp = requests.post("http://localhost:8120/api/v1/chat/sync", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + }, + json={ + "messages": [{"role": "user", "content": "Hello, what is 2+2?"}], + "stream": False + }, + timeout=30 + ) + print(f"Chat status: {chat_resp.status_code}") + try: + print(json.dumps(chat_resp.json(), indent=2)[:2000]) + except Exception: + print(chat_resp.text[:500]) +else: + print(resp2.text[:300]) diff --git a/am-mcp-gateway/tests/conftest.py b/am-mcp-gateway/tests/conftest.py new file mode 100644 index 0000000..c946027 --- /dev/null +++ b/am-mcp-gateway/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest +import os +import sys + +# Add app to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +@pytest.fixture(autouse=True) +def mock_env(monkeypatch): + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key") + monkeypatch.setenv("GOOGLE_API_KEY", "test-key") + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("OIDC_JWKS_URL", "http://mock-keycloak/certs") + monkeypatch.setenv("OIDC_ISSUER", "http://mock-keycloak") + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/4") + monkeypatch.setenv("CACHE_ENABLED", "false") # disable by default for unit tests diff --git a/am-mcp-gateway/tests/test_cache.py b/am-mcp-gateway/tests/test_cache.py new file mode 100644 index 0000000..461a6ff --- /dev/null +++ b/am-mcp-gateway/tests/test_cache.py @@ -0,0 +1,35 @@ +import pytest +from app.session.cache import ResponseCache + +@pytest.mark.asyncio +async def test_cache_dynamic_bypassing(): + cache = ResponseCache() + cache.enabled = True + + # Prompt with dynamic word + assert cache._should_cache("What is the valuation today?") is False + assert cache._should_cache("give me current stock price") is False + + # Normal prompt + assert cache._should_cache("Explain stock options") is True + +@pytest.mark.asyncio +async def test_cache_fallback_in_memory(): + cache = ResponseCache() + cache.enabled = True + cache.redis_client = None # Force in-memory fallback + + user_id = "user-123" + prompt = "Explain stock options" + model = "deepseek-chat" + + # Try reading empty cache + res = await cache.get(user_id, prompt, model) + assert res is None + + # Write to cache + await cache.set(user_id, prompt, model, "cached explanation") + + # Read cache + res = await cache.get(user_id, prompt, model) + assert res == "cached explanation" diff --git a/am-mcp-gateway/tests/test_litellm_mcp_sync.py b/am-mcp-gateway/tests/test_litellm_mcp_sync.py new file mode 100644 index 0000000..e6b56aa --- /dev/null +++ b/am-mcp-gateway/tests/test_litellm_mcp_sync.py @@ -0,0 +1,31 @@ +"""Tests for LiteLLM MCP manifest sync helpers.""" +from pathlib import Path + +from app.tools.litellm_mcp_sync import ( + build_allowed_tools_from_manifests, + discover_manifests, + prefixed_tool_name, + repo_root_from_gateway, +) + + +def test_prefixed_tool_name(): + assert prefixed_tool_name("am_mcp_gateway", "run_modern_ui_auth_test") == ( + "am_mcp_gateway-run_modern_ui_auth_test" + ) + + +def test_discover_modern_ui_manifest(): + gateway_root = Path(__file__).resolve().parents[1] + repo_root = repo_root_from_gateway(gateway_root) + manifests = discover_manifests(repo_root) + assert any("am-modern-ui" in str(p) for p in manifests) + + +def test_build_allowed_tools_from_manifest(): + gateway_root = Path(__file__).resolve().parents[1] + repo_root = repo_root_from_gateway(gateway_root) + manifests = discover_manifests(repo_root) + allowed, descriptions = build_allowed_tools_from_manifests(manifests) + assert "am_mcp_gateway-run_modern_ui_auth_test" in allowed + assert descriptions["am_mcp_gateway-run_modern_ui_auth_test"] diff --git a/am-mcp-gateway/tests/test_llm_router.py b/am-mcp-gateway/tests/test_llm_router.py new file mode 100644 index 0000000..a804357 --- /dev/null +++ b/am-mcp-gateway/tests/test_llm_router.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from app.llm.circuit_breaker import CircuitBreaker, CircuitState +from app.llm.router import LLMRouter + +@pytest.mark.asyncio +async def test_circuit_breaker_flow(): + breaker = CircuitBreaker("mock-provider", failure_threshold=2, recovery_timeout=1) + assert breaker.allow_request() is True + + # Record first failure + breaker.record_failure() + assert breaker.state == CircuitState.CLOSED + assert breaker.allow_request() is True + + # Record second failure (trips) + breaker.record_failure() + assert breaker.state == CircuitState.OPEN + assert breaker.allow_request() is False + + # Check recovery timeout + import time + time.sleep(1.1) + assert breaker.allow_request() is True + assert breaker.state == CircuitState.HALF_OPEN + + # Record success resets breaker + breaker.record_success() + assert breaker.state == CircuitState.CLOSED + assert breaker.allow_request() is True + +@pytest.mark.asyncio +async def test_llm_router_failover(): + router = LLMRouter() + + from app.llm.deepseek import DeepSeekProvider + from app.llm.gemini import GeminiProvider + + # Mock actual provider instances so class name mapping is preserved + mock_deepseek = DeepSeekProvider() + mock_deepseek.generate_chat = AsyncMock(side_effect=Exception("Connection Timeout")) + + mock_gemini = GeminiProvider() + mock_gemini.generate_chat = AsyncMock(return_value="Gemini response") + + router.providers_chain = [mock_deepseek, mock_gemini] + + # Call router + response, model, usage = await router.generate_chat("Hello") + + assert response == "Gemini response" + assert model == "gemini" + assert usage is None + + # Check that DeepSeek circuit breaker recorded failure + assert router.breakers["deepseek"].failures == 1 + diff --git a/am-subscription/am_subscription/api/webhook_router.py b/am-subscription/am_subscription/api/webhook_router.py index 9d64557..0de4d12 100644 --- a/am-subscription/am_subscription/api/webhook_router.py +++ b/am-subscription/am_subscription/api/webhook_router.py @@ -5,8 +5,9 @@ from am_platform_common import APIResponse -from am_subscription.deps import get_event_publisher +from am_subscription.deps import get_event_publisher, get_subscription_service from am_subscription.services.event_publisher import EventPublisher +from am_subscription.services.subscription_service import SubscriptionService logger = logging.getLogger(__name__) @@ -17,6 +18,7 @@ async def provider_webhook( request: Request, events: EventPublisher = Depends(get_event_publisher), + sub_service: SubscriptionService = Depends(get_subscription_service), x_lago_signature: str | None = Header(default=None), ): """Translate billing provider webhooks into canonical platform events.""" @@ -28,6 +30,8 @@ async def provider_webhook( ) correlation_id = body.get("webhook_id") or EventPublisher.new_correlation_id() + clean_user_id = user_id.replace("am-user-", "") if user_id and user_id.startswith("am-user-") else user_id + event_map = { "subscription.started": "am.subscription.created.v1", "subscription.terminated": "am.subscription.changed.v1", @@ -35,14 +39,37 @@ async def provider_webhook( } event_type = event_map.get(webhook_type, f"am.billing.webhook.{webhook_type}") + # 1. Publish platform event (logged or pushed to Kafka depending on configuration) await events.publish( event_type, tenant_id=user_id or "unknown", - user_id=user_id.replace("am-user-", "") if user_id and user_id.startswith("am-user-") else user_id, + user_id=clean_user_id, correlation_id=correlation_id, idempotency_key=correlation_id, payload={"webhook_type": webhook_type, "raw": body}, ) + # 2. Update local database subscription state + subscription_data = body.get("subscription") or {} + plan_code = subscription_data.get("plan_code") + provider_sub_id = subscription_data.get("external_id") + + if clean_user_id: + try: + await sub_service.process_billing_webhook( + webhook_type=webhook_type, + user_id=clean_user_id, + plan_code=plan_code, + provider_sub_id=provider_sub_id, + correlation_id=correlation_id, + ) + except Exception as exc: + logger.exception( + "failed_to_process_billing_webhook_in_db", + extra={"webhook_type": webhook_type, "user_id": clean_user_id, "error": str(exc)}, + ) + # Log the exception, but return 200/accepted to prevent Lago from retrying indefinitely + logger.info("provider_webhook_received", extra={"webhook_type": webhook_type, "signature": bool(x_lago_signature)}) return APIResponse(data={"accepted": True, "webhook_type": webhook_type}) + diff --git a/am-subscription/am_subscription/services/subscription_service.py b/am-subscription/am_subscription/services/subscription_service.py index 98a5acd..7b5dcd1 100644 --- a/am-subscription/am_subscription/services/subscription_service.py +++ b/am-subscription/am_subscription/services/subscription_service.py @@ -386,3 +386,133 @@ async def _append_audit( metadata_json=metadata, ) ) + + async def process_billing_webhook( + self, + webhook_type: str, + user_id: str, + plan_code: str | None, + provider_sub_id: str | None, + correlation_id: str, + ) -> None: + logger.info( + "process_billing_webhook", + extra={ + "webhook_type": webhook_type, + "user_id": user_id, + "plan_code": plan_code, + "provider_sub_id": provider_sub_id, + "correlation_id": correlation_id, + }, + ) + existing = await self.get_by_user(user_id) + + if webhook_type == "subscription.started": + if not plan_code: + logger.error("plan_code is missing for subscription.started", extra={"user_id": user_id}) + return + + plan = self._catalog.get_plan(plan_code) + + if existing: + previous_state = existing.state.value + previous_plan = existing.plan_code + + # Direct update bypassing the state machine, as billing provider is source of truth + existing.state = SubscriptionState.active + existing.plan_code = plan.code + existing.billing_interval = plan.interval + if provider_sub_id: + existing.provider_subscription_id = provider_sub_id + existing.updated_at = datetime.now(timezone.utc) + + await self._append_audit( + existing.id, + actor="billing_provider", + previous_state=previous_state, + next_state=existing.state.value, + reason="webhook_subscription_started", + correlation_id=correlation_id, + metadata={"previous_plan": previous_plan, "new_plan": plan.code}, + ) + else: + # Create a new subscription + subscription = Subscription( + user_id=user_id, + plan_code=plan.code, + state=SubscriptionState.active, + provider="lago", + provider_subscription_id=provider_sub_id, + billing_interval=plan.interval, + ) + self._session.add(subscription) + await self._session.flush() + + await self._append_audit( + subscription.id, + actor="billing_provider", + previous_state=None, + next_state=subscription.state.value, + reason="webhook_subscription_created", + correlation_id=correlation_id, + metadata={"plan_code": plan.code}, + ) + + # Ensure ProviderMap exists + result_map = await self._session.execute( + select(ProviderMap).where(ProviderMap.user_id == user_id) + ) + existing_map = result_map.scalar_one_or_none() + if not existing_map: + external_customer_id = f"am-user-{user_id}" + provider_map = ProviderMap( + user_id=user_id, + provider="lago", + external_customer_id=external_customer_id, + ) + self._session.add(provider_map) + + await self._session.commit() + + elif webhook_type == "subscription.terminated": + if existing: + previous_state = existing.state.value + existing.state = SubscriptionState.cancelled + existing.updated_at = datetime.now(timezone.utc) + + await self._append_audit( + existing.id, + actor="billing_provider", + previous_state=previous_state, + next_state=existing.state.value, + reason="webhook_subscription_terminated", + correlation_id=correlation_id, + ) + await self._session.commit() + else: + logger.warning( + "subscription.terminated received but no existing subscription found", + extra={"user_id": user_id}, + ) + + elif webhook_type == "invoice.payment_failure": + if existing: + previous_state = existing.state.value + existing.state = SubscriptionState.suspended + existing.updated_at = datetime.now(timezone.utc) + + await self._append_audit( + existing.id, + actor="billing_provider", + previous_state=previous_state, + next_state=existing.state.value, + reason="webhook_payment_failure", + correlation_id=correlation_id, + ) + await self._session.commit() + else: + logger.warning( + "invoice.payment_failure received but no existing subscription found", + extra={"user_id": user_id}, + ) + diff --git a/am-subscription/tests/test_webhook_sync.py b/am-subscription/tests/test_webhook_sync.py new file mode 100644 index 0000000..516e199 --- /dev/null +++ b/am-subscription/tests/test_webhook_sync.py @@ -0,0 +1,266 @@ +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 +import pytest + +from am_subscription.services.subscription_service import SubscriptionService +from am_subscription.models.db import Subscription, SubscriptionState, ProviderMap, SubscriptionAudit +from am_subscription.schemas.subscription import PlanDTO, PlanLimitsDTO, PlanEntitlementsDTO + + +@pytest.fixture +def anyio_backend(): + return 'asyncio' + + +@pytest.mark.anyio +async def test_process_billing_webhook_subscription_started_new(): + session = AsyncMock() + session.add = MagicMock() + catalog = MagicMock() + provider = MagicMock() + events = MagicMock() + + # Mock catalog + mock_plan = PlanDTO( + code="am_pro", + name="Pro", + interval="monthly", + description="Pro plan", + amount_inr=999, + features=[], + limits=PlanLimitsDTO(document_parses=50, portfolios=5, ai_portfolio_summaries=20, api_calls=50000), + entitlements=PlanEntitlementsDTO(live_market_data=True, realtime_indices=True, tradingview_charts=True, basket_trading=False) + ) + catalog.get_plan.return_value = mock_plan + + service = SubscriptionService( + session=session, + catalog=catalog, + provider=provider, + events=events, + default_plan_code="am_free", + ) + + mock_sub_result = MagicMock() + mock_sub_result.scalar_one_or_none.return_value = None + + mock_map_result = MagicMock() + mock_map_result.scalar_one_or_none.return_value = None + + session.execute.side_effect = [mock_sub_result, mock_map_result] + + user_id = "test-user-123" + await service.process_billing_webhook( + webhook_type="subscription.started", + user_id=user_id, + plan_code="am_pro", + provider_sub_id="lago-sub-999", + correlation_id="corr-111" + ) + + # Verify additions to the database session + added_objects = [args[0] for args, _ in session.add.call_args_list] + + subscriptions = [o for o in added_objects if isinstance(o, Subscription)] + audits = [o for o in added_objects if isinstance(o, SubscriptionAudit)] + maps = [o for o in added_objects if isinstance(o, ProviderMap)] + + assert len(subscriptions) == 1 + assert subscriptions[0].user_id == user_id + assert subscriptions[0].plan_code == "am_pro" + assert subscriptions[0].state == SubscriptionState.active + assert subscriptions[0].provider_subscription_id == "lago-sub-999" + assert subscriptions[0].billing_interval == "monthly" + + assert len(audits) == 1 + assert audits[0].actor == "billing_provider" + assert audits[0].reason == "webhook_subscription_created" + assert audits[0].next_state == "active" + assert audits[0].previous_state is None + + assert len(maps) == 1 + assert maps[0].user_id == user_id + assert maps[0].external_customer_id == f"am-user-{user_id}" + + assert session.flush.call_count == 1 + assert session.commit.call_count == 1 + + +@pytest.mark.anyio +async def test_process_billing_webhook_subscription_started_existing(): + session = AsyncMock() + session.add = MagicMock() + catalog = MagicMock() + provider = MagicMock() + events = MagicMock() + + mock_plan = PlanDTO( + code="am_premium", + name="Premium", + interval="monthly", + description="Premium plan", + amount_inr=1999, + features=[], + limits=PlanLimitsDTO(document_parses=200, portfolios=20, ai_portfolio_summaries=100, api_calls=250000), + entitlements=PlanEntitlementsDTO(live_market_data=True, realtime_indices=True, tradingview_charts=True, basket_trading=True) + ) + catalog.get_plan.return_value = mock_plan + + existing_sub = Subscription( + id=uuid4(), + user_id="test-user-123", + plan_code="am_pro", + state=SubscriptionState.active, + provider_subscription_id="lago-sub-111", + billing_interval="monthly" + ) + + service = SubscriptionService( + session=session, + catalog=catalog, + provider=provider, + events=events, + default_plan_code="am_free", + ) + + mock_sub_result = MagicMock() + mock_sub_result.scalar_one_or_none.return_value = existing_sub + + existing_map = ProviderMap( + user_id="test-user-123", + provider="lago", + external_customer_id="am-user-test-user-123" + ) + mock_map_result = MagicMock() + mock_map_result.scalar_one_or_none.return_value = existing_map + + session.execute.side_effect = [mock_sub_result, mock_map_result] + + await service.process_billing_webhook( + webhook_type="subscription.started", + user_id="test-user-123", + plan_code="am_premium", + provider_sub_id="lago-sub-222", + correlation_id="corr-222" + ) + + assert existing_sub.plan_code == "am_premium" + assert existing_sub.provider_subscription_id == "lago-sub-222" + assert existing_sub.state == SubscriptionState.active + + added_objects = [args[0] for args, _ in session.add.call_args_list] + audits = [o for o in added_objects if isinstance(o, SubscriptionAudit)] + subscriptions = [o for o in added_objects if isinstance(o, Subscription)] + maps = [o for o in added_objects if isinstance(o, ProviderMap)] + + assert len(audits) == 1 + assert audits[0].actor == "billing_provider" + assert audits[0].reason == "webhook_subscription_started" + assert audits[0].previous_state == "active" + assert audits[0].next_state == "active" + assert audits[0].metadata_json == {"previous_plan": "am_pro", "new_plan": "am_premium"} + + assert len(subscriptions) == 0 + assert len(maps) == 0 + + assert session.commit.call_count == 1 + + +@pytest.mark.anyio +async def test_process_billing_webhook_subscription_terminated(): + session = AsyncMock() + session.add = MagicMock() + catalog = MagicMock() + provider = MagicMock() + events = MagicMock() + + existing_sub = Subscription( + id=uuid4(), + user_id="test-user-123", + plan_code="am_pro", + state=SubscriptionState.active, + provider_subscription_id="lago-sub-111", + billing_interval="monthly" + ) + + service = SubscriptionService( + session=session, + catalog=catalog, + provider=provider, + events=events, + default_plan_code="am_free", + ) + + mock_sub_result = MagicMock() + mock_sub_result.scalar_one_or_none.return_value = existing_sub + session.execute.return_value = mock_sub_result + + await service.process_billing_webhook( + webhook_type="subscription.terminated", + user_id="test-user-123", + plan_code=None, + provider_sub_id=None, + correlation_id="corr-333" + ) + + assert existing_sub.state == SubscriptionState.cancelled + + added_objects = [args[0] for args, _ in session.add.call_args_list] + audits = [o for o in added_objects if isinstance(o, SubscriptionAudit)] + assert len(audits) == 1 + assert audits[0].actor == "billing_provider" + assert audits[0].reason == "webhook_subscription_terminated" + assert audits[0].previous_state == "active" + assert audits[0].next_state == "cancelled" + + assert session.commit.call_count == 1 + + +@pytest.mark.anyio +async def test_process_billing_webhook_payment_failure(): + session = AsyncMock() + session.add = MagicMock() + catalog = MagicMock() + provider = MagicMock() + events = MagicMock() + + existing_sub = Subscription( + id=uuid4(), + user_id="test-user-123", + plan_code="am_pro", + state=SubscriptionState.active, + provider_subscription_id="lago-sub-111", + billing_interval="monthly" + ) + + service = SubscriptionService( + session=session, + catalog=catalog, + provider=provider, + events=events, + default_plan_code="am_free", + ) + + mock_sub_result = MagicMock() + mock_sub_result.scalar_one_or_none.return_value = existing_sub + session.execute.return_value = mock_sub_result + + await service.process_billing_webhook( + webhook_type="invoice.payment_failure", + user_id="test-user-123", + plan_code=None, + provider_sub_id=None, + correlation_id="corr-444" + ) + + assert existing_sub.state == SubscriptionState.suspended + + added_objects = [args[0] for args, _ in session.add.call_args_list] + audits = [o for o in added_objects if isinstance(o, SubscriptionAudit)] + assert len(audits) == 1 + assert audits[0].actor == "billing_provider" + assert audits[0].reason == "webhook_payment_failure" + assert audits[0].previous_state == "active" + assert audits[0].next_state == "suspended" + + assert session.commit.call_count == 1 diff --git a/automation/helm/ai-gateway/langfuse-values.yaml b/automation/helm/ai-gateway/langfuse-values.yaml new file mode 100644 index 0000000..83b825a --- /dev/null +++ b/automation/helm/ai-gateway/langfuse-values.yaml @@ -0,0 +1,117 @@ +replicaCount: 1 + +global: + security: + allowInsecureImages: true + +postgresql: + deploy: false + auth: + password: "langfuse_dummy_pass" + +# Redis/Valkey - queue + cache. 1Gi is more than enough for preprod +redis: + deploy: true + image: + repository: bitnamilegacy/valkey + auth: + password: "langfuse_redis_pass_2026" + primary: + persistence: + size: 1Gi + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi + cpu: 200m + +langfuse: + salt: + value: "9f172a49341aeb6705c982c70d32db8d97e3a30b2b7050f587cb006e8b96b547" + nextauth: + url: "https://langfuse.munish.org" + # Secret set dynamically by terraform set_sensitive + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m + ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: "web,websecure" + hosts: + - host: langfuse.munish.org + paths: + - path: / + pathType: Prefix + - host: langfuse.asrax.in + paths: + - path: / + pathType: Prefix + +worker: + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m + + + +# ClickHouse - reduced to 1 shard/replica for preprod (no HA needed) +clickhouse: + deploy: true + image: + repository: bitnamilegacy/clickhouse + shards: 1 + replicaCount: 1 + persistence: + size: 2Gi + resources: + requests: + memory: 512Mi + cpu: 100m + limits: + memory: 2Gi + cpu: 1000m + zookeeper: + image: + repository: bitnamilegacy/zookeeper + replicaCount: 1 # 1 node is enough for single ClickHouse + persistence: + size: 1Gi + resources: + requests: + memory: 128Mi + cpu: 50m + limits: + memory: 256Mi + cpu: 200m + auth: + password: "langfuse_clickhouse_pass_2026" + +# MinIO (S3) - reduced to 2Gi, preprod won't store large exports +s3: + deploy: true + image: + repository: bitnamilegacy/minio + persistence: + size: 2Gi + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi + cpu: 200m + auth: + rootPassword: "langfuse_minio_pass_2026" diff --git a/automation/helm/ai-gateway/litellm-values.yaml b/automation/helm/ai-gateway/litellm-values.yaml new file mode 100644 index 0000000..2b63fc0 --- /dev/null +++ b/automation/helm/ai-gateway/litellm-values.yaml @@ -0,0 +1,43 @@ +# LiteLLM Helm Values +replicaCount: 1 +service: + type: ClusterIP + port: 4000 + +proxyConfigMap: + create: false + name: "litellm-config" + key: "litellm_config.yaml" + +db: + useExisting: true + deployStandalone: false + endpoint: "postgresql.infra.svc.cluster.local" + database: "litellm" + secret: + name: "litellm-secrets" + usernameKey: "username" + passwordKey: "password" + +masterkeySecretName: "litellm-secrets" +masterkeySecretKey: "masterkey" + +postgresql: + enabled: false + +ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: "web,websecure" + hosts: + - host: litellm.munish.org + paths: + - path: / + pathType: Prefix + - host: litellm.asrax.in + paths: + - path: / + pathType: Prefix + diff --git a/automation/helm/ai-gateway/mlflow-values.yaml b/automation/helm/ai-gateway/mlflow-values.yaml new file mode 100644 index 0000000..a72dc1f --- /dev/null +++ b/automation/helm/ai-gateway/mlflow-values.yaml @@ -0,0 +1,25 @@ +# MLflow Helm Values +replicaCount: 1 + +ingress: + enabled: true + ingressClassName: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: "web,websecure" + hosts: + - host: mlflow.munish.org + paths: + - path: / + pathType: Prefix + - host: mlflow.asrax.in + paths: + - path: / + pathType: Prefix + +# Enable standard PostgreSQL database dependency subchart for reliable experiment state persistence +postgresql: + enabled: true + auth: + database: mlflow + username: mlflow diff --git a/automation/helm/ai-gateway/qdrant-values.yaml b/automation/helm/ai-gateway/qdrant-values.yaml new file mode 100644 index 0000000..6e7fc27 --- /dev/null +++ b/automation/helm/ai-gateway/qdrant-values.yaml @@ -0,0 +1,12 @@ +# Qdrant Helm Values +replicaCount: 1 + +service: + type: ClusterIP + port: 6333 + +persistence: + enabled: true + size: 5Gi + accessModes: + - ReadWriteOnce diff --git a/automation/helm/deploy-mcp-gateway.ps1 b/automation/helm/deploy-mcp-gateway.ps1 new file mode 100644 index 0000000..2703ee0 --- /dev/null +++ b/automation/helm/deploy-mcp-gateway.ps1 @@ -0,0 +1,35 @@ +# deploy-mcp-gateway.ps1 +# Deploy am-mcp-gateway to the VPS Kubernetes cluster using Helm. + +$ErrorActionPreference = "Stop" + +# Define Paths +$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path +$platformRoot = (Get-Item (Join-Path $scriptPath "..\..")).FullName +$workspaceRoot = (Get-Item (Join-Path $scriptPath "..\..\..")).FullName +$kubeconfigPath = Join-Path $workspaceRoot "VPS\kubeconfig.vps" +$chartPath = Join-Path $workspaceRoot "am-pipelines\helm\universal-chart" +$valuesPath = Join-Path $platformRoot "am-mcp-gateway\helm\values.yaml" +$preprodValuesPath = Join-Path $platformRoot "am-mcp-gateway\helm\values.preprod.yaml" + +if (-not (Test-Path $kubeconfigPath)) { + Write-Error "Could not find kubeconfig at $kubeconfigPath" +} + +if (-not (Test-Path $chartPath)) { + Write-Error "Universal chart not found at $chartPath" +} + +Write-Host "Deploying am-mcp-gateway to namespace 'am-apps-preprod' on VPS..." +helm upgrade --install am-mcp-gateway $chartPath ` + --namespace am-apps-preprod ` + --create-namespace ` + --kubeconfig $kubeconfigPath ` + -f $valuesPath ` + -f $preprodValuesPath + +if ($LASTEXITCODE -ne 0) { + Write-Error "helm upgrade --install am-mcp-gateway failed" +} + +Write-Host "am-mcp-gateway deployment completed successfully!" diff --git a/automation/package.json b/automation/package.json index 2672506..95c6456 100644 --- a/automation/package.json +++ b/automation/package.json @@ -8,6 +8,7 @@ "lago:deploy": "node scripts/run-with-logs.js powershell.exe -ExecutionPolicy Bypass -File helm/deploy-lago.ps1", "lago:plans": "node scripts/run-with-logs.js python scripts/provision_lago_plans.py", "novu:deploy": "node scripts/run-with-logs.js powershell.exe -ExecutionPolicy Bypass -File helm/deploy-novu.ps1", + "mcp-gateway:deploy": "node scripts/run-with-logs.js powershell.exe -ExecutionPolicy Bypass -File helm/deploy-mcp-gateway.ps1", "compose:up": "node scripts/run-with-logs.js docker compose -f ../docker-compose.yml up -d", "compose:down": "node scripts/run-with-logs.js docker compose -f ../docker-compose.yml down", "compose:logs": "node scripts/run-with-logs.js docker compose -f ../docker-compose.yml logs -f keycloak", @@ -22,6 +23,12 @@ "tf:notification:init": "node scripts/run-with-logs.js python scripts/run_terraform.py notification init", "tf:notification:plan": "node scripts/run-with-logs.js python scripts/run_terraform.py notification plan", "tf:notification:apply": "node scripts/run-with-logs.js python scripts/run_terraform.py notification apply", - "tf:notification:output": "node scripts/run-with-logs.js python scripts/run_terraform.py notification output" + "tf:notification:output": "node scripts/run-with-logs.js python scripts/run_terraform.py notification output", + "tf:ai-gateway:init": "node scripts/run-with-logs.js python scripts/run_terraform.py ai-gateway init", + "tf:ai-gateway:plan": "node scripts/run-with-logs.js python scripts/run_terraform.py ai-gateway plan", + "tf:ai-gateway:apply": "node scripts/run-with-logs.js python scripts/run_terraform.py ai-gateway apply", + "tf:ai-gateway:output": "node scripts/run-with-logs.js python scripts/run_terraform.py ai-gateway output", + "infra:vault:sync:preprod": "node scripts/run-with-logs.js powershell.exe -ExecutionPolicy Bypass -File scripts/vault-sync.ps1 -Env preprod -VaultAddr http://localhost:8200", + "infra:vault:sync:dev": "node scripts/run-with-logs.js powershell.exe -ExecutionPolicy Bypass -File scripts/vault-sync.ps1 -Env dev -VaultAddr http://localhost:8200" } } diff --git a/automation/scripts/platform_env.py b/automation/scripts/platform_env.py index e5ebc3e..9fb621c 100644 --- a/automation/scripts/platform_env.py +++ b/automation/scripts/platform_env.py @@ -27,10 +27,16 @@ PLATFORM_ROOT / "am-notification", ] +MCP_GATEWAY_LIB_PATHS = [ + PLATFORM_ROOT / "libraries" / "am-platform-common", + PLATFORM_ROOT / "libraries" / "am-platform-security", + PLATFORM_ROOT / "am-mcp-gateway", +] + def notification_env() -> dict[str, str]: env = os.environ.copy() - env.update(load_env_files()) + env.update(load_env_files(PLATFORM_ROOT / "am-notification")) env["PYTHONPATH"] = os.pathsep.join(str(p) for p in NOTIFICATION_LIB_PATHS) env["APP_NAME"] = "am-notification" env["APP_PORT"] = "8111" @@ -41,7 +47,7 @@ def notification_env() -> dict[str, str]: kafka_bootstrap = env.get("KAFKA_BOOTSTRAP_SERVERS", "") if ".svc.cluster.local" in kafka_bootstrap and not env.get("AM_NOTIFICATION_KAFKA_BOOTSTRAP_SERVERS"): env["AM_NOTIFICATION_KAFKA_BOOTSTRAP_SERVERS"] = "kafka.asrax.in:8890" - return env + return apply_local_service_defaults(env) def python_exe() -> str: @@ -57,33 +63,67 @@ def python_exe() -> str: return sys.executable -def load_env_files() -> dict[str, str]: - merged: dict[str, str] = {} - for name in (".env", ".secrets.env"): - path = PLATFORM_ROOT / name - if not path.is_file(): +def load_file_vars(path: Path) -> dict[str, str]: + if not path.is_file(): + return {} + vars: dict[str, str] = {} + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: continue - for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - merged[key.strip()] = value.strip() + key, value = line.split("=", 1) + vars[key.strip()] = value.strip() + return vars + + +def apply_local_service_defaults(env: dict[str, str]) -> dict[str, str]: + """npm-run services on a dev laptop skip JWT validation (see package.json scripts).""" + env["AUTH_DISABLED"] = "true" + env.setdefault("LLM_CB_ENABLED", "false") + return env + + +def load_env_files(module_dir: Path | None = None) -> dict[str, str]: + merged: dict[str, str] = {} + env_name = os.getenv("APP_ENV", "dev") + + # 1. Load root .env + merged.update(load_file_vars(PLATFORM_ROOT / ".env")) + + # 2. Load module-specific .env + if module_dir: + merged.update(load_file_vars(module_dir / ".env")) + + # 3. Load secrets based on environment name + secrets_file = ".secrets.env" + if env_name == "preprod": + secrets_file = ".secrets.preprod.env" + elif env_name == "prod": + secrets_file = ".secrets.prod.env" + elif env_name == "dev" and (PLATFORM_ROOT / ".secrets.dev.env").is_file(): + secrets_file = ".secrets.dev.env" + + merged.update(load_file_vars(PLATFORM_ROOT / secrets_file)) + + # 4. Load module-specific environment-specific secrets (e.g. .env.preprod) + if module_dir: + merged.update(load_file_vars(module_dir / f".env.{env_name}")) + return merged def identity_env() -> dict[str, str]: env = os.environ.copy() - env.update(load_env_files()) + env.update(load_env_files(PLATFORM_ROOT / "am-identity")) env["PYTHONPATH"] = os.pathsep.join(str(p) for p in IDENTITY_LIB_PATHS) env["APP_NAME"] = "am-identity" env["APP_PORT"] = "8113" - return env + return apply_local_service_defaults(env) def subscription_env() -> dict[str, str]: env = os.environ.copy() - env.update(load_env_files()) + env.update(load_env_files(PLATFORM_ROOT / "am-subscription")) env["PYTHONPATH"] = os.pathsep.join(str(p) for p in SUBSCRIPTION_LIB_PATHS) env["APP_NAME"] = "am-subscription" env["APP_PORT"] = "8110" @@ -92,4 +132,14 @@ def subscription_env() -> dict[str, str]: if ".svc.cluster.local" in pg_host and not env.get("AM_SUBSCRIPTION_POSTGRES_HOST"): env["AM_SUBSCRIPTION_POSTGRES_HOST"] = "postgres.asrax.in" env.setdefault("AM_SUBSCRIPTION_POSTGRES_PORT", "8891") - return env + return apply_local_service_defaults(env) + + +def mcp_gateway_env() -> dict[str, str]: + env = os.environ.copy() + env.update(load_env_files(PLATFORM_ROOT / "am-mcp-gateway")) + env["PYTHONPATH"] = os.pathsep.join(str(p) for p in MCP_GATEWAY_LIB_PATHS) + env["APP_NAME"] = "am-mcp-gateway" + env["APP_PORT"] = "8120" + return apply_local_service_defaults(env) + diff --git a/automation/scripts/run_service.py b/automation/scripts/run_service.py index 0137f22..945e784 100644 --- a/automation/scripts/run_service.py +++ b/automation/scripts/run_service.py @@ -5,7 +5,7 @@ import subprocess import sys -from platform_env import PLATFORM_ROOT, identity_env, notification_env, subscription_env +from platform_env import PLATFORM_ROOT, identity_env, notification_env, subscription_env, mcp_gateway_env from uvicorn_runner import build_uvicorn_args @@ -43,14 +43,35 @@ def run_notification(*, reload: bool) -> int: ) +def run_mcp_gateway(*, reload: bool) -> int: + return run_uvicorn( + "app.main:app", + reload=reload, + env=mcp_gateway_env(), + cwd_name="am-mcp-gateway", + ) + + def main() -> None: if len(sys.argv) < 2: - print("Usage: run_service.py ") + print("Usage: run_service.py ") sys.exit(1) service = sys.argv[1] mode = sys.argv[2] if len(sys.argv) > 2 else "dev" - reload = mode == "dev" + reload = "dev" in mode or mode == "preprod" + + import os + if "preprod" in mode: + os.environ["APP_ENV"] = "preprod" + elif "prod" in mode: + os.environ["APP_ENV"] = "prod" + + import os + if "preprod" in mode: + os.environ["APP_ENV"] = "preprod" + elif "prod" in mode: + os.environ["APP_ENV"] = "prod" if service == "identity": sys.exit(run_identity(reload=reload)) @@ -58,10 +79,13 @@ def main() -> None: sys.exit(run_subscription(reload=reload)) if service == "notification": sys.exit(run_notification(reload=reload)) + if service == "mcp-gateway": + sys.exit(run_mcp_gateway(reload=reload)) print(f"Unknown service: {service}") sys.exit(1) + if __name__ == "__main__": main() diff --git a/automation/scripts/run_terraform.py b/automation/scripts/run_terraform.py index b72bd7c..6d90aaa 100644 --- a/automation/scripts/run_terraform.py +++ b/automation/scripts/run_terraform.py @@ -136,6 +136,104 @@ def _export_terraform_vars(target_folder: str) -> None: kubeconfig_abs = os.path.abspath(os.path.join(PLATFORM_ROOT, "..", "VPS", "kubeconfig.vps")) tf_vars["kubeconfig_path"] = kubeconfig_abs + elif target_folder == "ai-gateway": + updated = False + + litellm_key = merged.get("LITELLM_MASTER_KEY") + if not litellm_key or litellm_key.startswith("<"): + litellm_key = "sk-" + secrets.token_hex(24) + print("Generated new LITELLM_MASTER_KEY and appending to .secrets.env") + try: + with open(secrets_path, "a", encoding="utf-8") as f: + f.write(f"\n# Auto-generated LiteLLM master key\nLITELLM_MASTER_KEY={litellm_key}\n") + merged["LITELLM_MASTER_KEY"] = litellm_key + updated = True + except Exception as e: + print(f"Warning: Could not append LITELLM_MASTER_KEY: {e}") + + langfuse_pub = merged.get("LANGFUSE_PUBLIC_KEY") + if not langfuse_pub or langfuse_pub.startswith("<"): + langfuse_pub = "pk-lf-" + secrets.token_hex(16) + print("Generated new LANGFUSE_PUBLIC_KEY and appending to .secrets.env") + try: + with open(secrets_path, "a", encoding="utf-8") as f: + f.write(f"\n# Auto-generated Langfuse public key\nLANGFUSE_PUBLIC_KEY={langfuse_pub}\n") + merged["LANGFUSE_PUBLIC_KEY"] = langfuse_pub + updated = True + except Exception as e: + print(f"Warning: Could not append LANGFUSE_PUBLIC_KEY: {e}") + + langfuse_sec = merged.get("LANGFUSE_SECRET_KEY") + if not langfuse_sec or langfuse_sec.startswith("<"): + langfuse_sec = "sk-lf-" + secrets.token_hex(16) + print("Generated new LANGFUSE_SECRET_KEY and appending to .secrets.env") + try: + with open(secrets_path, "a", encoding="utf-8") as f: + f.write(f"\n# Auto-generated Langfuse secret key\nLANGFUSE_SECRET_KEY={langfuse_sec}\n") + merged["LANGFUSE_SECRET_KEY"] = langfuse_sec + updated = True + except Exception as e: + print(f"Warning: Could not append LANGFUSE_SECRET_KEY: {e}") + + langfuse_auth = merged.get("LANGFUSE_NEXTAUTH_SECRET") + if not langfuse_auth or langfuse_auth.startswith("<"): + langfuse_auth = secrets.token_hex(32) + print("Generated new LANGFUSE_NEXTAUTH_SECRET and appending to .secrets.env") + try: + with open(secrets_path, "a", encoding="utf-8") as f: + f.write(f"\n# Auto-generated Langfuse NextAuth secret\nLANGFUSE_NEXTAUTH_SECRET={langfuse_auth}\n") + merged["LANGFUSE_NEXTAUTH_SECRET"] = langfuse_auth + updated = True + except Exception as e: + print(f"Warning: Could not append LANGFUSE_NEXTAUTH_SECRET: {e}") + + langfuse_db = merged.get("LANGFUSE_DB_PASSWORD") + if not langfuse_db or langfuse_db.startswith("<"): + langfuse_db = secrets.token_hex(16) + print("Generated new LANGFUSE_DB_PASSWORD and appending to .secrets.env") + try: + with open(secrets_path, "a", encoding="utf-8") as f: + f.write(f"\n# Auto-generated Langfuse database password\nLANGFUSE_DB_PASSWORD={langfuse_db}\n") + merged["LANGFUSE_DB_PASSWORD"] = langfuse_db + updated = True + except Exception as e: + print(f"Warning: Could not append LANGFUSE_DB_PASSWORD: {e}") + + litellm_db = merged.get("LITELLM_DB_PASSWORD") + if not litellm_db or litellm_db.startswith("<"): + litellm_db = secrets.token_hex(16) + print("Generated new LITELLM_DB_PASSWORD and appending to .secrets.env") + try: + with open(secrets_path, "a", encoding="utf-8") as f: + f.write(f"\n# Auto-generated LiteLLM database password\nLITELLM_DB_PASSWORD={litellm_db}\n") + merged["LITELLM_DB_PASSWORD"] = litellm_db + updated = True + except Exception as e: + print(f"Warning: Could not append LITELLM_DB_PASSWORD: {e}") + + if updated: + merged = {**_load_env_file(env_path), **_load_env_file(secrets_path)} + + mapping = { + "LITELLM_MASTER_KEY": "litellm_master_key", + "DEEPSEEK_API_KEY": "deepseek_api_key", + "GOOGLE_API_KEY": "google_api_key", + "LANGFUSE_PUBLIC_KEY": "langfuse_public_key", + "LANGFUSE_SECRET_KEY": "langfuse_secret_key", + "LANGFUSE_NEXTAUTH_SECRET": "langfuse_nextauth_secret", + "LANGFUSE_HOST": "langfuse_host", + "LANGFUSE_DB_PASSWORD": "langfuse_db_password", + "LITELLM_DB_PASSWORD": "litellm_db_password", + "TOGETHER_API_KEY": "together_api_key" + } + for env_key, tf_key in mapping.items(): + val = merged.get(env_key) + if val and not val.startswith("<"): + tf_vars[tf_key] = val + + kubeconfig_abs = os.path.abspath(os.path.join(PLATFORM_ROOT, "..", "VPS", "kubeconfig.vps")) + tf_vars["kubeconfig_path"] = kubeconfig_abs + target_tf_dir = os.path.join(PLATFORM_ROOT, "automation", "terraform", target_folder) vars_path = os.path.join(target_tf_dir, "generated.auto.tfvars.json") @@ -192,14 +290,14 @@ def run(cmd: list[str], target_tf_dir: str): def main(): if len(sys.argv) < 3: - print("Usage: python run_terraform.py [keycloak|billing|notification] [init|plan|apply|output]") + print("Usage: python run_terraform.py [keycloak|billing|notification|ai-gateway] [init|plan|apply|output]") sys.exit(1) folder = sys.argv[1].lower() action = sys.argv[2].lower() - if folder not in ("keycloak", "billing", "notification"): - print(f"ERROR: Invalid folder '{folder}'. Must be 'keycloak', 'billing', or 'notification'.") + if folder not in ("keycloak", "billing", "notification", "ai-gateway"): + print("Usage: python run_terraform.py [keycloak|billing|notification|ai-gateway] [init|plan|apply|output]") sys.exit(1) target_tf_dir = os.path.join(PLATFORM_ROOT, "automation", "terraform", folder) diff --git a/automation/scripts/vault-sync.ps1 b/automation/scripts/vault-sync.ps1 index d1440d3..fa49b9e 100644 --- a/automation/scripts/vault-sync.ps1 +++ b/automation/scripts/vault-sync.ps1 @@ -138,6 +138,8 @@ Write-VaultSecret $path @{ GOOGLE_CLIENT_ID = $secrets["GOOGLE_CLIENT_ID"] GOOGLE_CLIENT_SECRET = $secrets["GOOGLE_CLIENT_SECRET"] ALLOWED_GOOGLE_REDIRECT_URIS = $secrets["ALLOWED_GOOGLE_REDIRECT_URIS"] + AM_MCP_CLIENT_ID = $secrets["AM_MCP_CLIENT_ID"] + AM_MCP_CLIENT_SECRET = $secrets["AM_MCP_CLIENT_SECRET"] } Test-VaultSecret $path diff --git a/automation/terraform/ai-gateway/.terraform.lock.hcl b/automation/terraform/ai-gateway/.terraform.lock.hcl new file mode 100644 index 0000000..006d50d --- /dev/null +++ b/automation/terraform/ai-gateway/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/helm" { + version = "2.17.0" + constraints = ">= 2.12.0, < 3.0.0" + hashes = [ + "h1:rsqAO9oKyDMLiysQqrWEzf9CNtU9NJtwEGk7bSItC9g=", + "zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4", + "zh:104eccfc781fc868da3c7fec4385ad14ed183eb985c96331a1a937ac79c2d1a7", + "zh:129345c82359837bb3f0070ce4891ec232697052f7d5ccf61d43d818912cf5f3", + "zh:3956187ec239f4045975b35e8c30741f701aa494c386aaa04ebabffe7749f81c", + "zh:66a9686d92a6b3ec43de3ca3fde60ef3d89fb76259ed3313ca4eb9bb8c13b7dd", + "zh:88644260090aa621e7e8083585c468c8dd5e09a3c01a432fb05da5c4623af940", + "zh:a248f650d174a883b32c5b94f9e725f4057e623b00f171936dcdcc840fad0b3e", + "zh:aa498c1f1ab93be5c8fbf6d48af51dc6ef0f10b2ea88d67bcb9f02d1d80d3930", + "zh:bf01e0f2ec2468c53596e027d376532a2d30feb72b0b5b810334d043109ae32f", + "zh:c46fa84cc8388e5ca87eb575a534ebcf68819c5a5724142998b487cb11246654", + "zh:d0c0f15ffc115c0965cbfe5c81f18c2e114113e7a1e6829f6bfd879ce5744fbb", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "3.2.0" + constraints = ">= 2.23.0" + hashes = [ + "h1:0rX4RXNq0jqXm0yMzrtZd/1W9QrDO0EOC+zkfDNxAfQ=", + "zh:2e33acc20154d96ce5b3ab6d5fa0407403759a0852c63276baed0bbef4dbf1d4", + "zh:38721fee7fa1857414942040291d930b3c3f2a979845a2ff66289a73ad9f17ff", + "zh:38ec0cb28383f0e50065a98e215f40edbfb93c202ad3140afd1590a93f1965aa", + "zh:74adb0c844e49cecda97869022c3dfd7929e532ccf7cf9dff6eee87255fa0a54", + "zh:843f70f10b296eaa9f847629ceb4c4b6439cabf13be722d81afadf45ced859fc", + "zh:b004807190af53c2f5236847e7b6ec3e086beba268c8a027fb70aaf93b75f2ae", + "zh:b8459ab1fe6d7cceef20b9c47cb5e94e041fd92aca808ff80381073bfa440042", + "zh:c8529299f1d92d20ec65e80f4d5d134f21e699a1ead40a63bd66163cbca05960", + "zh:c98a3dd8884abf3dc0f6d4eb9129e2d1849089ef17a55b90a73159d06c74dc70", + "zh:d1bc7c68bb1a2abbcda03bc37f13b61a149f4f86d194849400d614873445f749", + "zh:d97f761796c306fb15d77ada4ccb2413a721f0831a7e91e436d9db1ce8ee9e26", + "zh:eecf88b257b564a87a9b5c56a6bc10a5340c4ecec6ffd75f3a179e9e64e72107", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/automation/terraform/ai-gateway/main.tf b/automation/terraform/ai-gateway/main.tf new file mode 100644 index 0000000..1273823 --- /dev/null +++ b/automation/terraform/ai-gateway/main.tf @@ -0,0 +1,39 @@ +terraform { + required_version = ">= 1.5.0" + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.23.0" + } + helm = { + source = "hashicorp/helm" + version = ">= 2.12.0, < 3.0.0" + } + } +} + +provider "kubernetes" { + config_path = var.kubeconfig_path +} + +provider "helm" { + kubernetes { + config_path = var.kubeconfig_path + } +} + +module "ai_gateway" { + source = "../modules/ai-gateway" + + kubeconfig_path = var.kubeconfig_path + litellm_master_key = var.litellm_master_key + deepseek_api_key = var.deepseek_api_key + google_api_key = var.google_api_key + langfuse_public_key = var.langfuse_public_key + langfuse_secret_key = var.langfuse_secret_key + langfuse_nextauth_secret = var.langfuse_nextauth_secret + langfuse_host = var.langfuse_host + langfuse_db_password = var.langfuse_db_password + litellm_db_password = var.litellm_db_password + together_api_key = var.together_api_key +} diff --git a/automation/terraform/ai-gateway/outputs.tf b/automation/terraform/ai-gateway/outputs.tf new file mode 100644 index 0000000..716c5c6 --- /dev/null +++ b/automation/terraform/ai-gateway/outputs.tf @@ -0,0 +1,19 @@ +output "litellm_service" { + description = "LiteLLM internal service address" + value = "http://litellm.am-ai.svc.cluster.local:4000" +} + +output "langfuse_service" { + description = "Langfuse internal service address" + value = "http://langfuse.am-ai.svc.cluster.local:3000" +} + +output "qdrant_service" { + description = "Qdrant internal service address" + value = "http://qdrant.am-ai.svc.cluster.local:6333" +} + +output "mlflow_service" { + description = "MLflow internal service address" + value = "http://mlflow.am-ai.svc.cluster.local:5000" +} diff --git a/automation/terraform/ai-gateway/variables.tf b/automation/terraform/ai-gateway/variables.tf new file mode 100644 index 0000000..8a39dd4 --- /dev/null +++ b/automation/terraform/ai-gateway/variables.tf @@ -0,0 +1,68 @@ +variable "litellm_master_key" { + description = "Master API key for LiteLLM proxy" + type = string + sensitive = true +} + +variable "deepseek_api_key" { + description = "DeepSeek API key for LLM access" + type = string + sensitive = true + default = "" +} + +variable "google_api_key" { + description = "Google API key for Gemini" + type = string + sensitive = true + default = "" +} + +variable "langfuse_public_key" { + description = "Langfuse public key" + type = string + sensitive = true +} + +variable "langfuse_secret_key" { + description = "Langfuse secret key" + type = string + sensitive = true +} + +variable "langfuse_nextauth_secret" { + description = "NextAuth secret for Langfuse UI" + type = string + sensitive = true +} + +variable "langfuse_host" { + description = "Public URL for Langfuse (used in LiteLLM callback config)" + type = string + default = "https://langfuse.munish.org" +} + +variable "langfuse_db_password" { + description = "PostgreSQL password for Langfuse database" + type = string + sensitive = true +} + +variable "kubeconfig_path" { + description = "Path to kubeconfig file" + type = string + default = "kubeconfig.yaml" +} + +variable "litellm_db_password" { + description = "PostgreSQL password for LiteLLM database" + type = string + sensitive = true +} + +variable "together_api_key" { + description = "Together AI API key" + type = string + sensitive = true + default = "" +} diff --git a/automation/terraform/keycloak/deploy.ps1 b/automation/terraform/keycloak/deploy.ps1 index b867f12..54cb25b 100644 --- a/automation/terraform/keycloak/deploy.ps1 +++ b/automation/terraform/keycloak/deploy.ps1 @@ -51,12 +51,13 @@ terraform init -reconfigure # ── 2. Workspace ───────────────────────────────────────────────────────────── $existing = terraform workspace list 2>&1 -if ($existing -notmatch "\b$Env\b") { - Write-Host "`n>> Creating workspace: $Env" -ForegroundColor Yellow - terraform workspace new $Env -} else { +$existingClean = $existing | ForEach-Object { $_.Replace("*", "").Trim() } +if ($Env -in $existingClean) { Write-Host "`n>> Selecting workspace: $Env" -ForegroundColor Yellow terraform workspace select $Env +} else { + Write-Host "`n>> Creating workspace: $Env" -ForegroundColor Yellow + terraform workspace new $Env } # ── 3. Plan / Apply / Destroy ──────────────────────────────────────────────── @@ -74,7 +75,7 @@ if ($Destroy) { } else { Write-Host "`n>> terraform apply (env=$Env)" -ForegroundColor Green terraform apply -var-file="$VarFile" -auto-approve - Write-Host "`n[Keycloak Deploy] Done — workspace '$Env' applied successfully." -ForegroundColor Green + Write-Host "`n[Keycloak Deploy] Done - workspace '$Env' applied successfully." -ForegroundColor Green # ── 4. Emit OIDC issuer URL for quick sanity-check ──────────────────────── $RealmName = (terraform output -raw realm_name 2>$null) diff --git a/automation/terraform/keycloak/generated.auto.tfvars.json b/automation/terraform/keycloak/generated.auto.tfvars.json deleted file mode 100644 index fb79a9e..0000000 --- a/automation/terraform/keycloak/generated.auto.tfvars.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "google_client_id": "307768822337-ad7tee4d82cc0b4flgrfs157e5e6rc0g.apps.googleusercontent.com", - "google_client_secret": "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", - "keycloak_url": "https://auth.munish.org/auth", - "keycloak_admin_username": "admin", - "keycloak_admin_password": "adminpassword123", - "realm_name": "am-realm" -} \ No newline at end of file diff --git a/automation/terraform/keycloak/main.tf b/automation/terraform/keycloak/main.tf index dcbcc62..dec5d0e 100644 --- a/automation/terraform/keycloak/main.tf +++ b/automation/terraform/keycloak/main.tf @@ -35,7 +35,7 @@ resource "keycloak_realm" "am_realm" { # Session TTLs (recommended baseline) sso_session_idle_timeout = "30m" sso_session_max_lifespan = "10h" - access_token_lifespan = "5m" + access_token_lifespan = "24h" # Password policy: min length + mixed case + digits password_policy = "length(8) and lowerCase(1) and upperCase(1) and digits(1)" @@ -845,3 +845,73 @@ resource "keycloak_openid_client_scope" "am_user_scope" { name = "am-user" description = "Standard AM user claims: profile, email, roles, and platform" } + +# ========================================================= +# 7. USER ID PROTOCOL MAPPERS +# ========================================================= + +resource "keycloak_openid_user_property_protocol_mapper" "web_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_web_client.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + +resource "keycloak_openid_user_property_protocol_mapper" "diagnostic_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_diagnostic_client.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + +resource "keycloak_openid_user_property_protocol_mapper" "identity_service_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_identity_service.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + +resource "keycloak_openid_user_property_protocol_mapper" "android_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_android_client.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + +resource "keycloak_openid_user_property_protocol_mapper" "ios_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_ios_client.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + diff --git a/automation/terraform/modules/ai-gateway/litellm_config.yaml.tpl b/automation/terraform/modules/ai-gateway/litellm_config.yaml.tpl new file mode 100644 index 0000000..d9400ca --- /dev/null +++ b/automation/terraform/modules/ai-gateway/litellm_config.yaml.tpl @@ -0,0 +1,50 @@ +# LiteLLM routing configuration template +model_list: + - model_name: deepseek-chat + litellm_params: + model: deepseek/deepseek-chat + api_key: "os.environ/DEEPSEEK_API_KEY" + - model_name: gemini-1.5-pro + litellm_params: + model: gemini/gemini-1.5-pro + api_key: "os.environ/GOOGLE_API_KEY" + - model_name: gemini-1.5-flash + litellm_params: + model: gemini/gemini-1.5-flash + api_key: "os.environ/GOOGLE_API_KEY" + - model_name: together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite + litellm_params: + model: together_ai/meta-llama/Meta-Llama-3-8B-Instruct-Lite + api_key: "os.environ/TOGETHER_API_KEY" + - model_name: Qwen/Qwen3-VL-8B-Instruct + litellm_params: + model: together_ai/Qwen/Qwen3-VL-8B-Instruct + api_key: "os.environ/TOGETHER_API_KEY" + +litellm_settings: + drop_params: true + set_verbose: false + success_callback: ["langfuse"] + failure_callback: ["langfuse"] + +general_settings: + master_key: "os.environ/LITELLM_MASTER_KEY" + # Required for LiteLLM Admin UI to show request/response on Logs page. + store_model_in_db: true + store_prompts_in_spend_logs: true + +environment_variables: + LANGFUSE_HOST: "${langfuse_host}" + LANGFUSE_PUBLIC_KEY: "os.environ/LANGFUSE_PUBLIC_KEY" + LANGFUSE_SECRET_KEY: "os.environ/LANGFUSE_SECRET_KEY" + +# MCP tools — OpenAPI auto-register from am-mcp-gateway (manifest sync sets allowed_tools) +mcp_servers: + am_mcp_gateway: + transport: http + url: http://am-mcp-gateway.am-apps-preprod.svc.cluster.local:8120 + spec_path: http://am-mcp-gateway.am-apps-preprod.svc.cluster.local:8120/openapi.json + allow_all_keys: true + allowed_tools: + - am_mcp_gateway-run_modern_ui_auth_test + description: "AM MCP Gateway — UI test tools from module testing manifests" diff --git a/automation/terraform/modules/ai-gateway/main.tf b/automation/terraform/modules/ai-gateway/main.tf new file mode 100644 index 0000000..bb46f0d --- /dev/null +++ b/automation/terraform/modules/ai-gateway/main.tf @@ -0,0 +1,156 @@ +# ============================================================ +# Terraform Module: ai-gateway +# Provisions the full AM AI infrastructure in Kubernetes: +# - am-ai namespace +# - LiteLLM proxy (OpenAI-compatible LLM router) +# - Langfuse (LLM observability + tracing) +# - Qdrant (vector store) +# - MLflow (experiment tracking) +# - Ollama (local Qwen2.5-VL vision model) +# ============================================================ + +terraform { + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.23.0" + } + helm = { + source = "hashicorp/helm" + version = ">= 2.12.0" + } + } +} + +# ── Namespace ──────────────────────────────────────────────── +resource "kubernetes_namespace" "am_ai" { + metadata { + name = "am-ai" + labels = { + "app.kubernetes.io/managed-by" = "terraform" + "am.platform/tier" = "ai" + } + } +} + +# ── LiteLLM Secret ─────────────────────────────────────────── +resource "kubernetes_secret" "litellm_secrets" { + metadata { + name = "litellm-secrets" + namespace = kubernetes_namespace.am_ai.metadata[0].name + } + data = { + LITELLM_MASTER_KEY = var.litellm_master_key + DEEPSEEK_API_KEY = var.deepseek_api_key + GOOGLE_API_KEY = var.google_api_key + LANGFUSE_PUBLIC_KEY = var.langfuse_public_key + LANGFUSE_SECRET_KEY = var.langfuse_secret_key + TOGETHER_API_KEY = var.together_api_key + DATABASE_URL = "postgresql://litellm:${var.litellm_db_password}@postgresql.infra.svc.cluster.local:5432/litellm" + username = "litellm" + password = var.litellm_db_password + masterkey = var.litellm_master_key + } +} + +# ── LiteLLM ConfigMap (model routing config) ───────────────── +resource "kubernetes_config_map" "litellm_config" { + metadata { + name = "litellm-config" + namespace = kubernetes_namespace.am_ai.metadata[0].name + } + data = { + "litellm_config.yaml" = templatefile("${path.module}/litellm_config.yaml.tpl", { + langfuse_host = var.langfuse_host + }) + } +} + +# ── LiteLLM Helm Release ───────────────────────────────────── +resource "helm_release" "litellm" { + name = "litellm" + chart = "oci://ghcr.io/berriai/litellm-helm" + version = "1.88.1" + namespace = kubernetes_namespace.am_ai.metadata[0].name + + values = [file("${path.module}/../../../helm/ai-gateway/litellm-values.yaml")] + + set { + name = "environmentSecrets[0]" + value = kubernetes_secret.litellm_secrets.metadata[0].name + } + + depends_on = [kubernetes_namespace.am_ai, kubernetes_config_map.litellm_config] +} + +# ── Langfuse Helm Release ──────────────────────────────────── +resource "helm_release" "langfuse" { + name = "langfuse" + repository = "https://langfuse.github.io/langfuse-k8s" + chart = "langfuse" + version = "1.0.0" + namespace = kubernetes_namespace.am_ai.metadata[0].name + + values = [file("${path.module}/../../../helm/ai-gateway/langfuse-values.yaml")] + + set_sensitive { + name = "langfuse.nextauth.secret.value" + value = var.langfuse_nextauth_secret + } + + # External Postgres: pass all individual fields so the chart builds DATABASE_URL correctly + set { + name = "postgresql.host" + value = "postgresql.infra.svc.cluster.local" + } + set { + name = "postgresql.port" + value = "5432" + } + set { + name = "postgresql.auth.username" + value = "langfuse" + } + set_sensitive { + name = "postgresql.auth.password" + value = var.langfuse_db_password + } + set { + name = "postgresql.auth.database" + value = "langfuse" + } + # directUrl is used for migrations only — keep it for prisma migrate + set_sensitive { + name = "postgresql.directUrl" + value = "postgresql://langfuse:${var.langfuse_db_password}@postgresql.infra.svc.cluster.local:5432/langfuse" + } + + depends_on = [kubernetes_namespace.am_ai] +} + +# ── Qdrant StatefulSet (via Helm) ──────────────────────────── +resource "helm_release" "qdrant" { + name = "qdrant" + repository = "https://qdrant.github.io/qdrant-helm" + chart = "qdrant" + version = "0.9.1" + namespace = kubernetes_namespace.am_ai.metadata[0].name + + values = [file("${path.module}/../../../helm/ai-gateway/qdrant-values.yaml")] + + depends_on = [kubernetes_namespace.am_ai] +} + +# ── MLflow Helm Release ────────────────────────────────────── +resource "helm_release" "mlflow" { + name = "mlflow" + repository = "https://community-charts.github.io/helm-charts" + chart = "mlflow" + version = "0.7.19" + namespace = kubernetes_namespace.am_ai.metadata[0].name + + values = [file("${path.module}/../../../helm/ai-gateway/mlflow-values.yaml")] + + depends_on = [kubernetes_namespace.am_ai] +} + diff --git a/automation/terraform/modules/ai-gateway/variables.tf b/automation/terraform/modules/ai-gateway/variables.tf new file mode 100644 index 0000000..8a39dd4 --- /dev/null +++ b/automation/terraform/modules/ai-gateway/variables.tf @@ -0,0 +1,68 @@ +variable "litellm_master_key" { + description = "Master API key for LiteLLM proxy" + type = string + sensitive = true +} + +variable "deepseek_api_key" { + description = "DeepSeek API key for LLM access" + type = string + sensitive = true + default = "" +} + +variable "google_api_key" { + description = "Google API key for Gemini" + type = string + sensitive = true + default = "" +} + +variable "langfuse_public_key" { + description = "Langfuse public key" + type = string + sensitive = true +} + +variable "langfuse_secret_key" { + description = "Langfuse secret key" + type = string + sensitive = true +} + +variable "langfuse_nextauth_secret" { + description = "NextAuth secret for Langfuse UI" + type = string + sensitive = true +} + +variable "langfuse_host" { + description = "Public URL for Langfuse (used in LiteLLM callback config)" + type = string + default = "https://langfuse.munish.org" +} + +variable "langfuse_db_password" { + description = "PostgreSQL password for Langfuse database" + type = string + sensitive = true +} + +variable "kubeconfig_path" { + description = "Path to kubeconfig file" + type = string + default = "kubeconfig.yaml" +} + +variable "litellm_db_password" { + description = "PostgreSQL password for LiteLLM database" + type = string + sensitive = true +} + +variable "together_api_key" { + description = "Together AI API key" + type = string + sensitive = true + default = "" +} diff --git a/automation/terraform/modules/keycloak/main.tf b/automation/terraform/modules/keycloak/main.tf index 7fd3bdf..f448296 100644 --- a/automation/terraform/modules/keycloak/main.tf +++ b/automation/terraform/modules/keycloak/main.tf @@ -27,7 +27,7 @@ resource "keycloak_realm" "am_realm" { # Session TTLs (recommended baseline) sso_session_idle_timeout = "30m" sso_session_max_lifespan = "10h" - access_token_lifespan = "5m" + access_token_lifespan = "24h" # Password policy: min length + mixed case + digits password_policy = "length(8) and lowerCase(1) and upperCase(1) and digits(1)" @@ -837,3 +837,73 @@ resource "keycloak_openid_client_scope" "am_user_scope" { name = "am-user" description = "Standard AM user claims: profile, email, roles, and platform" } + +# ========================================================= +# 7. USER ID PROTOCOL MAPPERS +# ========================================================= + +resource "keycloak_openid_user_property_protocol_mapper" "web_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_web_client.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + +resource "keycloak_openid_user_property_protocol_mapper" "diagnostic_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_diagnostic_client.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + +resource "keycloak_openid_user_property_protocol_mapper" "identity_service_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_identity_service.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + +resource "keycloak_openid_user_property_protocol_mapper" "android_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_android_client.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + +resource "keycloak_openid_user_property_protocol_mapper" "ios_userid_mapper" { + realm_id = keycloak_realm.am_realm.id + client_id = keycloak_openid_client.am_ios_client.id + name = "userId-mapper" + user_property = "id" + claim_name = "userId" + claim_value_type = "String" + + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true +} + diff --git a/docs/AM_AI_PLATFORM_DESIGN.md b/docs/AM_AI_PLATFORM_DESIGN.md new file mode 100644 index 0000000..054f3d1 --- /dev/null +++ b/docs/AM_AI_PLATFORM_DESIGN.md @@ -0,0 +1,473 @@ +# AM AI Platform — Master Architecture & Technical Design Specification +> **Role:** Technical Architect & Software Developer +> **Status:** Proposal for Approval | **Target Repositories:** `am-platform`, `am-core-services`, `am-ui-test-agent` (New) + +This specification defines the complete technical design, module-level interfaces, class signatures, security validation filters, error mappings, and verification procedures for the AM AI Platform. It serves as the single source of truth for the multi-repository development. + +--- + +## 1. System Topology & Communication Interfaces + +The system maintains a clean separation of concerns across three microservices, communicating via standard REST, SSE (Server-Sent Events), and the MCP (Model Context Protocol). + +```mermaid +graph TD + Client[Flutter Web App / API Client] -- HTTP/SSE (Port 8120) --> Gateway[am-mcp-gateway] + + subgraph Gateway Observability & Resilience + Gateway -- Async logging --> Langfuse[Langfuse Tracing :3000] + Gateway -- Async metrics --> MLflow[MLflow Server :5000] + Gateway -- Read/Write Cache --> Redis[Redis Server :6379] + end + + Gateway -- SSE / JSON-RPC --> MCPServer[am-mcp-server :8080] + + subgraph Tool Executions (am-mcp-server) + MCPServer -- Read-Only Query --> Postgres[(PostgreSQL DB :5432)] + MCPServer -- Read-Only Pipeline --> Mongo[(MongoDB :27017)] + MCPServer -- Flux Metric Query --> Influx[(InfluxDB :8086)] + MCPServer -- Read-Only Commands --> RedisDB[(Redis DB :6379)] + MCPServer -- SDK Call --> OtherServices[Core Portfolio/Trade APIs] + end + + subgraph Autonomous Testing (am-ui-test-agent :8130) + TestAgent[LangGraph Agent] -- Playwright --> Client + TestAgent -- Direct Vision API --> Together[Together AI: Qwen2.5-VL] + TestAgent -- Context / Element lookup --> Qdrant[(Qdrant Vector Store :6333)] + end +``` + +### Protocol & Port Registry +| Component | Port | Interface Protocol | Namespace | Authentication | +|---|---|---|---|---| +| `am-mcp-gateway` | `8120` | HTTP / SSE | `am-apps-preprod` | Keycloak JWT Bearer | +| `am-mcp-server` | `8080` | HTTP / SSE (MCP) | `am-apps-preprod` | Internal static token / m2m | +| `am-ui-test-agent`| `8130` | HTTP / JSON | `am-apps-preprod` | Keycloak JWT Bearer | +| `Langfuse` | `3000` | HTTP / JSON | `am-ai` | Public/Secret API Key | +| `MLflow` | `5000` | HTTP / JSON | `am-ai` | Public Access | +| `Qdrant` | `6333` | HTTP / gRPC | `am-ai` | Optional API Key | + +--- + +## 2. Component A: `am-mcp-gateway` (FastAPI) + +Located at [am-platform/am-mcp-gateway/](file:///a:/InfraCode/AM-Portfolio-grp/am-platform/am-mcp-gateway/), this gateway coordinates client connections, handles failover logic, sanitizes tokens, and buffers model telemetry. + +### 2.1 Detailed Class & File Specifications + +#### 1. Security Module +* **File:** `app/security/jwks_cache.py` + * **Class:** `JWKSCache` + * Exposes an asynchronous client to fetch and parse Keycloak public certificates. + * Uses a local cache with a 5-minute TTL to verify JWT signatures rapidly. + ```python + class JWKSCache: + def __init__(self, jwks_url: str, cache_ttl: int = 300): + self.jwks_url = jwks_url + self.cache_ttl = cache_ttl + self._keys: dict = {} + self._last_fetched: float = 0.0 + + async def get_public_key(self, kid: str) -> dict: + """Fetches JWKS keys and returns the matching PEM key for 'kid'.""" + pass + ``` +* **File:** `app/security/jwt_bearer.py` + * **Class:** `JWTBearer` (inherits from FastAPI `HTTPBearer`) + * Verifies that incoming requests contain a valid Keycloak JWT. + ```python + class JWTBearer(HTTPBearer): + def __init__(self, auto_error: bool = True): + super().__init__(auto_error=auto_error) + + async def __call__(self, request: Request) -> TokenPayload: + """Extracts, decodes, and validates claims of the token.""" + pass + ``` + +#### 2. LLM Resiliency & Failover Module +* **File:** `app/llm/circuit_breaker.py` + * **Class:** `CircuitBreaker` + * Implements circuit breaker pattern for each downstream LLM provider. + ```python + class CircuitState(Enum): + CLOSED = "CLOSED" + OPEN = "OPEN" + HALF_OPEN = "HALF_OPEN" + + class CircuitBreaker: + def __init__(self, provider: str, failure_threshold: int = 5, recovery_timeout: int = 30): + self.provider = provider + self.failure_threshold = failure_threshold + self.recovery_timeout = recovery_timeout + self.state = CircuitState.CLOSED + self.failures = 0 + self.last_state_change = time.time() + + def record_success(self): + self.failures = 0 + self.state = CircuitState.CLOSED + + def record_failure(self): + self.failures += 1 + if self.failures >= self.failure_threshold: + self.state = CircuitState.OPEN + self.last_state_change = time.time() + + def allow_request(self) -> bool: + """Checks if request is permitted based on circuit state.""" + if self.state == CircuitState.CLOSED: + return True + if self.state == CircuitState.OPEN: + if time.time() - self.last_state_change > self.recovery_timeout: + self.state = CircuitState.HALF_OPEN + return True + return False + return True # HALF_OPEN state allows a single try + ``` +* **File:** `app/llm/router.py` + * **Class:** `LLMRouter` + * Resolves active configuration settings and coordinates downstream call retries. + ```python + class LLMRouter: + def __init__(self, fallback_chain: List[str]): + self.fallback_chain = fallback_chain + self.breakers = {provider: CircuitBreaker(provider) for provider in fallback_chain} + + async def generate_chat_stream(self, request: ChatRequest) -> AsyncIterator[str]: + """Loops through the fallback chain and yields streamed text chunks.""" + pass + ``` + +#### 3. Redis Response Caching +* **File:** `app/cache/response_cache.py` + * **Class:** `ResponseCache` + * Performs deterministic hashing of inputs. + ```python + class ResponseCache: + def __init__(self, redis_url: str, default_ttl: int = 300): + self.redis_client = redis.from_url(redis_url) + self.default_ttl = default_ttl + + def build_key(self, user_id: str, prompt: str, model: str) -> str: + sanitized = prompt.strip().lower() + h = hashlib.sha256(f"{user_id}:{sanitized}:{model}".encode()).hexdigest() + return f"mcp:cache:{h}" + + async def get(self, key: str) -> Optional[str]: + return await self.redis_client.get(key) + + async def set(self, key: str, value: str, ttl: Optional[int] = None): + await self.redis_client.set(key, value, ex=ttl or self.default_ttl) + ``` + +### 2.2 Endpoint Schema Specifications + +#### 1. Chat Completion Endpoint +* **Path:** `POST /api/v1/chat` +* **Headers:** + ```http + Authorization: Bearer + Content-Type: application/json + Accept: text/event-stream + ``` +* **Request JSON Schema:** + ```json + { + "type": "object", + "properties": { + "message": { "type": "string" }, + "model": { "type": "string", "default": "deepseek-chat" }, + "temperature": { "type": "number", "minimum": 0.0, "maximum": 2.0, "default": 0.2 }, + "stream": { "type": "boolean", "default": true } + }, + "required": ["message"] + } + ``` +* **Stream Response Format (SSE):** + ```http + data: {"chunk": "token", "model": "deepseek-chat"} + + data: {"chunk": " ", "model": "deepseek-chat"} + + data: {"chunk": "[DONE]", "model": "deepseek-chat"} + ``` + +--- + +## 3. Component B: `am-mcp-server` (Java Spring Boot) + +Located at [services/am-mcp-server](file:///a:/InfraCode/AM-Portfolio-grp/am-core-services/services/am-mcp-server), this server acts as the primary engine for tool execution. + +### 3.1 Swapping Transport Type to SSE +We switch the transport settings in [application.yaml](file:///a:/InfraCode/AM-Portfolio-grp/am-core-services/services/am-mcp-server/src/main/resources/application.yaml): +```yaml +spring: + ai: + mcp: + server: + name: am-platform-mcp + version: 1.0.0 + transport-type: SSE # Swapped from STDIO +``` +By selecting `SSE`, Spring AI auto-exposes: +* `GET /sse` — Establishes client Server-Sent Events flow. +* `POST /mcp/message` — Endpoint for receiving client JSON-RPC executions. + +--- + +### 3.2 Strategy Pattern & UniversalDbTool Integration with Knowledge Graph + +To optimize latency, accuracy, and coupling, we decouple the query generation from raw LLM calls. The system leverages an existing **Knowledge Graph Service** that maps natural language questions semantically to the correct database types, database names, and execution commands. The Spring Boot server acts as the secure executor. + +```mermaid +classDiagram + class UniversalDbTool { + -KnowledgeGraphService kgService + -DbStrategyRegistry strategyRegistry + +queryDatabase(String question) String + } + class KnowledgeGraphService { + <> + +resolve(String question) ResolvedQueryCommand + } + class ResolvedQueryCommand { + +DbType dbType + +String databaseName + +String command + } + class DbStrategyRegistry { + +getStrategy(DbType type) DbQueryStrategy + } + class DbQueryStrategy { + <> + +supports(DbType type) boolean + +execute(String database, String query) QueryResult + } + class PostgresQueryStrategy { + -JdbcTemplate jdbcTemplate + -QuerySafetyValidator validator + +execute(String database, String query) QueryResult + } + class MongoQueryStrategy { + -MongoTemplate mongoTemplate + +execute(String database, String query) QueryResult + } + + UniversalDbTool --> KnowledgeGraphService + UniversalDbTool --> DbStrategyRegistry + DbStrategyRegistry --> DbQueryStrategy + PostgresQueryStrategy ..|> DbQueryStrategy + MongoQueryStrategy ..|> DbQueryStrategy +``` + +#### 1. Resolved Command DTO & Service Interface +```java +package com.am.mcp.db.model; + +import com.am.mcp.db.DbType; + +public class ResolvedQueryCommand { + private DbType dbType; + private String databaseName; + private String command; + + public ResolvedQueryCommand(DbType dbType, String databaseName, String command) { + this.dbType = dbType; + this.databaseName = databaseName; + this.command = command; + } + + public DbType getDbType() { return dbType; } + public String getDatabaseName() { return databaseName; } + public String getCommand() { return command; } +} +``` + +```java +package com.am.mcp.db; + +import com.am.mcp.db.model.ResolvedQueryCommand; + +public interface KnowledgeGraphService { + /** + * Translates a natural language question into database, type, and command + * using the semantic Knowledge Graph. + */ + ResolvedQueryCommand resolve(String question); +} +``` + +#### 2. SQL AST Safety Validator (`QuerySafetyValidator.java`) +Uses `JSqlParser` to build a complete AST representation of the generated query and filters non-SELECT clauses. +```java +package com.am.mcp.db; + +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.Select; + +public class QuerySafetyValidator { + + public static void validatePostgresQuery(String sql) throws SecurityException { + try { + Statement stmt = CCJSqlParserUtil.parse(sql); + if (!(stmt instanceof Select)) { + throw new SecurityException("Forbidden Statement. Only SELECT queries are permitted."); + } + + String normalized = sql.toLowerCase(); + if (normalized.contains("pg_") || normalized.contains("information_schema")) { + throw new SecurityException("Catalog Access Forbidden. Reading system tables is blocked."); + } + } catch (Exception e) { + throw new SecurityException("Malformed SQL query: " + e.getMessage()); + } + } +} +``` + +#### 3. Redis Lettuce Whitelist Checks (`RedisQueryStrategy.java`) +```java +package com.am.mcp.db.strategies; + +import com.am.mcp.db.DbQueryStrategy; +import com.am.mcp.db.DbType; +import com.am.mcp.db.model.QueryResult; +import java.util.Set; + +public class RedisQueryStrategy implements DbQueryStrategy { + private static final Set ALLOWED_COMMANDS = Set.of( + "GET", "HGET", "KEYS", "LRANGE", "SMEMBERS", "TTL", "SCAN" + ); + + @Override + public boolean supports(DbType type) { + return type == DbType.REDIS; + } + + @Override + public QueryResult execute(String database, String commandString) throws SecurityException { + String baseCommand = commandString.trim().split(" ")[0].toUpperCase(); + if (!ALLOWED_COMMANDS.contains(baseCommand)) { + throw new SecurityException("Command " + baseCommand + " is forbidden on Redis."); + } + // Logic for execution via Lettuce Client goes here + return new QueryResult(commandString, "Redis execution result placeholder"); + } +} +``` + +--- + +## 4. Component C: `am-ui-test-agent` (Python + Playwright) + +An autonomous testing engine that runs visual and exploratory tests against the Flutter web app. + +### 4.1 LangGraph State & Transitions +The testing cycle is implemented as a state machine using LangGraph. + +```mermaid +stateDiagram-v2 + [*] --> Start + Start --> PlanNode : Read Test Specification + PlanNode --> ExecuteNode : Generate Step List + ExecuteNode --> AssertNode : Perform Action + AssertNode --> ReportNode : Success (All steps done) + AssertNode --> SelfHealNode : Failure (Selector missing) + SelfHealNode --> ExecuteNode : Selector Corrected + ReportNode --> [*] +``` + +#### State Definition (`state.py`) +```python +from typing import TypedDict, List, Dict, Any + +class AgentState(TypedDict): + target_url: str + specification: str + steps: List[str] + current_step_index: int + selectors_db: Dict[str, str] + failures_encountered: List[Dict[str, Any]] + screenshot_history: List[str] + report_output: str +``` + +#### Together AI Qwen2.5-VL Vision Endpoint Call Spec +To detect element offsets from screenshots without local model execution: +* **API Endpoint:** `POST https://api.together.xyz/v1/chat/completions` +* **JSON Payload Spec:** +```json +{ + "model": "Qwen/Qwen2.5-VL-7B-Instruct", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Identify the bounding box [ymin, xmin, ymax, xmax] normalized to 0-1000 for the button labeled 'Sign In'." + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,iVBORw0KG..." + } + } + ] + } + ] +} +``` + +### 4.2 Qdrant Collection Structures +Coordinates, visual structures, and element attributes are indexed across four collections: + +| Collection Name | Key Payload Details | Vector Dimension | Metric Type | +|---|---|---|---| +| `ui_patterns` | `page_route`, `visual_signature` | 512 (CLIP Image Encoder) | Cosine | +| `test_cases` | `spec_text`, `steps_json`, `status` | 1536 (DeepSeek Text) | Cosine | +| `selectors` | `element_role`, `xpath`, `coordinates`| 1536 (DeepSeek Text) | Cosine | +| `bug_memory` | `screenshot_base64`, `stack_trace` | 512 (CLIP Image Encoder) | Cosine | + +--- + +## 5. System Error Mapping & Resiliency Strategy + +### Comprehensive Error Handling Mapping +| Scenario | Detection Root Cause | HTTP Status | Response Payload | Recoverability Action | +|---|---|---|---|---| +| DeepSeek Timeout | Client wait > 60 seconds | `504 Gateway Timeout` | `{"error": "Timeout", "retry_after": 5}` | Route next request to Gemini | +| Full Outage | All model APIs return 5xx | `503 Service Unavailable`| `{"error": "Models Unavailable"}` | Offer default canned response from cache | +| Invalid Auth Token | JWKS public key mismatch | `401 Unauthorized` | `{"error": "token_invalid"}` | Clear signature cache, attempt single refetch | +| SQL DML attempt | AST validation failure | `403 Forbidden` | `{"error": "Forbidden query modification"}` | Terminate request, block agent action | +| Mongo aggregations loop | Aggregation pipeline contains `$where` | `403 Forbidden` | `{"error": "Forbidden aggregate operator"}`| Terminate request, block agent action | +| MCP Service Offline | Gateway call connection refused | `200 OK` | `{"response": "Chat logic only (Tools Offline)"}`| Disable tool call routing for 30s | + +--- + +## 6. Phase-Wise Development Checklist + +### Phase 1: `am-mcp-gateway` (FastAPI) +- [ ] Implement `app/config.py` loading environments. +- [ ] Implement `app/security/jwks_cache.py` and Keycloak Token verification. +- [ ] Implement `app/llm/circuit_breaker.py` circuit-breaking state logic. +- [ ] Implement `app/llm/router.py` failover sequence. +- [ ] Implement `app/cache/response_cache.py` with Redis caching. +- [ ] Implement `app/observability/langfuse_tracer.py` async callbacks. +- [ ] Implement `app/api/chat_router.py` endpoints. + +### Phase 2: `UniversalDbTool` (Spring Boot) +- [ ] Switch `spring.ai.mcp.server.transport-type: SSE` in `application.yaml`. +- [ ] Implement `QuerySafetyValidator.java` with JSqlParser selectors. +- [ ] Implement `PostgresQueryStrategy.java`, `MongoQueryStrategy.java`, `InfluxQueryStrategy.java`, and `RedisQueryStrategy.java`. +- [ ] Implement `DbStrategyRegistry.java` auto-binding. +- [ ] Implement `UniversalDbTool.java` `@Tool` endpoint. + +### Phase 3: `am-ui-test-agent` (Playwright) +- [ ] Scaffold `am-ui-test-agent` directories. +- [ ] Implement Playwright Browser lifecycle interface. +- [ ] Setup Together AI API Qwen2.5-VL connector. +- [ ] Bind Qdrant memory collection CRUD tools. +- [ ] Program LangGraph agent sequence workflow. diff --git a/docs/AM_AI_PLATFORM_PLAN.md b/docs/AM_AI_PLATFORM_PLAN.md new file mode 100644 index 0000000..23d2dec --- /dev/null +++ b/docs/AM_AI_PLATFORM_PLAN.md @@ -0,0 +1,637 @@ +# AM AI Platform — Enterprise Development Plan +> **Version:** 2.0 | **Date:** June 2026 | **Status:** Final +> **Principles:** Loosely Coupled · Low Latency · Observable · Safe · Scalable + +--- + +## Design Goals + +| Goal | Target | +|---|---| +| **Latency** | p50 < 800ms, p99 < 3s for LLM calls | +| **Coupling** | No service knows another's internal structure | +| **Availability** | Gateway stays up even if LLM API is down | +| **Safety** | All DB queries read-only, validated before execution | +| **Observability** | Every request traced end-to-end in Langfuse | +| **Scalability** | Each component scales independently | + +--- + +## Architecture — Loosely Coupled Design + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ AM Platform │ +│ │ +│ Client (Flutter/Service) │ +│ │ │ +│ ▼ HTTP/SSE (streaming) │ +│ ┌────────────────────────────────────────┐ │ +│ │ am-mcp-gateway :8120 │ │ +│ │ (Python FastAPI) │ │ +│ │ │ │ +│ │ ┌─────────┐ ┌──────────┐ ┌───────┐ │ │ +│ │ │ JWT │ │ LLM │ │ Cache │ │ │ +│ │ │ Auth │ │ Router │ │ Redis │ │ │ +│ │ └─────────┘ └──────────┘ └───────┘ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ (async, streamed) │ │ +│ │ ┌──────────────────────────────────┐ │ │ +│ │ │ Langfuse + MLflow │ │ │ +│ │ │ (non-blocking side-car) │ │ │ +│ │ └──────────────────────────────────┘ │ │ +│ └──────────────┬──────────┬──────────────┘ │ +│ │ │ │ +│ Direct │ │ Via Kafka (async tool results) │ +│ HTTP │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────┐ │ +│ │ am-mcp-server :8080 │ │ +│ │ (Java Spring AI) │ │ +│ │ │ │ +│ │ PortfolioTools MarketTools │ │ +│ │ TradeTools AnalysisTools │ │ +│ │ AiAgentTools UniversalDbTool 🆕 │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ am-ui-test-agent :8130 │ (Phase 3) │ +│ │ Playwright + Qwen-VL + Qdrant │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + +LLM Fallback Chain (auto-failover): + DeepSeek → Gemini → OpenAI + (circuit breaker per provider) +``` + +--- + +## Latency Optimisation Strategy + +### Problem: LLM calls are slow (1–30s) +### Solution: Stream everything + cache what you can + +| Technique | Where | Latency saving | +|---|---|---| +| **SSE Streaming** | Gateway → Client | User sees first token in ~300ms instead of waiting 5s | +| **JWKS Cache** | Gateway security | -200ms (no Keycloak roundtrip per request) | +| **Response Cache** | Redis (repeated queries) | -100% latency on cache hit | +| **Prompt Cache** | DeepSeek supports it | -30% tokens billed, faster response | +| **Connection Pool** | LLM HTTP client | -50ms connection setup per request | +| **Async Observability** | Langfuse/MLflow fire-and-forget | -0ms added to hot path | +| **Parallel Tool Calls** | am-mcp-server | Run independent tools concurrently | +| **Keep-Alive** | LLM API connections | -100ms TLS handshake | + +### Streaming Response Flow +``` +Client Gateway DeepSeek API + │ │ │ + │── POST /chat ────────►│ │ + │ │── POST (stream=true) ►│ + │ │ │ + │◄── SSE: token 1 ─────│◄── chunk 1 ──────────│ + │◄── SSE: token 2 ─────│◄── chunk 2 ──────────│ + │◄── SSE: token 3 ─────│◄── chunk 3 ──────────│ + │◄── SSE: [DONE] ───────│◄── [DONE] ───────────│ +``` +Client renders text as it arrives — no perceived wait. + +### Caching Strategy +``` +Request → Check Redis cache (TTL 5 min) + → HIT: return cached response (< 5ms) + → MISS: call LLM, store result, return (1–10s) + +Cache key: hash(user_id + message + model) +Do NOT cache: queries with "today", "now", "current price" +``` + +--- + +## Loose Coupling Strategy + +### Problem: Tight coupling = one failure breaks everything +### Solution: Each component is independently deployable and replaceable + +#### 1. Gateway ↔ MCP Server — Loose Contract +``` +Gateway does NOT import any Java code. +Gateway calls am-mcp-server via HTTP REST: + +POST http://am-mcp-server.am-apps-preprod.svc.cluster.local:8080/api/tools/execute +{ + "tool": "get_portfolio_summary", + "arguments": { "userId": "user123" } +} + +If am-mcp-server is DOWN → gateway returns LLM-only response (no tools) +Circuit breaker prevents cascade failure. +``` + +#### 2. LLM Provider — Swappable via env +```python +# Changing LLM = changing ONE env variable. No code change. +LLM_PROVIDER=deepseek → DeepSeek API +LLM_PROVIDER=gemini → Gemini API +LLM_PROVIDER=openai → OpenAI API + +# Auto-failover chain (configured in .env): +LLM_FALLBACK_CHAIN=deepseek,gemini,openai +``` + +#### 3. Observability — Fire and Forget +```python +# Langfuse and MLflow are NEVER on the hot path. +# They receive events via background async tasks. +# If Langfuse is down → request still succeeds. + +asyncio.create_task(langfuse.log_trace(trace_data)) # non-blocking +asyncio.create_task(mlflow.log_run(run_data)) # non-blocking +``` + +#### 4. UniversalDbTool — Strategy Pattern (no coupling between DBs) +```java +// Each DB strategy is independent. Adding ClickHouse = add one class. +// Removing InfluxDB = delete one class. Nothing else changes. + +interface DbQueryStrategy { + boolean supports(DbType type); + String generateQuery(String question, String database); + List> execute(String query, String database); +} +``` + +--- + +## Phase 1 — am-mcp-gateway + +### Entry Criteria +- [ ] DeepSeek API key available in Vault +- [ ] Langfuse deployed and reachable at `langfuse.munish.org` +- [ ] `am-mcp-service` Keycloak client exists (already in am-platform .env.example) + +### Exit Criteria (must pass before Phase 2 starts) +- [ ] `GET /health` returns 200 +- [ ] `POST /api/v1/chat` with valid JWT returns streamed response +- [ ] Langfuse shows trace for every request +- [ ] MLflow shows run for every request +- [ ] DeepSeek API down → Gemini fallback works +- [ ] Invalid JWT → 401 returned in < 50ms +- [ ] Load test: 50 concurrent users, p99 < 5s + +### Complete File Structure +``` +am-platform/ +└── am-mcp-gateway/ + ├── .env.example ← full env template + ├── .env ← gitignored + ├── .gitignore + ├── Dockerfile ← multi-stage build + ├── Makefile ← make run|test|docker-build + ├── requirements.txt + ├── requirements-dev.txt + ├── pyproject.toml + │ + ├── app/ + │ ├── main.py ← FastAPI app + lifespan + │ ├── config.py ← Pydantic BaseSettings + │ │ + │ ├── security/ + │ │ ├── jwt_bearer.py ← FastAPI Depends(JWTBearer()) + │ │ ├── jwks_cache.py ← LRU cache, 5-min TTL + │ │ └── models.py ← TokenPayload dataclass + │ │ + │ ├── llm/ + │ │ ├── base.py ← Abstract: chat() + stream() + │ │ ├── deepseek.py ← DEFAULT — httpx async + SSE + │ │ ├── gemini.py ← fallback 1 + │ │ ├── openai.py ← fallback 2 + │ │ ├── factory.py ← reads env, returns provider chain + │ │ └── circuit_breaker.py ← per-provider circuit breaker + │ │ + │ ├── cache/ + │ │ ├── response_cache.py ← Redis get/set with TTL + │ │ └── key_builder.py ← deterministic cache key + │ │ + │ ├── observability/ + │ │ ├── langfuse_tracer.py ← async, non-blocking + │ │ └── mlflow_tracker.py ← async, non-blocking + │ │ + │ ├── api/ + │ │ ├── chat_router.py ← POST /api/v1/chat (SSE stream) + │ │ ├── health_router.py ← GET /health, /ready + │ │ └── router.py + │ │ + │ ├── schemas/ + │ │ ├── chat.py ← ChatRequest, ChatResponse, StreamChunk + │ │ └── errors.py ← ErrorResponse, GatewayError + │ │ + │ ├── session/ + │ │ └── store.py ← Redis session (fallback: in-memory) + │ │ + │ └── middleware/ + │ ├── logging.py ← structlog + trace_id injection + │ └── rate_limiter.py ← per-user rate limit (Redis) + │ + └── tests/ + ├── conftest.py + ├── test_chat_stream.py ← SSE streaming test + ├── test_security.py ← JWT valid/invalid/expired + ├── test_fallback.py ← DeepSeek down → Gemini + └── test_cache.py ← cache hit/miss +``` + +### Environment Variables (`.env.example`) +```env +# ── Service ──────────────────────────────────────────────── +APP_PORT=8120 +APP_ENV=development +LOG_LEVEL=INFO +LOG_FORMAT=text # text (dev) | json (prod) + +# ── Security ─────────────────────────────────────────────── +OIDC_JWKS_URL=http://auth.munish.org/auth/realms/am-realm/protocol/openid-connect/certs +OIDC_ISSUER=http://auth.munish.org/auth/realms/am-realm +OIDC_JWKS_CACHE_TTL_SECONDS=300 # 5 min — reduces Keycloak calls +AM_MCP_CLIENT_ID=am-mcp-service +AM_MCP_CLIENT_SECRET= + +# ── LLM Provider ─────────────────────────────────────────── +# Primary + automatic failover chain +LLM_PROVIDER=deepseek +LLM_FALLBACK_CHAIN=deepseek,gemini,openai # left = highest priority +LLM_MODEL=deepseek-chat +LLM_TEMPERATURE=0.2 +LLM_MAX_TOKENS=4096 +LLM_TIMEOUT_SECONDS=60 +LLM_STREAM=true # stream response by default + +# Circuit breaker (per provider) +LLM_CB_FAILURE_THRESHOLD=5 # open after 5 consecutive failures +LLM_CB_RECOVERY_TIMEOUT_SECONDS=30 # try again after 30s + +# API Keys +DEEPSEEK_API_KEY= +GOOGLE_API_KEY= +OPENAI_API_KEY= + +# ── Caching ──────────────────────────────────────────────── +CACHE_ENABLED=true +CACHE_BACKEND=redis # redis | memory +CACHE_TTL_SECONDS=300 # 5 min default +REDIS_URL=redis://:password@redis.infra.svc.cluster.local:6379/4 + +# ── am-mcp-server (tool execution) ───────────────────────── +MCP_SERVER_URL=http://am-mcp-server.am-apps-preprod.svc.cluster.local:8080 +MCP_SERVER_TIMEOUT_SECONDS=20 +MCP_SERVER_ENABLED=true # false = LLM-only mode (no tools) + +# ── Observability — async, never blocks requests ──────────── +LANGFUSE_ENABLED=true +LANGFUSE_HOST=https://langfuse.munish.org +LANGFUSE_PUBLIC_KEY= +LANGFUSE_SECRET_KEY= +LANGFUSE_FLUSH_INTERVAL_SECONDS=5 # batch send traces + +MLFLOW_ENABLED=true +MLFLOW_TRACKING_URI=http://mlflow.am-ai.svc.cluster.local:5000 +MLFLOW_EXPERIMENT_NAME=am-mcp-gateway +MLFLOW_ASYNC=true # always fire-and-forget + +# ── Rate Limiting ────────────────────────────────────────── +RATE_LIMIT_ENABLED=true +RATE_LIMIT_REQUESTS_PER_MINUTE=60 # per user +RATE_LIMIT_BURST=10 + +# ── Session ──────────────────────────────────────────────── +SESSION_BACKEND=redis +SESSION_TTL_SECONDS=3600 + +# ── CORS ────────────────────────────────────────────────── +CORS_ORIGINS=http://localhost:9007,https://am.munish.org,https://am.asrax.in +``` + +### API Endpoints +| Method | Path | Auth | Mode | Description | +|---|---|---|---|---| +| `POST` | `/api/v1/chat` | JWT | Stream (SSE) | Main chat — streams response | +| `POST` | `/api/v1/chat/sync` | JWT | Sync | Chat — wait for full response | +| `GET` | `/api/v1/providers` | JWT | — | List LLM providers + status | +| `DELETE` | `/api/v1/cache` | JWT | — | Flush user's cache | +| `GET` | `/health` | Public | — | Liveness probe | +| `GET` | `/ready` | Public | — | Readiness probe | +| `GET` | `/metrics` | Internal | — | Prometheus metrics | + +### Error Handling +``` +LLM Timeout (>60s) → 504 Gateway Timeout +All providers down → 503 Service Unavailable + cached hint +Invalid JWT → 401 Unauthorized (< 50ms, no LLM call) +JWT expired → 401 with "token_expired" error code +Rate limit exceeded → 429 Too Many Requests + retry-after header +MCP server down → 200 with LLM-only response (graceful degrade) +Cache error → log warning, continue without cache (never fail) +Langfuse/MLflow error → log warning, continue (never affect request) +``` + +### Task List — Phase 1 +**Prerequisites** +- [ ] Langfuse deployed (`langfuse.munish.org` reachable) +- [ ] MLflow deployed (`mlflow.am-ai.svc.cluster.local:5000` reachable) +- [ ] DeepSeek API key added to Vault + am-platform secrets + +**Core** +- [ ] Directory structure + `.gitignore` +- [ ] `requirements.txt` + `requirements-dev.txt` + `pyproject.toml` +- [ ] `.env.example` with all variables +- [ ] `app/config.py` — Pydantic BaseSettings, singleton +- [ ] `app/main.py` — FastAPI app factory + lifespan (startup checks) + +**Security** +- [ ] `app/security/jwks_cache.py` — JWKS fetch + LRU cache (5-min TTL) +- [ ] `app/security/jwt_bearer.py` — FastAPI Depends, validates in < 5ms +- [ ] `app/security/models.py` — TokenPayload, UserClaims + +**LLM Layer** +- [ ] `app/llm/base.py` — `chat()` + `stream()` abstract methods +- [ ] `app/llm/deepseek.py` — httpx async, SSE streaming +- [ ] `app/llm/gemini.py` — google-genai SDK, SSE +- [ ] `app/llm/openai.py` — openai SDK, SSE +- [ ] `app/llm/factory.py` — reads `LLM_FALLBACK_CHAIN`, returns provider list +- [ ] `app/llm/circuit_breaker.py` — per-provider open/close/half-open + +**Caching** +- [ ] `app/cache/response_cache.py` — Redis get/set, TTL, serialization +- [ ] `app/cache/key_builder.py` — deterministic key, excludes time-sensitive queries + +**Observability (async)** +- [ ] `app/observability/langfuse_tracer.py` — `asyncio.create_task()` based +- [ ] `app/observability/mlflow_tracker.py` — `asyncio.create_task()` based + +**API** +- [ ] `app/schemas/chat.py` — ChatRequest, ChatResponse, StreamChunk, ErrorResponse +- [ ] `app/api/chat_router.py` — SSE streaming + sync endpoints +- [ ] `app/api/health_router.py` — liveness + readiness checks +- [ ] `app/api/router.py` — mount all routers +- [ ] `app/middleware/logging.py` — structlog + trace_id +- [ ] `app/middleware/rate_limiter.py` — Redis sliding window +- [ ] `app/session/store.py` — Redis session, in-memory fallback + +**Infrastructure** +- [ ] `Dockerfile` — multi-stage, non-root user +- [ ] `Makefile` — `run`, `test`, `docker-build`, `lint` +- [ ] Helm chart — `am-platform/helm/am-mcp-gateway/` +- [ ] Traefik route in `am-infra/traefik/apps.yaml` + +**Tests** +- [ ] `tests/conftest.py` — mock LLM provider + Redis +- [ ] `tests/test_chat_stream.py` — SSE streaming assertions +- [ ] `tests/test_security.py` — valid/expired/missing JWT +- [ ] `tests/test_fallback.py` — circuit breaker + provider fallover +- [ ] `tests/test_cache.py` — hit, miss, TTL expiry +- [ ] `tests/test_rate_limiter.py` + +--- + +## Phase 2 — UniversalDbTool (Java, am-mcp-server) + +### Design — Strategy Pattern +```java +// Router — selects strategy by dbType +@Tool(name = "query_database") +public String queryDatabase(String dbType, String database, String question) { + DbQueryStrategy strategy = registry.getStrategy(DbType.from(dbType)); + String query = strategy.generateQuery(question, database); // LLM generates + query = strategy.validate(query); // safety check + List> results = strategy.execute(query, database); + return ResponseHelper.toJson(Map.of( + "query", query, // always return generated query for audit + "results", results, + "count", results.size() + )); +} +``` + +### Query Safety Enforcement (per DB) +| DB | Safety Rule | +|---|---| +| PostgreSQL | Parse SQL AST → reject if not SELECT. Reject `INFORMATION_SCHEMA` access. | +| MongoDB | Reject `$where`, `$function`. Cap `$limit` to 50 if not set. | +| InfluxDB | Enforce time window ≤ 7 days. Read-only Flux queries only. | +| Redis | Allow: GET, HGET, KEYS, LRANGE, SMEMBERS, TTL. Block: SET, DEL, FLUSHDB. | + +### File Structure (additions to am-mcp-server) +``` +am-core-services/services/am-mcp-server/src/main/java/com/am/mcp/ +├── tools/ +│ ├── UniversalDbTool.java ← @Tool entry point +│ └── db/ +│ ├── DbType.java ← enum: MONGODB, POSTGRESQL, INFLUXDB, REDIS +│ ├── DbQueryStrategy.java ← interface +│ ├── DbStrategyRegistry.java ← Spring: finds all strategies by type +│ ├── QuerySafetyValidator.java ← per-type safety checks +│ ├── strategies/ +│ │ ├── MongoDbStrategy.java ← Spring Data Mongo (already connected) +│ │ ├── PostgresStrategy.java ← JdbcTemplate, read-only datasource +│ │ ├── InfluxDbStrategy.java ← InfluxDB Java client +│ │ └── RedisStrategy.java ← Lettuce, command allowlist +│ └── model/ +│ └── QueryResult.java ← query + results + metadata +└── config/ + └── UniversalDbConfig.java ← @ConditionalOnProperty beans per DB +``` + +### Task List — Phase 2 +**Entry criteria:** Phase 1 exit criteria all green. + +- [ ] `DbType.java` enum + `DbQueryStrategy.java` interface +- [ ] `DbStrategyRegistry.java` — Spring collects all strategies +- [ ] `QuerySafetyValidator.java` — SQL AST parser, Mongo allowlist +- [ ] `MongoDbStrategy.java` — uses existing Spring Data Mongo bean +- [ ] `PostgresStrategy.java` — read-only DataSource bean +- [ ] `InfluxDbStrategy.java` — InfluxDB client + Flux query gen +- [ ] `RedisStrategy.java` — Lettuce, allowlisted commands only +- [ ] `UniversalDbConfig.java` — conditional beans, env-toggled +- [ ] `UniversalDbTool.java` — @Tool, calls registry + validator +- [ ] `application.yaml` — add per-DB enable flags +- [ ] `pom.xml` — add InfluxDB client dependency +- [ ] Unit tests — each strategy with mocked driver +- [ ] Safety tests — SQL injection, Mongo $where, Redis FLUSHDB all rejected +- [ ] Integration test — NL → query → result, end-to-end + +--- + +## Phase 3 — am-ui-test-agent + +### Entry Criteria +- [ ] Phase 1 + Phase 2 exit criteria all green +- [ ] Qdrant deployed in K8s +- [ ] Test user account created in Keycloak (`test-agent@munish.org`) +- [ ] `TOGETHER_API_KEY` added to Vault (for Qwen2.5-VL vision model via Together AI API) + +### Design — LangGraph Agent +``` +TRIGGER: POST /api/v1/test/run + │ + ▼ +PLAN node (DeepSeek) + → reads test spec + Qdrant memory + → generates ordered step list + │ + ▼ +EXECUTE node (loop) + → Playwright performs action + → Screenshot captured + → Qwen2.5-VL describes what it sees + → Assert pass/fail + → If fail: SELF-HEAL node + → Vision finds element by appearance + → Store new selector in Qdrant + → Retry step + │ + ▼ +REPORT node + → HTML report with screenshots + → Bug list with reproduction steps + → Langfuse trace link + → Store in MongoDB +``` + +### Task List — Phase 3 +**Entry criteria:** Phase 1 + 2 exit criteria all green. + +**Scaffold** +- [ ] New repo `am-ui-test-agent` +- [ ] `.env.example`, `requirements.txt`, `pyproject.toml` +- [ ] `Dockerfile` + Helm chart + +**Browser** +- [ ] `app/browser/controller.py` — Playwright async, headed/headless toggle +- [ ] `app/browser/screenshot.py` — capture, resize, base64 encode +- [ ] `app/browser/dom_extractor.py` — extract all interactive elements + +**Vision** +- [ ] `app/vision/analyzer.py` — Qwen2.5-VL via API: describe screenshot +- [ ] `app/vision/element_detector.py` — find button/input by description +- [ ] `app/vision/diff_detector.py` — pixel diff + semantic diff + +**Memory (Qdrant)** +- [ ] `app/memory/qdrant_client.py` — connection + collection management +- [ ] `app/memory/ui_memory.py` — store page states + selectors +- [ ] `app/memory/test_memory.py` — store past runs + outcomes +- [ ] `app/memory/embedder.py` — text embeddings (DeepSeek) + image (CLIP) + +**Tools** +- [ ] `app/tools/navigate_tool.py` — go_to_url, click, type, scroll, wait +- [ ] `app/tools/assert_tool.py` — assert_text, assert_url, assert_visible +- [ ] `app/tools/auth_tool.py` — login via Keycloak (reusable across tests) +- [ ] `app/tools/screenshot_tool.py` — take + compare + diff + +**Agent** +- [ ] `app/agent/planner.py` — DeepSeek: spec → step list +- [ ] `app/agent/executor.py` — step runner + self-heal logic +- [ ] `app/agent/reporter.py` — aggregate results → bug list +- [ ] `app/agent/test_agent.py` — LangGraph graph wiring all nodes + +**API + Scheduler** +- [ ] `app/api/test_router.py` — POST /api/v1/test/run +- [ ] `app/api/report_router.py` — GET /api/v1/test/reports +- [ ] `app/api/webhook_router.py` — POST /api/v1/webhooks/deploy (CI/CD trigger) +- [ ] `app/scheduler/cron_runner.py` — nightly regression at 2am +- [ ] `app/reporting/html_reporter.py` — rich HTML with screenshots +- [ ] `app/reporting/storage.py` — persist to MongoDB + +**Observability** +- [ ] `app/observability/langfuse_tracer.py` — trace every test step +- [ ] `app/observability/mlflow_tracker.py` — pass rate, coverage, duration + +**Tests** +- [ ] `tests/test_browser_controller.py` +- [ ] `tests/test_vision_analyzer.py` +- [ ] `tests/test_qdrant_memory.py` +- [ ] `tests/test_self_healing.py` + +--- + +## Shared Infrastructure + +### Services to Deploy (Terraform + Helm in am-infra) +| Service | Component | Hosting / API Type | Est. Cost | +|---|---|---|---| +| Text LLM | **DeepSeek V3** | External API | ~$0.14/M tokens | +| Vision LLM | **Qwen2.5-VL** | Together AI API | ~$0.20/M tokens | +| Vector Store | **Qdrant** | Self-hosted K8s | Free | +| Deep Tracing | **Langfuse** | Self-hosted K8s | Free | +| Basic Tracking | **MLflow** | Self-hosted K8s | Free | +| Browser Control | **Playwright** | Runs in agent pod | Free | + +**Total LLM cost estimate:** < $10/month for all AI operations +**No GPU node required** — all models served via external APIs. Set `TOGETHER_API_KEY` in Vault. + +### Traefik Routes (am-infra/traefik/apps.yaml) +```yaml +# New routes to add: +am-mcp-gateway: + rule: Host(`am.munish.org`) && PathPrefix(`/mcp`) + service: am-mcp-gateway:8120 + middlewares: [forward-auth] + +am-ui-test-agent: + rule: Host(`am.munish.org`) && PathPrefix(`/ui-test`) + service: am-ui-test-agent:8130 + middlewares: [forward-auth] + +langfuse: + rule: Host(`langfuse.munish.org`) + service: langfuse.am-ai:3000 +``` + +### Port Registry (All Services) +| Service | Internal Port | Namespace | +|---|---|---| +| `am-mcp-gateway` | `8120` | `am-apps-preprod` | +| `am-ui-test-agent` | `8130` | `am-apps-preprod` | +| Langfuse | `3000` | `am-ai` | +| MLflow | `5000` | `am-ai` | +| Qdrant | `6333` | `am-ai` | + +> Qwen2.5-VL (vision) is called via Together AI external API — no internal port needed. + +--- + +## Risk Register + +| Risk | Probability | Impact | Mitigation | +|---|---|---|---| +| DeepSeek API unavailable | Medium | High | Auto-failover to Gemini → OpenAI | +| LLM generates unsafe DB query | Low | Critical | Validate before execution, read-only user | +| Langfuse down during request | Low | Low | Async, fire-and-forget, never blocks | +| am-mcp-server down | Medium | Medium | Gateway serves LLM-only response | +| Qwen-VL hallucination | Medium | Medium | Validate with DOM assertion after vision | +| Rate limit exceeded on LLM API | Medium | Medium | Redis rate limiter + queue overflow | + +--- + +## Timeline + +``` +Week 1: Infrastructure + └── Deploy Langfuse + MLflow to K8s + └── Configure Vault secrets (DeepSeek key etc.) + +Week 2: Phase 1 — am-mcp-gateway + └── Core service, LLM routing, JWT, streaming + └── Caching, rate limiting, observability + └── Tests passing, deployed to preprod + +Week 3: Phase 2 — UniversalDbTool + └── Strategy pattern + all 4 DB types + └── Safety validation for each + └── Tests + integration + +Week 4-6: Phase 3 — am-ui-test-agent + └── Week 4: browser + vision + memory + └── Week 5: agent (plan → execute → report) + └── Week 6: scheduler + CI/CD webhook + full coverage +``` diff --git a/docs/AM_MCP_GATEWAY_DESIGN.md b/docs/AM_MCP_GATEWAY_DESIGN.md new file mode 100644 index 0000000..fc262aa --- /dev/null +++ b/docs/AM_MCP_GATEWAY_DESIGN.md @@ -0,0 +1,281 @@ +# AM MCP Gateway — Technical Design Specification +> **Role:** Technical Architect & Software Developer +> **Status:** Finalized Design Specification | **Scope:** `am-platform/am-mcp-gateway/` + +This specification defines the complete technical architecture, data flow, classes, signatures, API schemas, and error handling for the `am-mcp-gateway` service. It acts as the single source of truth for development of the intelligent routing layer. + +--- + +## 1. System Context & Topology + +The `am-mcp-gateway` acts as the secure entry point for all client chat requests. It handles JSON Web Token (JWT) verification, response caching, intelligent LLM routing, resilience circuit-breaking, and asynchronous observability logging (traces and metrics). + +```mermaid +flowchart TD + Client["Client (Flutter Web App / API Client)"] -- "POST /api/v1/chat (JWT Required)" --> Gateway["am-mcp-gateway (Port 8120)"] + + subgraph Gateway Core Logic + Gateway --> Auth["JWTBearer (Keycloak JWKS Cache)"] + Auth --> CacheCheck["ResponseCache (Redis lookup)"] + CacheCheck -- "Cache Hit (<5ms)" --> ReturnCache["Return Cached Response"] + CacheCheck -- "Cache Miss" --> Router["LLMRouter"] + Router --> RouteDecider{"Is Finance Intent?"} + + RouteDecider -- "Yes" --> FinAgentClient["Finance Agent Client"] + RouteDecider -- "No" --> DirectLLM["Direct LLM Router"] + end + + subgraph Downstream Services + FinAgentClient -- "POST /api/v1/ai/chat" --> FinAgent["am-fin-agent (Port 8100)"] + DirectLLM --> DeepSeek["DeepSeek API"] + DirectLLM --> Gemini["Gemini API (Fallback 1)"] + DirectLLM --> OpenAI["OpenAI API (Fallback 2)"] + end + + subgraph Async Side-channel + Gateway -.-> |Async Log| Langfuse["Langfuse Server (Port 3000)"] + Gateway -.-> |Async Metrics| MLflow["MLflow Server (Port 5000)"] + end + + ReturnCache --> Client + FinAgent --> Client + DeepSeek --> Client + Gemini --> Client + OpenAI --> Client +``` + +### Protocol & Port Registry +| Component | Port | Interface Protocol | Namespace / Location | Authentication | +|---|---|---|---|---| +| `am-mcp-gateway` | `8120` | HTTP / SSE | `am-apps-preprod` | Keycloak JWT Bearer | +| `am-fin-agent` | `8100` | HTTP / JSON | `am-apps-preprod` | Internal m2m / Gateway Trusted | +| `Langfuse` | `3000` | HTTP / JSON | `am-ai` (Self-hosted) | API Keys | +| `MLflow` | `5000` | HTTP / JSON | `am-ai` (Self-hosted) | Unauthenticated | +| `Redis` | `6379` | TCP | `am-infra` (DB Index 4) | Password Authenticated | + +--- + +## 2. Directory & File Structure + +The gateway is located at [am-platform/am-mcp-gateway/](file:///a:/InfraCode/AM-Portfolio-grp/am-platform/am-mcp-gateway/). The component layout is as follows: + +``` +am-mcp-gateway/ +├── requirements.txt # Production dependencies (includes am-platform-security) +├── requirements-dev.txt # Development/Test dependencies +├── pyproject.toml # Python tool configurations +├── Dockerfile # Multi-stage production container definition +├── Makefile # Local automation tasks +├── helm/ +│ ├── values.yaml # Base deployment configuration & Ingress setup +│ ├── values.preprod.yaml # Preprod overrides +│ ├── values.prod.yaml # Production overrides +│ └── vault-mappings.yaml # HashiCorp Vault secrets schema mapping +├── app/ +│ ├── __init__.py +│ ├── main.py # App initialization, lifespan configuration, and CORS setups +│ ├── config.py # Pydantic BaseSettings config class definition +│ ├── llm/ +│ │ ├── __init__.py +│ │ ├── base.py # Base abstract classes for LLM provider wrappers +│ │ ├── circuit_breaker.py # Tripping states (CLOSED, OPEN, HALF_OPEN) per LLM provider +│ │ ├── router.py # Resilient fallback routing logic +│ │ ├── deepseek.py # DeepSeek API wrapper +│ │ ├── gemini.py # Gemini API wrapper +│ │ └── openai.py # OpenAI API wrapper +│ ├── session/ +│ │ ├── __init__.py +│ │ └── cache.py # Redis response cache with TTL hashing +│ ├── tools/ +│ │ ├── __init__.py +│ │ └── fin_agent_client.py# Client to proxy requests to am-fin-agent +│ ├── observability/ +│ │ ├── __init__.py +│ │ └── tracer.py # Async non-blocking Langfuse / MLflow callbacks +│ └── api/ +│ ├── __init__.py +│ ├── chat.py # Main chat router exposing chat and stream endpoints +│ └── health.py # Liveness and readiness endpoints +└── tests/ + ├── conftest.py + ├── test_llm_router.py + └── test_cache.py +``` + +--- + +## 3. Core Module Specifications + +### 3.1 Security Module +We do not re-implement authentication or token signature verification. Instead, the gateway relies on the shared package [am-platform-security](file:///a:/InfraCode/AM-Portfolio-grp/am-platform/libraries/am-platform-security/) to guarantee security consistency. + +* **FastAPI Dependency Injection**: + * The endpoints in [chat.py](file:///a:/InfraCode/AM-Portfolio-grp/am-platform/am-mcp-gateway/app/api/chat.py) inject the verification filter: + `Depends(require_auth_context(expected_audience=settings.AM_MCP_CLIENT_ID))` + * This validates Keycloak JWTs, handles OIDC issuer schema discrepancies (`http` vs `https`), and bypasses the WAF certificate fetching block by sending the mandatory `"User-Agent": "am-platform-security/1.0"` request header. + + +### 3.2 LLM & Failover Router +* **File:** [circuit_breaker.py](file:///a:/InfraCode/AM-Portfolio-grp/am-platform/am-mcp-gateway/app/llm/circuit_breaker.py) + * **Class:** `CircuitBreaker` + * Manages state machine (`CLOSED`, `OPEN`, `HALF_OPEN`) per LLM provider to isolate failing third-party APIs. + * Automatically trips to `OPEN` state after `LLM_CB_FAILURE_THRESHOLD` consecutive exceptions, rejecting requests instantly for `LLM_CB_RECOVERY_TIMEOUT_SECONDS` before testing via `HALF_OPEN`. +* **File:** [router.py](file:///a:/InfraCode/AM-Portfolio-grp/am-platform/am-mcp-gateway/app/llm/router.py) + * **Class:** `LLMRouter` + * Resolves client requests by cycling through `LLM_FALLBACK_CHAIN` (e.g. `deepseek`, `gemini`, `openai`). + * If the primary provider (e.g., DeepSeek) throws a connection or rate-limit error, the router marks a circuit failure, fallbacks to the next provider (Gemini), and streams or returns the result. + +```python +class LLMRouter: + def __init__(self, providers: List[BaseLLMProvider], circuit_breakers: Dict[str, CircuitBreaker]): + self.providers = providers + self.breakers = circuit_breakers + + async def execute_chat(self, prompt: str, user_id: str, stream: bool = True) -> AsyncIterator[str]: + """Iterates over providers and falls back if circuit breaker permits and provider fails.""" + # Routing logic details... + pass +``` + +### 3.3 Redis Response Caching +* **File:** `app/session/cache.py` + * **Class:** `ResponseCache` + * Computes SHA-256 hashes based on user ID, text prompt, and selected LLM model. + * Ignores caching for real-time/dynamic phrases containing tokens such as: `now`, `today`, `current price`, `latest stock`. + * Default TTL of 300 seconds (5 minutes) configured via Redis. + +### 3.4 Finance Agent Proxy +* **File:** `app/tools/fin_agent_client.py` + * **Class:** `FinAgentClient` + * Proxies messages to the local `am-fin-agent` instance running on port `8100` via [api.py](file:///a:/InfraCode/AM-Portfolio-grp/am-fin-agent/api.py). + * If a query relates to portfolio metrics, holdings, trades, or ETFs, the client invokes `POST /api/v1/ai/chat` on `am-fin-agent` rather than calling the LLMs directly. + * Implements structural schema matching: + * Sends `ChatRequest` (`message`, `userId`, `sessionId`). + * Receives `AiIntentResponse` (`message`, `widgetId`, `widgetParams`, `sessionId`, `toolsUsed`, `traceId`). + +--- + +## 4. API Endpoint Specifications + +### 4.1 Chat Completion (Streaming) +* **Path:** `POST /api/v1/chat` +* **Headers:** + ```http + Authorization: Bearer + Content-Type: application/json + Accept: text/event-stream + ``` +* **Request Body Schema:** + ```json + { + "message": "What is my current portfolio valuation?", + "model": "deepseek-chat", + "temperature": 0.2, + "stream": true, + "sessionId": "4be704d9-5e92-48e0-ac55-2ff1b83d1c44" + } + ``` +* **Stream Response Format (Server-Sent Events):** + ```http + data: {"chunk": "Based on ", "model": "deepseek-chat"} + + data: {"chunk": "your current holdings...", "model": "deepseek-chat"} + + data: {"chunk": "[DONE]", "model": "deepseek-chat"} + ``` + +### 4.2 Chat Completion (Synchronous) +* **Path:** `POST /api/v1/chat/sync` +* **Headers:** + ```http + Authorization: Bearer + Content-Type: application/json + ``` +* **Response Body Schema:** + ```json + { + "message": "Based on your current holdings, your portfolio is valued at $1,240,000.", + "model": "deepseek-chat", + "sessionId": "4be704d9-5e92-48e0-ac55-2ff1b83d1c44", + "traceId": "9d8e578c-ef0c-4fa2-bc89-6fa786bc9e81", + "cached": false + } + ``` + +--- + +## 5. System Error Mapping & Resiliency Strategy + +| Scenario | Root Cause | HTTP Status | Response Payload | Recoverability Action | +|---|---|---|---|---| +| DeepSeek Outage | Connection timeout / 503 | `504 Gateway Timeout` | `{"error": "Primary model failed. Retrying..."}` | Automatically routes request to Gemini. Trips DeepSeek breaker. | +| All Providers Offline | All fallback LLMs return errors | `503 Service Unavailable` | `{"error": "All LLM services currently unavailable."}` | Serves canned responses or offline greeting from local cache. | +| Expired Keycloak Token | JWT Expired Claim (`exp`) | `401 Unauthorized` | `{"error": "token_expired"}` | Client must refresh Keycloak session. | +| Invalid Signature | Public Key mismatch | `401 Unauthorized` | `{"error": "token_invalid_signature"}` | Force-refreshes the JWKS Cache once. Reject if still invalid. | +| Finance Agent Offline | Connection Refused on :8100 | `200 OK` | `{"message": "I cannot access live portfolio tools right now. (Tools Offline)", "widgetId": "TEXT_RESPONSE"}` | Gracefully downgrades response to LLM-only, alerting the user. | + +--- + +## 6. Helm & HashiCorp Vault Configuration + +The `am-mcp-gateway` is deployed using Helm and secures configurations via Vault mappings. + +### 6.1 Vault Secret Mappings (`helm/vault-mappings.yaml`) +Dynamic injection of sensitive keys into the gateway's environment variables: +```yaml +vault: + secretPaths: + redis: + mappings: + REDIS_URL: "REDIS_URL" + llm-api-keys: + mappings: + DEEPSEEK_API_KEY: "DEEPSEEK_API_KEY" + GOOGLE_API_KEY: "GOOGLE_API_KEY" + OPENAI_API_KEY: "OPENAI_API_KEY" + identity-oidc: + mappings: + OIDC_JWKS_URL: "OIDC_JWKS_URL" + OIDC_ISSUER: "OIDC_ISSUER" + AM_MCP_CLIENT_SECRET: "AM_MCP_CLIENT_SECRET" + observability: + mappings: + LANGFUSE_PUBLIC_KEY: "LANGFUSE_PUBLIC_KEY" + LANGFUSE_SECRET_KEY: "LANGFUSE_SECRET_KEY" +``` + +### 6.2 Helm Values & Ingress (`helm/values.yaml`) +Routes traffic through the Traefik Ingress: +```yaml +global: + vault: + enabled: true + role: "am-backend-role" + authPath: "auth/kubernetes" + serviceAccountName: "am-backend-sa" + +image: + repository: am-mcp-gateway + +replicaCount: 1 +port: 8120 + +service: + port: 8120 + +ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: >- + am-apps-preprod-global-cors@kubernetescrd, + am-apps-preprod-strip-prefix-apps@kubernetescrd + hosts: + - host: am.asrax.in + paths: + - path: /mcp + pathType: Prefix +``` + diff --git a/docs/AM_UI_TEST_AGENT_DESIGN.md b/docs/AM_UI_TEST_AGENT_DESIGN.md new file mode 100644 index 0000000..a7553dd --- /dev/null +++ b/docs/AM_UI_TEST_AGENT_DESIGN.md @@ -0,0 +1,17 @@ +# AM UI Test Agent — Technical Design Specification + +> **This document has moved.** + +The UI Agent AI Testing documentation now lives under: + +**[ui-agent-ai-testing/](ui-agent-ai-testing/README.md)** + +| Document | Link | +|----------|------| +| Index | [ui-agent-ai-testing/README.md](ui-agent-ai-testing/README.md) | +| Full agent design (this file) | [ui-agent-ai-testing/AM_UI_TEST_AGENT_DESIGN.md](ui-agent-ai-testing/AM_UI_TEST_AGENT_DESIGN.md) | +| Hybrid design review | [ui-agent-ai-testing/DESIGN_REVIEW_HYBRID.md](ui-agent-ai-testing/DESIGN_REVIEW_HYBRID.md) | +| Weekly release runbook | [ui-agent-ai-testing/OPERATIONS_WEEKLY_UI_RELEASE.md](ui-agent-ai-testing/OPERATIONS_WEEKLY_UI_RELEASE.md) | +| Implementation status | [ui-agent-ai-testing/IMPLEMENTATION_STATUS.md](ui-agent-ai-testing/IMPLEMENTATION_STATUS.md) | + +Moved: 2026-06-14 diff --git a/docs/README.md b/docs/README.md index 06e5a06..27a2162 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ - `plan_subscription.md` - subscription, entitlements, and usage plan. - `plan_notification.md` - notification routing and inbox plan. - `plan_payments.md` - future payment abstraction and billing plan, outside the main 10/10 platform score. +- **`ui-agent-ai-testing/`** - UI test agent, hybrid design review (Qdrant + LLM), weekly release runbook. Start at [`ui-agent-ai-testing/README.md`](ui-agent-ai-testing/README.md). +- `AM_UI_TEST_AGENT_DESIGN.md` - redirect stub; content moved to `ui-agent-ai-testing/`. ## Current Status diff --git a/docs/ui-agent-ai-testing/AM_UI_TEST_AGENT_DESIGN.md b/docs/ui-agent-ai-testing/AM_UI_TEST_AGENT_DESIGN.md new file mode 100644 index 0000000..f7a7fbc --- /dev/null +++ b/docs/ui-agent-ai-testing/AM_UI_TEST_AGENT_DESIGN.md @@ -0,0 +1,475 @@ +# AM UI Test Agent — Technical Design Specification + +> **Role:** Technical Architect & Software Developer +> **Status:** Finalized Design Specification | **Scope:** `am-ui-test-agent/` (New Repository) +> **Location:** Moved to [`ui-agent-ai-testing/`](README.md) (2026-06-14) + +**Companion docs:** + +- [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md) — cost-effective hybrid design validation (Qdrant + LLM on drift) +- [OPERATIONS_WEEKLY_UI_RELEASE.md](OPERATIONS_WEEKLY_UI_RELEASE.md) — weekly seed / compare / promote runbook +- [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) — built vs planned + +This specification defines the complete technical design, module-level interfaces, state machine definitions, vector database collections, and integration protocols for the autonomous visual UI testing agent (`am-ui-test-agent`). + +--- + +## 1. System Context & Workflow + +The `am-ui-test-agent` is an autonomous execution service that runs visual, behavioral, and self-healing tests against the AM Flutter Web App. It operates as a LangGraph agent workflow using Playwright for browser interactions, Qwen2.5-VL (via Together AI) for visual element detection, and Qdrant for semantic selectors and state memory. + +> **Implementation note:** Auth profile (`AUTH_FLOW_MAIN`) today uses fixed Playwright steps and text/URL asserts. Full vision assertion and `design_review` node are specified in [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md). + +```mermaid +flowchart TD + Trigger["CI/CD Webhook / Cron Trigger (Port 8130)"] --> Agent["LangGraph Agent Controller"] + + subgraph Agent State Loop + Agent --> Plan["PlanNode (DeepSeek-V3)"] + Plan --> Execute["ExecuteNode (Playwright Action)"] + Execute --> Screenshot["Capture Screenshot & DOM"] + Screenshot --> Assert{"AssertNode (Vision Assertion)"} + Assert --> DesignReview["DesignReviewNode (Hybrid Qdrant + LLM)"] + + Assert -- "Fail (Broken Selector)" --> SelfHeal["SelfHealNode (Qwen2.5-VL Locator)"] + SelfHeal --> |Update Qdrant Selectors| Execute + + DesignReview --> Report["ReportNode (Aggregate Logs & Persist)"] + end + + subgraph Storage & External APIs + SelfHeal -- "Normalize Coordinates" --> Together["Together AI: Qwen2.5-VL"] + DesignReview -- "Compare baselines" --> Qdrant["Qdrant ui_patterns"] + Execute -- "Interact" --> WebApp["AM Flutter Web App (Port 9000 / 9005)"] + Agent -- "Query Memory" --> Qdrant + Report -- "Persist Results" --> Mongo["MongoDB (Port 27017)"] + Report -.-> |Observability| Langfuse["Langfuse Server (Port 3000)"] + end + + Report --> Finalize["Output HTML Test Summary"] +``` + +### Protocol & Port Registry +| Component | Port | Interface Protocol | Namespace / Location | Authentication | +|---|---|---|---|---| +| `am-ui-test-agent` | `8130` | HTTP / JSON | `am-apps-preprod` | Keycloak JWT Bearer / CI Token | +| `Qdrant` | `6333` | HTTP / gRPC | `am-ai` (Self-hosted) | API Token | +| `MongoDB` | `27017` | MongoDB Wire | `am-infra` (Test Reports DB) | Password Authenticated | +| `Together AI API` | External | HTTPS REST | `https://api.together.xyz` | API Key | + +--- + +## 2. Directory & File Structure + +The agent is located in its own repository `a:/InfraCode/AM-Portfolio-grp/am-ui-test-agent/`. The scaffolded file layout is as follows: + +``` +am-ui-test-agent/ +├── requirements.txt # System dependencies (playwright, langgraph, qdrant-client, motor) +├── pyproject.toml # Formatting and linting configuration +├── Dockerfile # Production-ready image with Chrome/Playwright drivers preinstalled +├── Makefile # Local commands to run and test the agent +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI runner, mounts routes, and sets cron timers +│ ├── config.py # Pydantic base configuration +│ ├── browser/ +│ │ ├── __init__.py +│ │ ├── controller.py # Playwright browser lifecycle wrapper (headed/headless modes) +│ │ ├── dom.py # Interactive DOM component tree extractor +│ │ └── screenshot.py # Captures, crops, and base64 encodes screen views +│ ├── vision/ +│ │ ├── __init__.py +│ │ ├── analyzer.py # Connects to Qwen2.5-VL API for screenshot analytics +│ │ └── coordinate.py # Translates normalized bounding boxes (0-1000) to pixel points +│ ├── memory/ +│ │ ├── __init__.py +│ │ ├── qdrant.py # Collection manager and client wrapper for Qdrant +│ │ └── embedder.py # Computes text (DeepSeek) and image (CLIP) embeddings +│ ├── agent/ +│ │ ├── __init__.py +│ │ ├── state.py # TypedDict defining LangGraph state properties +│ │ ├── graph.py # LangGraph pipeline compilation (nodes & conditional edges) +│ │ ├── planner.py # Spec parsing node (spec -> step list) +│ │ ├── executor.py # Step executor node +│ │ ├── design_review.py # Hybrid Qdrant baseline + LLM on drift (see DESIGN_REVIEW_HYBRID.md) +│ │ ├── self_healer.py # Self-healing engine for repairing broken element selectors +│ │ └── reporter.py # Aggregates results into final summary formats +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── test_runner.py # Endpoint to trigger runs: POST /api/v1/test/run +│ │ ├── design_baselines.py # POST /api/v1/design/baseline/promote +│ │ └── report_viewer.py # Endpoint to view historic runs and screenshot diffs +│ └── scheduler/ +│ ├── __init__.py +│ └── cron.py # Nightly regression cron runner (runs at 2:00 AM) +└── tests/ + ├── conftest.py + ├── test_browser.py # Tests Playwright actions & Keycloak Login + ├── test_vision.py # Mocks Qwen2.5-VL and validates bounding box conversion + └── test_agent_graph.py # Validates node state transitions +``` + +Platform documentation: `am-platform/docs/ui-agent-ai-testing/` + +--- + +## 3. LangGraph Agent State Machine + +The core executor runs as a state machine using LangGraph. It manages the following state schema throughout the lifecycle: + +* **File:** `app/agent/state.py` +```python +from typing import TypedDict, List, Dict, Any, Optional + +class AgentState(TypedDict): + target_url: str # Starting URL of the Flutter Web application + specification: str # Text description of the desired behavior (Gherkin style) + steps: List[str] # Synthesized sequence of steps to perform + current_step_index: int # Pointer to the current step being executed + selectors_db: Dict[str, str] # Map of element name -> selector resolved from Qdrant + failures_encountered: List[Dict[str, Any]] # Collection of assertions/actions that failed + screenshot_history: List[str] # Base64-encoded screenshots captured after each step + report_output: Optional[str] # Path to final compiled HTML report + mongodb_report_id: Optional[str] # Key mapping to the persisted run in MongoDB +``` + +### State Node Responsibilities +1. **PlanNode (`Planner`)**: Reads Gherkin text and compiles an ordered list of tasks (e.g. `Click 'Sign In'`, `Type 'test@am.org' in email field`). +2. **ExecuteNode (`Executor`)**: Retrieves selector from Qdrant. Performs the actions on Playwright. +3. **AssertNode (`Assertion Check`)**: Compares screenshots against baseline images or checks for the presence of target elements to verify visual regression. +4. **DesignReviewNode (`Design Review`)**: Hybrid Qdrant similarity + vision LLM on drift. See [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md). +5. **SelfHealNode (`Self-Healer`)**: Triggers when Playwright fails to find a selector. Uses Qwen2.5-VL to locate the button/field on the screenshot, calculates coordinates, executes the click, and updates the selectors database in Qdrant. +6. **ReportNode (`Reporter`)**: Generates an HTML report, saves artifacts to MongoDB, and signals completion. + +--- + +## 4. Playwright & Computer Vision Integration + +### 4.1 Vision Model Bounding Box Conversion +When an element cannot be matched via CSS/XPath selectors, the agent queries the `Qwen/Qwen2.5-VL-7B-Instruct` model hosted on Together AI. + +* **Vision API Request Spec:** +```json +{ + "model": "Qwen/Qwen2.5-VL-7B-Instruct", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Find the element labeled 'Allocate Portfolio' and provide its bounding box [ymin, xmin, ymax, xmax] normalized to 0-1000." + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,iVBORw0KG..." + } + } + ] + } + ] +} +``` + +* **Coordinate Translation Algorithm:** +Qwen2.5-VL outputs a bounding box in normalized format `[ymin, xmin, ymax, xmax]` range `[0, 1000]`. +```python +def translate_normalized_box(box: List[int], viewport_width: int, viewport_height: int) -> Dict[str, int]: + """Translates normalized 0-1000 box from Qwen2.5-VL to actual screen pixel coordinates.""" + ymin, xmin, ymax, xmax = box + + # Map back to viewport size + pixel_xmin = int((xmin / 1000.0) * viewport_width) + pixel_xmax = int((xmax / 1000.0) * viewport_width) + pixel_ymin = int((ymin / 1000.0) * viewport_height) + pixel_ymax = int((ymax / 1000.0) * viewport_height) + + # Return center point for Playwright to execute the click action + center_x = (pixel_xmin + pixel_xmax) // 2 + center_y = (pixel_ymin + pixel_ymax) // 2 + + return {"x": center_x, "y": center_y} +``` +Playwright then performs `page.mouse.click(center_x, center_y)` to interact with the element. + +--- + +## 5. Qdrant Memory Collections + +Four separate collections are maintained in Qdrant to store state patterns, locator logs, historic test data, and UI patterns. + +| Collection Name | Dimension | Metric | Payload Details | +|---|---|---|---| +| `ui_patterns` | `512` | Cosine | CLIP image embeddings of interface components mapped to specific page routes. | +| `test_cases` | `1536` | Cosine | Text embeddings (DeepSeek) of Gherkin specs and past execution outputs. | +| `selectors` | `1536` | Cosine | Map of logical names (e.g. `login_submit_btn`) to HTML selectors and coordinates. | +| `bug_memory` | `512` | Cosine | Mapped visual screenshots of glitches, crashes, and CSS layout breakages. | + +Baseline lifecycle (`seed` / `compare` / `promote`) applies to **`ui_patterns`** only. Details: [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md). + +--- + +## 6. API & Scheduling Interface + +### 6.1 Trigger Test Execution +* **Path:** `POST /api/v1/test/run` +* **Headers:** + ```http + Authorization: Bearer + Content-Type: application/json + ``` +* **Request Body:** + ```json + { + "targetUrl": "http://localhost:9005", + "specification": "Feature: Portfolio Table Sorting\n Scenario: Sort by valuation\n Given I am logged in\n When I click the 'Valuation' header\n Then the table should sort in descending order", + "headless": true + } + ``` +* **Response:** + ```json + { + "testId": "60c72b2f9b1d8a0015f8ba32", + "status": "RUNNING", + "message": "LangGraph state machine initialized and running." + } + ``` + +### 6.2 Auth flow (implemented) + +* **Path:** `POST /api/v1/test/run/auth` +* **Body:** `{ "targetUrl", "uiMode", "baselineMode" }` — see [OPERATIONS_WEEKLY_UI_RELEASE.md](OPERATIONS_WEEKLY_UI_RELEASE.md) + +### 6.3 Nightly Cron Orchestrator +A background task scheduler runs inside FastAPI using `APScheduler`. At **2:00 AM** daily, the scheduler fetches active test suites from MongoDB, requests a Bearer token via client credentials grant (`AM_MCP_CLIENT_ID` / `AM_MCP_CLIENT_SECRET`) from Keycloak, and initiates headless regressions against staging. +* Reports are outputted to `/var/www/reports/` and an alert is published to Keycloak/Slack if failures exceed 0. + +--- + +## 7. CI/CD Integration & Pre-Release Gating + +To prevent regressions from reaching production, the test agent integrates directly into the commit lifecycle of the [am-modern-ui](file:///a:/InfraCode/AM-Portfolio-grp/am-modern-ui) codebase. + +```mermaid +sequenceDiagram + autonumber + actor Dev as Developer + participant UI as am-modern-ui Repo + participant CI as GitHub Actions CI + participant Agent as am-ui-test-agent + participant App as Staging App (Port 9005) + + Dev->>UI: git push commit / open PR + UI->>CI: Trigger CI Workflow + CI->>CI: Build & Deploy UI to Preprod Preview + CI->>Agent: POST /api/v1/test/run {"profile": "RELEASE_GATE", "commitSha": "abc1234"} + + rect rgb(20, 20, 30) + note right of Agent: LangGraph Run: RELEASE_GATE Suite + Agent->>App: Launch Playwright & Execute Major Flows + App-->>Agent: Output responses & screenshots + Agent->>Agent: Assertions check (and Self-Heal if needed) + end + + Agent-->>CI: Callback POST (status: success/failed, reportUrl) + alt Pass + CI->>Dev: green_heart PR Check Passed / Proceed to Release + else Fail + CI->>Dev: red_circle PR Check Failed / Block Release + end +``` + +### 7.1 Release-Blocking Critical Flows (Release Gates) +When the `profile` parameter is set to `RELEASE_GATE`, the agent dynamically fetches and executes only the critical business paths. A single failure in any of these flows will block the build/deployment pipeline: + +1. **Authentication Loop**: Keycloak SSO redirect, MFA token handshake, session preservation, and logout. +2. **Portfolio Dashboard Integrity**: Valuation loading check (p50 < 800ms), holdings table rendering, and asset allocation pie chart SVG verification. +3. **Trade Execution Cycle**: Opening trade ticket widget, inputting mock buy/sell actions, checking slippage calculations, executing transaction, and verifying update in trade history table. +4. **Document Upload Pipeline**: Navigating to document ingestion, uploading PDF files, checking status polling, and asserting that parsed database data reflects the visual summary. + +### 7.2 Webhook Schema Enhancement +* **Path:** `POST /api/v1/test/run` +* **Request Payload:** +```json +{ + "targetUrl": "https://preprod.am.munish.org/portfolio", + "profile": "RELEASE_GATE", + "commitSha": "a1b2c3d4e5f6g7h8", + "branch": "main", + "callbackUrl": "https://github.com/api/v3/repos/AM-Portfolio-grp/am-modern-ui/statuses/a1b2c3d4e5f6g7h8" +} +``` + +### 7.3 CI Pipeline Definition (`am-modern-ui`) +A standard pipeline file [ui-test-gate.yml](file:///a:/InfraCode/AM-Portfolio-grp/am-modern-ui/.github/workflows/ui-test-gate.yml) is run on every Pull Request targetting `main` or `release/*` branches. + +```yaml +name: Pre-Release Gate UI Test + +on: + pull_request: + branches: + - main + - 'release/*' + +jobs: + ui-visual-test: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Deploy Preview Environment + run: | + # Command to deploy branch to preprod-preview namespace... + echo "Deploying to preview environment..." + + - name: Trigger Agent Release Gate + id: trigger_agent + run: | + RESPONSE=$(curl -s -X POST "http://am-ui-test-agent.am-apps-preprod.svc.cluster.local:8130/api/v1/test/run" \ + -H "Content-Type: application/json" \ + -d '{ + "targetUrl": "http://am-portfolio-preview-${{ github.event.pull_request.head.sha }}:9005", + "profile": "RELEASE_GATE", + "commitSha": "${{ github.event.pull_request.head.sha }}", + "callbackUrl": "https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" + }') + echo "TEST_ID=$(echo $RESPONSE | jq -r .testId)" >> $GITHUB_ENV + + - name: Poll Test Status + run: | + # Loop to poll GET /api/v1/test/status/$TEST_ID until completed. + # Returns non-zero exit code if status is FAILED. + python scripts/poll_test_status.py --test-id ${{ env.TEST_ID }} +``` + +> [!WARNING] +> **No Direct Bypasses**: The release branch protection rules in GitHub must require the status context `ui-visual-test` to be green before pull requests can be merged. + +Weekly UI baseline promotion on `main`: [OPERATIONS_WEEKLY_UI_RELEASE.md](OPERATIONS_WEEKLY_UI_RELEASE.md). + +--- + +## 8. Fully Autonomous Exploratory & Goal-Driven Mode + +In addition to executing Gherkin scripts, the agent operates in a **Fully Autonomous mode** where it acts as a self-directed QA engineer. Instead of step-by-step instructions, the agent accepts high-level testing goals and code diffs, exploring the UI dynamically to find bugs. + +```mermaid +flowchart TD + Commit["Commit Trigger / Diff Info"] --> Analyze["AnalyzeDiffNode (Git Diff Parsing)"] + Analyze --> PlanGoal["GoalPlannerNode (LLM Strategy)"] + + subgraph Autonomous Loop + PlanGoal --> Action["ActionSelectorNode (Inspect DOM & Qwen2.5-VL Screen Analysis)"] + Action --> Interact["Playwright Browser Action"] + Interact --> CheckOracle["VisualOracleNode (Visual anomalies, JS errors, API failures)"] + CheckOracle -- "Goal Not Met & No Failure" --> PlanGoal + CheckOracle -- "Selector Broken" --> Heal["SelfHealNode"] + Heal --> Action + end + + CheckOracle -- "Goal Achieved" --> Pass["ReportPass (Merge allowed)"] + CheckOracle -- "Defect / Bug Found" --> Fail["ReportFail (Block merge & catalog bug)"] +``` + +> **Cost note:** VisualOracle runs vision on every step — suitable for exploratory mode, not daily auth CI. Use [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md) for cost-effective release gating. + +### 8.1 Git Diff Analyzer & Target Routing +When a commit is pushed, the webhook payload includes the Git patch or diff. The `AnalyzeDiffNode` extracts filenames and changes to target its exploration: + +```python +class DiffAnalyzer: + def identify_changed_targets(self, git_diff: str) -> List[str]: + """Parses git diff to map modified code components to UI screens. + + Example: + - Modifying 'allocation_pie.dart' maps to target route '/portfolio/allocation' + - Modifying 'auth_service.dart' maps to target route '/login' + """ + # Parses file names and returns prioritized list of route targets + pass +``` + +### 8.2 The Autonomous Loop & Visual Oracle +The agent maintains a state memory of visited pages, interactive elements clicked, and inputs entered. At each step, the `VisualOracleNode` runs the following automated checks: + +* **Visual Anomaly Detection**: Qwen2.5-VL reviews the screenshot to detect overlapping texts, clipped elements, alignment glitches, or unrendered charts. +* **Functional Error Detection**: The agent intercepts Playwright console messages and network logs to catch `500 Internal Server Errors`, uncaught JavaScript exceptions, or Keycloak handshake failures. +* **Goal State Evaluation**: The agent compares the current screen state against the high-level goal (e.g. *"Ensure that allocating 100% funds to cash is successfully registered and shows on the pie chart"*). + +### 8.3 State Schema Extensions for Autonomy +To support exploratory state tracking, the `AgentState` schema in [state.py](file:///a:/InfraCode/AM-Portfolio-grp/am-ui-test-agent/app/agent/state.py) is extended: + +```python +class AutonomousAgentState(AgentState): + testing_goal: str # High-level goal (e.g., "Verify allocation table sorts by custom field") + git_diff: Optional[str] # Unified diff patch of the current commit + visited_routes: List[str] # Visited path names to prevent circular navigation loops + action_log: List[Dict[str, Any]] # Sequential record of exploratory clicks, scrolls, and typings + visual_anomalies: List[str] # Anomalies identified by Qwen2.5-VL + design_review_results: List[Dict[str, Any]] # Per-screenshot hybrid review (planned) + design_review_summary: Dict[str, Any] # overall_verdict, review_required (planned) +``` + +--- + +## 9. Helm & HashiCorp Vault Configuration + +The `am-ui-test-agent` is packaged as a Helm chart and loads credentials via HashiCorp Vault. + +### 9.1 Vault Secret Mappings (`helm/vault-mappings.yaml`) +Securely binds environment configurations: +```yaml +vault: + secretPaths: + mongodb: + mappings: + MONGO_URI: "url" + qdrant: + mappings: + QDRANT_API_KEY: "QDRANT_API_KEY" + together-ai: + mappings: + TOGETHER_API_KEY: "TOGETHER_API_KEY" + identity-oidc: + mappings: + AM_MCP_CLIENT_SECRET: "AM_MCP_CLIENT_SECRET" +``` + +### 9.2 Helm Values & Ingress (`helm/values.yaml`) +Exposes test-triggering endpoints through Traefik Ingress: +```yaml +global: + vault: + enabled: true + role: "am-backend-role" + authPath: "auth/kubernetes" + serviceAccountName: "am-backend-sa" + +image: + repository: am-ui-test-agent + +replicaCount: 1 +port: 8130 + +service: + port: 8130 + +ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: >- + am-apps-preprod-global-cors@kubernetescrd, + am-apps-preprod-strip-prefix-apps@kubernetescrd + hosts: + - host: am.asrax.in + paths: + - path: /ui-test + pathType: Prefix +``` diff --git a/docs/ui-agent-ai-testing/DESIGN_REVIEW_HYBRID.md b/docs/ui-agent-ai-testing/DESIGN_REVIEW_HYBRID.md new file mode 100644 index 0000000..f32fb62 --- /dev/null +++ b/docs/ui-agent-ai-testing/DESIGN_REVIEW_HYBRID.md @@ -0,0 +1,298 @@ +# Hybrid Design Review — Qdrant Baselines + Vision LLM on Drift + +> **Status:** Specification (implementation planned) +> **Scope:** `am-ui-test-agent` design_review node, Qdrant `ui_patterns`, report schema +> **Audience:** Platform engineers, QA, release owners + +--- + +## 1. Problem + +The auth E2E flow today validates **behavior only**: + +| Step | Mechanism | +|------|-----------| +| Plan | Fixed 10 steps from `AUTH_FLOW_MAIN` profile — not LLM-generated | +| Execute | Playwright: navigate, Demo Login, screenshots | +| Assert | Code checks URL `/home`, text `Dashboard`, `Portfolio` | +| Report | Template from `action_log` — no LLM narrative | +| Self-heal | Skipped for auth/demo-login flows | +| Qdrant | Client exists; **not used** in the agent graph | + +A UI can **pass all text asserts** while layout is broken, or **fail visually** on every weekly redesign if we only use pixel diff without a promote workflow. + +**Goal:** Auto-validate design/production readiness at scale. Manual review only when `design_review.review_required === true`. + +--- + +## 2. Hybrid approach (definition) + +**Hybrid** combines three layers. Layers 1–2 are cheap; layer 3 runs **only when layer 2 detects change**. + +```mermaid +flowchart TD + L1[Layer 1 — Functional asserts] --> L2[Layer 2 — Qdrant similarity vs active baseline] + L2 --> high{similarity >= 0.92} + high -->|yes ~85% runs| pass[Auto PASS — 0 LLM calls] + high -->|no| L3[Layer 3 — Vision LLM classify drift] + L3 --> v{verdict} + v -->|matches_baseline| pass + v -->|intentional_redesign| warn[PASSED_WITH_DESIGN_DRIFT — promote baseline] + v -->|layout_regression / missing_content| fail[FAILED — block release] + v -->|uncertain| flag[review_required true — warn only] +``` + +| Layer | Question | Cost | +|-------|----------|------| +| **1 — Functional** | Does the app still work? | Playwright only (existing) | +| **2 — Similarity** | Does it look like last **approved** design? | Local embed + Qdrant search (self-hosted, ~free) | +| **3 — Vision LLM** | Is the difference a redesign or a bug? | Qwen2.5-VL via LiteLLM — **only on drift** | + +### Why not the alternatives? + +| Approach | Weekly UI releases | Cost | Verdict | +|----------|-------------------|------|---------| +| LLM checklist every screenshot | OK | **High** (~4 vision calls × every run) | Rejected for CI volume | +| Qdrant / golden screenshots only | **Fails every release** until manual baseline swap | Low | Incomplete alone | +| **Hybrid (chosen)** | **Good** — promote updates baseline | **Low** (~4 calls per release + rare regressions) | **Recommended** | + +--- + +## 3. What “correct design” means + +**Golden screenshots** stored in Qdrant collection `ui_patterns`, keyed by: + +| Payload field | Example | +|---------------|---------| +| `profile` | `AUTH_FLOW_MAIN` | +| `route` | `/home` | +| `step_label` | `10. Screenshot — authenticated main shell` | +| `design_version` | `4` (monotonic) | +| `status` | `active` \| `superseded` | +| `screenshot_ref` | Path under `REPORT_DIR/baselines/` | +| `commit_sha` | Git SHA when baseline was promoted | +| `approved_at` | ISO timestamp | + +Vector: **512-dimensional** cosine similarity (design doc §5). Implementation uses a **local hash embedder** by default (no API cost); optional LiteLLM CLIP model in preprod/prod. + +Only **one active baseline** per `(profile, route, step_label)`. Older versions remain as `superseded` for audit. + +--- + +## 4. Baseline modes + +| Mode | When | Behavior | +|------|------|----------| +| **`seed`** | First-time setup; empty Qdrant | After functional PASS, upsert first baselines. No design fail. | +| **`compare`** | PR checks, nightly, daily CI | Compare to active baseline; LLM only if similarity below threshold. | +| **`promote`** | UI merged to `main` (weekly release) | Functional PASS + LLM `intentional_redesign` → supersede old baseline, upsert new. | + +### API (planned) + +```http +POST /api/v1/test/run/auth +Content-Type: application/json + +{ + "targetUrl": "https://am.asrax.in", + "uiMode": "main", + "baselineMode": "compare" +} +``` + +```http +POST /api/v1/design/baseline/promote +Content-Type: application/json + +{ + "testId": "44dc0244-8480-4f47-b985-aab6fa576d8c", + "stepLabels": ["10. Screenshot — authenticated main shell"] +} +``` + +CLI: + +```powershell +python scripts/run_auth_test.py --target-file ../am-modern-ui/testing/targets.preprod.json --baseline-mode seed +python scripts/run_auth_test.py --baseline-mode promote +``` + +--- + +## 5. Balanced release gate + +**Locked decision:** Balanced — not strict, not loose. + +| Condition | Test status | Blocks merge? | +|-----------|-------------|---------------| +| Functional assert fails | `FAILED` | **Yes** | +| LLM: `layout_regression` or `missing_content` | `FAILED` | **Yes** | +| LLM: `intentional_redesign` (functional pass) | `PASSED_WITH_DESIGN_DRIFT` | **No** (warn until promote completes) | +| LLM: `uncertain` | `PASSED` or drift warn | **No** — `review_required: true` | +| Similarity ≥ 0.92 or LLM `matches_baseline` | `PASSED` | **No** | + +`DESIGN_GATE_STRICT=true` (optional env) would fail on any drift before promote — **not recommended** for weekly UI releases. + +### Config (planned) + +```python +DESIGN_REVIEW_ENABLED: bool = True +DESIGN_SIMILARITY_PASS: float = 0.92 # auto-pass, no LLM +DESIGN_SIMILARITY_REVIEW: float = 0.78 # always invoke LLM below this +DESIGN_GATE_STRICT: bool = False +DESIGN_BUG_MEMORY_THRESHOLD: float = 0.95 # optional fast-fail vs bug_memory +BASELINE_MODE: Literal["compare", "seed", "promote"] = "compare" +CLIP_EMBEDDING_MODEL: Optional[str] = None # unset = local embedder +``` + +--- + +## 6. LangGraph integration + +Current graph: + +```text +plan → execute → assert → report +``` + +Target graph: + +```text +plan → execute → assert → design_review → report +``` + +Skip `design_review` when: + +- `DESIGN_REVIEW_ENABLED=false` +- Qdrant unreachable (degrade to today’s behavior) +- Functional failures already recorded (go straight to report) + +New state fields: + +```python +design_review_results: List[Dict[str, Any]] +design_review_summary: Dict[str, Any] # overall_verdict, review_required, etc. +visual_anomalies: List[str] # populated from design + bug_memory +``` + +Vision LLM prompt returns structured JSON: + +```json +{ + "verdict": "matches_baseline | intentional_redesign | layout_regression | missing_content | uncertain", + "confidence": 0.91, + "summary": "Nav and dashboard regions intact; color palette shifted.", + "issues": [] +} +``` + +Functional checklist passed into prompt: e.g. `Dashboard`, `Portfolio` visible, URL contains `/home`. + +--- + +## 7. Report schema extension + +Schema: `am-ui-test-report/v1` + +```json +{ + "schema": "am-ui-test-report/v1", + "status": "PASSED", + "design_review": { + "enabled": true, + "auto_reviewed": true, + "review_required": false, + "overall_verdict": "pass", + "baseline_mode": "compare", + "screenshots": [ + { + "step_label": "10. Screenshot — authenticated main shell", + "route": "/home", + "similarity": 0.94, + "verdict": "matches_baseline", + "llm_summary": "Layout consistent with baseline v4.", + "baseline_design_version": 4, + "llm_called": false + } + ] + } +} +``` + +**Manual review policy:** Open HTML/JSON only when `design_review.review_required === true`. + +HTML adds a **Design Review** section: + +- Green auto-pass card when all screenshots pass similarity. +- Side-by-side baseline vs current thumbnail when drift detected. +- LLM summary + verdict badge. + +--- + +## 8. Cost model (weekly UI releases) + +Auth flow captures **~4 screenshots** per run. + +| Strategy | LLM calls / week (1 release + 6 stable days) | +|----------|-----------------------------------------------| +| Vision on every screenshot, daily | 7 × 4 = **28+** | +| **Hybrid** | ~4 on promote day + 0–4 if regression ≈ **4–8** | + +Between releases, stable UI → **0 LLM calls** per run. + +Qdrant + local embedding → negligible marginal cost (cluster `am-ai`, port 6333). + +--- + +## 9. Qdrant collections (design doc alignment) + +| Collection | Dim | Hybrid usage | +|------------|-----|--------------| +| **`ui_patterns`** | 512 | **Primary** — golden screenshots per route/step | +| **`bug_memory`** | 512 | Phase 2 — high similarity to known glitch → fail without LLM | +| **`selectors`** | 1536 | Self-heal only (auth skips today) | +| **`test_cases`** | 1536 | Autonomous / Gherkin mode (future) | + +--- + +## 10. CI / branch policy + +| Branch / event | `baselineMode` | May update Qdrant? | +|----------------|----------------|--------------------| +| PR → `main` | `compare` | **No** | +| Push / merge to `main` (weekly UI) | `promote` | **Yes** | +| Nightly cron | `compare` | **No** | +| One-time setup | `seed` | **Yes** | + +PRs **compare** against production baselines on `main` — they must not overwrite golden screenshots. Only the post-merge promote job on `main` advances `design_version`. + +Example GitHub Actions (after UI merge): + +```yaml +- name: Promote design baselines + if: github.ref == 'refs/heads/main' + run: | + cd am-modern-ui && npm run test:auth:preprod -- --baseline-mode=promote +``` + +--- + +## 11. Implementation phases + +| Phase | Deliverable | Status | +|-------|-------------|--------| +| P0 | Auth flow + template reports | **Done** | +| P1 | `embedder.py`, Qdrant `ui_patterns` CRUD, `design_review` node, report fields | Planned | +| P2 | `baselineMode` on API/CLI, promote endpoint, CI on `main` | Planned | +| P3 | Gateway MCP `promote_design_baseline`, `bug_memory` fast-fail | Planned | + +See [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) for file-level checklist. + +--- + +## 12. References + +- [AM_UI_TEST_AGENT_DESIGN.md](AM_UI_TEST_AGENT_DESIGN.md) — AssertNode baseline comparison, Qdrant §5, VisualOracle §8 +- [OPERATIONS_WEEKLY_UI_RELEASE.md](OPERATIONS_WEEKLY_UI_RELEASE.md) — step-by-step runbook +- Code: `am-ui-test-agent/app/agent/graph.py`, `app/memory/qdrant.py`, `app/profiles/modern_ui/auth_flow.py` +- Reports: `AM-Portfolio-grp/reports/ui-test/{testId}.json` diff --git a/docs/ui-agent-ai-testing/IMPLEMENTATION_STATUS.md b/docs/ui-agent-ai-testing/IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..6d93606 --- /dev/null +++ b/docs/ui-agent-ai-testing/IMPLEMENTATION_STATUS.md @@ -0,0 +1,89 @@ +# Implementation Status — UI Agent AI Testing + +> Last updated: 2026-06-14 +> Spec: [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md) + +--- + +## Summary + +| Area | Status | +|------|--------| +| Auth E2E (Playwright, fixed steps) | **Done** | +| Rich HTML/JSON reports (`am-ui-test-report/v1`) | **Done** | +| MCP gateway `run_modern_ui_auth_test` | **Done** | +| LiteLLM MCP tool sync from manifest | **Done** | +| Qdrant `ui_patterns` search/upsert/supersede | **Done** | +| Local image embedder (`embedder.py`) | **Done** | +| Design review node (Hybrid) | **Done** | +| Baseline lifecycle seed/compare/promote | **Done** | +| Promote API `POST /api/v1/design/baseline/promote` | **Done** | +| CLI `--baseline-mode` | **Done** | +| Gateway `promote_design_baseline` MCP tool | Planned (P3) | + +--- + +## LangGraph pipeline + +| Node | File | Status | +|------|------|--------| +| plan | `app/agent/planner.py` | Done | +| execute | `app/agent/executor.py` | Done | +| assert | `app/agent/assertions.py` | Done | +| **design_review** | `app/agent/design_review.py` | **Done** | +| self_heal | `app/agent/self_healer.py` | Done | +| report | `app/agent/reporter.py` | Done | + +Graph: `plan → execute → assert → design_review → report` + +--- + +## Configuration (`.env`) + +```env +DESIGN_REVIEW_ENABLED=true +DESIGN_SIMILARITY_PASS=0.92 +DESIGN_SIMILARITY_REVIEW=0.78 +DESIGN_GATE_STRICT=false +BASELINE_MODE=compare +QDRANT_HOST=localhost +QDRANT_PORT=6333 +# CLIP_EMBEDDING_MODEL= # optional LiteLLM embedding model +``` + +--- + +## Quick commands + +```powershell +# Seed baselines (first time, Qdrant required) +python scripts/run_auth_test.py --target-file ../am-modern-ui/testing/targets.preprod.json --baseline-mode seed + +# Normal compare (default) +npm run test:auth:preprod + +# Promote after weekly UI merge +python scripts/run_auth_test.py --target-file ../am-modern-ui/testing/targets.preprod.json --baseline-mode promote + +# Manual promote from report +curl -X POST http://localhost:8130/api/v1/design/baseline/promote -H "Content-Type: application/json" -d "{\"testId\":\"YOUR-TEST-ID\"}" +``` + +--- + +## Tests + +```powershell +cd am-ui-test-agent +python -m pytest tests/ -q +``` + +Includes: `test_design_status.py`, `test_embedder.py`, `test_design_review_report.py` + +--- + +## Related + +- [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md) +- [OPERATIONS_WEEKLY_UI_RELEASE.md](OPERATIONS_WEEKLY_UI_RELEASE.md) +- [AM_UI_TEST_AGENT_DESIGN.md](AM_UI_TEST_AGENT_DESIGN.md) diff --git a/docs/ui-agent-ai-testing/OPERATIONS_WEEKLY_UI_RELEASE.md b/docs/ui-agent-ai-testing/OPERATIONS_WEEKLY_UI_RELEASE.md new file mode 100644 index 0000000..02f8ac9 --- /dev/null +++ b/docs/ui-agent-ai-testing/OPERATIONS_WEEKLY_UI_RELEASE.md @@ -0,0 +1,237 @@ +# Operations Runbook — Weekly UI Release (Hybrid Design Review) + +> **Prerequisite:** [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md) +> **Cadence:** UI changes ship to production approximately **once per week** + +--- + +## 1. Overview + +Each weekly UI release follows a **three-mode baseline lifecycle**: + +```text +seed (once) → compare (daily / PR) → promote (on main after merge) +``` + +You should **not** read every test report. Only open reports where: + +```json +"design_review": { "review_required": true } +``` + +--- + +## 2. One-time setup (seed) + +Run once per environment (`preprod`, then `prod` when ready) after Qdrant is reachable. + +### Local + +```powershell +# Terminal 1 — agent +cd am-ui-test-agent +npm run preprod + +# Terminal 2 — seed baselines from known-good auth run +cd am-ui-test-agent +python scripts/run_auth_test.py ` + --target-file ../am-modern-ui/testing/targets.preprod.json ` + --target main ` + --baseline-mode seed ` + --open-report +``` + +### API + +```http +POST http://localhost:8130/api/v1/test/run/auth +Content-Type: application/json + +{ + "targetUrl": "https://am.asrax.in", + "uiMode": "main", + "baselineMode": "seed" +} +``` + +**Expected outcome:** + +- Functional status: `PASSED` +- Qdrant `ui_patterns`: 3–4 new points with `status=active`, `design_version=1` +- Report: `design_review.overall_verdict = pass`, baselines seeded message +- **No LLM calls** (seed mode skips design fail) + +Verify Qdrant (optional): + +```bash +curl http://qdrant.am-ai.svc.cluster.local:6333/collections/ui_patterns +``` + +--- + +## 3. Weekly release workflow + +### Timeline + +```mermaid +sequenceDiagram + participant Mon as Monday UI merge + participant Promote as promote job on main + participant Qdrant as Qdrant vN to vN+1 + participant TueSun as Tue-Sun CI compare + + Mon->>Promote: merge am-modern-ui PR + Promote->>Qdrant: auth test baselineMode=promote + Note over Promote: ~1-4 vision LLM calls + Promote->>Qdrant: upsert vN+1 supersede vN + loop Daily + TueSun->>Qdrant: compare vs vN+1 + Note over TueSun: 0 LLM if stable + end +``` + +### Step 1 — PR phase (`compare`) + +While the UI PR is open, CI runs auth tests with **`baselineMode=compare`** (default). + +| Result | Meaning | Action | +|--------|---------|--------| +| `PASSED`, similarity high | No visual drift vs current production baseline | None | +| `PASSED_WITH_DESIGN_DRIFT` | PR changes pixels; LLM says redesign | **Expected** on UI PRs — does not block merge in balanced mode | +| `FAILED` functional | Broken flow | Fix before merge | +| `FAILED` + `layout_regression` | Broken layout | Fix before merge | +| `review_required: true` | LLM uncertain | Human skim **this PR’s report only** | + +PRs **must not** run `promote` — they compare against `main` baselines without overwriting them. + +### Step 2 — Merge to `main` + +After merge, run **promote** exactly once (CI job or manual): + +```powershell +cd am-modern-ui +npm run test:auth:preprod -- --baseline-mode=promote +``` + +Or via agent API: + +```http +POST /api/v1/test/run/auth +{ "uiMode": "main", "baselineMode": "promote" } +``` + +**What promote does:** + +1. Runs full auth flow (10 steps). +2. Requires functional PASS. +3. Compares screenshots to **previous week’s** active baselines → low similarity (expected). +4. Calls vision LLM (~1–4 times) → expects `intentional_redesign`. +5. Supersedes old baselines; upserts new `design_version`. +6. Status: `PASSED` or `PASSED_WITH_DESIGN_DRIFT`. + +**Your weekly checklist (5 minutes):** + +- [ ] Promote job green on `main` +- [ ] If `review_required: true`, open **one** report and confirm redesign is intentional +- [ ] Done — ignore other reports until next incident + +### Step 3 — Rest of week (`compare`) + +Nightly cron and ad-hoc runs use **`compare`** only. + +- Stable UI → auto-pass, **0 LLM** +- Accidental layout break → `layout_regression` → **FAILED** → fix + redeploy + +--- + +## 4. Mid-week hotfix (UI pixels change again) + +If a hotfix changes visuals before the next scheduled release: + +```powershell +npm run test:auth:preprod -- --baseline-mode=promote +``` + +Same as weekly promote. Increments `design_version` again. + +--- + +## 5. Manual promote from a specific report + +When automated promote fails but you approve the screenshots manually: + +```http +POST /api/v1/design/baseline/promote +Content-Type: application/json + +{ + "testId": "44dc0244-8480-4f47-b985-aab6fa576d8c", + "stepLabels": [ + "3. Screenshot — login form visible", + "10. Screenshot — authenticated main shell" + ] +} +``` + +(Optional — planned MCP tool: `promote_design_baseline` on gateway.) + +--- + +## 6. Environment matrix + +| Environment | Target URL source | Seed when | Promote when | +|-------------|-------------------|-----------|--------------| +| Local | `targets.local.json` | Dev setup | Rarely | +| Preprod | `targets.preprod.json` → `https://am.asrax.in` | First Qdrant connect | Weekly `main` merge | +| Prod | TBD targets.prod.json | Before prod gate enabled | Prod release tag | + +Reports path: `AM-Portfolio-grp/reports/ui-test/` (local) or PVC in cluster. + +--- + +## 7. Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Every run `PASSED_WITH_DESIGN_DRIFT` | Promote not run after last UI merge | Run `promote` on `main` | +| Design review skipped | Qdrant down or `DESIGN_REVIEW_ENABLED=false` | Check agent logs; restore Qdrant | +| LLM on every run | Baselines missing; similarity always low | Re-run `seed` or `promote` | +| PR fails design but UI intentional | Strict gate or missing promote on main | Use balanced gate; promote after merge | +| False `layout_regression` | LLM noise | Tune thresholds; flag `uncertain` for human | + +--- + +## 8. CI templates (planned) + +### PR — compare only + +```yaml +# am-modern-ui/.github/workflows/ui-test-gate.yml +- name: Auth E2E compare + run: npm run test:auth:preprod + # default baselineMode=compare +``` + +### Main — promote after merge + +```yaml +on: + push: + branches: [main] + +jobs: + promote-design-baselines: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Promote Qdrant baselines + run: npm run test:auth:preprod -- --baseline-mode=promote +``` + +--- + +## 9. Related docs + +- [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md) — architecture and gate rules +- [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) — what works today vs planned CLI flags +- [README.md](README.md) — doc index diff --git a/docs/ui-agent-ai-testing/README.md b/docs/ui-agent-ai-testing/README.md new file mode 100644 index 0000000..ba4d568 --- /dev/null +++ b/docs/ui-agent-ai-testing/README.md @@ -0,0 +1,81 @@ +# UI Agent AI Testing + +Documentation for the **AM UI Test Agent** (`am-ui-test-agent/`), MCP gateway ui-test tools, and the **Hybrid design review** pipeline (Playwright + Qdrant + vision LLM). + +This folder is the canonical home for ui-agent AI testing specs. The former top-level file `AM_UI_TEST_AGENT_DESIGN.md` lives here as [`AM_UI_TEST_AGENT_DESIGN.md`](AM_UI_TEST_AGENT_DESIGN.md). + +--- + +## Documents + +| Document | Purpose | +|----------|---------| +| [AM_UI_TEST_AGENT_DESIGN.md](AM_UI_TEST_AGENT_DESIGN.md) | Full technical design: LangGraph, Playwright, Qdrant collections, CI gates, autonomous mode, Helm | +| [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md) | **Hybrid design review** — golden baselines, similarity gate, LLM on drift, balanced release gate | +| [OPERATIONS_WEEKLY_UI_RELEASE.md](OPERATIONS_WEEKLY_UI_RELEASE.md) | Runbook for weekly UI releases: seed, compare, promote, CI wiring | +| [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) | What is built today vs planned (auth flow, reports, Qdrant, design_review node) | + +--- + +## Repositories & entry points + +```text +AM-Portfolio-grp/ +├── am-ui-test-agent/ # Playwright + LangGraph agent (port 8130) +├── am-modern-ui/testing/ # Thin wrapper: manifest, targets, run_auth.py +├── am-platform/am-mcp-gateway/ # MCP proxy: run-auth, LiteLLM tool sync +└── reports/ui-test/ # Local HTML + JSON reports +``` + +| Action | Command | +|--------|---------| +| Start agent (preprod) | `cd am-ui-test-agent && npm run preprod` | +| Run auth E2E | `cd am-modern-ui && npm run test:auth:preprod` | +| View report | `reports/ui-test/{testId}.html` | + +--- + +## Architecture (summary) + +```mermaid +flowchart LR + subgraph trigger [Trigger] + CI[CI / Cron / MCP] + CLI[run_auth_test.py] + end + subgraph agent [am-ui-test-agent] + Plan[plan] + Exec[execute] + Assert[assert] + Design[design_review] + Report[report] + end + subgraph storage [Storage] + Qdrant[Qdrant ui_patterns] + Disk[reports/ui-test] + end + subgraph llm [LLM on drift only] + Vision[Qwen2.5-VL via LiteLLM] + end + CI --> agent + CLI --> agent + Plan --> Exec --> Assert --> Design --> Report + Design --> Qdrant + Design --> Vision + Report --> Disk +``` + +**Locked product decisions (2026-06):** + +- **Gate:** Balanced — fail on functional errors + clear visual regressions; warn on intentional redesign until promote. +- **Comparison:** Hybrid — Qdrant golden screenshots + local embedding + vision LLM only when similarity drops. +- **Cadence:** Weekly UI releases — `promote` on `main` after merge; `compare` on PRs and nightly. + +See [DESIGN_REVIEW_HYBRID.md](DESIGN_REVIEW_HYBRID.md) for full detail. + +--- + +## Related platform docs + +- [AM_MCP_GATEWAY_DESIGN.md](../AM_MCP_GATEWAY_DESIGN.md) — gateway OpenAPI + MCP tool proxy +- [AM_AI_PLATFORM_DESIGN.md](../AM_AI_PLATFORM_DESIGN.md) — LiteLLM, Qdrant namespace `am-ai` diff --git a/langfuse-default-values.txt b/langfuse-default-values.txt new file mode 100644 index 0000000..de36135 Binary files /dev/null and b/langfuse-default-values.txt differ diff --git a/langfuse-vals.txt b/langfuse-vals.txt new file mode 100644 index 0000000..de36135 Binary files /dev/null and b/langfuse-vals.txt differ diff --git a/libraries/am-platform-security/am_platform_security/config.py b/libraries/am-platform-security/am_platform_security/config.py index 91f91f3..1944beb 100644 --- a/libraries/am-platform-security/am_platform_security/config.py +++ b/libraries/am-platform-security/am_platform_security/config.py @@ -10,8 +10,9 @@ class SecuritySettings(BaseSettings): - oidc_issuer: str = Field(..., alias="OIDC_ISSUER") - oidc_jwks_url: str = Field(..., alias="OIDC_JWKS_URL") + auth_disabled: bool = Field(default=False, alias="AUTH_DISABLED") + oidc_issuer: str = Field(default="http://localhost/disabled", alias="OIDC_ISSUER") + oidc_jwks_url: str = Field(default="http://localhost/disabled/certs", alias="OIDC_JWKS_URL") service_role_name: str = Field(default="service", alias="SERVICE_ROLE_NAME") model_config = SettingsConfigDict( diff --git a/libraries/am-platform-security/am_platform_security/dependencies.py b/libraries/am-platform-security/am_platform_security/dependencies.py index 0f2e41c..c7d6a66 100644 --- a/libraries/am-platform-security/am_platform_security/dependencies.py +++ b/libraries/am-platform-security/am_platform_security/dependencies.py @@ -12,6 +12,9 @@ _bearer = HTTPBearer(auto_error=False) +_LOCAL_DEV_SUBJECT = "local-dev-user" +_LOCAL_DEV_CLIENT_ID = "local-dev-client" + @lru_cache(maxsize=1) def get_token_validator() -> TokenValidator: @@ -19,6 +22,18 @@ def get_token_validator() -> TokenValidator: return TokenValidator(settings) +def _dev_auth_context(access_token: str = "local-dev-token") -> AuthContext: + return AuthContext( + subject=_LOCAL_DEV_SUBJECT, + client_id=_LOCAL_DEV_CLIENT_ID, + token_type="service", + roles=["user", "service"], + scopes=[], + claims={"sub": _LOCAL_DEV_SUBJECT}, + access_token=access_token, + ) + + def _extract_bearer_token(credentials: HTTPAuthorizationCredentials | None) -> str: if credentials is None or credentials.scheme.lower() != "bearer": raise HTTPException( @@ -35,7 +50,12 @@ def require_auth_context( def dependency( credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), validator: TokenValidator = Depends(get_token_validator), + settings: SecuritySettings = Depends(get_security_settings), ) -> AuthContext: + if settings.auth_disabled: + token = credentials.credentials if credentials else "local-dev-token" + return _dev_auth_context(token) + token = _extract_bearer_token(credentials) return validator.validate( token, @@ -77,6 +97,9 @@ def dependency( ), settings: SecuritySettings = Depends(get_security_settings), ) -> AuthContext: + if settings.auth_disabled: + return context + if settings.service_role_name not in context.roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/libraries/am-platform-security/am_platform_security/validator.py b/libraries/am-platform-security/am_platform_security/validator.py index 584274e..f0fd0ab 100644 --- a/libraries/am-platform-security/am_platform_security/validator.py +++ b/libraries/am-platform-security/am_platform_security/validator.py @@ -101,7 +101,7 @@ def validate( ) return AuthContext( - subject=claims.get("sub", ""), + subject=claims.get("userId") or claims.get("sub", ""), client_id=client_id, token_type=token_type, roles=roles, diff --git a/libraries/am-platform-security/tests/test_security.py b/libraries/am-platform-security/tests/test_security.py index 88c9c92..3ce1b25 100644 --- a/libraries/am-platform-security/tests/test_security.py +++ b/libraries/am-platform-security/tests/test_security.py @@ -19,9 +19,80 @@ def test_security_settings_reads_expected_env_vars(): assert settings.oidc_jwks_url.endswith("/certs") +def test_auth_disabled_skips_oidc_requirements(monkeypatch): + monkeypatch.setenv("AUTH_DISABLED", "true") + monkeypatch.delenv("OIDC_ISSUER", raising=False) + monkeypatch.delenv("OIDC_JWKS_URL", raising=False) + settings = SecuritySettings() + assert settings.auth_disabled is True + + def test_issuer_matches_http_https_variants(): configured = "https://auth.munish.org/auth/realms/am-realm" token_iss = "http://auth.munish.org/auth/realms/am-realm" assert _issuer_matches(token_iss, configured) assert _issuer_matches(configured, configured) assert not _issuer_matches("http://other.example/realms/am-realm", configured) + + +def test_token_validator_extracts_userid_claim(monkeypatch): + import jwt + from unittest.mock import MagicMock + from am_platform_security.validator import TokenValidator + + settings = SecuritySettings() + settings.oidc_issuer = "https://issuer.example/realms/am-realm" + settings.oidc_jwks_url = "https://issuer.example/realms/am-realm/protocol/openid-connect/certs" + + validator = TokenValidator(settings) + + # Mock JWK client + mock_key = MagicMock() + mock_key.key = "mock_public_key" + monkeypatch.setattr(validator._jwk_client, "get_signing_key_from_jwt", lambda t: mock_key) + + # Mock jwt.decode to return claims with userId + mock_claims = { + "iss": "https://issuer.example/realms/am-realm", + "sub": "user-sub-123", + "userId": "user-id-abc", + "azp": "am-web-client", + "scope": "openid email", + "realm_access": {"roles": ["user"]}, + "token_type": "user", + } + monkeypatch.setattr(jwt, "decode", lambda *args, **kwargs: mock_claims) + + auth_ctx = validator.validate("fake_token") + assert auth_ctx.subject == "user-id-abc" + assert auth_ctx.claims.get("userId") == "user-id-abc" + + +def test_token_validator_falls_back_to_sub(monkeypatch): + import jwt + from unittest.mock import MagicMock + from am_platform_security.validator import TokenValidator + + settings = SecuritySettings() + settings.oidc_issuer = "https://issuer.example/realms/am-realm" + settings.oidc_jwks_url = "https://issuer.example/realms/am-realm/protocol/openid-connect/certs" + + validator = TokenValidator(settings) + + mock_key = MagicMock() + mock_key.key = "mock_public_key" + monkeypatch.setattr(validator._jwk_client, "get_signing_key_from_jwt", lambda t: mock_key) + + mock_claims = { + "iss": "https://issuer.example/realms/am-realm", + "sub": "user-sub-123", + "azp": "am-web-client", + "scope": "openid email", + "realm_access": {"roles": ["user"]}, + "token_type": "user", + } + monkeypatch.setattr(jwt, "decode", lambda *args, **kwargs: mock_claims) + + auth_ctx = validator.validate("fake_token") + assert auth_ctx.subject == "user-sub-123" + diff --git a/package.json b/package.json index e7967ca..fdb674f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "am-identity", "am-subscription", "am-notification", + "am-mcp-gateway", "libraries/am-platform-common", "libraries/am-platform-security" ], @@ -44,6 +45,16 @@ "notification:lint": "npm run lint -w @am-platform/notification", "notification:compile": "npm run compile -w @am-platform/notification", + "mcp-gateway:dev": "npm run dev -w @am-platform/mcp-gateway", + "mcp-gateway:start": "npm run mcp-gateway:dev", + "mcp-gateway:preprod": "npm run preprod -w @am-platform/mcp-gateway", + "mcp-gateway:prod": "npm run prod -w @am-platform/mcp-gateway", + "mcp-gateway:lint": "npm run lint -w @am-platform/mcp-gateway", + "mcp-gateway:compile": "npm run compile -w @am-platform/mcp-gateway", + "mcp-gateway:test": "npm run test -w @am-platform/mcp-gateway", + "mcp-gateway:test:litellm-langfuse": "npm run test:litellm-langfuse -w @am-platform/mcp-gateway", + "mcp-gateway:sync:litellm-mcp": "npm run sync:litellm-mcp-tools -w @am-platform/mcp-gateway", + "common:test": "npm run test -w @am-platform/common", "common:lint": "npm run lint -w @am-platform/common", "security:test": "npm run test -w @am-platform/security", @@ -53,10 +64,14 @@ "infra:lago:deploy": "npm run lago:deploy -w @am-platform/automation", "infra:lago:plans": "npm run lago:plans -w @am-platform/automation", "infra:novu:deploy": "npm run novu:deploy -w @am-platform/automation", + "infra:mcp-gateway:deploy": "npm run mcp-gateway:deploy -w @am-platform/automation", "infra:compose:up": "npm run compose:up -w @am-platform/automation", "infra:compose:down": "npm run compose:down -w @am-platform/automation", "infra:compose:logs": "npm run compose:logs -w @am-platform/automation", "infra:postgres:tunnel": "kubectl --kubeconfig ../VPS/kubeconfig.vps -n infra port-forward svc/postgresql 15432:5432", + "infra:vault:tunnel": "kubectl --kubeconfig ../VPS/kubeconfig.vps -n vault port-forward svc/vault 8200:8200", + "vault:sync:preprod": "npm run infra:vault:sync:preprod -w @am-platform/automation", + "vault:sync:dev": "npm run infra:vault:sync:dev -w @am-platform/automation", "infra:tcp:redeploy": "python ../am-infra/scripts/redeploy_tcp_proxies_remote.py", "postman:build": "python postman/build_platform_postman.py", "infra:tf:keycloak:init": "npm run tf:keycloak:init -w @am-platform/automation", @@ -71,6 +86,10 @@ "infra:tf:notification:plan": "npm run tf:notification:plan -w @am-platform/automation", "infra:tf:notification:apply": "npm run tf:notification:apply -w @am-platform/automation", "infra:tf:notification:output": "npm run tf:notification:output -w @am-platform/automation", + "infra:tf:ai-gateway:init": "npm run tf:ai-gateway:init -w @am-platform/automation", + "infra:tf:ai-gateway:plan": "npm run tf:ai-gateway:plan -w @am-platform/automation", + "infra:tf:ai-gateway:apply": "npm run tf:ai-gateway:apply -w @am-platform/automation", + "infra:tf:ai-gateway:output": "npm run tf:ai-gateway:output -w @am-platform/automation", "run:identity": "npm run identity:dev", "run:identity:prod": "npm run identity:dev:prod", @@ -79,6 +98,12 @@ "dev:notification": "npm run notification:dev", "dev:subscription": "npm run subscription:dev", "dev:identity": "npm run identity:dev", + "run:mcp-gateway": "npm run mcp-gateway:dev", + "run:mcp-gateway:preprod": "npm run mcp-gateway:preprod", + "run:mcp-gateway:prod": "npm run mcp-gateway:prod", + "dev:mcp-gateway": "npm run mcp-gateway:dev", + "dev:mcp-gateway:preprod": "npm run mcp-gateway:preprod", + "dev:mcp-gateway:prod": "npm run mcp-gateway:prod", "test:common": "npm run common:test", "test:security": "npm run security:test", "lint:fix": "node automation/scripts/run-with-logs.js npm run lint:fix --workspaces --if-present", @@ -87,6 +112,7 @@ "deploy:keycloak": "npm run infra:keycloak:deploy", "deploy:lago": "npm run infra:lago:deploy", "deploy:novu": "npm run infra:novu:deploy", + "deploy:mcp-gateway": "npm run infra:mcp-gateway:deploy", "lago:plans": "npm run infra:lago:plans", "tf:keycloak:init": "npm run infra:tf:keycloak:init", "tf:keycloak:plan": "npm run infra:tf:keycloak:plan", @@ -99,7 +125,11 @@ "tf:notification:init": "npm run infra:tf:notification:init", "tf:notification:plan": "npm run infra:tf:notification:plan", "tf:notification:apply": "npm run infra:tf:notification:apply", - "tf:notification:output": "npm run infra:tf:notification:output" + "tf:notification:output": "npm run infra:tf:notification:output", + "tf:ai-gateway:init": "npm run infra:tf:ai-gateway:init", + "tf:ai-gateway:plan": "npm run infra:tf:ai-gateway:plan", + "tf:ai-gateway:apply": "npm run infra:tf:ai-gateway:apply", + "tf:ai-gateway:output": "npm run infra:tf:ai-gateway:output" }, "author": "Antigravity", "license": "ISC" diff --git a/postman/AM-Platform.local.postman_environment.json b/postman/AM-Platform.local.postman_environment.json index 2a12aad..ada4dfa 100644 --- a/postman/AM-Platform.local.postman_environment.json +++ b/postman/AM-Platform.local.postman_environment.json @@ -26,6 +26,12 @@ "type": "default", "enabled": true }, + { + "key": "mcp_gateway_base_url", + "value": "http://localhost:8120", + "type": "default", + "enabled": true + }, { "key": "base_url", "value": "http://localhost:8113", @@ -56,6 +62,18 @@ "type": "secret", "enabled": true }, + { + "key": "mcp_client_id", + "value": "am-mcp-service", + "type": "default", + "enabled": true + }, + { + "key": "mcp_client_secret", + "value": "", + "type": "secret", + "enabled": true + }, { "key": "portfolio_client_id", "value": "am-portfolio-service", diff --git a/postman/AM-Platform.postman_collection.json b/postman/AM-Platform.postman_collection.json index ae774dc..a237c0f 100644 --- a/postman/AM-Platform.postman_collection.json +++ b/postman/AM-Platform.postman_collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "am-platform-collection-v1", "name": "AM Platform", - "description": "Unified Postman collection for **am-platform** thin-layer services.\n\n## Environments\n| File | Use when |\n|------|----------|\n| `AM-Platform.local.postman_environment.json` | `npm run platform:dev` (localhost) |\n| `AM-Platform.preprod.postman_environment.json` | Gateway at `am-dev.asrax.in/api` |\n\n## Auto-capture (collection scripts)\n**Pre-request:** fresh `idempotency_key` for meter/check POSTs; `X-Request-Id` header.\n\n**Post-response:** saves `access_token`, `refresh_token`, `service_access_token`, `user_sub`, `subscription_id`, `plan_code`, `notification_id`, Google SSO vars.\n\n## Modules\n| Folder | Service |\n|--------|--------|\n| Identity | am-identity |\n| Subscription | am-subscription |\n| Notification | am-notification |", + "description": "Unified Postman collection for **am-platform** thin-layer services.\n\n## Environments\n| File | Use when |\n|------|----------|\n| `AM-Platform.local.postman_environment.json` | `npm run platform:dev` (localhost) |\n| `AM-Platform.preprod.postman_environment.json` | Gateway at `am-dev.asrax.in/api` |\n\n## Auto-capture (collection scripts)\n**Pre-request:** fresh `idempotency_key` for meter/check POSTs; `X-Request-Id` header.\n\n**Post-response:** saves `access_token`, `refresh_token`, `service_access_token`, `user_sub`, `subscription_id`, `plan_code`, `notification_id`, Google SSO vars.\n\n## Modules\n| Folder | Service |\n|--------|--------|\n| Identity | am-identity |\n| Subscription | am-subscription |\n| Notification | am-notification |\n| MCP Gateway | am-mcp-gateway |", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "event": [ @@ -215,6 +215,10 @@ "key": "keycloak_url", "value": "http://auth.munish.org/auth" }, + { + "key": "mcp_gateway_base_url", + "value": "http://localhost:8120" + }, { "key": "notification_base_url", "value": "http://localhost:8111" @@ -1575,6 +1579,90 @@ ] } ] + }, + { + "name": "MCP Gateway", + "description": "API collection for **am-mcp-gateway** (port 8120).\n\n## Quick start\n1. Run **Health → Health Check**\n2. Get an `access_token` from Identity login or client credentials grant.\n3. Run **Chat → Chat Sync** or **Chat → Chat Stream**.", + "item": [ + { + "name": "00 Health", + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": "{{mcp_gateway_base_url}}/health", + "description": "Liveness check for the gateway." + }, + "response": [] + } + ] + }, + { + "name": "01 Chat", + "item": [ + { + "name": "Chat Sync", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"message\": \"What is 2+2?\",\n \"model\": \"together_ai/meta-llama/Llama-3-8b-chat-hf\",\n \"temperature\": 0.2,\n \"stream\": false\n}" + }, + "url": "{{mcp_gateway_base_url}}/api/v1/chat/sync", + "description": "Sends a synchronous chat request to the LLM via LiteLLM." + }, + "response": [] + }, + { + "name": "Chat Stream", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"message\": \"Explain quantum computing in one sentence.\",\n \"model\": \"together_ai/meta-llama/Llama-3-8b-chat-hf\",\n \"temperature\": 0.2,\n \"stream\": true\n}" + }, + "url": "{{mcp_gateway_base_url}}/api/v1/chat", + "description": "Sends a streaming chat request to the LLM via LiteLLM (returns Server-Sent Events)." + }, + "response": [] + } + ] + } + ] } ] } diff --git a/postman/AM-Platform.preprod.postman_environment.json b/postman/AM-Platform.preprod.postman_environment.json index d2c9554..4d9077f 100644 --- a/postman/AM-Platform.preprod.postman_environment.json +++ b/postman/AM-Platform.preprod.postman_environment.json @@ -26,6 +26,12 @@ "type": "default", "enabled": true }, + { + "key": "mcp_gateway_base_url", + "value": "https://am.asrax.in/mcp", + "type": "default", + "enabled": true + }, { "key": "base_url", "value": "https://am.asrax.in/identity", @@ -56,6 +62,18 @@ "type": "secret", "enabled": true }, + { + "key": "mcp_client_id", + "value": "am-mcp-service", + "type": "default", + "enabled": true + }, + { + "key": "mcp_client_secret", + "value": "", + "type": "secret", + "enabled": true + }, { "key": "portfolio_client_id", "value": "am-portfolio-service", @@ -204,4 +222,4 @@ "_postman_variable_scope": "environment", "_postman_exported_at": "2026-05-30T00:00:00.000Z", "_postman_exported_using": "build_platform_postman.py" -} \ No newline at end of file +} diff --git a/postman/build_platform_postman.py b/postman/build_platform_postman.py index 29c0557..8e9261c 100644 --- a/postman/build_platform_postman.py +++ b/postman/build_platform_postman.py @@ -36,6 +36,14 @@ "collection": PLATFORM / "am-notification" / "postman" / "AM-Notification.postman_collection.json", "environment": PLATFORM / "am-notification" / "postman" / "AM-Notification.local.postman_environment.json", }, + { + "folder": "MCP Gateway", + "slug": "mcp_gateway", + "base_var": "mcp_gateway_base_url", + "default_port": "8120", + "collection": PLATFORM / "am-mcp-gateway" / "postman" / "AM-MCP-Gateway.postman_collection.json", + "environment": PLATFORM / "am-mcp-gateway" / "postman" / "AM-MCP-Gateway.local.postman_environment.json", + }, ) @@ -201,7 +209,8 @@ def build_collection() -> dict: "|--------|--------|\n" "| Identity | am-identity |\n" "| Subscription | am-subscription |\n" - "| Notification | am-notification |" + "| Notification | am-notification |\n" + "| MCP Gateway | am-mcp-gateway |" ), "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", }, diff --git a/postman/environment.defaults.json b/postman/environment.defaults.json index 9c41a38..63b827c 100644 --- a/postman/environment.defaults.json +++ b/postman/environment.defaults.json @@ -4,11 +4,14 @@ "identity_base_url", "subscription_base_url", "notification_base_url", + "mcp_gateway_base_url", "base_url", "keycloak_url", "keycloak_realm", "identity_client_id", "identity_client_secret", + "mcp_client_id", + "mcp_client_secret", "portfolio_client_id", "portfolio_client_secret", "gateway_client_id", @@ -43,6 +46,7 @@ "identity_base_url": "http://localhost:8113", "subscription_base_url": "http://localhost:8110", "notification_base_url": "http://localhost:8111", + "mcp_gateway_base_url": "http://localhost:8120", "base_url": "http://localhost:8113", "keycloak_url": "http://auth.munish.org/auth", "google_redirect_uri": "http://localhost:9000/callback" @@ -56,6 +60,7 @@ "identity_base_url": "https://am-dev.asrax.in/identity", "subscription_base_url": "https://am-dev.asrax.in/subscriptions", "notification_base_url": "https://am-dev.asrax.in/notifications", + "mcp_gateway_base_url": "https://am-dev.asrax.in/mcp", "base_url": "https://am-dev.asrax.in/identity", "keycloak_url": "https://auth.munish.org/auth", "google_redirect_uri": "https://am-dev.asrax.in/callback" @@ -69,6 +74,7 @@ "identity_base_url": "https://am.asrax.in/identity", "subscription_base_url": "https://am.asrax.in/subscriptions", "notification_base_url": "https://am.asrax.in/notifications", + "mcp_gateway_base_url": "https://am.asrax.in/mcp", "base_url": "https://am.asrax.in/identity", "keycloak_url": "https://auth.munish.org/auth", "google_redirect_uri": "https://am.asrax.in/callback" @@ -82,6 +88,7 @@ "identity_base_url": "https://am.asrax.in/identity", "subscription_base_url": "https://am.asrax.in/subscriptions", "notification_base_url": "https://am.asrax.in/notifications", + "mcp_gateway_base_url": "https://am.asrax.in/mcp", "base_url": "https://am.asrax.in/identity", "keycloak_url": "https://auth.munish.org/auth", "google_redirect_uri": "https://am.asrax.in/callback" @@ -92,6 +99,8 @@ "keycloak_realm": "am-realm", "identity_client_id": "am-identity-service", "identity_client_secret": "", + "mcp_client_id": "am-mcp-service", + "mcp_client_secret": "", "portfolio_client_id": "am-portfolio-service", "portfolio_client_secret": "", "gateway_client_id": "am-gateway-client", @@ -118,6 +127,7 @@ }, "secret_keys": [ "identity_client_secret", + "mcp_client_secret", "portfolio_client_secret", "gateway_client_secret", "notification_client_secret",