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:
gabrii
2025-09-14 17:38:55 +02:00
commit 8a87d0033b
40 changed files with 2204 additions and 0 deletions
+20
View File
@@ -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.
+20
View File
@@ -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=
+16
View File
@@ -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"
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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" ]
+19
View File
@@ -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.
+279
View File
@@ -0,0 +1,279 @@
# Cursor Azure GPT-5
[![Python](https://img.shields.io/badge/Python-3.13-3776AB?logo=python&logoColor=fff)](#)
[![Flask](https://img.shields.io/badge/Flask-009485?logo=flask&logoColor=fff)](#)
[![Pytest](https://img.shields.io/badge/Pytest-fff?logo=pytest&logoColor=000)](#)
[![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff)](#)
![GitHub License](https://img.shields.io/github/license/gabrii/Cursor-Azure-GPT-5)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/gabrii/Cursor-Azure-GPT-5/lint.yml?label=lint)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/gabrii/Cursor-Azure-GPT-5/lint.yml?label=test)
![Codecov](https://img.shields.io/codecov/c/github/gabrii/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_:
![How to use Azure API key in Cursor for GPT-5](assets/cursor_model_config.jpg)
### 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_: ![Azure not working in Cursor](assets/cursor_chat_config.jpg)
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>
+4
View File
@@ -0,0 +1,4 @@
"""Main application package."""
__all__ = ["create_app"]
from .app import create_app
+41
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
+57
View File
@@ -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)
+260
View File
@@ -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
+272
View File
@@ -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,
)
+106
View File
@@ -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
],
}
)
+93
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Common utilities shared across the application (logging, SSE, etc.)."""
+285
View File
@@ -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)
+73
View File
@@ -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)
+265
View File
@@ -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]")
+30
View File
@@ -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
View File
@@ -0,0 +1,5 @@
"""Create an application instance."""
from app import create_app
app = create_app()
+41
View File
@@ -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
+11
View File
@@ -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
View File
+19
View File
@@ -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
+20
View File
@@ -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
+16
View File
@@ -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
+21
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env sh
set -e
if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
set -- supervisord "$@"
fi
exec "$@"
+1
View File
@@ -0,0 +1 @@
"""Tests for the app."""
+27
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Factories to help in tests."""
+20
View File
@@ -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}"
)
+26
View File
@@ -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)