From 7346fd9b6bd16a5ab51492d457a1b5315f297dad Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Wed, 22 Jan 2025 16:42:08 +0200 Subject: [PATCH 01/12] updates --- backend/build.sh | 1 + 1 file changed, 1 insertion(+) create mode 100755 backend/build.sh diff --git a/backend/build.sh b/backend/build.sh new file mode 100755 index 0000000..adc6724 --- /dev/null +++ b/backend/build.sh @@ -0,0 +1 @@ +docker buildx build -t registry.gitlab.com/sabside/promptsail:backend --push . \ No newline at end of file From 35d8df744fcdc447e28cae6910b1c784dd296c26 Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 12:19:40 +0200 Subject: [PATCH 02/12] udpates --- .gitignore | 3 + backend/.aider.conf.yml | 435 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 backend/.aider.conf.yml diff --git a/.gitignore b/.gitignore index 86e3ddf..2269a79 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,6 @@ backend/poetry.lock private_examples/* docker-compose-wk.yml examples/poetry.lock + +backend/.aider.conf.yml +ui/.aider.conf.yml \ No newline at end of file diff --git a/backend/.aider.conf.yml b/backend/.aider.conf.yml new file mode 100644 index 0000000..28540f4 --- /dev/null +++ b/backend/.aider.conf.yml @@ -0,0 +1,435 @@ +########################################################## +# Sample .aider.conf.yml +# This file lists *all* the valid configuration entries. +# Place in your home dir, or at the root of your git repo. +########################################################## + +# Note: You can only put OpenAI and Anthropic API keys in the yaml +# config file. Keys for all APIs can be stored in a .env file +# https://aider.chat/docs/config/dotenv.html + +########## +# options: + +## show this help message and exit +#help: xxx + +############# +# Main model: + +## Specify the model to use for the main chat +model: deepseek/deepseek-reasoner + +## Use claude-3-opus-20240229 model for the main chat +#opus: false + +## Use claude-3-5-sonnet-20241022 model for the main chat +sonnet: false + +## Use claude-3-5-haiku-20241022 model for the main chat +#haiku: false + +## Use gpt-4-0613 model for the main chat +#4: false + +## Use gpt-4o model for the main chat +#4o: false + +## Use gpt-4o-mini model for the main chat +#mini: false + +## Use gpt-4-1106-preview model for the main chat +#4-turbo: false + +## Use gpt-3.5-turbo model for the main chat +#35turbo: false + +## Use deepseek/deepseek-chat model for the main chat +deepseek: true + +## Use o1-mini model for the main chat +#o1-mini: false + +## Use o1-preview model for the main chat +#o1-preview: false + +######################## +# API Keys and settings: + +## Specify the OpenAI API key +#openai-api-key: sk-3afe9afe346b4fd48c5d07c35fcb186a + +## Specify the Anthropic API key +#anthropic-api-key: xxx + +## Specify the api base url +#openai-api-base: https://api.deepseek.com + +## (deprecated, use --set-env OPENAI_API_TYPE=) +#openai-api-type: xxx + +## (deprecated, use --set-env OPENAI_API_VERSION=) +#openai-api-version: xxx + +## (deprecated, use --set-env OPENAI_API_DEPLOYMENT_ID=) +#openai-api-deployment-id: xxx + +## (deprecated, use --set-env OPENAI_ORGANIZATION=) +#openai-organization-id: xxx + +## Set an environment variable (to control API settings, can be used multiple times) +#set-env: xxx +## Specify multiple values like this: +#set-env: +# - xxx +# - yyy +# - zzz + +## Set an API key for a provider (eg: --api-key provider= sets PROVIDER_API_KEY=) +#api-key: xxx +## Specify multiple values like this: +#api-key: +# - xxx +# - yyy +# - zzz + +################# +# Model settings: + +## List known models which match the (partial) MODEL name +#list-models: xxx + +## Specify a file with aider model settings for unknown models +#model-settings-file: .aider.model.settings.yml + +## Specify a file with context window and costs for unknown models +#model-metadata-file: .aider.model.metadata.json + +## Add a model alias (can be used multiple times) +#alias: xxx +## Specify multiple values like this: +#alias: +# - xxx +# - yyy +# - zzz + +## Verify the SSL cert when connecting to models (default: True) +#verify-ssl: true + +## Timeout in seconds for API calls (default: None) +#timeout: xxx + +## Specify what edit format the LLM should use (default depends on model) +#edit-format: xxx + +## Use architect edit format for the main chat +architect: true + +## Specify the model to use for commit messages and chat history summarization (default depends on --model) +#weak-model: xxx + +## Specify the model to use for editor tasks (default depends on --model) +editor-model: deepseek/deepseek-reasoner + +## Specify the edit format for the editor model (default: depends on editor model) +#editor-edit-format: xxx + +## Only work with models that have meta-data available (default: True) +#show-model-warnings: true + +## Soft limit on tokens for chat history, after which summarization begins. If unspecified, defaults to the model's max_chat_history_tokens. +#max-chat-history-tokens: xxx + +################# +# Cache settings: + +## Enable caching of prompts (default: False) +cache-prompts: true + +## Number of times to ping at 5min intervals to keep prompt cache warm (default: 0) +cache-keepalive-pings: 1 + +################### +# Repomap settings: + +## Suggested number of tokens to use for repo map, use 0 to disable +#map-tokens: xxx + +## Control how often the repo map is refreshed. Options: auto, always, files, manual (default: auto) +#map-refresh: auto + +## Multiplier for map tokens when no files are specified (default: 2) +#map-multiplier-no-files: true + +################ +# History Files: + +## Specify the chat input history file (default: .aider.input.history) +#input-history-file: .aider.input.history + +## Specify the chat history file (default: .aider.chat.history.md) +#chat-history-file: .aider.chat.history.md + +## Restore the previous chat history messages (default: False) +#restore-chat-history: false + +## Log the conversation with the LLM to this file (for example, .aider.llm.history) +#llm-history-file: xxx + +################## +# Output settings: + +## Use colors suitable for a dark terminal background (default: False) +#dark-mode: false + +## Use colors suitable for a light terminal background (default: False) +#light-mode: false + +## Enable/disable pretty, colorized output (default: True) +#pretty: true + +## Enable/disable streaming responses (default: True) +#stream: true + +## Set the color for user input (default: #00cc00) +#user-input-color: #00cc00 + +## Set the color for tool output (default: None) +#tool-output-color: xxx + +## Set the color for tool error messages (default: #FF2222) +#tool-error-color: #FF2222 + +## Set the color for tool warning messages (default: #FFA500) +#tool-warning-color: #FFA500 + +## Set the color for assistant output (default: #0088ff) +#assistant-output-color: #0088ff + +## Set the color for the completion menu (default: terminal's default text color) +#completion-menu-color: xxx + +## Set the background color for the completion menu (default: terminal's default background color) +#completion-menu-bg-color: xxx + +## Set the color for the current item in the completion menu (default: terminal's default background color) +#completion-menu-current-color: xxx + +## Set the background color for the current item in the completion menu (default: terminal's default text color) +#completion-menu-current-bg-color: xxx + +## Set the markdown code theme (default: default, other options include monokai, solarized-dark, solarized-light, or a Pygments builtin style, see https://pygments.org/styles for available themes) +#code-theme: default + +## Show diffs when committing changes (default: False) +#show-diffs: false + +############### +# Git settings: + +## Enable/disable looking for a git repo (default: True) +#git: true + +## Enable/disable adding .aider* to .gitignore (default: True) +#gitignore: true + +## Specify the aider ignore file (default: .aiderignore in git root) +#aiderignore: .aiderignore + +## Only consider files in the current subtree of the git repository +#subtree-only: false + +## Enable/disable auto commit of LLM changes (default: True) +auto-commits: false + +## Enable/disable commits when repo is found dirty (default: True) +#dirty-commits: true + +## Attribute aider code changes in the git author name (default: True) +#attribute-author: true + +## Attribute aider commits in the git committer name (default: True) +#attribute-committer: true + +## Prefix commit messages with 'aider: ' if aider authored the changes (default: False) +#attribute-commit-message-author: false + +## Prefix all commit messages with 'aider: ' (default: False) +#attribute-commit-message-committer: false + +## Commit all pending changes with a suitable commit message, then exit +#commit: false + +## Specify a custom prompt for generating commit messages +#commit-prompt: xxx + +## Perform a dry run without modifying files (default: False) +#dry-run: false + +## Skip the sanity check for the git repository (default: False) +#skip-sanity-check-repo: false + +## Enable/disable watching files for ai coding comments (default: False) +#watch-files: false + +######################## +# Fixing and committing: + +## Lint and fix provided files, or dirty files if none provided +#lint: false + +## Specify lint commands to run for different languages, eg: "python: flake8 --select=..." (can be used multiple times) +#lint-cmd: xxx +## Specify multiple values like this: +#lint-cmd: +# - xxx +# - yyy +# - zzz + +## Enable/disable automatic linting after changes (default: True) +#auto-lint: true + +## Specify command to run tests +test-cmd: npm run test + +## Enable/disable automatic testing after changes (default: False) +auto-test: true + +## Run tests, fix problems found and then exit +#test: false + +############ +# Analytics: + +## Enable/disable analytics for current session (default: random) +#analytics: xxx + +## Specify a file to log analytics events +#analytics-log: xxx + +## Permanently disable analytics +#analytics-disable: false + +############ +# Upgrading: + +## Check for updates and return status in the exit code +#just-check-update: false + +## Check for new aider versions on launch +#check-update: true + +## Show release notes on first run of new version (default: None, ask user) +#show-release-notes: xxx + +## Install the latest version from the main branch +#install-main-branch: false + +## Upgrade aider to the latest version from PyPI +#upgrade: false + +## Show the version number and exit +#version: xxx + +######## +# Modes: + +## Specify a single message to send the LLM, process reply then exit (disables chat mode) +#message: xxx + +## Specify a file containing the message to send the LLM, process reply, then exit (disables chat mode) +#message-file: xxx + +## Run aider in your browser (default: False) +#gui: false + +## Enable automatic copy/paste of chat between aider and web UI (default: False) +#copy-paste: false + +## Apply the changes from the given file instead of running the chat (debug) +#apply: xxx + +## Apply clipboard contents as edits using the main model's editor format +#apply-clipboard-edits: false + +## Do all startup activities then exit before accepting user input (debug) +#exit: false + +## Print the repo map and exit (debug) +#show-repo-map: false + +## Print the system prompts and exit (debug) +#show-prompts: false + +################# +# Voice settings: + +## Audio format for voice recording (default: wav). webm and mp3 require ffmpeg +#voice-format: wav + +## Specify the language for voice using ISO 639-1 code (default: auto) +#voice-language: en + +## Specify the input device name for voice recording +#voice-input-device: xxx + +################# +# Other settings: + +## specify a file to edit (can be used multiple times) +#file: xxx +## Specify multiple values like this: +#file: +# - xxx +# - yyy +# - zzz + +## specify a read-only file (can be used multiple times) +#read: xxx +## Specify multiple values like this: +#read: +# - xxx +# - yyy +# - zzz + +## Use VI editing mode in the terminal (default: False) +#vim: false + +## Specify the language to use in the chat (default: None, uses system settings) +#chat-language: xxx + +## Always say yes to every confirmation +#yes-always: false + +## Enable verbose output +#verbose: false + +## Load and execute /commands from a file on launch +#load: xxx + +## Specify the encoding for input and output (default: utf-8) +#encoding: utf-8 + +## Line endings to use when writing files (default: platform) +#line-endings: platform + +## Specify the config file (default: search for .aider.conf.yml in git root, cwd or home directory) +#config: xxx + +## Specify the .env file to load (default: .env in git root) +#env-file: .env + +## Enable/disable suggesting shell commands (default: True) +#suggest-shell-commands: true + +## Enable/disable fancy input with history and completion (default: True) +#fancy-input: true + +## Enable/disable multi-line input mode with Meta-Enter to submit (default: False) +#multiline: false + +## Enable/disable detection and offering to add URLs to chat (default: True) +#detect-urls: true + +## Specify which editor to use for the /editor command +editor: cursor \ No newline at end of file From 98c33ffe95639c18cfde106ac64a4fa7eae295a0 Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 15:13:12 +0200 Subject: [PATCH 03/12] chore: Update dependencies and add package inclusion in pyproject.toml --- backend/pyproject.toml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2bc4708..d8e58ff 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -3,10 +3,13 @@ name = "PromptSail-backend" version = "0.5.4" description = "Prompt Sail - LLM governance, monitoring and analysis system" authors = ["Przemysław Górecki , Krzysztof Sopyła "] +packages = [ + { include = "src" } +] [tool.poetry.dependencies] python = "^3.10" -uvicorn = {extras = ["stable"], version = "^0.23.2"} +uvicorn = {extras = ["standard"], version = "^0.23.2"} httpx = "^0.25.0" python-dotenv = "^1.0.0" dependency-injector = "^4.41.0" @@ -23,17 +26,22 @@ tiktoken = "^0.5.2" pyjwt = "^2.8.0" cryptography = "^42.0.5" pillow = "^10.3.0" +loguru = "^0.7.2" # Better logging +python-multipart = "^0.0.6" # Required for FastAPI file uploads -[tool.poetry.group.dev] - - [tool.poetry.group.dev.dependencies] pytest = "^7.4.2" +pytest-cov = "^4.1.0" +pytest-asyncio = "^0.23.5" pre-commit = "^3.5.0" requests-mock = "^1.11.0" locust = "^2.24.1" +mypy = "^1.8.0" +black = "^24.1.1" +isort = "^5.13.2" +flake8 = "^6.1.0" [build-system] From 83c445280374d829c9d9a6d470662a6dcefde9e5 Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 15:15:16 +0200 Subject: [PATCH 04/12] fix: improve proxy response handling and logging - Fix chunked response handling to return proper JSON - Add proper Content-Type and Content-Length headers - Remove problematic transfer-encoding headers - Enhance request/response logging for better debugging - Fix double body read in request handling --- .gitignore | 3 +- backend/Dockerfile | 121 ++--------------------------- backend/compose.yml | 19 +++++ backend/src/app/app.py | 109 ++++++++++++++++++++++++-- backend/src/app/reverse_proxy.py | 126 +++++++++++++++++++++++-------- 5 files changed, 225 insertions(+), 153 deletions(-) create mode 100644 backend/compose.yml diff --git a/.gitignore b/.gitignore index 2269a79..51f12d0 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,5 @@ docker-compose-wk.yml examples/poetry.lock backend/.aider.conf.yml -ui/.aider.conf.yml \ No newline at end of file +ui/.aider.conf.yml +.aider* diff --git a/backend/Dockerfile b/backend/Dockerfile index bf821fc..dddd4a1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,117 +1,8 @@ -# syntax=docker/dockerfile:1 -# Keep this syntax directive! It's used to enable Docker BuildKit +FROM python:3.10-slim -# Based on https://github.com/python-poetry/poetry/discussions/1879?sort=top#discussioncomment-216865 -# but I try to keep it updated (see history) +WORKDIR /app +RUN pip install poetry +COPY . . +RUN poetry install -################################ -# PYTHON-BASE -# Sets up all our shared environment variables -################################ -FROM python:3.10.2-slim-buster as python-base -LABEL org.opencontainers.image.source="https://github.com/PromptSail/prompt_sail" - - # python -ENV PYTHONUNBUFFERED=1 \ - # prevents python creating .pyc files - PYTHONDONTWRITEBYTECODE=1 \ - \ - # pip - PIP_DISABLE_PIP_VERSION_CHECK=on \ - PIP_DEFAULT_TIMEOUT=100 \ - \ - # poetry - # https://python-poetry.org/docs/configuration/#using-environment-variables - POETRY_VERSION=1.7.1 \ - # make poetry install to this location - POETRY_HOME="/opt/poetry" \ - # make poetry create the virtual environment in the project's root - # it gets named `.venv` - POETRY_VIRTUALENVS_IN_PROJECT=true \ - # do not ask any interactive question - POETRY_NO_INTERACTION=1 \ - \ - # paths - # this is where our requirements + virtual environment will live - PYSETUP_PATH="/opt/pysetup" \ - VENV_PATH="/opt/pysetup/.venv" - - -# prepend poetry and venv to path -ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" - - -################################ -# BUILDER-BASE -# Used to build deps + create our virtual environment -################################ -FROM python-base as builder-base -# RUN apt-get update \ -# && apt-get install --no-install-recommends -y \ -# # deps for installing poetry -# curl \ -# # deps for building python deps -# build-essential - -# suggested by AI -RUN apt-get update \ - && apt-get install --no-install-recommends -y \ - curl \ - build-essential \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# install poetry - respects $POETRY_VERSION & $POETRY_HOME -# The --mount will mount the buildx cache directory to where -# Poetry and Pip store their cache so that they can re-use it -RUN --mount=type=cache,target=/root/.cache \ - curl -sSL https://install.python-poetry.org | python3 - - -# copy project requirement files here to ensure they will be cached. -WORKDIR $PYSETUP_PATH -COPY ../pyproject.toml ./ - -# install runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally -RUN --mount=type=cache,target=/root/.cache \ - poetry install --only main - - -################################ -# DEVELOPMENT -# Image used during development / testing - as dev container -################################ -# FROM python-base as development -# ENV FASTAPI_ENV=development -# WORKDIR $PYSETUP_PATH - -# # copy in our built poetry + venv -# COPY --from=builder-base $POETRY_HOME $POETRY_HOME -# COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH - -# # quicker install as runtime deps are already installed -# RUN --mount=type=cache,target=/root/.cache \ -# poetry install --with dev - -# # will become mountpoint of our code -# WORKDIR /src - -# EXPOSE 8000 -# # Run the application -# CMD uvicorn app:app --proxy-headers --host 0.0.0.0 --port=${PORT:-8000} - - -################################ -# PRODUCTION -# Final image used for runtime -################################ -FROM python-base as production -ENV FASTAPI_ENV=production -COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH - -COPY src /src/ -COPY static /static/ -WORKDIR /src -EXPOSE 8000 -# Run the application -CMD uvicorn app:app --proxy-headers --host 0.0.0.0 --port=${PORT:-8000} -#CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "app:app"] +ENV PYTHONPATH=/app/src diff --git a/backend/compose.yml b/backend/compose.yml new file mode 100644 index 0000000..d6e89f9 --- /dev/null +++ b/backend/compose.yml @@ -0,0 +1,19 @@ +version: '3.8' +services: + backend: + build: . + volumes: + - .:/app + ports: + - "8000:8000" + environment: + - MONGO_URL=mongodb://mongodb:27017 + - DEBUG=True + depends_on: + - mongodb + command: poetry run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload + + mongodb: + image: mongo:latest + ports: + - "27017:27017" \ No newline at end of file diff --git a/backend/src/app/app.py b/backend/src/app/app.py index d9cb06a..82e2c27 100644 --- a/backend/src/app/app.py +++ b/backend/src/app/app.py @@ -1,12 +1,27 @@ from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +import logging +import json +import sys +from starlette.concurrency import iterate_in_threadpool +import traceback from config import config from config.containers import TopLevelContainer -from fastapi import FastAPI container = TopLevelContainer() container.config.override(config) +# Create a custom handler and formatter for our middleware logs +request_handler = logging.StreamHandler(sys.stdout) +request_handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s')) + +# Create a separate logger for request logging +request_logger = logging.getLogger("request_logger") +request_logger.addHandler(request_handler) +request_logger.setLevel(logging.INFO) +# Prevent logs from being passed to parent handlers +request_logger.propagate = False @asynccontextmanager async def fastapi_lifespan(app: FastAPI): @@ -79,11 +94,91 @@ async def fastapi_lifespan(app: FastAPI): yield ... +# Add this middleware function before creating the FastAPI app +async def log_request_middleware(request: Request, call_next): + request_logger.info(f"\n{'='*50}\nIncoming Request: {request.method} {request.url.path}\n{'='*50}") + request_logger.info(f"Request Headers:\n{json.dumps(dict(request.headers), indent=2)}") + + # Only try to log body for POST/PUT/PATCH requests + if request.method in ["POST", "PUT", "PATCH"]: + body = await request.body() + if body: + try: + body_json = json.loads(body) + + # Specifically log if this is an OpenAI request + if "chat/completions" in request.url.path: + request_logger.info("\n=== OpenAI Request Details ===") + request_logger.info(f"Model: {body_json.get('model', 'not specified')}") + request_logger.info(f"Temperature: {body_json.get('temperature', 'not specified')}") + request_logger.info(f"Stream: {body_json.get('stream', 'not specified')}") + if "messages" in body_json: + request_logger.info(f"Number of messages: {len(body_json['messages'])}") + request_logger.info("\nSystem Message:") + system_msg = next((m for m in body_json['messages'] if m['role'] == 'system'), None) + if system_msg: + request_logger.info(f"{system_msg['content'][:200]}...") + request_logger.info("\nLast User Message:") + last_user_msg = next((m for m in reversed(body_json['messages']) if m['role'] == 'user'), None) + if last_user_msg: + request_logger.info(last_user_msg['content']) + request_logger.info("\nFull Request Payload:") + request_logger.info(json.dumps(body_json, indent=2)) + except json.JSONDecodeError: + request_logger.info(f"Request Body (raw): {body.decode()}") + + # Restore the request body + async def get_body(): + return body + request._body = body + request.body = get_body + + response = await call_next(request) + + request_logger.info(f"\n{'='*50}\nResponse Status: {response.status_code}\n{'='*50}") + + # For error responses (4xx and 5xx), try to log the response body + if response.status_code >= 400: + try: + # Create a new response with the same content + response_body = [chunk async for chunk in response.body_iterator] + # Log the body content + body = b''.join(response_body).decode() + + request_logger.error("\n=== Error Details ===") + try: + json_body = json.loads(body) + request_logger.error(f"Response Body (JSON):\n{json.dumps(json_body, indent=2)}") + except json.JSONDecodeError: + request_logger.error(f"Response Body (raw):\n{body}") + + # If it's an OpenAI error, try to parse it differently + if "openai" in str(request.url).lower(): + request_logger.error("\nOpenAI Error Response:") + request_logger.error(f"URL: {request.url}") + request_logger.error(f"Method: {request.method}") + request_logger.error(f"Headers sent: {dict(request.headers)}") + request_logger.error(f"Status code: {response.status_code}") + request_logger.error(f"Response body: {body}") + + # Create a new iterator with the same content + response.body_iterator = iterate_in_threadpool(iter(response_body)) + except Exception as e: + request_logger.error(f"Error reading response body: {str(e)}") + request_logger.error(f"Error traceback: {traceback.format_exc()}") + + return response + +# Modify the FastAPI app creation to include the middleware +app = FastAPI( + lifespan=fastapi_lifespan, + title="PromptSail API", + description="API for PromptSail - prompt management and monitoring tool", + version="0.5.4", + openapi_version="3.1.0", +) + +# Add the middleware to the app +app.middleware("http")(log_request_middleware) -app = FastAPI(lifespan=fastapi_lifespan, - title="PromptSail API", - description="API for PromptSail - prompt management and monitoring tool", - version="0.5.4", - openapi_version="3.1.0", - ) app.container = container diff --git a/backend/src/app/reverse_proxy.py b/backend/src/app/reverse_proxy.py index 8d10897..a8c2621 100644 --- a/backend/src/app/reverse_proxy.py +++ b/backend/src/app/reverse_proxy.py @@ -4,16 +4,28 @@ from _datetime import datetime, timezone from app.dependencies import get_logger, get_provider_pricelist, get_transaction_context from fastapi import Depends, Request -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, Response from lato import Application, TransactionContext from projects.use_cases import get_project_by_slug from raw_transactions.use_cases import store_raw_transactions from starlette.background import BackgroundTask from transactions.use_cases import store_transaction from utils import ApiURLBuilder +import logging +import json +import sys from .app import app +# Create a custom handler and formatter for proxy logs +proxy_handler = logging.StreamHandler(sys.stdout) +proxy_handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s')) + +# Create a separate logger for proxy logging +proxy_logger = logging.getLogger("proxy_logger") +proxy_logger.addHandler(proxy_handler) +proxy_logger.setLevel(logging.INFO) +proxy_logger.propagate = False async def iterate_stream(response, buffer): """ @@ -126,57 +138,111 @@ async def reverse_proxy( - Supports various HTTP methods (GET, POST, PUT, PATCH, DELETE) """ logger = get_logger(request) - - # if not request.state.is_handled_by_proxy: - # return RedirectResponse("/ui") - # project = ctx.call(get_project_by_slug, slug=request.state.slug) - tags = tags.split(",") if tags is not None else [] project = ctx.call(get_project_by_slug, slug=project_slug) url = ApiURLBuilder.build(project, provider_slug, path, target_path) - - # todo: remove this, this logic should be in the use case pricelist = get_provider_pricelist(request) logger.debug(f"got projects for {project}") - # Get the body as bytes for non-GET requests + # Get the body once and store it body = await request.body() if request.method != "GET" else None + # Create headers without transfer-encoding + headers = {k: v for k, v in request.headers.items() + if k.lower() not in ("host", "transfer-encoding")} + + # Add Content-Length if we have a body + if body: + headers["Content-Length"] = str(len(body)) + + # Log the outgoing request details + proxy_logger.info(f"\n{'='*50}\nOutgoing Request to OpenAI\n{'='*50}") + proxy_logger.info(f"URL: {url}") + proxy_logger.info(f"Method: {request.method}") + proxy_logger.info("Headers being sent to OpenAI:") + proxy_logger.info(json.dumps(headers, indent=2)) + + # If it's a POST/PUT request, log the body + if body: + try: + body_json = json.loads(body) + proxy_logger.info("Request Body to OpenAI:") + proxy_logger.info(json.dumps(body_json, indent=2)) + except json.JSONDecodeError: + proxy_logger.info(f"Raw body: {body.decode()}") + # Make the request to the upstream server client = httpx.AsyncClient() - # todo: copy timeout from request, temporary set to 100s timeout = httpx.Timeout(100.0, connect=50.0) request_time = datetime.now(tz=timezone.utc) ai_provider_request = client.build_request( method=request.method, url=url, - headers={ - k: v for k, v in request.headers.items() if k.lower() not in ("host",) - }, + headers=headers, params=request.query_params, content=body, timeout=timeout, ) + + # Log the final request that will be sent + proxy_logger.info("\n=== Final Request to OpenAI ===") + proxy_logger.info(f"Full URL: {ai_provider_request.url}") + proxy_logger.info("Final Headers:") + proxy_logger.info(json.dumps(dict(ai_provider_request.headers), indent=2)) + + # Log the actual request body being sent + if ai_provider_request.content: + try: + # Try to decode and parse as JSON + content = ai_provider_request.content.decode('utf-8') + content_json = json.loads(content) + proxy_logger.info("Request Body being sent to OpenAI:") + proxy_logger.info(json.dumps(content_json, indent=2)) + except Exception as e: + proxy_logger.info(f"Raw request body: {ai_provider_request.content}") + logger.debug(f"Requesting on: {url}") ai_provider_response = await client.send(ai_provider_request, stream=True, follow_redirects=True) + # Log the response headers immediately + proxy_logger.info("\n=== Response from OpenAI ===") + proxy_logger.info(f"Status: {ai_provider_response.status_code}") + proxy_logger.info("Response Headers:") + proxy_logger.info(json.dumps(dict(ai_provider_response.headers), indent=2)) + + # If it's a streaming response, collect all chunks and return as one response buffer = [] - return StreamingResponse( - iterate_stream(ai_provider_response, buffer), - status_code=ai_provider_response.status_code, - headers=ai_provider_response.headers, - background=BackgroundTask( - close_stream, - ctx["app"], - project.id, - ai_provider_request, - ai_provider_response, - buffer, - tags, - ai_model_version, - pricelist, - request_time, - ), - ) + if ai_provider_response.headers.get("transfer-encoding") == "chunked": + async for chunk in ai_provider_response.aiter_bytes(): + buffer.append(chunk) + content = b''.join(buffer) + return Response( + content=content, + status_code=ai_provider_response.status_code, + headers={ + "Content-Type": "application/json", + **{k: v for k, v in ai_provider_response.headers.items() + if k.lower() not in ("transfer-encoding", "content-encoding")} + } + ) + else: + # For non-streaming responses, just pass through + return StreamingResponse( + iterate_stream(ai_provider_response, buffer), + status_code=ai_provider_response.status_code, + headers=ai_provider_response.headers, + background=BackgroundTask( + close_stream, + ctx["app"], + project.id, + ai_provider_request, + ai_provider_response, + buffer, + tags, + ai_model_version, + pricelist, + request_time, + ), + ) From 6b2ca7471066ba7a672d939a1e86b416956be1f5 Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 17:07:05 +0200 Subject: [PATCH 05/12] updates --- backend/compose.yml | 2 + backend/src/app/app.py | 71 +++++++++++++------------------- backend/src/app/db_logging.py | 60 +++++++++++++++++++++++++++ backend/src/app/reverse_proxy.py | 64 ++++++++++++++++++++++++---- 4 files changed, 147 insertions(+), 50 deletions(-) create mode 100644 backend/src/app/db_logging.py diff --git a/backend/compose.yml b/backend/compose.yml index d6e89f9..948e99f 100644 --- a/backend/compose.yml +++ b/backend/compose.yml @@ -9,6 +9,8 @@ services: environment: - MONGO_URL=mongodb://mongodb:27017 - DEBUG=True + - LOG_TO_MONGODB=True + - LOG_TO_STDOUT=True depends_on: - mongodb command: poetry run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/backend/src/app/app.py b/backend/src/app/app.py index 82e2c27..9f7ebb2 100644 --- a/backend/src/app/app.py +++ b/backend/src/app/app.py @@ -1,6 +1,8 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Request import logging +import os +from fastapi import FastAPI, Request +from app.logging import logger, logging_context import json import sys from starlette.concurrency import iterate_in_threadpool @@ -16,11 +18,16 @@ request_handler = logging.StreamHandler(sys.stdout) request_handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s')) +# Check logging settings +log_to_stdout = os.getenv("LOG_TO_STDOUT", "True").lower() == "true" + # Create a separate logger for request logging +root_logger = logging.getLogger() +root_logger.handlers = [] # Remove default handlers request_logger = logging.getLogger("request_logger") request_logger.addHandler(request_handler) request_logger.setLevel(logging.INFO) -# Prevent logs from being passed to parent handlers +# Prevent logs from being propagated to parent loggers request_logger.propagate = False @asynccontextmanager @@ -40,6 +47,11 @@ async def fastapi_lifespan(app: FastAPI): project_repository = ctx["project_repository"] settings_repository = ctx["settings_repository"] + log_to_mongodb = os.getenv("LOG_TO_MONGODB", "False").lower() == "true" + logger.info(f"log_to_mongodb: {log_to_mongodb}") + logger.info(f"log_to_stdout: {log_to_stdout}") + + if project_repository.count() == 0: data1 = Project( name="Models Playground", @@ -94,44 +106,31 @@ async def fastapi_lifespan(app: FastAPI): yield ... -# Add this middleware function before creating the FastAPI app +# Create the FastAPI app first +app = FastAPI( + lifespan=fastapi_lifespan, + title="PromptSail API", + description="API for PromptSail - prompt management and monitoring tool", + version="0.5.4", + openapi_version="3.1.0", +) + +@app.middleware("http") async def log_request_middleware(request: Request, call_next): + if not log_to_stdout: + return await call_next(request) + request_logger.info(f"\n{'='*50}\nIncoming Request: {request.method} {request.url.path}\n{'='*50}") request_logger.info(f"Request Headers:\n{json.dumps(dict(request.headers), indent=2)}") - # Only try to log body for POST/PUT/PATCH requests if request.method in ["POST", "PUT", "PATCH"]: body = await request.body() if body: try: body_json = json.loads(body) - - # Specifically log if this is an OpenAI request - if "chat/completions" in request.url.path: - request_logger.info("\n=== OpenAI Request Details ===") - request_logger.info(f"Model: {body_json.get('model', 'not specified')}") - request_logger.info(f"Temperature: {body_json.get('temperature', 'not specified')}") - request_logger.info(f"Stream: {body_json.get('stream', 'not specified')}") - if "messages" in body_json: - request_logger.info(f"Number of messages: {len(body_json['messages'])}") - request_logger.info("\nSystem Message:") - system_msg = next((m for m in body_json['messages'] if m['role'] == 'system'), None) - if system_msg: - request_logger.info(f"{system_msg['content'][:200]}...") - request_logger.info("\nLast User Message:") - last_user_msg = next((m for m in reversed(body_json['messages']) if m['role'] == 'user'), None) - if last_user_msg: - request_logger.info(last_user_msg['content']) - request_logger.info("\nFull Request Payload:") - request_logger.info(json.dumps(body_json, indent=2)) + request_logger.info(f"Request Body:\n{json.dumps(body_json, indent=2)}") except json.JSONDecodeError: - request_logger.info(f"Request Body (raw): {body.decode()}") - - # Restore the request body - async def get_body(): - return body - request._body = body - request.body = get_body + request_logger.info(f"Raw body: {body.decode()}") response = await call_next(request) @@ -169,16 +168,4 @@ async def get_body(): return response -# Modify the FastAPI app creation to include the middleware -app = FastAPI( - lifespan=fastapi_lifespan, - title="PromptSail API", - description="API for PromptSail - prompt management and monitoring tool", - version="0.5.4", - openapi_version="3.1.0", -) - -# Add the middleware to the app -app.middleware("http")(log_request_middleware) - app.container = container diff --git a/backend/src/app/db_logging.py b/backend/src/app/db_logging.py new file mode 100644 index 0000000..03c265b --- /dev/null +++ b/backend/src/app/db_logging.py @@ -0,0 +1,60 @@ +from datetime import datetime +from enum import Enum +import json +from typing import Any, Dict, Optional +from pymongo import MongoClient +from pymongo.collection import Collection + +class Direction(str, Enum): + INCOMING = "incoming" + OUTGOING = "outgoing" + +class MongoDBLogger: + def __init__(self, mongo_url: str, collection_name: str = "proxy_logs"): + self.client = MongoClient(mongo_url) + self.db = self.client.promptsail + self.collection: Collection = self.db[collection_name] + + def log_request( + self, + direction: Direction, + method: str, + url: str, + headers: Dict[str, str], + body: Optional[Any] = None, + status_code: Optional[int] = None + ) -> None: + """ + Log a request/response to MongoDB with timestamp and direction. + + Args: + direction: Direction enum indicating if this is an incoming or outgoing request + method: HTTP method used + url: Request URL + headers: Request headers + body: Request/response body (optional) + status_code: Response status code (optional, for responses) + """ + log_entry = { + "timestamp": datetime.utcnow(), + "direction": direction, + "method": method, + "url": url, + "headers": headers + } + + # Handle body if present + if body: + try: + if isinstance(body, bytes): + body = body.decode('utf-8') + if isinstance(body, str): + body = json.loads(body) + log_entry["body"] = body + except (json.JSONDecodeError, UnicodeDecodeError): + log_entry["body"] = str(body) + + if status_code is not None: + log_entry["status_code"] = status_code + + self.collection.insert_one(log_entry) diff --git a/backend/src/app/reverse_proxy.py b/backend/src/app/reverse_proxy.py index a8c2621..52018a4 100644 --- a/backend/src/app/reverse_proxy.py +++ b/backend/src/app/reverse_proxy.py @@ -3,7 +3,7 @@ import httpx from _datetime import datetime, timezone from app.dependencies import get_logger, get_provider_pricelist, get_transaction_context -from fastapi import Depends, Request +from fastapi import Depends, Request, HTTPException from fastapi.responses import StreamingResponse, Response from lato import Application, TransactionContext from projects.use_cases import get_project_by_slug @@ -14,6 +14,8 @@ import logging import json import sys +import os +from .db_logging import MongoDBLogger, Direction from .app import app @@ -21,12 +23,24 @@ proxy_handler = logging.StreamHandler(sys.stdout) proxy_handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s')) +# Check if we should log to stdout +log_to_mongodb = os.getenv("LOG_TO_MONGODB", "False").lower() == "true" +log_to_stdout = os.getenv("LOG_TO_STDOUT", "True").lower() == "true" + # Create a separate logger for proxy logging proxy_logger = logging.getLogger("proxy_logger") -proxy_logger.addHandler(proxy_handler) +if log_to_stdout: + proxy_logger.addHandler(proxy_handler) proxy_logger.setLevel(logging.INFO) proxy_logger.propagate = False +# Initialize MongoDB logger if enabled +mongo_logger = None +if log_to_mongodb: + mongo_url = os.getenv("MONGO_URL", "mongodb://localhost:27017") + mongo_logger = MongoDBLogger(mongo_url) + proxy_logger.info("MongoDB logging enabled") + async def iterate_stream(response, buffer): """ Asynchronously iterate over the raw stream of a response and accumulate chunks in a buffer. @@ -139,7 +153,20 @@ async def reverse_proxy( """ logger = get_logger(request) tags = tags.split(",") if tags is not None else [] - project = ctx.call(get_project_by_slug, slug=project_slug) + try: + project = ctx.call(get_project_by_slug, slug=project_slug) + except Exception: + if mongo_logger: + mongo_logger.log_request( + direction=Direction.OUTGOING, + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=None, + status_code=404 + ) + raise HTTPException(status_code=404, detail=f"Project not found: {project_slug}") + url = ApiURLBuilder.build(project, provider_slug, path, target_path) pricelist = get_provider_pricelist(request) @@ -157,11 +184,12 @@ async def reverse_proxy( headers["Content-Length"] = str(len(body)) # Log the outgoing request details - proxy_logger.info(f"\n{'='*50}\nOutgoing Request to OpenAI\n{'='*50}") - proxy_logger.info(f"URL: {url}") - proxy_logger.info(f"Method: {request.method}") - proxy_logger.info("Headers being sent to OpenAI:") - proxy_logger.info(json.dumps(headers, indent=2)) + if log_to_stdout: + proxy_logger.info(f"\n{'='*50}\nOutgoing Request to OpenAI\n{'='*50}") + proxy_logger.info(f"URL: {url}") + proxy_logger.info(f"Method: {request.method}") + proxy_logger.info("Headers being sent to OpenAI:") + proxy_logger.info(json.dumps(headers, indent=2)) # If it's a POST/PUT request, log the body if body: @@ -172,6 +200,16 @@ async def reverse_proxy( except json.JSONDecodeError: proxy_logger.info(f"Raw body: {body.decode()}") + # Log to MongoDB if enabled + if mongo_logger: + mongo_logger.log_request( + direction=Direction.OUTGOING, + method=request.method, + url=str(url), + headers=dict(headers), + body=body.decode() if body else None + ) + # Make the request to the upstream server client = httpx.AsyncClient() timeout = httpx.Timeout(100.0, connect=50.0) @@ -212,6 +250,16 @@ async def reverse_proxy( proxy_logger.info("Response Headers:") proxy_logger.info(json.dumps(dict(ai_provider_response.headers), indent=2)) + # Log response to MongoDB if enabled + if mongo_logger: + mongo_logger.log_request( + direction=Direction.INCOMING, + method=request.method, + url=str(url), + headers=dict(ai_provider_response.headers), + status_code=ai_provider_response.status_code + ) + # If it's a streaming response, collect all chunks and return as one response buffer = [] if ai_provider_response.headers.get("transfer-encoding") == "chunked": From f16a9f6b41a92890d896a5025630a660a0f4f7d4 Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 17:30:58 +0200 Subject: [PATCH 06/12] Added logging to the database for all incoming and outgoing requests. --- backend/compose.yml | 2 +- backend/src/app/db_logging.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/compose.yml b/backend/compose.yml index 948e99f..902b5e2 100644 --- a/backend/compose.yml +++ b/backend/compose.yml @@ -10,7 +10,7 @@ services: - MONGO_URL=mongodb://mongodb:27017 - DEBUG=True - LOG_TO_MONGODB=True - - LOG_TO_STDOUT=True + - LOG_TO_STDOUT=False depends_on: - mongodb command: poetry run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/backend/src/app/db_logging.py b/backend/src/app/db_logging.py index 03c265b..d831629 100644 --- a/backend/src/app/db_logging.py +++ b/backend/src/app/db_logging.py @@ -4,6 +4,7 @@ from typing import Any, Dict, Optional from pymongo import MongoClient from pymongo.collection import Collection +from app.logging import logger, logging_context class Direction(str, Enum): INCOMING = "incoming" @@ -11,9 +12,13 @@ class Direction(str, Enum): class MongoDBLogger: def __init__(self, mongo_url: str, collection_name: str = "proxy_logs"): - self.client = MongoClient(mongo_url) - self.db = self.client.promptsail + logger.info(f"Connecting to MongoDB at {mongo_url}") + self.client = MongoClient(mongo_url, serverSelectionTimeoutMS=5000) + self.db = self.client.prompt_sail # Use the known database name self.collection: Collection = self.db[collection_name] + # Test connection and collection + self.client.server_info() + logger.info(f"MongoDB connected, using collection: {collection_name}") def log_request( self, @@ -57,4 +62,6 @@ def log_request( if status_code is not None: log_entry["status_code"] = status_code - self.collection.insert_one(log_entry) + result = self.collection.insert_one(log_entry) + logger.info(f"MongoDB log entry inserted with ID: {result.inserted_id}") + return result.inserted_id From f7b52644097f766c705e03af3c9b97857a1c73ca Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 20:18:46 +0200 Subject: [PATCH 07/12] Now we can see transactions in the DB and th eUI --- backend/src/app/reverse_proxy.py | 115 +++++++++++----- backend/src/config/containers.py | 2 +- backend/src/seedwork/repositories.py | 13 +- backend/src/transactions/repositories.py | 10 +- backend/src/transactions/use_cases.py | 4 + backend/src/utils.py | 166 ++++++++++++----------- 6 files changed, 191 insertions(+), 119 deletions(-) diff --git a/backend/src/app/reverse_proxy.py b/backend/src/app/reverse_proxy.py index 52018a4..a54d37e 100644 --- a/backend/src/app/reverse_proxy.py +++ b/backend/src/app/reverse_proxy.py @@ -55,9 +55,11 @@ async def iterate_stream(response, buffer): Yields: - Chunks of the response data as they are received """ + logger.debug("Starting to iterate over response stream") async for chunk in response.aiter_raw(): buffer.append(chunk) yield chunk + logger.debug(f"Finished stream iteration, buffer size: {len(buffer)}") async def close_stream( @@ -88,27 +90,53 @@ async def close_stream( - **pricelist**: List of provider prices for cost calculation - **request_time**: Timestamp when the request was initiated """ + logger = app.dependency_provider["logger"] # Get logger first + logger.debug("Starting close_stream") await ai_provider_response.aclose() + + logger.debug(f"Storing transaction for project {project_id}") + logger.debug(f"Request URL: {ai_provider_request.url}") + logger.debug(f"Response status: {ai_provider_response.status_code}") + logger.debug(f"Buffer length: {len(buffer) if buffer else 0}") + with app.transaction_context() as ctx: - data = ctx.call( - store_transaction, - project_id=project_id, - ai_provider_request=ai_provider_request, - ai_provider_response=ai_provider_response, - buffer=buffer, - tags=tags, - ai_model_version=ai_model_version, - pricelist=pricelist, - request_time=request_time, - ) - ctx.call( - store_raw_transactions, - request=ai_provider_request, - request_content=data["request_content"], - response=ai_provider_response, - response_content=data["response_content"], - transaction_id=data["transaction_id"], - ) + try: + logger.debug("Calling store_transaction") + data = ctx.call( + store_transaction, + project_id=project_id, + ai_provider_request=ai_provider_request, + ai_provider_response=ai_provider_response, + buffer=buffer, + tags=tags, + ai_model_version=ai_model_version, + pricelist=pricelist, + request_time=request_time, + ) + logger.debug(f"Transaction stored successfully with ID: {data.get('transaction_id')}") + + # Also store raw transaction data + try: + logger.debug("Storing raw transaction data") + ctx.call( + store_raw_transactions, + request=ai_provider_request, + request_content=data["request_content"], + response=ai_provider_response, + response_content=data["response_content"], + transaction_id=data["transaction_id"], + ) + logger.debug("Raw transaction data stored successfully") + except Exception as raw_e: + logger.error(f"Failed to store raw transaction data: {str(raw_e)}") + logger.error(f"Error type: {type(raw_e)}") + logger.error(f"Error args: {raw_e.args}") + + except Exception as e: + logger.error(f"Failed to store transaction: {str(e)}") + logger.error(f"Error type: {type(e)}") + logger.error(f"Error args: {e.args}") + raise @app.api_route( @@ -263,9 +291,26 @@ async def reverse_proxy( # If it's a streaming response, collect all chunks and return as one response buffer = [] if ai_provider_response.headers.get("transfer-encoding") == "chunked": + logger.debug("Handling chunked response") async for chunk in ai_provider_response.aiter_bytes(): buffer.append(chunk) content = b''.join(buffer) + + # Set up background task for chunked responses too + logger.debug("Setting up background task for chunked response") + background = BackgroundTask( + close_stream, + app=ctx["app"], + project_id=project.id, + ai_provider_request=ai_provider_request, + ai_provider_response=ai_provider_response, + buffer=buffer, + tags=tags, + ai_model_version=ai_model_version, + pricelist=pricelist, + request_time=request_time, + ) + return Response( content=content, status_code=ai_provider_response.status_code, @@ -273,24 +318,28 @@ async def reverse_proxy( "Content-Type": "application/json", **{k: v for k, v in ai_provider_response.headers.items() if k.lower() not in ("transfer-encoding", "content-encoding")} - } + }, + background=background # Add background task here too ) else: - # For non-streaming responses, just pass through + # In the reverse_proxy function, before returning StreamingResponse + logger.debug("Setting up background task for transaction storage") + background = BackgroundTask( + close_stream, + app=ctx["app"], + project_id=project.id, + ai_provider_request=ai_provider_request, + ai_provider_response=ai_provider_response, + buffer=buffer, + tags=tags, + ai_model_version=ai_model_version, + pricelist=pricelist, + request_time=request_time, + ) + logger.debug("Returning streaming response with background task") return StreamingResponse( iterate_stream(ai_provider_response, buffer), status_code=ai_provider_response.status_code, headers=ai_provider_response.headers, - background=BackgroundTask( - close_stream, - ctx["app"], - project.id, - ai_provider_request, - ai_provider_response, - buffer, - tags, - ai_model_version, - pricelist, - request_time, - ), + background=background, ) diff --git a/backend/src/config/containers.py b/backend/src/config/containers.py index 286c9e4..d832bd1 100644 --- a/backend/src/config/containers.py +++ b/backend/src/config/containers.py @@ -249,7 +249,7 @@ class TopLevelContainer(containers.DeclarativeContainer): container=__self__, ) - # todo: remove this, this logic should be in the use case + # Restore this for now until we properly move it to the use case provider_pricelist = providers.Singleton( lambda config: read_provider_pricelist(config.PRICE_LIST_PATH), config=config diff --git a/backend/src/seedwork/repositories.py b/backend/src/seedwork/repositories.py index 62da5e5..1488d06 100644 --- a/backend/src/seedwork/repositories.py +++ b/backend/src/seedwork/repositories.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from seedwork.exceptions import NotFoundException from utils import deserialize_data, serialize_data +from app.logging import logger class DocumentNotFoundException(NotFoundException): @@ -34,9 +35,15 @@ def add(self, doc: BaseModel): :param doc: The BaseModel object to be added. :return: The result of the add operation. """ - data = serialize_data(doc.model_dump()) - result = self._collection.insert_one(data) - return result + try: + data = serialize_data(doc.model_dump()) + logger.debug(f"Attempting to insert document into {self._collection.name}") + result = self._collection.insert_one(data) + logger.debug(f"MongoDB insert result: {result.inserted_id}") + return result + except Exception as e: + logger.error(f"MongoDB insert error in collection {self._collection.name}: {str(e)}") + raise def update(self, doc: BaseModel): """ diff --git a/backend/src/transactions/repositories.py b/backend/src/transactions/repositories.py index 54ea884..bc4937a 100644 --- a/backend/src/transactions/repositories.py +++ b/backend/src/transactions/repositories.py @@ -4,6 +4,7 @@ from seedwork.exceptions import NotFoundException from seedwork.repositories import MongoRepository from transactions.models import Transaction +from app.logging import logger class TransactionNotFoundException(NotFoundException): @@ -28,8 +29,13 @@ def add(self, doc): :param doc: The document to be added to the repository. :return: The result of the add operation. """ - result = super().add(doc) - return result + try: + result = super().add(doc) + logger.debug(f"Successfully added transaction to MongoDB: {doc.id}") + return result + except Exception as e: + logger.error(f"Failed to add transaction to MongoDB: {str(e)}") + raise def get_for_project(self, project_id: str) -> list[Transaction]: """ diff --git a/backend/src/transactions/use_cases.py b/backend/src/transactions/use_cases.py index b9e867d..77ebb38 100644 --- a/backend/src/transactions/use_cases.py +++ b/backend/src/transactions/use_cases.py @@ -7,6 +7,9 @@ from transactions.repositories import TransactionRepository from transactions.schemas import CreateTransactionSchema from utils import create_transaction_query_from_filters +import logging + +logger = logging.getLogger(__name__) def get_transactions_for_project( @@ -200,6 +203,7 @@ def store_transaction( :param transaction_repository: An instance of TransactionRepository used for storing transaction data. :return: None """ + logger.debug(f"store_transaction called for project {project_id}") response_content = utils.preprocess_buffer(ai_provider_request, ai_provider_response, buffer) diff --git a/backend/src/utils.py b/backend/src/utils.py index 61f7441..ddcee82 100644 --- a/backend/src/utils.py +++ b/backend/src/utils.py @@ -37,6 +37,9 @@ from fastapi import HTTPException from datetime import datetime, timezone +import logging + +logger = logging.getLogger(__name__) def serialize_data(obj): @@ -2055,87 +2058,90 @@ def resize_b64_image(b64_image: str | str, new_size: tuple[int, int]) -> str: return resized_b64_string -def preprocess_buffer(request, response, buffer) -> dict: - decoder = response._get_content_decoder() - buf = b"".join(buffer) - if "localhost" in str(request.__dict__["url"]) or "host.docker.internal" in str( - request.__dict__["url"] - ): - content = buf.decode("utf-8").split("\n") - rest, content = content[-2], content[:-2] - response_content = { - pair.split(":")[0]: pair.split(":")[1] - for pair in rest.split(',"context"')[0][1:].replace('"', "").split(",") +def preprocess_buffer(ai_provider_request, ai_provider_response, buffer): + """Preprocess the response buffer.""" + logger.debug("Starting buffer preprocessing") + try: + # If we have raw bytes in buffer + if buffer and isinstance(buffer[0], bytes): + buf = b''.join(buffer) + logger.debug(f"Raw buffer size: {len(buf)} bytes") + + # Check content encoding + encoding = ai_provider_response.headers.get('content-encoding', '').lower() + logger.debug(f"Response content encoding: {encoding}") + + decoded_content = None + if encoding == 'br': # Brotli + import brotli + try: + decoded = brotli.decompress(buf) + decoded_content = decoded.decode('utf-8') + except Exception as e: + logger.error(f"Brotli decompression failed: {str(e)}") + # Fall back to raw content + decoded_content = buf.decode('utf-8', errors='ignore') + else: + # Handle other encodings or no encoding + decoded_content = buf.decode('utf-8', errors='ignore') + + # Try to parse as JSON + try: + logger.debug(f"Attempting to parse JSON from: {decoded_content[:200]}...") + return json.loads(decoded_content) + except json.JSONDecodeError as e: + logger.error(f"JSON parsing failed: {str(e)}") + # Handle streaming response format + content = [] + chunks = decoded_content.replace("data: ", "").split("\n\n")[::-1][3:][::-1] + for chunk in chunks: + try: + content.append(json.loads(chunk)["choices"][0]["delta"]["content"].replace("\n", " ")) + except (json.JSONDecodeError, KeyError, IndexError) as e: + logger.error(f"Failed to parse chunk: {str(e)}") + continue + + content = "".join(content) + example = json.loads(chunks[0]) + messages = json.loads(ai_provider_request._content.decode("utf8"))["messages"] + + input_tokens = count_tokens_for_streaming_response(messages, example["model"]) + output_tokens = count_tokens_for_streaming_response(content, example["model"]) + + return { + "id": example["id"], + "object": "chat.completion", + "created": example["created"], + "model": example["model"], + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": content}, + "logprobs": None, + "finish_reason": "stop" + }], + "system_fingerprint": example.get("system_fingerprint"), + "usage": { + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens + } + } + else: + # If buffer contains already decoded content + content = ''.join(buffer) if buffer else '' + try: + return json.loads(content) + except json.JSONDecodeError: + logger.error("Failed to parse non-bytes buffer as JSON") + return {"error": "Invalid JSON response"} + + except Exception as e: + logger.error(f"Buffer preprocessing failed: {str(e)}") + # Return a valid dict as fallback + return { + "error": str(e), + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} } - content = "".join( - list( - map( - lambda msg: [ - text - for text in [text for text in msg[1:-1].split('response":"')][ - 1 - ].split('"') - ][0], - content, - ) - ) - ) - response_content["response"] = content - else: - try: - response_content = decoder.decode(buf) - response_content = json.loads(response_content) - except json.JSONDecodeError: - content = [] - for i in ( - chunks := buf.decode() - .replace("data: ", "") - .split("\n\n")[::-1][3:][::-1] - ): - content.append( - json.loads(i)["choices"][0]["delta"]["content"].replace("\n", " ") - ) - content = "".join(content) - example = json.loads(chunks[0]) - messages = [ - message - for message in json.loads(request.__dict__["_content"].decode("utf8"))[ - "messages" - ] - ] - input_tokens = count_tokens_for_streaming_response( - messages, example["model"] - ) - output_tokens = count_tokens_for_streaming_response( - content, example["model"] - ) - response_content = dict( - id=example["id"], - object="chat.completion", - created=example["created"], - model=example["model"], - choices=[ - dict( - index=0, - message=dict(role="assistant", content=content), - logprobs=None, - finish_reason="stop", - ) - ], - system_fingerprint=example["system_fingerprint"], - usage=dict( - prompt_tokens=input_tokens, - completion_tokens=output_tokens, - total_tokens=input_tokens + output_tokens, - ), - ) - if isinstance(response_content, list): - response_content = response_content[0] - if "usage" not in response_content: - response_content["usage"] = dict( - prompt_tokens=0, completion_tokens=0, total_tokens=0 - ) - return response_content class PeriodEnum(str, Enum): From 7b413793bfb44edc74a5b6f728be41f8c3b0055c Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 20:33:05 +0200 Subject: [PATCH 08/12] fix: handle null messages in transaction display and add missing import - Add null check in DisplayMessage component to prevent frontend errors - Import deserialize_data in transaction repository to fix project view --- backend/src/transactions/models.py | 5 + backend/src/transactions/repositories.py | 42 ++++- backend/src/transactions/use_cases.py | 147 +++++++++--------- backend/src/utils.py | 123 +++++++++++---- .../AllTransactions/TransactionsTable.tsx | 6 +- 5 files changed, 209 insertions(+), 114 deletions(-) diff --git a/backend/src/transactions/models.py b/backend/src/transactions/models.py index 3c35899..ff39818 100644 --- a/backend/src/transactions/models.py +++ b/backend/src/transactions/models.py @@ -39,3 +39,8 @@ class Transaction(BaseModel): # self_harm: Any = None # violence: Any = None # sexual: Any = None + + def __init__(self, **data): + super().__init__(**data) + self.prompt = data.get('prompt', '') + self.last_message = data.get('last_message', '') diff --git a/backend/src/transactions/repositories.py b/backend/src/transactions/repositories.py index bc4937a..cc1004f 100644 --- a/backend/src/transactions/repositories.py +++ b/backend/src/transactions/repositories.py @@ -1,8 +1,9 @@ from datetime import datetime from operator import attrgetter +import json from seedwork.exceptions import NotFoundException -from seedwork.repositories import MongoRepository +from seedwork.repositories import MongoRepository, deserialize_data from transactions.models import Transaction from app.logging import logger @@ -98,13 +99,38 @@ def get_paginated_and_filtered( ] return paginated - def get_filtered(self, query: dict[str, str | datetime | None]): - """ - Retrieve a paginated and filtered list of transactions from the repository. - :param query: Query parameters to filter transactions. - :return: A filtered list of Transaction objects based on the specified criteria. - """ - return self.find(query) + def get_filtered(self, query: dict) -> list[Transaction]: + """Get transactions based on filter query.""" + logger.debug(f"Getting filtered transactions with query: {query}") + transactions = list(self._collection.find(query)) + logger.debug(f"Found {len(transactions)} transactions") + + # Transform transactions to include required frontend fields + transformed = [] + for t in transactions: + t = deserialize_data(t) + try: + # Extract prompt from request content + request_content = json.loads(t.get('request_content', '{}')) + messages = request_content.get('messages', []) + prompt = messages[-1].get('content', '') if messages else '' + + # Extract last message from response content + response_content = json.loads(t.get('response_content', '{}')) + last_message = response_content.get('choices', [{}])[0].get('message', {}).get('content', '') + + # Add these to the transaction data + t['prompt'] = prompt + t['last_message'] = last_message + + except (json.JSONDecodeError, IndexError, KeyError) as e: + logger.error(f"Error extracting messages: {str(e)}") + t['prompt'] = '' + t['last_message'] = '' + + transformed.append(t) + + return [Transaction(**t) for t in transformed] def delete_cascade(self, project_id: str): """ diff --git a/backend/src/transactions/use_cases.py b/backend/src/transactions/use_cases.py index 77ebb38..2015bc9 100644 --- a/backend/src/transactions/use_cases.py +++ b/backend/src/transactions/use_cases.py @@ -1,5 +1,6 @@ import json import re +import uuid import utils from _datetime import datetime, timezone @@ -189,114 +190,106 @@ def store_transaction( request_time, transaction_repository: TransactionRepository, ) -> dict: - """ - Store a transaction in the repository based on request, response, and additional information. - - :param ai_provider_request: The request object. - :param ai_provider_response: The response object. - :param buffer: The buffer containing the response content. - :param project_id: The Project ID associated with the transaction. - :param tags: The tags associated with the transaction. - :param request_time: The timestamp of the request. - :param ai_model_version: Optional. Specific tag for AI model. Helps with cost count. - :param pricelist: The pricelist for the models. - :param transaction_repository: An instance of TransactionRepository used for storing transaction data. - :return: None - """ + """Store a transaction in the repository.""" logger.debug(f"store_transaction called for project {project_id}") response_content = utils.preprocess_buffer(ai_provider_request, ai_provider_response, buffer) + logger.debug(f"Preprocessed response content: {json.dumps(response_content)[:200]}...") + + # Extract request content + try: + request_json = json.loads(ai_provider_request._content.decode("utf8")) + prompt = request_json.get("messages", [{}])[-1].get("content", "") + except Exception as e: + logger.error(f"Failed to extract prompt: {str(e)}") + prompt = "" + + # Extract response content + try: + last_message = response_content.get("choices", [{}])[0].get("message", {}).get("content", "") + except Exception as e: + logger.error(f"Failed to extract last message: {str(e)}") + last_message = "" param_extractor = utils.TransactionParamExtractor( ai_provider_request, ai_provider_response, response_content ) params = param_extractor.extract() + logger.debug(f"Extracted params: {json.dumps(params)[:200]}...") - ai_model_version = ( - ai_model_version if ai_model_version is not None else params["model"] - ) - - pricelist = [ - item - for item in pricelist - if item.provider == params["provider"] - and re.match(item.match_pattern, ai_model_version) + # Calculate costs based on pricelist + ai_model_version = ai_model_version if ai_model_version is not None else params["model"] + matching_pricelist = [ + item for item in pricelist + if item.provider == params["provider"] and re.match(item.match_pattern, ai_model_version) ] + logger.debug(f"Found {len(matching_pricelist)} matching pricelist items") - if ( - params["status_code"] == 200 - and params["input_tokens"] is not None - and params["output_tokens"] is not None - ): - if len(pricelist) > 0: - if pricelist[0].mode == "image_generation": + # Calculate costs + if params["prompt_tokens"] is not None and params["completion_tokens"] is not None: + if matching_pricelist: + pricelist_item = matching_pricelist[0] + if pricelist_item.mode == "image_generation": input_cost = 0 - output_cost = ( - int(param_extractor.request_content["n"]) * pricelist[0].total_price - ) + output_cost = int(param_extractor.request_content.get("n", 1)) * pricelist_item.total_price total_cost = output_cost else: - if pricelist[0].input_price == 0: + if pricelist_item.input_price == 0: input_cost, output_cost = 0, 0 - total_cost = ( - (params["input_tokens"] + params["output_tokens"]) - / 1000 - * pricelist[0].total_price - ) + total_cost = (params["prompt_tokens"] + params["completion_tokens"]) / 1000 * pricelist_item.total_price else: - input_cost = pricelist[0].input_price * ( - params["input_tokens"] / 1000 - ) - output_cost = pricelist[0].output_price * ( - params["output_tokens"] / 1000 - ) + input_cost = pricelist_item.input_price * (params["prompt_tokens"] / 1000) + output_cost = pricelist_item.output_price * (params["completion_tokens"] / 1000) total_cost = input_cost + output_cost + logger.debug(f"Calculated costs - input: {input_cost}, output: {output_cost}, total: {total_cost}") else: - input_cost, output_cost, total_cost = None, None, None - else: - input_cost, output_cost, total_cost = 0, 0, 0 - - if params["output_tokens"] is not None and params["output_tokens"] > 0: - generation_speed = ( - params["output_tokens"] - / (datetime.now(tz=timezone.utc) - request_time).total_seconds() - ) - elif params["output_tokens"] == 0: - generation_speed = None + input_cost = output_cost = total_cost = None + logger.debug("No matching pricelist item found, costs set to None") else: - generation_speed = 0 + input_cost = output_cost = total_cost = 0 + logger.debug("Token counts not available, costs set to 0") - try: - content = json.loads(ai_provider_request.content) - except UnicodeDecodeError: - content = param_extractor.request_content + # Calculate generation speed + if params["completion_tokens"] is not None and params["completion_tokens"] > 0: + generation_speed = params["completion_tokens"] / (datetime.now(tz=timezone.utc) - request_time).total_seconds() + logger.debug(f"Calculated generation speed: {generation_speed}") + else: + generation_speed = None + logger.debug("Generation speed set to None") + # Create transaction transaction = Transaction( + id=str(uuid.uuid4()), project_id=project_id, + request_time=request_time, + response_time=datetime.now(timezone.utc), + status_code=ai_provider_response.status_code, + request_content=ai_provider_request._content.decode("utf8"), + response_content=json.dumps(response_content), tags=tags, + ai_model_version=ai_model_version, provider=params["provider"], - model=ai_model_version, - prompt=params["prompt"], - type=params["type"], - os=params["os"], - input_tokens=params["input_tokens"], - output_tokens=params["output_tokens"], - library=params["library"], - status_code=params["status_code"], - messages=params["messages"], - last_message=params["last_message"], - error_message=params["error_message"], + model=params["model"], + prompt_tokens=params["prompt_tokens"], + completion_tokens=params["completion_tokens"], + total_tokens=params["total_tokens"], input_cost=input_cost, output_cost=output_cost, total_cost=total_cost, - request_time=request_time, generation_speed=generation_speed, + prompt=prompt, + last_message=last_message, ) - transaction_repository.add(transaction) + logger.debug(f"Created transaction object with id {transaction.id}") + + # Store in repository + result = transaction_repository.add(transaction) + logger.debug(f"Stored transaction in repository: {result}") + return { - "response_content": response_content, - "request_content": param_extractor.request_content, "transaction_id": transaction.id, + "request_content": transaction.request_content, + "response_content": transaction.response_content, } @@ -337,6 +330,8 @@ def get_list_of_filtered_transactions( ) transactions = transaction_repository.get_filtered(query) return transactions + + def add_transaction( data: CreateTransactionSchema, transaction_repository: TransactionRepository ) -> Transaction: diff --git a/backend/src/utils.py b/backend/src/utils.py index ddcee82..49cfe80 100644 --- a/backend/src/utils.py +++ b/backend/src/utils.py @@ -2079,16 +2079,40 @@ def preprocess_buffer(ai_provider_request, ai_provider_response, buffer): decoded_content = decoded.decode('utf-8') except Exception as e: logger.error(f"Brotli decompression failed: {str(e)}") - # Fall back to raw content decoded_content = buf.decode('utf-8', errors='ignore') else: - # Handle other encodings or no encoding decoded_content = buf.decode('utf-8', errors='ignore') # Try to parse as JSON try: logger.debug(f"Attempting to parse JSON from: {decoded_content[:200]}...") - return json.loads(decoded_content) + parsed = json.loads(decoded_content) + # Ensure the response has the expected structure + if isinstance(parsed, dict): + if "choices" in parsed and isinstance(parsed["choices"], list): + # Standard completion response + return parsed + else: + # Wrap non-standard response in expected format + return { + "id": parsed.get("id", "unknown"), + "object": "chat.completion", + "created": parsed.get("created", int(datetime.now().timestamp())), + "model": parsed.get("model", "unknown"), + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": str(parsed.get("content", "")) + }, + "finish_reason": "stop" + }], + "usage": parsed.get("usage", { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + }) + } except json.JSONDecodeError as e: logger.error(f"JSON parsing failed: {str(e)}") # Handle streaming response format @@ -2102,45 +2126,88 @@ def preprocess_buffer(ai_provider_request, ai_provider_response, buffer): continue content = "".join(content) - example = json.loads(chunks[0]) - messages = json.loads(ai_provider_request._content.decode("utf8"))["messages"] - - input_tokens = count_tokens_for_streaming_response(messages, example["model"]) - output_tokens = count_tokens_for_streaming_response(content, example["model"]) - + try: + example = json.loads(chunks[0]) + messages = json.loads(ai_provider_request._content.decode("utf8"))["messages"] + + input_tokens = count_tokens_for_streaming_response(messages, example["model"]) + output_tokens = count_tokens_for_streaming_response(content, example["model"]) + + return { + "id": example.get("id", "unknown"), + "object": "chat.completion", + "created": example.get("created", int(datetime.now().timestamp())), + "model": example.get("model", "unknown"), + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens + } + } + except Exception as e: + logger.error(f"Failed to process streaming response: {str(e)}") + return { + "id": "unknown", + "object": "chat.completion", + "created": int(datetime.now().timestamp()), + "model": "unknown", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + } + } + else: + # If buffer contains already decoded content + content = ''.join(buffer) if buffer else '' + try: + return json.loads(content) + except json.JSONDecodeError: + logger.error("Failed to parse non-bytes buffer as JSON") return { - "id": example["id"], + "id": "unknown", "object": "chat.completion", - "created": example["created"], - "model": example["model"], + "created": int(datetime.now().timestamp()), + "model": "unknown", "choices": [{ "index": 0, "message": {"role": "assistant", "content": content}, - "logprobs": None, "finish_reason": "stop" }], - "system_fingerprint": example.get("system_fingerprint"), "usage": { - "prompt_tokens": input_tokens, - "completion_tokens": output_tokens, - "total_tokens": input_tokens + output_tokens + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 } } - else: - # If buffer contains already decoded content - content = ''.join(buffer) if buffer else '' - try: - return json.loads(content) - except json.JSONDecodeError: - logger.error("Failed to parse non-bytes buffer as JSON") - return {"error": "Invalid JSON response"} except Exception as e: logger.error(f"Buffer preprocessing failed: {str(e)}") - # Return a valid dict as fallback return { - "error": str(e), - "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + "id": "error", + "object": "chat.completion", + "created": int(datetime.now().timestamp()), + "model": "unknown", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": str(e)}, + "finish_reason": "error" + }], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + } } diff --git a/ui/src/components/tables/AllTransactions/TransactionsTable.tsx b/ui/src/components/tables/AllTransactions/TransactionsTable.tsx index d3b4813..bfd75da 100644 --- a/ui/src/components/tables/AllTransactions/TransactionsTable.tsx +++ b/ui/src/components/tables/AllTransactions/TransactionsTable.tsx @@ -19,14 +19,16 @@ interface Props { projectFilters?: boolean; } -const DisplayMessage: React.FC<{ str: string }> = ({ str }) => { +const DisplayMessage: React.FC<{ str: string | null }> = ({ str }) => { + if (!str) return <>-; // Handle null/undefined case const message = useHandleTransactionImage(str); if (typeof message === 'string') return <>{message.length > 25 ? message.substring(0, 23) + '...' : message}; - else { + else if (message) { // Check if message exists before accessing props const element = cloneElement(message, { ...message.props, className: 'max-h-[25px]' }); return element; } + return <>-; // Fallback for any other case }; const TransactionsTable: React.FC = ({ From f3e126e765cf0f78214d7313ea2d75ddf3b676b1 Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 20:41:50 +0200 Subject: [PATCH 09/12] updated OpenAI model pricing --- .../src/config/data/provider_price_list.json | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/backend/src/config/data/provider_price_list.json b/backend/src/config/data/provider_price_list.json index 238503c..b0290d9 100644 --- a/backend/src/config/data/provider_price_list.json +++ b/backend/src/config/data/provider_price_list.json @@ -955,5 +955,181 @@ "total_price": 0, "is_active": true, "mode": "chat" + }, + { + "model_name": "gpt-4o", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o)$", + "input_price": 2.50, + "output_price": 10.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-2024-08-06", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-2024-08-06)$", + "input_price": 2.50, + "output_price": 10.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-audio-preview", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-audio-preview)$", + "input_price": 2.50, + "output_price": 10.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-audio-preview-2024-12-17", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-audio-preview-2024-12-17)$", + "input_price": 2.50, + "output_price": 10.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-realtime-preview", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-realtime-preview)$", + "input_price": 5.00, + "output_price": 20.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-realtime-preview-2024-12-17", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-realtime-preview-2024-12-17)$", + "input_price": 5.00, + "output_price": 20.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-mini", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-mini)$", + "input_price": 0.15, + "output_price": 0.60, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-mini-2024-07-18", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-mini-2024-07-18)$", + "input_price": 0.15, + "output_price": 0.60, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-mini-audio-preview", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-mini-audio-preview)$", + "input_price": 0.15, + "output_price": 0.60, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-mini-audio-preview-2024-12-17", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-mini-audio-preview-2024-12-17)$", + "input_price": 0.15, + "output_price": 0.60, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-mini-realtime-preview", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-mini-realtime-preview)$", + "input_price": 0.60, + "output_price": 2.40, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "gpt-4o-mini-realtime-preview-2024-12-17", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(gpt-4o-mini-realtime-preview-2024-12-17)$", + "input_price": 0.60, + "output_price": 2.40, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "o1", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(o1)$", + "input_price": 15.00, + "output_price": 60.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "o1-2024-12-17", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(o1-2024-12-17)$", + "input_price": 15.00, + "output_price": 60.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "o1-mini", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(o1-mini)$", + "input_price": 3.00, + "output_price": 12.00, + "total_price": 0, + "is_active": true, + "mode": "chat" + }, + { + "model_name": "o1-mini-2024-09-12", + "provider": "OpenAI", + "start_date": "", + "match_pattern": "(?i)^(o1-mini-2024-09-12)$", + "input_price": 3.00, + "output_price": 12.00, + "total_price": 0, + "is_active": true, + "mode": "chat" } ] \ No newline at end of file From d8d45fe9648acd6754ab57d659aee47550b2c65b Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 21:16:50 +0200 Subject: [PATCH 10/12] fixed error with unpopulated model data --- backend/compose.yml | 7 +++- backend/src/transactions/models.py | 2 + backend/src/transactions/use_cases.py | 56 ++++++++++++++++----------- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/backend/compose.yml b/backend/compose.yml index 902b5e2..c03f701 100644 --- a/backend/compose.yml +++ b/backend/compose.yml @@ -18,4 +18,9 @@ services: mongodb: image: mongo:latest ports: - - "27017:27017" \ No newline at end of file + - "27017:27017" + volumes: + - mongodb_data:/data/db + +volumes: + mongodb_data: \ No newline at end of file diff --git a/backend/src/transactions/models.py b/backend/src/transactions/models.py index ff39818..dbb82eb 100644 --- a/backend/src/transactions/models.py +++ b/backend/src/transactions/models.py @@ -33,6 +33,8 @@ class Transaction(BaseModel): response_time: datetime = Field( default_factory=lambda: datetime.now(tz=timezone.utc) ) + request_content: str | None = None # Make it optional + response_content: str | None = None # Make it optional # old fields # hate: Any = None diff --git a/backend/src/transactions/use_cases.py b/backend/src/transactions/use_cases.py index 2015bc9..4c2cc43 100644 --- a/backend/src/transactions/use_cases.py +++ b/backend/src/transactions/use_cases.py @@ -217,16 +217,22 @@ def store_transaction( params = param_extractor.extract() logger.debug(f"Extracted params: {json.dumps(params)[:200]}...") + # Get token counts with safe defaults + prompt_tokens = params.get("prompt_tokens", 0) + completion_tokens = params.get("completion_tokens", 0) + total_tokens = params.get("total_tokens", 0) + # Calculate costs based on pricelist - ai_model_version = ai_model_version if ai_model_version is not None else params["model"] + ai_model_version = ai_model_version if ai_model_version is not None else params.get("model", "") matching_pricelist = [ item for item in pricelist - if item.provider == params["provider"] and re.match(item.match_pattern, ai_model_version) + if item.provider == params.get("provider", "") and re.match(item.match_pattern, ai_model_version) ] logger.debug(f"Found {len(matching_pricelist)} matching pricelist items") # Calculate costs - if params["prompt_tokens"] is not None and params["completion_tokens"] is not None: + input_cost = output_cost = total_cost = 0 + if prompt_tokens is not None and completion_tokens is not None: if matching_pricelist: pricelist_item = matching_pricelist[0] if pricelist_item.mode == "image_generation": @@ -235,27 +241,24 @@ def store_transaction( total_cost = output_cost else: if pricelist_item.input_price == 0: - input_cost, output_cost = 0, 0 - total_cost = (params["prompt_tokens"] + params["completion_tokens"]) / 1000 * pricelist_item.total_price + input_cost = output_cost = 0 + total_cost = (prompt_tokens + completion_tokens) / 1000 * pricelist_item.total_price else: - input_cost = pricelist_item.input_price * (params["prompt_tokens"] / 1000) - output_cost = pricelist_item.output_price * (params["completion_tokens"] / 1000) + input_cost = pricelist_item.input_price * (prompt_tokens / 1000) + output_cost = pricelist_item.output_price * (completion_tokens / 1000) total_cost = input_cost + output_cost logger.debug(f"Calculated costs - input: {input_cost}, output: {output_cost}, total: {total_cost}") else: - input_cost = output_cost = total_cost = None - logger.debug("No matching pricelist item found, costs set to None") - else: - input_cost = output_cost = total_cost = 0 - logger.debug("Token counts not available, costs set to 0") + logger.debug("No matching pricelist item found, costs set to 0") # Calculate generation speed - if params["completion_tokens"] is not None and params["completion_tokens"] > 0: - generation_speed = params["completion_tokens"] / (datetime.now(tz=timezone.utc) - request_time).total_seconds() - logger.debug(f"Calculated generation speed: {generation_speed}") - else: - generation_speed = None - logger.debug("Generation speed set to None") + generation_speed = None + if completion_tokens and completion_tokens > 0: + try: + generation_speed = completion_tokens / (datetime.now(tz=timezone.utc) - request_time).total_seconds() + logger.debug(f"Calculated generation speed: {generation_speed}") + except Exception as e: + logger.error(f"Failed to calculate generation speed: {str(e)}") # Create transaction transaction = Transaction( @@ -268,17 +271,24 @@ def store_transaction( response_content=json.dumps(response_content), tags=tags, ai_model_version=ai_model_version, - provider=params["provider"], - model=params["model"], - prompt_tokens=params["prompt_tokens"], - completion_tokens=params["completion_tokens"], - total_tokens=params["total_tokens"], + provider=params.get("provider", "unknown"), + model=params.get("model", "unknown"), + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, input_cost=input_cost, output_cost=output_cost, total_cost=total_cost, generation_speed=generation_speed, prompt=prompt, last_message=last_message, + type="chat", + os=params.get("os", "unknown"), + input_tokens=prompt_tokens, + output_tokens=completion_tokens, + library=params.get("library", "unknown"), + messages=params.get("messages", []), + error_message=params.get("error_message", None) ) logger.debug(f"Created transaction object with id {transaction.id}") From 4856e6c4d716f81a1e9e41a9f5c8b9be21226ad5 Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Thu, 23 Jan 2025 21:30:07 +0200 Subject: [PATCH 11/12] fixed error with unpopulated model data --- backend/src/transactions/use_cases.py | 98 +++++++++++++++++---------- 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/backend/src/transactions/use_cases.py b/backend/src/transactions/use_cases.py index 4c2cc43..e06471a 100644 --- a/backend/src/transactions/use_cases.py +++ b/backend/src/transactions/use_cases.py @@ -217,48 +217,78 @@ def store_transaction( params = param_extractor.extract() logger.debug(f"Extracted params: {json.dumps(params)[:200]}...") - # Get token counts with safe defaults - prompt_tokens = params.get("prompt_tokens", 0) - completion_tokens = params.get("completion_tokens", 0) - total_tokens = params.get("total_tokens", 0) + # Extract params safely for logging + logger.debug(f"Token counts from params - input: {params.get('input_tokens')}, output: {params.get('output_tokens')}, total: {params.get('total_tokens')}") # Calculate costs based on pricelist - ai_model_version = ai_model_version if ai_model_version is not None else params.get("model", "") - matching_pricelist = [ - item for item in pricelist - if item.provider == params.get("provider", "") and re.match(item.match_pattern, ai_model_version) + ai_model_version = ( + ai_model_version if ai_model_version is not None else params["model"] + ) + + pricelist = [ + item + for item in pricelist + if item.provider == params["provider"] + and re.match(item.match_pattern, ai_model_version) ] - logger.debug(f"Found {len(matching_pricelist)} matching pricelist items") - - # Calculate costs - input_cost = output_cost = total_cost = 0 - if prompt_tokens is not None and completion_tokens is not None: - if matching_pricelist: - pricelist_item = matching_pricelist[0] - if pricelist_item.mode == "image_generation": + + if ( + params["status_code"] == 200 + and params["input_tokens"] is not None + and params["output_tokens"] is not None + ): + if len(pricelist) > 0: + if pricelist[0].mode == "image_generation": input_cost = 0 - output_cost = int(param_extractor.request_content.get("n", 1)) * pricelist_item.total_price + output_cost = ( + int(param_extractor.request_content["n"]) * pricelist[0].total_price + ) total_cost = output_cost else: - if pricelist_item.input_price == 0: - input_cost = output_cost = 0 - total_cost = (prompt_tokens + completion_tokens) / 1000 * pricelist_item.total_price + if pricelist[0].input_price == 0: + input_cost, output_cost = 0, 0 + total_cost = ( + (params["input_tokens"] + params["output_tokens"]) + / 1000 + * pricelist[0].total_price + ) else: - input_cost = pricelist_item.input_price * (prompt_tokens / 1000) - output_cost = pricelist_item.output_price * (completion_tokens / 1000) + input_cost = pricelist[0].input_price * ( + params["input_tokens"] / 1000 + ) + output_cost = pricelist[0].output_price * ( + params["output_tokens"] / 1000 + ) total_cost = input_cost + output_cost - logger.debug(f"Calculated costs - input: {input_cost}, output: {output_cost}, total: {total_cost}") else: - logger.debug("No matching pricelist item found, costs set to 0") + input_cost, output_cost, total_cost = None, None, None + else: + input_cost, output_cost, total_cost = 0, 0, 0 - # Calculate generation speed - generation_speed = None - if completion_tokens and completion_tokens > 0: + # Calculate generation speed with logging + if params.get("output_tokens") is not None and params.get("output_tokens") > 0: try: - generation_speed = completion_tokens / (datetime.now(tz=timezone.utc) - request_time).total_seconds() - logger.debug(f"Calculated generation speed: {generation_speed}") + current_time = datetime.now(tz=timezone.utc) + time_diff = (current_time - request_time).total_seconds() + logger.debug(f"Speed calculation - tokens: {params.get('output_tokens')}, time_diff: {time_diff}") + + generation_speed = ( + params["output_tokens"] + / time_diff + ) + logger.debug(f"Calculated generation speed: {generation_speed} tokens/second") except Exception as e: logger.error(f"Failed to calculate generation speed: {str(e)}") + generation_speed = None + elif params.get("output_tokens") == 0: + generation_speed = None + logger.debug("Generation speed set to None - output_tokens is 0") + else: + generation_speed = 0 + logger.debug("Generation speed set to 0 - output_tokens is None") + + # Let's also log the params to see what we're getting from the extractor + logger.debug(f"Full params from extractor: {json.dumps(params)}") # Create transaction transaction = Transaction( @@ -273,9 +303,9 @@ def store_transaction( ai_model_version=ai_model_version, provider=params.get("provider", "unknown"), model=params.get("model", "unknown"), - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=total_tokens, + prompt_tokens=params.get("prompt_tokens", 0), + completion_tokens=params.get("completion_tokens", 0), + total_tokens=params.get("total_tokens", 0), input_cost=input_cost, output_cost=output_cost, total_cost=total_cost, @@ -284,8 +314,8 @@ def store_transaction( last_message=last_message, type="chat", os=params.get("os", "unknown"), - input_tokens=prompt_tokens, - output_tokens=completion_tokens, + input_tokens=params.get("input_tokens", 0), + output_tokens=params.get("output_tokens", 0), library=params.get("library", "unknown"), messages=params.get("messages", []), error_message=params.get("error_message", None) From 3557151f9ff42fdff1b9d9aab4df58e678be9836 Mon Sep 17 00:00:00 2001 From: Sabelo Simelane Date: Fri, 24 Jan 2025 01:49:00 +0200 Subject: [PATCH 12/12] production ready fixes --- backend/Dockerfile | 2 +- backend/Dockerfile_v1.1 | 6 ++- backend/build.sh | 2 +- backend/src/app/middleware.py | 19 +++++++- backend/src/app/reverse_proxy.py | 79 +++++++++++++++----------------- backend/src/config/containers.py | 22 +++++++-- ui/Dockerfile | 4 +- 7 files changed, 82 insertions(+), 52 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index dddd4a1..dd8948d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,4 +5,4 @@ RUN pip install poetry COPY . . RUN poetry install -ENV PYTHONPATH=/app/src +ENV PYTHONPATH=/app/src \ No newline at end of file diff --git a/backend/Dockerfile_v1.1 b/backend/Dockerfile_v1.1 index 29e46e2..0b91da9 100644 --- a/backend/Dockerfile_v1.1 +++ b/backend/Dockerfile_v1.1 @@ -15,7 +15,8 @@ FROM python:3.10.2-slim-buster AS base ENV LANG=C.UTF-8 \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ - POETRY_VERSION=1.7.1 + POETRY_VERSION=1.7.1 \ + PYTHONPATH=/src WORKDIR /src @@ -71,6 +72,7 @@ ARG BUILD_SHA ENV BUILD_SHA=${BUILD_SHA} ENV VIRTUAL_ENV=/src/.venv \ + PYTHONPATH=/src \ PATH="/src/.venv/bin:$PATH" # copy the packages build in runtime stage @@ -84,4 +86,4 @@ COPY --from=runtime ${VIRTUAL_ENV} ${VIRTUAL_ENV} EXPOSE 8000 # Run the application -CMD uvicorn app:app --proxy-headers --host 0.0.0.0 --port=${PORT:-8000} \ No newline at end of file +CMD cd /src && uvicorn app:app --proxy-headers --host 0.0.0.0 --port=${PORT:-8000} \ No newline at end of file diff --git a/backend/build.sh b/backend/build.sh index adc6724..161c4e5 100755 --- a/backend/build.sh +++ b/backend/build.sh @@ -1 +1 @@ -docker buildx build -t registry.gitlab.com/sabside/promptsail:backend --push . \ No newline at end of file +docker buildx build --platform linux/amd64 -t registry.gitlab.com/sabside/promptsail:backend --push . \ No newline at end of file diff --git a/backend/src/app/middleware.py b/backend/src/app/middleware.py index 661b84b..d2b9a6c 100644 --- a/backend/src/app/middleware.py +++ b/backend/src/app/middleware.py @@ -1,6 +1,8 @@ from config import config from fastapi import HTTPException, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response +from starlette.requests import ClientDisconnect +import json from .app import app from .dependencies import get_logger @@ -115,3 +117,18 @@ async def __call__(request: Request, call_next): # response = await call_next(request) # return response + +class LoggingMiddleware: + async def __call__(self, request: Request, call_next): + try: + try: + response = await call_next(request) + return response + except ClientDisconnect: + return Response( + content=json.dumps({"error": "Client disconnected"}), + status_code=499 + ) + except Exception as e: + logger.error(f"Error message: {str(e)}. Args: {e.args}") + raise diff --git a/backend/src/app/reverse_proxy.py b/backend/src/app/reverse_proxy.py index a54d37e..f75c051 100644 --- a/backend/src/app/reverse_proxy.py +++ b/backend/src/app/reverse_proxy.py @@ -1,7 +1,7 @@ from typing import Annotated import httpx -from _datetime import datetime, timezone +from datetime import datetime, timezone from app.dependencies import get_logger, get_provider_pricelist, get_transaction_context from fastapi import Depends, Request, HTTPException from fastapi.responses import StreamingResponse, Response @@ -15,7 +15,9 @@ import json import sys import os +from app.logging import logger from .db_logging import MongoDBLogger, Direction +from starlette.requests import ClientDisconnect from .app import app @@ -24,7 +26,6 @@ proxy_handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s')) # Check if we should log to stdout -log_to_mongodb = os.getenv("LOG_TO_MONGODB", "False").lower() == "true" log_to_stdout = os.getenv("LOG_TO_STDOUT", "True").lower() == "true" # Create a separate logger for proxy logging @@ -34,13 +35,6 @@ proxy_logger.setLevel(logging.INFO) proxy_logger.propagate = False -# Initialize MongoDB logger if enabled -mongo_logger = None -if log_to_mongodb: - mongo_url = os.getenv("MONGO_URL", "mongodb://localhost:27017") - mongo_logger = MongoDBLogger(mongo_url) - proxy_logger.info("MongoDB logging enabled") - async def iterate_stream(response, buffer): """ Asynchronously iterate over the raw stream of a response and accumulate chunks in a buffer. @@ -57,8 +51,16 @@ async def iterate_stream(response, buffer): """ logger.debug("Starting to iterate over response stream") async for chunk in response.aiter_raw(): - buffer.append(chunk) - yield chunk + try: + buffer.append(chunk) + yield chunk + except ClientDisconnect: + logger.warning("Client disconnected during stream") + await response.aclose() + return + except Exception as e: + logger.error(f"Error during stream iteration: {str(e)}") + raise logger.debug(f"Finished stream iteration, buffer size: {len(buffer)}") @@ -184,15 +186,6 @@ async def reverse_proxy( try: project = ctx.call(get_project_by_slug, slug=project_slug) except Exception: - if mongo_logger: - mongo_logger.log_request( - direction=Direction.OUTGOING, - method=request.method, - url=str(request.url), - headers=dict(request.headers), - body=None, - status_code=404 - ) raise HTTPException(status_code=404, detail=f"Project not found: {project_slug}") url = ApiURLBuilder.build(project, provider_slug, path, target_path) @@ -201,7 +194,15 @@ async def reverse_proxy( logger.debug(f"got projects for {project}") # Get the body once and store it - body = await request.body() if request.method != "GET" else None + try: + body = await request.body() if request.method != "GET" else None + except ClientDisconnect: + return Response( + content=json.dumps({ + "error": "Client disconnected during request" + }), + status_code=499 + ) # Create headers without transfer-encoding headers = {k: v for k, v in request.headers.items() @@ -210,6 +211,12 @@ async def reverse_proxy( # Add Content-Length if we have a body if body: headers["Content-Length"] = str(len(body)) + + # Preserve WebSocket headers if present + if request.headers.get("upgrade", "").lower() == "websocket": + headers["Connection"] = request.headers.get("Connection") + headers["Upgrade"] = request.headers.get("Upgrade") + headers["Sec-WebSocket-Version"] = request.headers.get("Sec-WebSocket-Version") # Log the outgoing request details if log_to_stdout: @@ -228,16 +235,6 @@ async def reverse_proxy( except json.JSONDecodeError: proxy_logger.info(f"Raw body: {body.decode()}") - # Log to MongoDB if enabled - if mongo_logger: - mongo_logger.log_request( - direction=Direction.OUTGOING, - method=request.method, - url=str(url), - headers=dict(headers), - body=body.decode() if body else None - ) - # Make the request to the upstream server client = httpx.AsyncClient() timeout = httpx.Timeout(100.0, connect=50.0) @@ -278,22 +275,20 @@ async def reverse_proxy( proxy_logger.info("Response Headers:") proxy_logger.info(json.dumps(dict(ai_provider_response.headers), indent=2)) - # Log response to MongoDB if enabled - if mongo_logger: - mongo_logger.log_request( - direction=Direction.INCOMING, - method=request.method, - url=str(url), - headers=dict(ai_provider_response.headers), - status_code=ai_provider_response.status_code - ) - # If it's a streaming response, collect all chunks and return as one response buffer = [] if ai_provider_response.headers.get("transfer-encoding") == "chunked": logger.debug("Handling chunked response") async for chunk in ai_provider_response.aiter_bytes(): - buffer.append(chunk) + try: + buffer.append(chunk) + except ClientDisconnect: + logger.warning("Client disconnected during chunked response") + await ai_provider_response.aclose() + return Response(status_code=499) + except Exception as e: + logger.error(f"Error processing chunk: {str(e)}") + raise content = b''.join(buffer) # Set up background task for chunked responses too diff --git a/backend/src/config/containers.py b/backend/src/config/containers.py index d832bd1..3ea743d 100644 --- a/backend/src/config/containers.py +++ b/backend/src/config/containers.py @@ -2,6 +2,7 @@ import inspect import json import uuid +import os from logging import Logger from typing import Optional from uuid import UUID @@ -20,6 +21,7 @@ from settings.repositories import SettingsRepository from transactions.repositories import TransactionRepository from utils import read_provider_pricelist +from app.db_logging import MongoDBLogger # Added import # logger = logging.getLogger("ps") # logger.setLevel(logging.DEBUG) @@ -237,9 +239,17 @@ class TopLevelContainer(containers.DeclarativeContainer): config = providers.Configuration() logger = providers.Object(logger) db_client = providers.Singleton( - lambda config: pymongo.MongoClient(config.MONGO_URL).get_database( - config.DATABASE_NAME - ), + lambda config: ( + logger.info(f"Attempting MongoDB connection with URL: {config.MONGO_URL} and database: {config.DATABASE_NAME}"), + pymongo.MongoClient( + config.MONGO_URL, + serverSelectionTimeoutMS=int(os.getenv("MONGO_SERVER_TIMEOUT_MS", "30000")), + connectTimeoutMS=int(os.getenv("MONGO_CONNECT_TIMEOUT_MS", "20000")), + socketTimeoutMS=int(os.getenv("MONGO_SOCKET_TIMEOUT_MS", "20000")), + retryWrites=True, + retryReads=True + ).get_database(config.DATABASE_NAME) + )[1], config=config, ) application: Application = providers.Singleton( @@ -255,6 +265,12 @@ class TopLevelContainer(containers.DeclarativeContainer): config=config ) + # MongoDB logger for proxy requests + mongo_logger = providers.Singleton( + MongoDBLogger, + db_client=db_client + ) + class TransactionContainer(containers.DeclarativeContainer): """ diff --git a/ui/Dockerfile b/ui/Dockerfile index 05a6b82..b726316 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -27,9 +27,9 @@ FROM nginx:1.25-alpine-slim COPY --from=build /app/dist /usr/share/nginx/html # copy the custom nginx configuration -COPY /deployment/nginx-custom.conf /etc/nginx/conf.d/default.conf +COPY ./deployment/nginx-custom.conf /etc/nginx/conf.d/default.conf # copy the custom entrypoint script that replaces the placeholders in the ui/.env.production file -COPY /deployment/docker_env_replacement.sh /docker-entrypoint.d/docker_env_replacement.sh +COPY ./deployment/docker_env_replacement.sh /docker-entrypoint.d/docker_env_replacement.sh RUN chmod +x /docker-entrypoint.d/docker_env_replacement.sh