Squash everything befor release.
Some past commits might have contained Cursor's IP or accidentally commited API keys. Before making the repository public, all the history has been squashed into a single commit with.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Lint and Format After Edits
|
||||
|
||||
After finishing any big code changes, and before yielding to the user, run with your terminal tool the following comand:
|
||||
|
||||
```
|
||||
source .venv/bin/activate && flask lint
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Run **black** to automatically format the codebase.
|
||||
- Run **flake8** to flag any remaining formatting or lint issues.
|
||||
|
||||
You don't need to preface it with /bin/bash -c, nor add | cat at the end. Just run it as is.
|
||||
|
||||
This ensures the codebase remains clean, consistent, and compliant with style guidelines after every edit.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Environment variables for local development
|
||||
FLASK_APP=autoapp.py
|
||||
FLASK_DEBUG=1
|
||||
FLASK_ENV=development
|
||||
GUNICORN_WORKERS=1
|
||||
LOG_LEVEL=debug
|
||||
RECORD_TRAFFIC=off
|
||||
|
||||
# Arbitrary API key to protect your service.
|
||||
SERVICE_API_KEY=change-me
|
||||
|
||||
# Mandatory Azure settings
|
||||
AZURE_BASE_URL=https://change-me.openai.azure.com
|
||||
AZURE_API_KEY=change-me
|
||||
AZURE_DEPLOYMENT=gpt-5
|
||||
|
||||
# Optional Azure settings
|
||||
AZURE_API_VERSION=
|
||||
AZURE_SUMMARY_LEVEL=
|
||||
AZURE_TRUNCATION=
|
||||
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/requirements"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Build Status
|
||||
|
||||
on:
|
||||
- push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements/dev.txt
|
||||
- run: cp .env.example .env
|
||||
- name: Run Python lints
|
||||
run: flask lint --check
|
||||
- name: Run Python tests
|
||||
run: flask test
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Cache
|
||||
*.py[cod]
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Packages
|
||||
*.egg
|
||||
*.egg-info
|
||||
build
|
||||
eggs
|
||||
parts
|
||||
bin
|
||||
var
|
||||
sdist
|
||||
develop-eggs
|
||||
.installed.cfg
|
||||
lib
|
||||
lib64
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
|
||||
# Complexity
|
||||
output/*.html
|
||||
output/*/index.html
|
||||
|
||||
# Virtualenvs
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Configuration
|
||||
.env
|
||||
|
||||
# Recordings
|
||||
recordings/
|
||||
|
||||
# Legacy openai backend
|
||||
backend_openai.py
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# ================================== BUILDER ===================================
|
||||
ARG INSTALL_PYTHON_VERSION=${INSTALL_PYTHON_VERSION:-PYTHON_VERSION_NOT_SET}
|
||||
|
||||
FROM python:${INSTALL_PYTHON_VERSION}-slim-bullseye AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements requirements
|
||||
RUN pip install --no-cache -r requirements/prod.txt
|
||||
|
||||
COPY autoapp.py ./
|
||||
COPY app app
|
||||
COPY .env.example .env
|
||||
|
||||
# ================================= PRODUCTION =================================
|
||||
FROM python:${INSTALL_PYTHON_VERSION}-slim-bullseye as production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN useradd -m sid
|
||||
RUN chown -R sid:sid /app
|
||||
USER sid
|
||||
ENV PATH="/home/sid/.local/bin:${PATH}"
|
||||
|
||||
COPY requirements requirements
|
||||
RUN pip install --no-cache --user -r requirements/prod.txt
|
||||
|
||||
COPY supervisord/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY supervisord/gunicorn.conf /etc/supervisor/conf.d/gunicorn.conf
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
ENTRYPOINT ["/bin/bash", "supervisord/supervisord_entrypoint.sh"]
|
||||
CMD ["-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
|
||||
# ================================= DEVELOPMENT ================================
|
||||
FROM builder AS development
|
||||
RUN pip install --no-cache -r requirements/dev.txt
|
||||
EXPOSE 5000
|
||||
CMD [ "flask", "run", "--host=0.0.0.0" ]
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright 2025 Gabriel Gavilan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Cursor Azure GPT-5
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A service that allows Cursor to use Azure GPT-5 deployments by:
|
||||
- Adapting incoming Cursor **completions API** requests to the **Responses API**
|
||||
- Forwarding the requests to Azure
|
||||
- Adapting outgoing Azure **Responses API** streams into **completions API** streams
|
||||
|
||||
This project originates from Cursor's lack of support for Azure models that are only served through the **Responses API**. It will hopefully become obsolete as Cursor continues to improve its model support.
|
||||
|
||||
## Feature highlights
|
||||
|
||||
- Switching between `high`/`medium`/`low` reasoning effort levels by selecting different models in Cursor.
|
||||
- Configuring different _reasoning summary_ levels.
|
||||
- Displaying _reasoning summaries_ in Cursor natively, like any other reasoning model.
|
||||
- Production-ready, so you can share the service among different users in an organization.
|
||||
- When running from a terminal, [rich](https://github.com/Textualize/rich) logging of the model's context on every request, including Markdown rendering, syntax highlighting, tool calls/outputs, and more.
|
||||
|
||||
Upcoming features:
|
||||
- Multimodal: Will be implemented as soon as better testing is in place and there is demand (PRs welcome).
|
||||
- Multiple models simultaneously: Even Cursor's Azure configuration only supports a single deployment at a time. It would be fairly easy to implement support for multiple models in this service, covering even more needs.
|
||||
- Full test coverage: See the [Testing](#testing) section for an explanation of the current low coverage.
|
||||
|
||||
Feel free to create or vote on any [project issues](https://github.com/gabrii/Cursor-Azure-GPT-5/issues), and star the project to show your support.
|
||||
|
||||
## Quick start
|
||||
|
||||
If you prefer to deploy the service (for example, to allow multiple members of your team to use it), check the [Production](#production) section, as the project comes with production-ready containers using `supervisord` and `gunicorn`.
|
||||
|
||||
### 1. Service configuration
|
||||
|
||||
Make a copy of the file `.env.example` as `.env` and update the following flags:
|
||||
|
||||
| Flag | Description | Default |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `AZURE_BASE_URL` | Your Azure OpenAI endpoint base URL (no trailing slash), e.g. `https://<resource>.openai.azure.com`. | required |
|
||||
| `AZURE_API_KEY` | Azure OpenAI API key. | required |
|
||||
| `AZURE_DEPLOYMENT` | Name of the Azure model deployment to use. | `gpt-5` |
|
||||
| `SERVICE_API_KEY` | Arbitrary API key to protect your service. Set it to a random string. | `change-me` |
|
||||
|
||||
Alternatively, you can pass them through the environment where you run the application.
|
||||
|
||||
<details>
|
||||
<summary>Optional Configuration</summary>
|
||||
|
||||
| Flag | Description | Default |
|
||||
| --------------------- | ---------------------------------------------------------------------- | -------------------- |
|
||||
| `AZURE_API_VERSION` | Azure OpenAI Responses API version to call. | `2025-04-01-preview` |
|
||||
| `AZURE_SUMMARY_LEVEL` | Reasoning summary level for responses. | `detailed` |
|
||||
| `AZURE_TRUNCATION` | Truncation strategy for long inputs. | `auto` |
|
||||
| `FLASK_ENV` | Flask environment. Use `development` for dev or `production` for prod. | `production` |
|
||||
| `RECORD_TRAFFIC` | Toggle writing request/response traffic to `recordings/` | `off` |
|
||||
|
||||
</details>
|
||||
|
||||
### 2. Exposing the service
|
||||
|
||||
<details>
|
||||
<summary>Why do I have to?</summary>
|
||||
|
||||
> Since Cursor routes requests through its external prompt-building service rather than directly from the IDE to your API, your custom endpoint must be publicly reachable on the Internet.
|
||||
>
|
||||
> Consider using Cloudflare because its tunnels are free and require no account.
|
||||
</details>
|
||||
|
||||
[Install `cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) and run:
|
||||
|
||||
```bash
|
||||
cloudflared tunnel --url http://localhost:8080
|
||||
```
|
||||
|
||||
Copy the URL of your tunnel from the output of the command. It looks something like this:
|
||||
|
||||
```text
|
||||
+----------------------------------------------------+
|
||||
| Your quick Tunnel has been created! Visit it at: |
|
||||
| https://foo-bar.trycloudflare.com |
|
||||
+----------------------------------------------------+
|
||||
```
|
||||
|
||||
Then paste it into _Cursor Settings > Models > API Keys > OpenAI API Key > Override OpenAI Base URL_:
|
||||
|
||||

|
||||
|
||||
### 3. Configuring Cursor
|
||||
|
||||
In addition to updating the OpenAI Base URL, you need to:
|
||||
|
||||
1. Set _OpenAI API Key_ to the value of `SERVICE_API_KEY` in your `.env`
|
||||
|
||||
2. Ensure the toggles for both options are **on**, as shown in the previous image.
|
||||
|
||||
3. Add the custom models called exactly `gpt-high`, `gpt-medium`, and `gpt-low`, as shown in the previous image. You don't need to remove other models.
|
||||
|
||||
<details>
|
||||
<summary>Additional steps if you face this error:
|
||||
<img src="assets/cursor_invalid_model.jpg" alt="The model does not work with your current plan or api key" width="100%">
|
||||
</summary>
|
||||
|
||||
> This is a bug on Cursor's side when custom models edit files in **∞ Agent** mode. Regardless of the model, and even if `edit_file` is working correctly, Cursor may show this pop-up and interrupt generation after the first `edit_file` function call.
|
||||
>
|
||||
> This only happens when using model names Cursor has not allowlisted or prepared for, such as `gpt-high`. However, we can't use the standard model names such as `gpt-5-high` because Cursor does not route those to custom OpenAI Base URLs.
|
||||
>
|
||||
> For now, this bug can be bypassed by using the Custom Modes beta
|
||||
>
|
||||
> In the near future, either the bug in Agent mode will be fixed or those two remaining functions will be added to Custom Modes—or, even better, Azure support will improve enough to render this project obsolete.
|
||||
|
||||
4. Enable Custom Modes Beta in _Cursor Settings > Chat_: 
|
||||
|
||||
5. Create a custom mode:
|
||||
|
||||
<img src="assets/cursor_custom_mode.gif" alt="Fix for cursor BYOK from azure" width="200">
|
||||
|
||||
</details>
|
||||
|
||||
### 4. Running the service
|
||||
|
||||
To run the production version of the app:
|
||||
|
||||
```bash
|
||||
docker compose up flask-prod
|
||||
```
|
||||
|
||||
> For instructions on how to run locally without Docker, and the different development commands, see the [Development](#development) section.
|
||||
|
||||
## Development
|
||||
|
||||
### Running locally
|
||||
|
||||
<details><summary>Expand</summary>
|
||||
|
||||
#### Bootstrap your local environment
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
pip install -r requirements/dev.txt
|
||||
```
|
||||
|
||||
#### Running the development server
|
||||
|
||||
```bash
|
||||
flask run -p 8080
|
||||
```
|
||||
|
||||
#### Running the production server*
|
||||
|
||||
```bash
|
||||
export FLASK_ENV=production
|
||||
export FLASK_DEBUG=0
|
||||
export LOG_LEVEL=info
|
||||
flask run -p 8080
|
||||
```
|
||||
|
||||
This will only run the Flask server with the production settings. For a closer approximation of the production server running with `supervisord` and `gunicorn`, check [Running with Docker](#running-with-docker).
|
||||
|
||||
#### Running tests
|
||||
|
||||
```bash
|
||||
flask test
|
||||
```
|
||||
|
||||
To run only specific tests, you can use the pytest `-k` argument:
|
||||
|
||||
```bash
|
||||
flask test -k ...
|
||||
```
|
||||
|
||||
#### Running linter
|
||||
|
||||
```bash
|
||||
flask lint
|
||||
```
|
||||
|
||||
The `lint` command will attempt to fix any linting/style errors in the code. If you only want to know if the code will pass CI and do not wish for the linter to make changes, add the `--check` argument.
|
||||
|
||||
```bash
|
||||
flask lint --check
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Running with Docker
|
||||
|
||||
<details><summary>Expand</summary>
|
||||
|
||||
#### Running the development server
|
||||
|
||||
```bash
|
||||
docker compose up flask-dev
|
||||
```
|
||||
|
||||
#### Running the production server
|
||||
|
||||
```bash
|
||||
docker compose up flask-prod
|
||||
```
|
||||
|
||||
This image runs the server through `supervisord` and `gunicorn`. See the [Production](#production) section for more details.
|
||||
|
||||
When running flask-prod, the production flags are set in `docker-compose.yml`:
|
||||
|
||||
```yml
|
||||
FLASK_ENV: production
|
||||
FLASK_DEBUG: 0
|
||||
LOG_LEVEL: info
|
||||
GUNICORN_WORKERS: 4
|
||||
```
|
||||
|
||||
The list of `environment:` variables in the `docker-compose.yml` file takes precedence over any variables specified in `.env`.
|
||||
|
||||
#### Running tests
|
||||
|
||||
```bash
|
||||
docker compose run --rm manage test
|
||||
```
|
||||
|
||||
To run only specific tests, you can use the pytest `-k` argument:
|
||||
|
||||
```bash
|
||||
docker compose run --rm manage test -k ...
|
||||
```
|
||||
|
||||
#### Running linter
|
||||
|
||||
```bash
|
||||
docker compose run --rm manage lint
|
||||
```
|
||||
|
||||
The `lint` command will attempt to fix any linting/style errors in the code. If you only want to know if the code will pass CI and do not wish for the linter to make changes, add the `--check` argument.
|
||||
|
||||
```bash
|
||||
docker compose run --rm manage lint --check
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Testing
|
||||
|
||||
Currently, testing and coverage for the project are nonexistent. Only the test skeletons, configuration, commands, and a test-friendly architecture are in place.
|
||||
|
||||
To make the generation of test fixtures easier, the `RECORD_TRAFFIC` flag has been added, which creates files with all the incoming/outgoing traffic between this service and Cursor/Azure.
|
||||
|
||||
Currently, those fixtures would include sensitive data, such as system prompts, tools, and the entire scaffolding from Cursor's prompt-building service.
|
||||
|
||||
To avoid violating Cursor's intellectual property, a redaction layer will have to be implemented so the recorded traffic can be published and used in tests while remaining MIT-licensed.
|
||||
|
||||
This is a top priority and will be developed next, before any other features, as traffic recording will also be a valuable tool for users of the service to report issues on GitHub and to improve testing for other contributors to confidently contribute to the project.
|
||||
|
||||
## Production
|
||||
|
||||
<details><summary>Expand</summary>
|
||||
|
||||
### Configure server
|
||||
|
||||
You might want to review and modify the following configuration files:
|
||||
|
||||
| File | Description |
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `supervisord/gunicorn.conf` | Supervisor program config for Gunicorn (bind :5000, gevent; workers/log level from env; logs to stdout/stderr). |
|
||||
| `supervisord/supervisord_entrypoint.sh` | Container entrypoint that execs supervisord (prepends it when args start with -). |
|
||||
| `supervisord/supervisord.conf` | Main Supervisord config: socket, logging, nodaemon; includes conf.d program configs. |
|
||||
|
||||
### Build, tag, and push the image
|
||||
|
||||
```bash
|
||||
docker compose build flask-prod
|
||||
docker tag app-production your-tag
|
||||
docker push your-tag
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Main application package."""
|
||||
|
||||
__all__ = ["create_app"]
|
||||
from .app import create_app
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"""The app module, containing the app factory function."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from . import commands
|
||||
from .blueprint import blueprint
|
||||
|
||||
|
||||
def create_app(config_object="app.settings"):
|
||||
"""Create application factory, as explained here: http://flask.pocoo.org/docs/patterns/appfactories/.
|
||||
|
||||
:param config_object: The configuration object to use.
|
||||
"""
|
||||
app = Flask(__name__.split(".")[0])
|
||||
app.config.from_object(config_object)
|
||||
register_commands(app)
|
||||
register_blueprints(app)
|
||||
configure_logger(app)
|
||||
return app
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register Flask blueprints."""
|
||||
app.register_blueprint(blueprint)
|
||||
return None
|
||||
|
||||
|
||||
def register_commands(app):
|
||||
"""Register Click commands."""
|
||||
app.cli.add_command(commands.test)
|
||||
app.cli.add_command(commands.lint)
|
||||
|
||||
|
||||
def configure_logger(app):
|
||||
"""Configure loggers."""
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
if not app.logger.handlers:
|
||||
app.logger.addHandler(handler)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""Authentication module."""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import Response, current_app, request
|
||||
|
||||
|
||||
def valid_brearer_token():
|
||||
"""Validate the bearer token."""
|
||||
service_api_key = current_app.config["SERVICE_API_KEY"]
|
||||
return request.authorization and request.authorization.token == service_api_key
|
||||
|
||||
|
||||
def require_auth(func):
|
||||
"""Require authentication for the given route."""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
"""Wrapper function return Unauthorized if the token is invalid."""
|
||||
if valid_brearer_token():
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
return Response("Unauthorized", 401)
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Azure adapter package.
|
||||
|
||||
This package exposes the AzureAdapter for integrating with Azure's Responses API.
|
||||
"""
|
||||
|
||||
__all__ = ["AzureAdapter"]
|
||||
from .adapter import AzureAdapter
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Azure adapter orchestrating request/response transformations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from flask import Request, Response
|
||||
|
||||
from ..common.recording import record_payload
|
||||
|
||||
# Local adapters
|
||||
from .request_adapter import RequestAdapter
|
||||
from .response_adapter import ResponseAdapter
|
||||
|
||||
|
||||
class AzureAdapter:
|
||||
"""Orchestrate forwarding of a Flask Request to Azure's Responses API.
|
||||
|
||||
Provides a Completions-compatible interface to the caller by composing a
|
||||
RequestAdapter (pre-request transformations) and a ResponseAdapter
|
||||
(post-request transformations). The adapters receive a reference to this
|
||||
instance for shared per-request state (models/early_response).
|
||||
"""
|
||||
|
||||
# Per-request state (streaming completions only)
|
||||
inbound_model: Optional[str] = None
|
||||
early_response: Optional[Response] = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize child adapters and shared state references."""
|
||||
# Composition: child adapters get a reference to this orchestrator
|
||||
self.request_adapter = RequestAdapter(self)
|
||||
self.response_adapter = ResponseAdapter(self)
|
||||
|
||||
# Public API
|
||||
def forward(self, req: Request) -> Response:
|
||||
"""Forward the Flask request upstream and adapt the response back.
|
||||
|
||||
High-level flow:
|
||||
1) RequestAdapter builds the upstream request kwargs and stores state
|
||||
on this adapter (models) or sets early_response.
|
||||
2) Perform the upstream HTTP call using a short-lived requests call.
|
||||
3) ResponseAdapter converts the upstream response into a Flask Response.
|
||||
"""
|
||||
request_kwargs = self.request_adapter.adapt(req)
|
||||
|
||||
# Allow early short-circuit responses (e.g., config errors)
|
||||
if self.early_response is not None:
|
||||
return self.early_response
|
||||
|
||||
record_payload(request_kwargs.get("json", {}), "upstream_request")
|
||||
|
||||
# Perform upstream request with kwargs directly (no long-lived session)
|
||||
resp = requests.request(**request_kwargs)
|
||||
|
||||
return self.response_adapter.adapt(resp)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Request adaptation helpers for Azure Responses API.
|
||||
|
||||
This module defines RequestAdapter, which transforms incoming OpenAI-style
|
||||
requests into Azure Responses API request parameters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from flask import Request, Response, current_app
|
||||
|
||||
|
||||
class RequestAdapter:
|
||||
"""Handle pre-request adaptation for the Azure Responses API.
|
||||
|
||||
Transforms OpenAI Completions/Chat-style inputs into Azure Responses API
|
||||
request parameters suitable for streaming completions in this codebase.
|
||||
Returns request_kwargs for requests.request(**kwargs). If an early
|
||||
short-circuit is needed (for example, missing config), sets
|
||||
self.adapter.early_response and returns an empty dict. Also sets
|
||||
per-request state on the adapter (model).
|
||||
"""
|
||||
|
||||
def __init__(self, adapter: Any) -> None:
|
||||
"""Initialize the adapter with a reference to the AzureAdapter."""
|
||||
self.adapter = adapter # AzureAdapter instance for shared config/env
|
||||
|
||||
# ---- Helpers (kept local to minimize cross-module coupling) ----
|
||||
def _parse_json_body(self, req: Request, body: bytes) -> Optional[Any]:
|
||||
if not body:
|
||||
return None
|
||||
data = req.get_json(silent=True, force=False)
|
||||
if data is not None:
|
||||
return data
|
||||
try:
|
||||
return json.loads(body.decode(req.charset or "utf-8", errors="replace"))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def _copy_request_headers_for_azure(
|
||||
self, src: Request, *, api_key: str
|
||||
) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {k: v for k, v in src.headers.items()}
|
||||
headers.pop("Host", None)
|
||||
# Azure prefers api-key header
|
||||
headers.pop("Authorization", None)
|
||||
headers["api-key"] = api_key
|
||||
return headers
|
||||
|
||||
def _messages_to_responses_input_and_instructions(
|
||||
self, messages: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
instructions_parts: List[str] = []
|
||||
input_items: List[Dict[str, Any]] = []
|
||||
|
||||
def content_to_text(c: Any) -> str:
|
||||
if c is None:
|
||||
return ""
|
||||
if isinstance(c, str):
|
||||
return c
|
||||
if isinstance(c, list):
|
||||
parts: List[str] = []
|
||||
for it in c:
|
||||
if isinstance(it, dict):
|
||||
if it.get("type") in {"text", "input_text"} and "text" in it:
|
||||
parts.append(str(it.get("text", "")))
|
||||
elif "content" in it and isinstance(it["content"], str):
|
||||
parts.append(it["content"])
|
||||
else:
|
||||
parts.append(str(it))
|
||||
return "\n".join([p for p in parts if p])
|
||||
return json.dumps(c, ensure_ascii=False)
|
||||
|
||||
for m in messages:
|
||||
role = m.get("role")
|
||||
c = m.get("content")
|
||||
if role in {"system", "developer"}:
|
||||
text = content_to_text(c)
|
||||
if text:
|
||||
instructions_parts.append(text)
|
||||
continue
|
||||
# For user/assistant/tools as inputs
|
||||
if role == "tool":
|
||||
item = {
|
||||
"type": "function_call_output",
|
||||
"output": content_to_text(c),
|
||||
"status": "completed",
|
||||
"call_id": m.get("tool_call_id"),
|
||||
}
|
||||
input_items.append(item)
|
||||
else:
|
||||
text = content_to_text(c)
|
||||
item = {
|
||||
"role": role or "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text" if role == "user" else "output_text",
|
||||
"text": text,
|
||||
},
|
||||
],
|
||||
}
|
||||
input_items.append(item)
|
||||
|
||||
if tool_calls := m.get("tool_calls"):
|
||||
for tool_call in tool_calls:
|
||||
function = tool_call.get("function", {})
|
||||
item = {
|
||||
"type": "function_call",
|
||||
"name": function.get("name"),
|
||||
"arguments": function.get("arguments"),
|
||||
"call_id": tool_call.get("id"),
|
||||
}
|
||||
input_items.append(item)
|
||||
|
||||
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
|
||||
return {
|
||||
"input": input_items if input_items else None,
|
||||
"instructions": instructions,
|
||||
}
|
||||
|
||||
def _transform_tools_for_responses(self, tools: Any) -> Any:
|
||||
if not isinstance(tools, list):
|
||||
return tools
|
||||
out: List[Dict[str, Any]] = []
|
||||
for t in tools:
|
||||
if not isinstance(t, dict):
|
||||
out.append(t)
|
||||
continue
|
||||
ttype = t.get("type")
|
||||
if ttype == "function" and isinstance(t.get("function"), dict):
|
||||
f = t["function"]
|
||||
transformed: Dict[str, Any] = {
|
||||
"type": "function",
|
||||
"name": f.get("name"),
|
||||
}
|
||||
if "description" in f:
|
||||
transformed["description"] = f["description"]
|
||||
if "parameters" in f:
|
||||
transformed["parameters"] = f["parameters"]
|
||||
transformed["strict"] = False
|
||||
out.append(transformed)
|
||||
else:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
def _transform_tool_choice(self, tool_choice: Any) -> Any:
|
||||
if tool_choice in (None, "auto", "none"):
|
||||
return tool_choice
|
||||
if isinstance(tool_choice, dict):
|
||||
t = tool_choice.get("type")
|
||||
if t == "function":
|
||||
fn = tool_choice.get("function") or {}
|
||||
name = fn.get("name")
|
||||
if name:
|
||||
return {"type": "function", "name": name}
|
||||
return tool_choice
|
||||
|
||||
# ---- Main adaptation (always streaming completions-like) ----
|
||||
def adapt(self, req: Request) -> Dict[str, Any]:
|
||||
"""Build requests.request kwargs for the Azure Responses API call.
|
||||
|
||||
Validates the inbound request, sets early_response on error, maps inputs
|
||||
to the Responses schema, and returns a dict suitable for
|
||||
requests.request(**kwargs).
|
||||
"""
|
||||
# Reset per-request state
|
||||
self.adapter.inbound_model = None
|
||||
self.adapter.early_response = None
|
||||
|
||||
# Validate method
|
||||
if (req.method or "").upper() != "POST":
|
||||
self.adapter.early_response = Response(
|
||||
"Only POST supported for Azure backend",
|
||||
status=405,
|
||||
mimetype="text/plain",
|
||||
)
|
||||
return {}
|
||||
|
||||
# Parse request body
|
||||
raw_body = req.get_data(cache=True)
|
||||
payload = self._parse_json_body(req, raw_body)
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
# Determine target model: prefer env AZURE_MODEL/AZURE_DEPLOYMENT
|
||||
inbound_model = payload.get("model") if isinstance(payload, dict) else None
|
||||
self.adapter.inbound_model = inbound_model
|
||||
|
||||
settings = current_app.config
|
||||
|
||||
upstream_headers = self._copy_request_headers_for_azure(
|
||||
req, api_key=settings["AZURE_API_KEY"]
|
||||
)
|
||||
|
||||
# Map Chat/Completions to Responses (always streaming)
|
||||
messages = payload.get("messages") or []
|
||||
tools_in = payload.get("tools") or []
|
||||
tool_choice_in = payload.get("tool_choice")
|
||||
top_p = payload.get("top_p")
|
||||
max_tokens = payload.get("max_tokens") or payload.get("max_output_tokens")
|
||||
prompt_cache_key = payload.get("user") or payload.get("prompt_cache_key")
|
||||
|
||||
mapped = (
|
||||
self._messages_to_responses_input_and_instructions(messages)
|
||||
if isinstance(messages, list)
|
||||
else {"input": None, "instructions": None}
|
||||
)
|
||||
|
||||
responses_body: Dict[str, Any] = {}
|
||||
if mapped.get("instructions"):
|
||||
responses_body["instructions"] = mapped["instructions"]
|
||||
if mapped.get("input") is not None:
|
||||
responses_body["input"] = mapped["input"]
|
||||
responses_body["model"] = settings["AZURE_DEPLOYMENT"]
|
||||
|
||||
# Transform tools and tool choice
|
||||
if tools_in:
|
||||
responses_body["tools"] = self._transform_tools_for_responses(tools_in)
|
||||
mapped_tool_choice = self._transform_tool_choice(tool_choice_in)
|
||||
if mapped_tool_choice is not None:
|
||||
responses_body["tool_choice"] = mapped_tool_choice
|
||||
|
||||
# Optional sampling/limits
|
||||
if top_p is not None:
|
||||
responses_body["top_p"] = top_p
|
||||
if max_tokens is not None:
|
||||
responses_body["max_output_tokens"] = max_tokens
|
||||
if prompt_cache_key is not None:
|
||||
responses_body["prompt_cache_key"] = prompt_cache_key
|
||||
|
||||
# Always streaming
|
||||
responses_body["stream"] = True
|
||||
|
||||
reasoning_effort = inbound_model.replace("gpt-", "").lower()
|
||||
if reasoning_effort not in {"high", "medium", "low"}:
|
||||
raise ValueError(
|
||||
"Model name must be either gpt-high, gpt-medium, or gpt-low"
|
||||
)
|
||||
|
||||
responses_body["reasoning"] = {
|
||||
"effort": reasoning_effort,
|
||||
"summary": settings["AZURE_SUMMARY_LEVEL"],
|
||||
}
|
||||
|
||||
responses_body["store"] = False
|
||||
responses_body["stream_options"] = {"include_obfuscation": False}
|
||||
responses_body["truncation"] = settings["AZURE_TRUNCATION"]
|
||||
|
||||
request_kwargs: Dict[str, Any] = {
|
||||
"method": "POST",
|
||||
"url": settings["AZURE_RESPONSES_API_URL"],
|
||||
"headers": upstream_headers,
|
||||
"json": responses_body,
|
||||
"data": None,
|
||||
"stream": True,
|
||||
"timeout": (60, None),
|
||||
}
|
||||
return request_kwargs
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Response adaptation helpers for Azure Responses API streams.
|
||||
|
||||
This module defines ResponseAdapter, which converts Azure SSE streams into
|
||||
OpenAI Chat Completions-compatible streaming responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from string import ascii_letters, digits
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from flask import Response
|
||||
|
||||
from ..common.sse import chunks_to_sse, sse_to_events
|
||||
|
||||
|
||||
class ResponseAdapter:
|
||||
"""Handle post-request adaptation from Azure Responses API to Flask.
|
||||
|
||||
Translates Azure SSE events into OpenAI Chat Completions chunks, including
|
||||
reasoning <think> tags and function call streaming. Direct /v1/responses
|
||||
streams are passed through.
|
||||
"""
|
||||
|
||||
# Per-request chat completion id (for streaming)
|
||||
_chat_completion_id: Optional[str] = None
|
||||
|
||||
def __init__(self, adapter: Any) -> None:
|
||||
"""Initialize the adapter with a reference to the AzureAdapter."""
|
||||
self.adapter = adapter # AzureAdapter instance for shared config/env
|
||||
|
||||
# ---- Helpers ----
|
||||
@staticmethod
|
||||
def _create_chat_completion_id() -> str:
|
||||
"""Return a new pseudo-random chat completion id."""
|
||||
alphabet = ascii_letters + digits
|
||||
return "chatcmpl-" + "".join(random.choices(alphabet, k=24))
|
||||
|
||||
@staticmethod
|
||||
def _filter_response_headers(
|
||||
headers: Dict[str, str], *, streaming: bool
|
||||
) -> Dict[str, str]:
|
||||
"""Filter hop-by-hop and incompatible headers for downstream responses."""
|
||||
# Minimal hop-by-hop headers list for downstream filtering
|
||||
hop_by_hop_headers = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
out: Dict[str, str] = {}
|
||||
for k, v in headers.items():
|
||||
if k.lower() in hop_by_hop_headers:
|
||||
continue
|
||||
if streaming and k.lower() == "content-length":
|
||||
continue
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
def _build_completion_chunk(
|
||||
self,
|
||||
*,
|
||||
delta: Optional[Dict[str, Any]] = None,
|
||||
finish_reason: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a Chat Completions chunk dict with the provided delta."""
|
||||
return {
|
||||
"id": self._chat_completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": self.adapter.inbound_model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": delta or {},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# ---- Event handlers (per SSE event) ----
|
||||
def _output_item__added(
|
||||
self, obj: Optional[Dict[str, Any]]
|
||||
) -> Iterable[Dict[str, Any]]:
|
||||
"""Handle response.output_item.added events and emit chunks as needed."""
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
item_type = obj.get("item", {}).get("type")
|
||||
if item_type == "reasoning":
|
||||
# Mark that we should open <think> on first reasoning delta
|
||||
self._started_thinking = True
|
||||
return []
|
||||
if item_type == "function_call":
|
||||
out: list[Dict[str, Any]] = []
|
||||
if getattr(self, "_thinking", False):
|
||||
out.append(
|
||||
self._build_completion_chunk(
|
||||
delta={"role": "assistant", "content": "</think>\n\n"}
|
||||
)
|
||||
)
|
||||
self._thinking = False
|
||||
name = obj.get("item", {}).get("name")
|
||||
arguments = obj.get("item", {}).get("arguments")
|
||||
call_id = obj.get("item", {}).get("call_id")
|
||||
out.append(
|
||||
self._build_completion_chunk(
|
||||
delta={
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": call_id or "",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name or "",
|
||||
"arguments": arguments or "",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
self._called_function = True
|
||||
return out
|
||||
return []
|
||||
|
||||
def _function_call_arguments__delta(
|
||||
self, obj: Optional[Dict[str, Any]]
|
||||
) -> Iterable[Dict[str, Any]]:
|
||||
"""Handle response.function_call.arguments.delta events."""
|
||||
out: list[Dict[str, Any]] = []
|
||||
if getattr(self, "_thinking", False):
|
||||
out.append(
|
||||
self._build_completion_chunk(
|
||||
delta={"role": "assistant", "content": "</think>\n\n"}
|
||||
)
|
||||
)
|
||||
self._thinking = False
|
||||
arguments_delta = obj.get("delta", "") if isinstance(obj, dict) else ""
|
||||
out.append(
|
||||
self._build_completion_chunk(
|
||||
delta={
|
||||
"tool_calls": [
|
||||
{"index": 0, "function": {"arguments": arguments_delta}}
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def _output_item__done(
|
||||
self, obj: Optional[Dict[str, Any]]
|
||||
) -> Optional[Iterable[Dict[str, Any]]]:
|
||||
"""Handle response.output_item.done events (no-op for completions)."""
|
||||
# No-op for completions mapping
|
||||
return None
|
||||
|
||||
def _reasoning_summary_text__delta(
|
||||
self, obj: Optional[Dict[str, Any]]
|
||||
) -> Iterable[Dict[str, Any]]:
|
||||
"""Handle reasoning.summary_text.delta events and emit text chunks."""
|
||||
out: list[Dict[str, Any]] = []
|
||||
if getattr(self, "_started_thinking", False):
|
||||
out.append(
|
||||
self._build_completion_chunk(
|
||||
delta={"role": "assistant", "content": "<think>\n\n"}
|
||||
)
|
||||
)
|
||||
self._thinking = True
|
||||
self._started_thinking = False
|
||||
out.append(
|
||||
self._build_completion_chunk(
|
||||
delta={
|
||||
"role": "assistant",
|
||||
"content": (obj.get("delta", "") if isinstance(obj, dict) else ""),
|
||||
}
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def _reasoning_summary_text__done(
|
||||
self, obj: Optional[Dict[str, Any]]
|
||||
) -> Iterable[Dict[str, Any]]:
|
||||
"""Handle reasoning.summary_text.done events and close think block."""
|
||||
return [
|
||||
self._build_completion_chunk(delta={"role": "assistant", "content": "\n\n"})
|
||||
]
|
||||
|
||||
def _output_text__delta(
|
||||
self, obj: Optional[Dict[str, Any]]
|
||||
) -> Iterable[Dict[str, Any]]:
|
||||
"""Handle response.output_text.delta events and emit text chunks."""
|
||||
out: list[Dict[str, Any]] = []
|
||||
if getattr(self, "_thinking", False):
|
||||
out.append(
|
||||
self._build_completion_chunk(
|
||||
delta={"role": "assistant", "content": "</think>\n\n"}
|
||||
)
|
||||
)
|
||||
self._thinking = False
|
||||
out.append(
|
||||
self._build_completion_chunk(
|
||||
delta={
|
||||
"role": "assistant",
|
||||
"content": (obj.get("delta", "") if isinstance(obj, dict) else ""),
|
||||
}
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def adapt(self, upstream_resp: Any) -> Response:
|
||||
"""Adapt an upstream Azure streaming response into SSE for Flask."""
|
||||
|
||||
def generate() -> Iterable[bytes]:
|
||||
# Generate once per stream
|
||||
self._chat_completion_id = self._create_chat_completion_id()
|
||||
# Initialize per-stream state on the instance
|
||||
self._started_thinking = False
|
||||
self._thinking = False
|
||||
self._called_function = False
|
||||
|
||||
def gen_dicts() -> Iterable[Dict[str, Any]]:
|
||||
try:
|
||||
for ev in sse_to_events(
|
||||
upstream_resp.iter_content(chunk_size=8192)
|
||||
):
|
||||
if ev.is_done:
|
||||
# Upstream [DONE] sentinel
|
||||
continue
|
||||
handler_name = "_" + (ev.event or "").replace(
|
||||
"response.", ""
|
||||
).replace(".", "__")
|
||||
handler = getattr(self, handler_name, None)
|
||||
if not handler:
|
||||
continue
|
||||
res = handler(ev.json)
|
||||
if res is not None:
|
||||
for chunk in res:
|
||||
yield chunk
|
||||
finally:
|
||||
# Emit finish reason at the end of stream
|
||||
if getattr(self, "_called_function", False):
|
||||
yield self._build_completion_chunk(finish_reason="tool_calls")
|
||||
else:
|
||||
yield self._build_completion_chunk(finish_reason="stop")
|
||||
|
||||
# Wrap as SSE with [DONE]
|
||||
try:
|
||||
yield from chunks_to_sse(gen_dicts())
|
||||
finally:
|
||||
upstream_resp.close()
|
||||
|
||||
headers = self._filter_response_headers(
|
||||
dict(getattr(upstream_resp, "headers", {})), streaming=True
|
||||
)
|
||||
headers["Content-Type"] = "text/event-stream; charset=utf-8"
|
||||
headers.pop("Content-Length", None)
|
||||
headers["Cache-Control"] = "no-cache"
|
||||
headers["Connection"] = "keep-alive"
|
||||
headers["X-Accel-Buffering"] = "no"
|
||||
return Response(
|
||||
generate(),
|
||||
status=getattr(upstream_resp, "status_code", 200),
|
||||
headers=headers,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Flask blueprint and request routing for the proxy service.
|
||||
|
||||
This module defines the application blueprint, configures logging, and
|
||||
forwards incoming HTTP requests to the configured backend implementation.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from loguru import logger
|
||||
from rich.traceback import install as install_rich_traceback
|
||||
|
||||
from .auth import require_auth
|
||||
from .azure.adapter import AzureAdapter
|
||||
from .common.logging import log_request
|
||||
from .common.recording import increment_last_recording, record_payload
|
||||
|
||||
blueprint = Blueprint("blueprint", __name__)
|
||||
|
||||
# Pretty tracebacks for easier debugging
|
||||
install_rich_traceback(show_locals=False)
|
||||
|
||||
|
||||
# Configure Loguru to print colorful logs to stdout
|
||||
logger.remove()
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
colorize=True,
|
||||
enqueue=False,
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
format=(
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> "
|
||||
"| <level>{level: <8}</level> "
|
||||
"| <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> "
|
||||
"- <level>{message}</level>"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
ALL_METHODS = [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"HEAD",
|
||||
"TRACE",
|
||||
]
|
||||
|
||||
|
||||
@blueprint.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""Return a simple health check payload."""
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@blueprint.route("/", defaults={"path": ""}, methods=ALL_METHODS)
|
||||
@blueprint.route("/<path:path>", methods=ALL_METHODS)
|
||||
@require_auth
|
||||
def catch_all(path: str):
|
||||
"""Forward any request path to the Azure backend.
|
||||
|
||||
Logs the incoming request and forwards it to the selected backend
|
||||
implementation, returning the backend's response. If forwarding fails,
|
||||
returns a 502 JSON error payload.
|
||||
"""
|
||||
log_request(request)
|
||||
increment_last_recording()
|
||||
record_payload(request.json, "downstream_request")
|
||||
adapter = AzureAdapter()
|
||||
return adapter.forward(request)
|
||||
|
||||
|
||||
@blueprint.route("/models", methods=["GET"])
|
||||
@blueprint.route("/v1/models", methods=["GET"])
|
||||
@require_auth
|
||||
def models():
|
||||
"""Return a list of available models."""
|
||||
models = [
|
||||
"gpt-4.1-high",
|
||||
"gpt-4.1-medium",
|
||||
"gpt-4.1-low",
|
||||
"gpt-5",
|
||||
"gpt-5-high",
|
||||
"openai/gpt-high",
|
||||
"openai/gpt-5",
|
||||
"custom/gpt-high",
|
||||
"foo",
|
||||
"high",
|
||||
]
|
||||
return jsonify(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": model,
|
||||
"object": "model",
|
||||
"created": 1686935002,
|
||||
"owned_by": "openai",
|
||||
}
|
||||
for model in models
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Click commands."""
|
||||
|
||||
import os
|
||||
from glob import glob
|
||||
from subprocess import call
|
||||
|
||||
import click
|
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
PROJECT_ROOT = os.path.join(HERE, os.pardir)
|
||||
TEST_PATH = os.path.join(PROJECT_ROOT, "tests")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"-c/-C",
|
||||
"--coverage/--no-coverage",
|
||||
default=True,
|
||||
is_flag=True,
|
||||
help="Show coverage report",
|
||||
)
|
||||
@click.option(
|
||||
"-k",
|
||||
"--filter",
|
||||
default=None,
|
||||
help="Filter tests by keyword expressions",
|
||||
)
|
||||
def test(coverage, filter):
|
||||
"""Run the tests."""
|
||||
import pytest
|
||||
|
||||
args = [TEST_PATH, "--verbose"]
|
||||
if coverage:
|
||||
args.append("--cov=app")
|
||||
args.append("--cov-branch")
|
||||
args.append("--cov-report=xml")
|
||||
args.append("--cov-report=html")
|
||||
args.append("--cov-report=term")
|
||||
if filter:
|
||||
args.extend(["-k", filter])
|
||||
rv = pytest.main(args=args)
|
||||
exit(rv)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"-f",
|
||||
"--fix-imports",
|
||||
default=True,
|
||||
is_flag=True,
|
||||
help="Fix imports using isort, before linting",
|
||||
)
|
||||
@click.option(
|
||||
"-c",
|
||||
"--check",
|
||||
default=False,
|
||||
is_flag=True,
|
||||
help="Don't make any changes to files, just confirm they are formatted correctly",
|
||||
)
|
||||
def lint(fix_imports, check):
|
||||
"""Lint and check code style with black, flake8 and isort."""
|
||||
skip = [
|
||||
"requirements",
|
||||
"migrations",
|
||||
"supervisord",
|
||||
"htmlcov",
|
||||
"__pycache__",
|
||||
]
|
||||
root_files = glob("*.py")
|
||||
root_directories = [
|
||||
name for name in next(os.walk("."))[1] if not name.startswith(".")
|
||||
]
|
||||
files_and_directories = [
|
||||
arg for arg in root_files + root_directories if arg not in skip
|
||||
]
|
||||
|
||||
def execute_tool(description, *args):
|
||||
"""Execute a checking tool with its arguments."""
|
||||
command_line = list(args) + files_and_directories
|
||||
click.echo(f"{description}: {' '.join(command_line)}")
|
||||
rv = call(command_line)
|
||||
if rv != 0:
|
||||
exit(rv)
|
||||
|
||||
isort_args = []
|
||||
black_args = []
|
||||
if check:
|
||||
isort_args.append("--check")
|
||||
black_args.append("--check")
|
||||
if fix_imports:
|
||||
execute_tool("Fixing import order", "isort", *isort_args)
|
||||
execute_tool("Formatting style", "black", *black_args)
|
||||
execute_tool("Checking code style", "flake8")
|
||||
@@ -0,0 +1 @@
|
||||
"""Common utilities shared across the application (logging, SSE, etc.)."""
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Utilities for structured, pretty logging of requests and SSE events."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from flask import Request
|
||||
from rich.console import Console
|
||||
from rich.json import JSON
|
||||
from rich.markdown import Markdown
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel
|
||||
|
||||
from .sse import SSEEvent
|
||||
|
||||
# Global console instance for consistent logging across modules
|
||||
console = Console()
|
||||
|
||||
|
||||
# --- Request logging helpers ---
|
||||
|
||||
|
||||
def should_redact() -> bool:
|
||||
"""Return True if sensitive values should be redacted in logs."""
|
||||
# Set LOG_REDACT=false to disable redaction (default True)
|
||||
return os.environ.get("LOG_REDACT", "true").strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
}
|
||||
|
||||
|
||||
def redact_value(value: str) -> str:
|
||||
"""Mask a potentially sensitive value for safer logging."""
|
||||
if not value:
|
||||
return value
|
||||
if len(value) <= 8:
|
||||
return "***"
|
||||
return value[:4] + "…" + value[-4:]
|
||||
|
||||
|
||||
def redact_headers(headers: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Return a copy of headers with sensitive values redacted when enabled."""
|
||||
if not should_redact():
|
||||
return dict(headers)
|
||||
redacted: Dict[str, str] = {}
|
||||
sensitive = {
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"x-api-key",
|
||||
"api-key",
|
||||
"api_key",
|
||||
"x-azure-openai-key",
|
||||
"azure-openai-key",
|
||||
}
|
||||
for k, v in headers.items():
|
||||
if k.lower() in sensitive:
|
||||
redacted[k] = redact_value(v)
|
||||
else:
|
||||
# Heuristic: mask common bearer/api-key looking values
|
||||
if isinstance(v, str) and (
|
||||
v.startswith("Bearer ") or v.startswith("sk-") or "api_key" in k.lower()
|
||||
):
|
||||
redacted[k] = redact_value(v)
|
||||
else:
|
||||
redacted[k] = v
|
||||
return redacted
|
||||
|
||||
|
||||
def multidict_to_dict(md) -> Dict[str, List[str]]:
|
||||
"""Convert a werkzeug MultiDict-like object to a plain dict of lists."""
|
||||
try:
|
||||
return {k: list(vs) for k, vs in md.lists()}
|
||||
except AttributeError:
|
||||
# Fallback for objects without .lists()
|
||||
return {k: [md.get(k)] for k in md.keys()}
|
||||
|
||||
|
||||
def files_summary(req: Request) -> List[Dict[str, Any]]:
|
||||
"""Return a summary of uploaded files from a Flask request."""
|
||||
items: List[Dict[str, Any]] = []
|
||||
for name, storage in req.files.items():
|
||||
items.append(
|
||||
{
|
||||
"field": name,
|
||||
"filename": getattr(storage, "filename", "<unavailable>"),
|
||||
"content_type": getattr(storage, "content_type", "<unknown>"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _capture_request_details(req: Request, request_id: str) -> Dict[str, Any]:
|
||||
"""Collect a structured snapshot of request information for logging."""
|
||||
# Note: access request inside request context
|
||||
hdrs = {k: v for k, v in req.headers.items()}
|
||||
redacted_headers = redact_headers(hdrs)
|
||||
|
||||
details: Dict[str, Any] = {
|
||||
"id": request_id,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime()),
|
||||
"remote_addr": (req.headers.get("X-Forwarded-For") or req.remote_addr or ""),
|
||||
"method": req.method,
|
||||
"scheme": req.scheme,
|
||||
"path": "/" + (req.view_args.get("path", "") if req.view_args else ""),
|
||||
"full_path": req.full_path, # includes trailing ?
|
||||
"url": req.url,
|
||||
"route_args": dict(req.view_args or {}),
|
||||
"query_args": multidict_to_dict(req.args),
|
||||
"form": multidict_to_dict(req.form),
|
||||
"json": req.get_json(silent=True),
|
||||
"files": files_summary(req),
|
||||
"cookies": req.cookies.to_dict() if req.cookies else {},
|
||||
"headers": redacted_headers,
|
||||
"user_agent": str(req.user_agent) if req.user_agent else "",
|
||||
}
|
||||
return details
|
||||
|
||||
|
||||
def log_request(req: Request) -> str:
|
||||
"""Pretty-print a Flask request using Rich and return the request id."""
|
||||
request_id = uuid.uuid4().hex[:8]
|
||||
details = _capture_request_details(req, request_id)
|
||||
|
||||
method = details.get("method")
|
||||
path = details.get("path") or "/"
|
||||
rid = details.get("id")
|
||||
|
||||
# Rich pretty print of the full request details
|
||||
console.rule(f"[bold]Request #{rid}[/bold] — {method} {path}")
|
||||
console.print(Panel.fit("Headers"))
|
||||
console.print(details.get("headers"))
|
||||
console.print(Panel.fit("Args / Form / JSON"))
|
||||
json_payload = details.get("json")
|
||||
cleaned_json = json_payload
|
||||
if isinstance(json_payload, dict):
|
||||
# Remove verbose fields to log them separately
|
||||
cleaned_json = {
|
||||
k: v
|
||||
for k, v in json_payload.items()
|
||||
if k
|
||||
not in {
|
||||
"tools",
|
||||
}
|
||||
}
|
||||
console.print_json(
|
||||
data={
|
||||
"query_args": details.get("query_args"),
|
||||
"form": details.get("form"),
|
||||
"json": cleaned_json,
|
||||
}
|
||||
)
|
||||
|
||||
# Separate section for chat messages (role + content)
|
||||
messages = []
|
||||
if isinstance(json_payload, dict):
|
||||
maybe_messages = json_payload.get("messages")
|
||||
if isinstance(maybe_messages, list):
|
||||
messages = maybe_messages
|
||||
|
||||
if messages:
|
||||
console.rule(f"Messages ({len(messages)})")
|
||||
|
||||
def render_content(content: Any) -> str:
|
||||
"""Render a message content value into readable text for logs."""
|
||||
# Show content with actual newlines
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
for it in content:
|
||||
if isinstance(it, dict):
|
||||
t = it.get("type")
|
||||
if t == "text" and "text" in it:
|
||||
parts.append(str(it.get("text", "")))
|
||||
elif "content" in it and isinstance(it["content"], str):
|
||||
parts.append(it["content"])
|
||||
else:
|
||||
parts.append(json.dumps(it, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
parts.append(str(it))
|
||||
return "\n".join(p for p in parts if p is not None)
|
||||
# Fallback: pretty JSON
|
||||
return json.dumps(content, ensure_ascii=False, indent=2)
|
||||
|
||||
for idx, msg in enumerate(messages, start=1):
|
||||
role = ""
|
||||
content_val: Any = ""
|
||||
name = None
|
||||
if isinstance(msg, dict):
|
||||
role = str(msg.get("role", ""))
|
||||
content_val = msg.get("content", "")
|
||||
name = msg.get("name")
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
title = (
|
||||
f"Message {idx}: {role}"
|
||||
if not name
|
||||
else f"Message {idx}: {role} ({name}) - {tool_call_id}"
|
||||
)
|
||||
console.rule(title)
|
||||
console.print(
|
||||
Padding(
|
||||
Markdown(
|
||||
render_content(content_val)
|
||||
.replace("<", "\n`<")
|
||||
.replace(">", ">`\n")
|
||||
.replace(">`\n\n\n`<", ">`\n\n`<")
|
||||
),
|
||||
(1, 0),
|
||||
)
|
||||
)
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
for tool_call in tool_calls:
|
||||
function = tool_call.get("function", {})
|
||||
arguments = function.get("arguments")
|
||||
console.print(
|
||||
Padding(
|
||||
Panel.fit(f"Tool call [italic]{tool_call.get('id')}[italic]"),
|
||||
(0, 4),
|
||||
)
|
||||
)
|
||||
console.print(
|
||||
Padding(
|
||||
f"[bold][magenta]{function.get('name')}[/magenta] ([/bold]",
|
||||
(0, 4),
|
||||
)
|
||||
)
|
||||
if arguments:
|
||||
console.print(Padding(JSON(arguments), (0, 8)))
|
||||
console.print(Padding("[bold])[/bold]", (0, 4)))
|
||||
if tool_calls:
|
||||
console.print()
|
||||
|
||||
return request_id
|
||||
|
||||
|
||||
# --- SSE logging helpers ---
|
||||
|
||||
|
||||
def _clean_payload(obj: Any) -> Any:
|
||||
"""Default cleaning to reduce noisy fields in logs.
|
||||
|
||||
- If obj is a dict, remove top-level 'tools'
|
||||
- If it contains a nested 'response' dict, also remove its 'tools'
|
||||
Returns a shallow-cleaned copy when applicable; otherwise returns the input unchanged.
|
||||
"""
|
||||
if not isinstance(obj, dict):
|
||||
return obj
|
||||
# Shallow copy top-level
|
||||
cleaned = {k: v for k, v in obj.items()}
|
||||
if "tools" in cleaned:
|
||||
cleaned = {k: v for k, v in cleaned.items() if k != "tools"}
|
||||
resp = cleaned.get("response")
|
||||
if isinstance(resp, dict) and "tools" in resp:
|
||||
# Shallow copy nested response to drop tools
|
||||
new_resp = {k: v for k, v in resp.items() if k != "tools"}
|
||||
cleaned = {**cleaned, "response": new_resp}
|
||||
return cleaned
|
||||
|
||||
|
||||
def log_event(ev: SSEEvent) -> None:
|
||||
"""Pretty-print one SSE event using Rich.
|
||||
|
||||
- Title reflects whether the event had an 'event' name and its index
|
||||
- If payload parses as JSON (ev.json), it is cleaned and printed as JSON; otherwise raw text is printed
|
||||
"""
|
||||
obj = ev.json
|
||||
if obj is not None:
|
||||
title = (
|
||||
f"SSE JSON #{ev.index}" if not ev.event else f"SSE {ev.event} #{ev.index}"
|
||||
)
|
||||
console.print(Panel.fit(title))
|
||||
console.print_json(data=_clean_payload(obj))
|
||||
else:
|
||||
title = f"SSE data #{ev.index}"
|
||||
if ev.event:
|
||||
title = f"SSE {ev.event} #{ev.index}"
|
||||
console.print(Panel.fit(title))
|
||||
console.print(ev.data)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Lightweight recording helpers for debugging request/response flows.
|
||||
|
||||
Artifacts are stored under the project-level ``recordings/`` folder using a
|
||||
monotonically increasing numeric prefix so related request/response files are
|
||||
easy to correlate.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Any, Dict
|
||||
|
||||
from flask import current_app, has_app_context
|
||||
|
||||
RECORDINGS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "recordings")
|
||||
|
||||
# Private, module-level counter tracking the latest recording index.
|
||||
__LAST_RECORDING_INDEX = 0
|
||||
|
||||
# Initialize the counter based on existing files in the recordings directory so
|
||||
# that subsequent runs continue incrementing from the maximum observed index.
|
||||
files = os.listdir(RECORDINGS_DIR)
|
||||
for file in files:
|
||||
try:
|
||||
recording_index = int(file.split("_")[0])
|
||||
if recording_index > __LAST_RECORDING_INDEX:
|
||||
__LAST_RECORDING_INDEX = recording_index
|
||||
except (ValueError, IndexError):
|
||||
# Ignore unrelated files that do not follow the "<index>_<name>.*" pattern
|
||||
pass
|
||||
|
||||
|
||||
def config_bypass(func):
|
||||
"""Bypass the wrapped function when RECORD_TRAFFIC is disabled."""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
enabled = False
|
||||
if has_app_context():
|
||||
enabled = current_app.config["RECORD_TRAFFIC"]
|
||||
if not enabled:
|
||||
return None
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@config_bypass
|
||||
def increment_last_recording() -> None:
|
||||
"""Advance the shared recording index for a new request lifecycle."""
|
||||
|
||||
global __LAST_RECORDING_INDEX
|
||||
__LAST_RECORDING_INDEX += 1
|
||||
|
||||
|
||||
@config_bypass
|
||||
def record_payload(payload: Dict[str, Any], name: str) -> None:
|
||||
"""Write a JSON payload for the current recording index."""
|
||||
|
||||
file_name = f"{__LAST_RECORDING_INDEX}_{name}.json"
|
||||
file_path = os.path.join(RECORDINGS_DIR, file_name)
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
|
||||
@config_bypass
|
||||
def record_sse(sse: bytes, name: str) -> None:
|
||||
"""Write raw SSE bytes for the current recording index."""
|
||||
|
||||
file_name = f"{__LAST_RECORDING_INDEX}_{name}.sse"
|
||||
file_path = os.path.join(RECORDINGS_DIR, file_name)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(sse)
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Server-Sent Events (SSE) utilities.
|
||||
|
||||
This module provides helpers to decode and encode SSE streams, including:
|
||||
- An incremental decoder that turns byte chunks into parsed events
|
||||
- Convenience iterators to yield JSON payloads from SSE streams
|
||||
- Helpers to encode Python values back into SSE-formatted bytes
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple
|
||||
|
||||
from .recording import record_sse
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSEEvent:
|
||||
"""A parsed Server-Sent Event.
|
||||
|
||||
Attributes:
|
||||
event: Optional event type name sent by the server.
|
||||
data: Raw data payload for the event (possibly multi-line).
|
||||
id: Optional event ID, if provided by the server.
|
||||
retry: Optional reconnection delay in milliseconds.
|
||||
index: Monotonic sequence number assigned by the decoder.
|
||||
"""
|
||||
|
||||
event: Optional[str]
|
||||
data: str
|
||||
id: Optional[str] = None
|
||||
retry: Optional[int] = None
|
||||
# Monotonic sequence number (1-based) within a stream, set by the decoder
|
||||
index: int = 0
|
||||
# Lazy JSON cache (computed on first access of .json)
|
||||
_json_cached: bool = field(default=False, init=False, repr=False)
|
||||
_json_value: Optional[Any] = field(default=None, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def is_done(self) -> bool:
|
||||
"""Return True if this event marks the end of the stream.
|
||||
|
||||
The end-of-stream sentinel is the literal string "[DONE]".
|
||||
"""
|
||||
return self.data.strip() == "[DONE]"
|
||||
|
||||
@property
|
||||
def json(self) -> Optional[Any]:
|
||||
"""Return the data parsed as JSON, caching the result.
|
||||
|
||||
Returns None if the data is empty, invalid JSON, or the [DONE] sentinel.
|
||||
"""
|
||||
if not self._json_cached:
|
||||
val: Optional[Any]
|
||||
text = (self.data or "").strip()
|
||||
if self.is_done or not text:
|
||||
val = None
|
||||
else:
|
||||
try:
|
||||
val = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
val = None
|
||||
self._json_value = val
|
||||
self._json_cached = True
|
||||
return self._json_value
|
||||
|
||||
|
||||
class SSEDecoder:
|
||||
"""Incremental SSE decoder.
|
||||
|
||||
Feed incoming bytes and iterate parsed events. The decoder keeps state
|
||||
across feeds and yields events when a blank line delimiter is encountered.
|
||||
"""
|
||||
|
||||
def __init__(self, encoding: str = "utf-8") -> None:
|
||||
"""Initialize the decoder with the given text encoding."""
|
||||
self.encoding = encoding
|
||||
self.buffer: bytes = b""
|
||||
self.full_buffer: bytes = b""
|
||||
self._event_lines: List[bytes] = []
|
||||
self._seq: int = 0
|
||||
|
||||
def _parse_event(self, lines: List[bytes]) -> SSEEvent:
|
||||
ev_type: Optional[str] = None
|
||||
data_parts: List[bytes] = []
|
||||
ev_id: Optional[str] = None
|
||||
retry: Optional[int] = None
|
||||
|
||||
for line in lines:
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(b"event:"):
|
||||
ev_type = (
|
||||
line.split(b":", 1)[1]
|
||||
.strip()
|
||||
.decode(self.encoding, errors="replace")
|
||||
)
|
||||
elif line.startswith(b"data:"):
|
||||
part = line[5:]
|
||||
if part.startswith(b" "):
|
||||
part = part[1:]
|
||||
data_parts.append(part)
|
||||
elif line.startswith(b"id:"):
|
||||
val = line.split(b":", 1)[1]
|
||||
if val.startswith(b" "):
|
||||
val = val[1:]
|
||||
ev_id = val.decode(self.encoding, errors="replace")
|
||||
elif line.startswith(b"retry:"):
|
||||
val = line.split(b":", 1)[1]
|
||||
if val.startswith(b" "):
|
||||
val = val[1:]
|
||||
try:
|
||||
retry = int(val.strip())
|
||||
except ValueError:
|
||||
retry = None
|
||||
elif line.startswith(b":"):
|
||||
# Comment line, ignore
|
||||
pass
|
||||
|
||||
data_text = (
|
||||
b"\n".join(data_parts).decode(self.encoding, errors="replace")
|
||||
if data_parts
|
||||
else ""
|
||||
)
|
||||
return SSEEvent(event=ev_type, data=data_text, id=ev_id, retry=retry)
|
||||
|
||||
def feed(self, chunk: bytes) -> Iterator[SSEEvent]:
|
||||
"""Feed a new bytes chunk and yield any complete parsed events."""
|
||||
if not chunk:
|
||||
return
|
||||
self.buffer += chunk
|
||||
self.full_buffer += chunk
|
||||
while True:
|
||||
idx = self.buffer.find(b"\n")
|
||||
if idx == -1:
|
||||
break
|
||||
line = self.buffer[: idx + 1]
|
||||
self.buffer = self.buffer[idx + 1 :]
|
||||
stripped = line.rstrip(b"\r\n")
|
||||
if stripped == b"":
|
||||
if self._event_lines:
|
||||
ev = self._parse_event(self._event_lines)
|
||||
self._seq += 1
|
||||
ev.index = self._seq
|
||||
yield ev
|
||||
self._event_lines = []
|
||||
else:
|
||||
self._event_lines.append(stripped)
|
||||
record_sse(self.full_buffer, "upstream_response")
|
||||
|
||||
def end_of_input(self) -> Iterator[SSEEvent]:
|
||||
"""Flush and yield a trailing event if the stream ended mid-message."""
|
||||
# Flush any pending event if the stream ended without a final blank line
|
||||
if self._event_lines:
|
||||
ev = self._parse_event(self._event_lines)
|
||||
self._seq += 1
|
||||
ev.index = self._seq
|
||||
yield ev
|
||||
self._event_lines = []
|
||||
|
||||
|
||||
def sse_to_events(
|
||||
stream: Iterable[bytes], encoding: str = "utf-8"
|
||||
) -> Iterator[SSEEvent]:
|
||||
"""Convert an SSE byte-stream into parsed SSEEvent objects."""
|
||||
decoder = SSEDecoder(encoding=encoding)
|
||||
for chunk in stream:
|
||||
yield from decoder.feed(chunk)
|
||||
yield from decoder.end_of_input()
|
||||
|
||||
|
||||
def sse_to_chunks(
|
||||
stream: Iterable[bytes], *, skip_done: bool = True, encoding: str = "utf-8"
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""Convert an SSE byte-stream to an iterator of JSON dicts.
|
||||
|
||||
- Collects multi-line data fields per SSE spec
|
||||
- Uses event.json to avoid repeated json.loads
|
||||
- Skips the [DONE] sentinel by default
|
||||
"""
|
||||
for ev in sse_to_events(stream, encoding=encoding):
|
||||
if skip_done and ev.is_done:
|
||||
continue
|
||||
if ev.json is None:
|
||||
continue
|
||||
yield ev.json
|
||||
|
||||
|
||||
def sse_to_json_events(
|
||||
stream: Iterable[bytes], *, skip_done: bool = True, encoding: str = "utf-8"
|
||||
) -> Iterator[Tuple[Optional[str], Dict[str, Any]]]:
|
||||
"""Yield (event, json_obj) pairs for events whose data parses as JSON.
|
||||
|
||||
Non-JSON events are skipped. The [DONE] sentinel is skipped if skip_done
|
||||
is True.
|
||||
"""
|
||||
for ev in sse_to_events(stream, encoding=encoding):
|
||||
if skip_done and ev.is_done:
|
||||
continue
|
||||
obj = ev.json
|
||||
if obj is None:
|
||||
continue
|
||||
yield (ev.event, obj)
|
||||
|
||||
|
||||
def encode_sse_data(
|
||||
data: str, *, event: Optional[str] = None, id: Optional[str] = None
|
||||
) -> bytes:
|
||||
"""Encode a single SSE message into bytes.
|
||||
|
||||
If the data contains newlines, they are split into multiple "data:" lines
|
||||
as per the SSE spec. Optionally include event and id.
|
||||
"""
|
||||
out = bytearray()
|
||||
if id is not None:
|
||||
out.extend(b"id: ")
|
||||
out.extend(id.encode("utf-8"))
|
||||
out.extend(b"\n")
|
||||
if event is not None:
|
||||
out.extend(b"event: ")
|
||||
out.extend(event.encode("utf-8"))
|
||||
out.extend(b"\n")
|
||||
|
||||
if data == "":
|
||||
out.extend(b"data:\n")
|
||||
else:
|
||||
for line in data.splitlines():
|
||||
out.extend(b"data: ")
|
||||
out.extend(line.encode("utf-8"))
|
||||
out.extend(b"\n")
|
||||
out.extend(b"\n")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_sse_json(
|
||||
obj: Any, *, event: Optional[str] = None, id: Optional[str] = None
|
||||
) -> bytes:
|
||||
"""Encode a Python object as JSON in SSE format and return bytes."""
|
||||
payload = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
return encode_sse_data(payload, event=event, id=id)
|
||||
|
||||
|
||||
def chunks_to_sse(
|
||||
chunks: Iterable[Dict[str, Any]], *, add_done: bool = True
|
||||
) -> Iterator[bytes]:
|
||||
"""Encode an iterator of JSON-able dicts into SSE byte messages.
|
||||
|
||||
If add_done is True, a final [DONE] sentinel event is yielded.
|
||||
"""
|
||||
buffer = b""
|
||||
try:
|
||||
for obj in chunks:
|
||||
sse = encode_sse_json(obj)
|
||||
buffer += sse
|
||||
yield sse
|
||||
finally:
|
||||
if add_done:
|
||||
sse = done_event_bytes()
|
||||
buffer += sse
|
||||
yield sse
|
||||
record_sse(buffer, "downstream_response")
|
||||
|
||||
|
||||
def done_event_bytes() -> bytes:
|
||||
"""Return the SSE-encoded [DONE] sentinel as bytes."""
|
||||
return encode_sse_data("[DONE]")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Application configuration.
|
||||
|
||||
Most configuration is set via environment variables.
|
||||
|
||||
For local development, use a .env file to set
|
||||
environment variables.
|
||||
"""
|
||||
|
||||
from environs import Env
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
ENV = env.str("FLASK_ENV", default="production")
|
||||
DEBUG = ENV == "development"
|
||||
RECORD_TRAFFIC = env.bool("RECORD_TRAFFIC", False)
|
||||
|
||||
SERVICE_API_KEY = env.str("SERVICE_API_KEY", "change-me")
|
||||
|
||||
AZURE_BASE_URL = env.str("AZURE_BASE_URL", "change_me").rstrip("/")
|
||||
AZURE_API_KEY = env.str("AZURE_API_KEY", "change_me")
|
||||
AZURE_DEPLOYMENT = env.str("AZURE_DEPLOYMENT", "gpt-5")
|
||||
|
||||
AZURE_API_VERSION = env.str("AZURE_API_VERSION", "2025-04-01-preview")
|
||||
AZURE_SUMMARY_LEVEL = env.str("AZURE_SUMMARY_LEVEL", "detailed")
|
||||
AZURE_TRUNCATION = env.str("AZURE_TRUNCATION", "auto")
|
||||
|
||||
AZURE_RESPONSES_API_URL = (
|
||||
f"{AZURE_BASE_URL}/openai/responses?api-version={AZURE_API_VERSION}"
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Executable
+5
@@ -0,0 +1,5 @@
|
||||
"""Create an application instance."""
|
||||
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,41 @@
|
||||
x-build-args: &build_args
|
||||
INSTALL_PYTHON_VERSION: "3.13"
|
||||
|
||||
x-default-volumes: &default_volumes
|
||||
volumes:
|
||||
- ./:/app
|
||||
|
||||
services:
|
||||
flask-dev:
|
||||
build:
|
||||
context: .
|
||||
target: development
|
||||
args:
|
||||
<<: *build_args
|
||||
image: "app-development"
|
||||
ports:
|
||||
- "8080:5000"
|
||||
<<: *default_volumes
|
||||
|
||||
flask-prod:
|
||||
build:
|
||||
context: .
|
||||
target: production
|
||||
args:
|
||||
<<: *build_args
|
||||
image: "app-production"
|
||||
ports:
|
||||
- "8080:5000"
|
||||
environment:
|
||||
FLASK_ENV: production
|
||||
FLASK_DEBUG: 0
|
||||
LOG_LEVEL: info
|
||||
GUNICORN_WORKERS: 4
|
||||
<<: *default_volumes
|
||||
|
||||
manage:
|
||||
entrypoint: flask
|
||||
image: "app-development"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
<<: *default_volumes
|
||||
@@ -0,0 +1,11 @@
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = ["tests/*", "autoapp.py"]
|
||||
|
||||
[tool.flake8]
|
||||
ignore = ["D401", "D202", "E226", "E302", "E41", "W503", "E203"]
|
||||
max-line-length = 120
|
||||
max-complexity = 30
|
||||
@@ -0,0 +1,19 @@
|
||||
# Everything the developer needs in addition to the production requirements
|
||||
-r prod.txt
|
||||
|
||||
# Testing
|
||||
factory-boy==3.3.3
|
||||
pytest==8.4.2
|
||||
pytest-cov==7.0.0
|
||||
WebTest==3.0.6
|
||||
|
||||
# Lint and code style
|
||||
black==25.1.0
|
||||
flake8-blind-except==0.2.1
|
||||
flake8-debugger==4.1.2
|
||||
flake8-docstrings==1.7.0
|
||||
flake8-isort==6.1.2
|
||||
Flake8-pyproject==1.2.3
|
||||
flake8==7.3.0
|
||||
isort==6.0.1
|
||||
pep8-naming==0.15.1
|
||||
@@ -0,0 +1,20 @@
|
||||
# Everything needed in production
|
||||
|
||||
# Flask
|
||||
click>=7.0
|
||||
Flask==3.1.2
|
||||
|
||||
# Logging
|
||||
rich==14.1.0
|
||||
loguru==0.7.3
|
||||
|
||||
# Requests
|
||||
requests==2.32.5
|
||||
|
||||
# Deployment
|
||||
gevent==25.8.2
|
||||
gunicorn>=19.9.0
|
||||
supervisor==4.3.0
|
||||
|
||||
# Environment variable parsing
|
||||
environs==14.3.0
|
||||
@@ -0,0 +1,16 @@
|
||||
[program:gunicorn]
|
||||
directory=/app
|
||||
command=gunicorn
|
||||
app:create_app()
|
||||
-b :5000
|
||||
-w %(ENV_GUNICORN_WORKERS)s
|
||||
-k gevent
|
||||
--max-requests=5000
|
||||
--max-requests-jitter=500
|
||||
--log-level=%(ENV_LOG_LEVEL)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
@@ -0,0 +1,21 @@
|
||||
[unix_http_server]
|
||||
file=/tmp/supervisor.sock ; path to your socket file
|
||||
|
||||
[supervisord]
|
||||
logfile=/tmp/supervisord.log ; supervisord log file
|
||||
logfile_maxbytes=50MB ; maximum size of logfile before rotation
|
||||
logfile_backups=10 ; number of backed up logfiles
|
||||
loglevel=%(ENV_LOG_LEVEL)s ; info, debug, warn, trace
|
||||
pidfile=/tmp/supervisord.pid ; pidfile location
|
||||
nodaemon=true ; run supervisord as a daemon
|
||||
minfds=1024 ; number of startup file descriptors
|
||||
minprocs=200 ; number of process descriptors
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket
|
||||
|
||||
[include]
|
||||
files = /etc/supervisor/conf.d/*.conf
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
|
||||
set -- supervisord "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the app."""
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Defines fixtures available to all tests."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from webtest import TestApp
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for the tests."""
|
||||
_app = create_app("tests.settings")
|
||||
_app.logger.setLevel(logging.CRITICAL)
|
||||
ctx = _app.test_request_context()
|
||||
ctx.push()
|
||||
|
||||
yield _app
|
||||
|
||||
ctx.pop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testapp(app):
|
||||
"""Create Webtest app."""
|
||||
return TestApp(app)
|
||||
@@ -0,0 +1 @@
|
||||
"""Factories to help in tests."""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Settings module for test app."""
|
||||
|
||||
ENV = "development"
|
||||
TESTING = True
|
||||
|
||||
SERVICE_API_KEY = "test-service-api-key"
|
||||
|
||||
AZURE_API_VERSION = "2025-04-01-preview"
|
||||
AZURE_BASE_URL = "test-base-url"
|
||||
AZURE_API_KEY = "test-api-key"
|
||||
AZURE_DEPLOYMENT = "gpt-5"
|
||||
AZURE_SUMMARY_LEVEL = "detailed"
|
||||
AZURE_TRUNCATION = "auto"
|
||||
|
||||
RECORD_TRAFFIC = False
|
||||
|
||||
|
||||
AZURE_RESPONSES_API_URL = (
|
||||
f"{AZURE_BASE_URL}/openai/responses?api-version={AZURE_API_VERSION}"
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Functional tests using WebTest.
|
||||
|
||||
See: http://webtest.readthedocs.org/
|
||||
"""
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Config."""
|
||||
|
||||
def test_config_is_set(self, testapp):
|
||||
"""Ensure required config values are set."""
|
||||
app = testapp.app
|
||||
assert app.config["AZURE_BASE_URL"] != "change_me"
|
||||
assert app.config["AZURE_API_KEY"] != "change_me"
|
||||
|
||||
|
||||
class TestModels:
|
||||
"""Models."""
|
||||
|
||||
def test_models_endpoint_returns_200(self, testapp):
|
||||
"""Ensure /models endpoint returns HTTP 200."""
|
||||
testapp.get("/models", status=401)
|
||||
|
||||
def test_health_endpoint_returns_200(self, testapp):
|
||||
"""Ensure /health endpoint returns HTTP 200."""
|
||||
testapp.get("/health", status=200)
|
||||
Reference in New Issue
Block a user