Files
azure-gpt-cursor/app/azure/request_adapter.py
T
gabrii 8a87d0033b 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.
2025-09-14 17:38:55 +02:00

261 lines
9.8 KiB
Python

"""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