diff --git a/app/app.py b/app/app.py index de50d87..bd0dd7b 100644 --- a/app/app.py +++ b/app/app.py @@ -1,9 +1,7 @@ """The app module, containing the app factory function.""" -import logging -import sys - from flask import Flask +from rich.traceback import install as install_rich_traceback from . import commands from .blueprint import blueprint @@ -16,9 +14,9 @@ def create_app(config_object="app.settings"): """ app = Flask(__name__.split(".")[0]) app.config.from_object(config_object) + configure_logging(app) register_commands(app) register_blueprints(app) - configure_logger(app) return app @@ -34,8 +32,6 @@ def register_commands(app): 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) +def configure_logging(app): + """Configure logging.""" + install_rich_traceback() diff --git a/app/auth.py b/app/auth.py index 842bf12..a69a77a 100644 --- a/app/auth.py +++ b/app/auth.py @@ -2,7 +2,9 @@ from functools import wraps -from flask import Response, current_app, request +from flask import current_app, request + +from .exceptions import CursorConfigurationError def valid_brearer_token(): @@ -20,8 +22,8 @@ def require_auth(func): if valid_brearer_token(): return func(*args, **kwargs) else: - error_message = ( - "\nAuthentication with Cursor-Azure-GPT-5 service failed.\n\n" + raise CursorConfigurationError( + "Authentication with Cursor-Azure-GPT-5 service failed.\n\n" "These value of:\n" "\tCursor Settings > Models > API Keys > OpenAI API Key\n\n" "Must match the value of:\n" @@ -29,6 +31,5 @@ def require_auth(func): "Ensure the values match exactly, and try again.\n" "If modifying the .env file, restart the service for the changes to apply." ) - return Response(error_message, 400) return wrapper diff --git a/app/azure/adapter.py b/app/azure/adapter.py index a5fc4ef..7d8c36e 100644 --- a/app/azure/adapter.py +++ b/app/azure/adapter.py @@ -64,19 +64,16 @@ class AzureAdapter: resp_content = resp.text body = request_kwargs.get("json", {}) - if "instructions" in body: - body["instructions"] = body["instructions"][:7] + "..." - - if "tools" in body: - body["tools"] = f"...redacted {len(body['tools'])} tools..." - - if "input" in body: - body["input"] = f"...redacted {len(body['input'])} input items..." - - if "prompt_cache_key" in body: - body["prompt_cache_key"] = re.sub( - r"(...)(.*)(...)", "\\1***\\3", body["prompt_cache_key"] - ) + body["instructions"] = body.get("instructions", "no instructions")[:16] + "..." + body["tools"] = f"...redacted {len(body.get('tools', 'no tools'))} tools..." + body["input"] = ( + f"...redacted {len(body.get('input', 'no input'))} input items..." + ) + body["prompt_cache_key"] = re.sub( + r"(...)(.*)(...)", + "\\1***\\3", + body.get("prompt_cache_key", "no prompt_cache_key"), + ) report = { "endpoint": re.sub( r"(//.)(.*?)(.\.)", "\\1***\\3", request_kwargs.get("url") diff --git a/app/azure/request_adapter.py b/app/azure/request_adapter.py index f7fe0e7..697db1e 100644 --- a/app/azure/request_adapter.py +++ b/app/azure/request_adapter.py @@ -10,6 +10,8 @@ from typing import Any, Dict, List from flask import Request, current_app +from ..exceptions import CursorConfigurationError, ServiceConfigurationError + class RequestAdapter: """Handle pre-request adaptation for the Azure Responses API. @@ -43,11 +45,9 @@ class RequestAdapter: for m in messages: role = m.get("role") - c = m.get("content") + content = m.get("content") if role in {"system", "developer"}: - text = c - if text: - instructions_parts.append(text) + instructions_parts.append(content) continue # For user/assistant/tools as inputs if role == "tool": @@ -55,19 +55,18 @@ class RequestAdapter: item = { "type": "function_call_output", - "output": c, + "output": content, "status": "completed", "call_id": call_id, } input_items.append(item) else: - text = c item = { "role": role or "user", "content": [ { "type": "input_text" if role == "user" else "output_text", - "text": text, + "text": content, }, ], } @@ -87,26 +86,22 @@ class RequestAdapter: instructions = "\n\n".join(instructions_parts) if instructions_parts else None return { - "input": input_items if input_items else None, "instructions": instructions, + "input": input_items if input_items else None, } def _transform_tools_for_responses(self, tools: Any) -> Any: out: List[Dict[str, Any]] = [] - for t in tools: - 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) + for tool in tools: + function = tool.get("function") + transformed: Dict[str, Any] = { + "type": "function", + "name": function.get("name"), + "description": function.get("description"), + "parameters": function.get("parameters"), + "strict": False, + } + out.append(transformed) return out # ---- Main adaptation (always streaming completions-like) ---- @@ -134,39 +129,31 @@ class RequestAdapter: # 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") - prompt_cache_key = payload.get("user") or payload.get("prompt_cache_key") - mapped = ( + responses_body = ( 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) - if tool_choice_in is not None: - responses_body["tool_choice"] = tool_choice_in + responses_body["tools"] = self._transform_tools_for_responses( + payload.get("tools", []) + ) + responses_body["tool_choice"] = payload.get("tool_choice") - if prompt_cache_key is not None: - responses_body["prompt_cache_key"] = prompt_cache_key + responses_body["prompt_cache_key"] = payload.get("user") # Always streaming responses_body["stream"] = True reasoning_effort = inbound_model.replace("gpt-", "").lower() if reasoning_effort not in {"high", "medium", "low", "minimal"}: - raise ValueError( - "Model name must be either gpt-high, gpt-medium, gpt-low, or gpt-minimal" + raise CursorConfigurationError( + "Model name must be either gpt-high, gpt-medium, gpt-low, or gpt-minimal." + f"\n\nGot: {inbound_model}" ) responses_body["reasoning"] = { @@ -177,6 +164,11 @@ class RequestAdapter: # but allowing it for now to be able to test it on other models if settings["AZURE_SUMMARY_LEVEL"] in {"auto", "detailed", "concise"}: responses_body["reasoning"]["summary"] = settings["AZURE_SUMMARY_LEVEL"] + else: + raise ServiceConfigurationError( + "AZURE_SUMMARY_LEVEL must be either auto, detailed, or concise." + f"\n\nGot: {settings['AZURE_SUMMARY_LEVEL']}" + ) # No need to pass verbosity if it's set to medium, as it's the model's default if settings["AZURE_VERBOSITY_LEVEL"] in {"low", "high"}: diff --git a/app/azure/response_adapter.py b/app/azure/response_adapter.py index 7dfdeba..b063c36 100644 --- a/app/azure/response_adapter.py +++ b/app/azure/response_adapter.py @@ -15,6 +15,9 @@ from flask import Response, stream_with_context from ..common.sse import chunks_to_sse, sse_to_events +# Centralized events that should end a block before handling +THINKING_STOP_EVENTS = {"response.output_text.delta", "response.output_item.added"} + class ResponseAdapter: """Handle post-request adaptation from Azure Responses API to Flask. @@ -64,115 +67,84 @@ class ResponseAdapter: # ---- 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.""" + ) -> Optional[Dict[str, Any]]: + """Handle response.output_item.added events and emit a single chunk.""" item_type = obj.get("item", {}).get("type") if item_type == "reasoning": self._thinking = True - return [ - self._build_completion_chunk( - delta={"role": "assistant", "content": "\n\n"} - ) - ] + return self._build_completion_chunk( + delta={"role": "assistant", "content": "\n\n"} + ) if item_type == "function_call": - out: list[Dict[str, Any]] = [] - if self._thinking: - out.append( - self._build_completion_chunk( - delta={"role": "assistant", "content": "\n\n"} - ) - ) - self._thinking = False self._tool_calls += 1 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": self._tool_calls - 1, - "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]] = [] - arguments_delta = obj.get("delta", "") if isinstance(obj, dict) else "" - out.append( - self._build_completion_chunk( + return self._build_completion_chunk( delta={ + "role": "assistant", + "content": None, "tool_calls": [ { "index": self._tool_calls - 1, - "function": {"arguments": arguments_delta}, + "id": call_id or "", + "type": "function", + "function": { + "name": name or "", + "arguments": arguments or "", + }, } - ] + ], } ) + return None + + def _function_call_arguments__delta( + self, obj: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + """Handle response.function_call.arguments.delta events.""" + arguments_delta = obj.get("delta", "") if isinstance(obj, dict) else "" + return self._build_completion_chunk( + delta={ + "tool_calls": [ + { + "index": self._tool_calls - 1, + "function": {"arguments": arguments_delta}, + } + ] + } ) - return out 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.""" - return [ - self._build_completion_chunk( - delta={ - "role": "assistant", - "content": (obj.get("delta", "") if isinstance(obj, dict) else ""), - } - ) - ] + ) -> Optional[Dict[str, Any]]: + """Handle reasoning.summary_text.delta events and emit text chunk.""" + return self._build_completion_chunk( + delta={ + "role": "assistant", + "content": (obj.get("delta", "") if isinstance(obj, dict) else ""), + } + ) 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"}) - ] + ) -> Optional[Dict[str, Any]]: + """Handle reasoning.summary_text.done events and close with blank line inside 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 self._thinking: - out.append( - self._build_completion_chunk( - delta={"role": "assistant", "content": "\n\n"} - ) - ) - self._thinking = False - out.append( - self._build_completion_chunk( - delta={ - "role": "assistant", - "content": (obj.get("delta", "") if isinstance(obj, dict) else ""), - } - ) + ) -> Optional[Dict[str, Any]]: + """Handle response.output_text.delta events and emit text chunk.""" + return 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.""" @@ -196,10 +168,17 @@ class ResponseAdapter: handler = getattr(self, handler_name, None) if not handler: continue + + # Centrally close blocks whenever a stop event is seen + if self._thinking and (ev.event in THINKING_STOP_EVENTS): + yield self._build_completion_chunk( + delta={"role": "assistant", "content": "\n\n"} + ) + self._thinking = False + res = handler(ev.json) if res is not None: - for chunk in res: - yield chunk + yield res finally: # Emit finish reason at the end of stream if self._tool_calls > 0: diff --git a/app/blueprint.py b/app/blueprint.py index d65ee7b..461b009 100644 --- a/app/blueprint.py +++ b/app/blueprint.py @@ -4,11 +4,7 @@ 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 @@ -18,30 +14,10 @@ from .common.recording import ( init_last_recording, record_payload, ) +from .exceptions import ConfigurationError 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=( - "{time:YYYY-MM-DD HH:mm:ss.SSS} " - "| {level: <8} " - "| {name}:{function}:{line} " - "- {message}" - ), -) - - ALL_METHODS = [ "GET", "POST", @@ -103,3 +79,9 @@ def models(): ], } ) + + +@blueprint.errorhandler(ConfigurationError) +def configuration_error(e: ConfigurationError): + """Return a 400 JSON error payload for ValueError.""" + return e.get_response_content(), 400 diff --git a/app/commands.py b/app/commands.py index c6158fa..5bd7ae4 100644 --- a/app/commands.py +++ b/app/commands.py @@ -41,13 +41,6 @@ def test(coverage, filter): @click.command() -@click.option( - "-f", - "--fix-imports", - default=True, - is_flag=True, - help="Fix imports using isort, before linting", -) @click.option( "-c", "--check", @@ -55,7 +48,7 @@ def test(coverage, filter): is_flag=True, help="Don't make any changes to files, just confirm they are formatted correctly", ) -def lint(fix_imports, check): +def lint(check): """Lint and check code style with black, flake8 and isort.""" skip = [ "requirements", @@ -85,7 +78,6 @@ def lint(fix_imports, check): if check: isort_args.append("--check") black_args.append("--check") - if fix_imports: - execute_tool("Fixing import order", "isort", *isort_args) + execute_tool("Fixing import order", "isort", *isort_args) execute_tool("Formatting style", "black", *black_args) execute_tool("Checking code style", "flake8") diff --git a/app/common/logging.py b/app/common/logging.py index 7575d0d..4d9f793 100644 --- a/app/common/logging.py +++ b/app/common/logging.py @@ -2,22 +2,28 @@ import json import os +import re import time import uuid from typing import Any, Dict, List from flask import Request -from rich.console import Console +from rich import box +from rich.console import Console, Group from rich.json import JSON from rich.markdown import Markdown from rich.padding import Padding from rich.panel import Panel +from rich.table import Table # Global console instance for consistent logging across modules console = Console() - - -# --- Request logging helpers --- +ROLE_COLORS = { + "tool": "magenta", + "system": "yellow", + "user": "cyan", + "assistant": "light_green", +} def should_redact() -> bool: @@ -92,6 +98,13 @@ def _capture_request_details(req: Request, request_id: str) -> Dict[str, Any]: return details +def escape_tags(text: str) -> str: + """Escapes xml-like tags in text so that they are visible when rendered as Markdown.""" + return re.sub( + "(<[^<\n]+?)(>)", "\\1>`\n", re.sub("(<)([^>\n]+?>)", "\n`<\\2", text) + ).replace(">`\n\n\n`<", ">`\n\n`<") + + def log_request(req: Request) -> str: """Pretty-print a Flask request using Rich and return the request id.""" request_id = uuid.uuid4().hex[:8] @@ -103,132 +116,145 @@ def log_request(req: Request) -> str: # 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", - } - } + + # Remove verbose fields to log them separately + cleaned_json = { + k: v if k not in {"tools", "messages"} else "Pretty-printed below ↓" + for k, v in json_payload.items() + } console.print_json( - data={ - "query_args": details.get("query_args"), - "form": details.get("form"), - "json": cleaned_json, - } + data=cleaned_json, + indent=None, ) - # 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 + messages = json_payload.get("messages", []) + tools = json_payload.get("tools", []) - if messages: - console.rule(f"Messages ({len(messages)})") + for idx, tool in enumerate(tools, start=1): + table = Table( + caption="[italic]Required fields are marked with *[/italic]", + pad_edge=False, + box=box.SIMPLE, + leading=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}" + table.add_column("Name", justify="right", style="cyan", no_wrap=True) + table.add_column("Description", style="white") + + for tool in tools: + function = tool.get("function") + required = function.get("parameters").get("required") + + params_table = Table( + show_header=False, + show_edge=True, + show_lines=True, + title="Parameters", + title_justify="left", + expand=True, + leading=3, ) - console.rule(title) - console.print( - Padding( - Markdown( - content_val.replace("<", "\n`<") - .replace(">", ">`\n") - .replace(">`\n\n\n`<", ">`\n\n`<") - ), - (1, 0), + params_table.add_column("Name", justify="right", no_wrap=True, style="cyan") + params_table.add_column("Description", style="white") + for param_name, param_value in ( + function.get("parameters").get("properties").items() + ): + param_type = param_value.get("type") + if param_type == "array": + param_type += f"({param_value.get('items').get('type')})" + if param_name in required: + param_type = f"[bold]*{param_type}[/bold]" + param_type = f"[magenta]{param_type}[/magenta]" + param_name = f"{param_name}" + params_table.add_row( + Group(param_name, param_type), + f"{param_value.get("description")}", + ) + table.add_row( + f"{function.get('name')}", + Group( + Markdown(escape_tags(function.get("description"))), + params_table, + ), + ) + + console.print( + Panel( + table, + title=f"[italic]{0}/{len(messages)}[/italic] [bold][/bold]", + title_align="left", + subtitle="[bold][/bold]", + subtitle_align="right", + border_style=ROLE_COLORS["tool"], + ) + ) + + for idx, msg in enumerate(messages, start=1): + role = str(msg.get("role", "")) + content_val = msg.get("content", "") + name = msg.get("name") + tool_call_id = msg.get("tool_call_id") + message_title = ( + f"[italic]{idx}/{len(messages)}[/italic] [bold]<{role}>[/bold]" + if not name + else f"[italic]{idx}/{len(messages)}[/italic] [bold]<{role} name={name} id={tool_call_id}>[/bold]" + ) + message_elements = [] + message_elements.append( + Padding( + Markdown( + escape_tags(content_val), + ), + (1, 0), + ) + ) + tool_calls = msg.get("tool_calls", []) + for tool_call in tool_calls: + function = tool_call.get("function", {}) + arguments = function.get("arguments") + tool_elements = [] + tool_title = f"[bold][/bold]" + tool_elements.append( + f"[bold][magenta]{function.get('name')}[/magenta] [blue]([/blue][/bold]", + ) + try: + tool_elements.append(Padding(JSON(arguments), (0, 4))) + except json.JSONDecodeError: + tool_elements.append( + Padding("[red]Invalid JSON generated by the model:[/red]", (0, 4)) + ) + tool_elements.append(arguments) + tool_elements.append("[bold][blue])[/blue][/bold]") + message_elements.append( + Panel( + Group(*tool_elements), + title=tool_title, + border_style=ROLE_COLORS["tool"], ) ) - 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), - ) + message_style = ROLE_COLORS.get(role, "red") + message_subtitle = message_title[message_title.find("<") :].replace("<", "") ) - console.print( - Padding( - f"[bold][magenta]{function.get('name')}[/magenta] ([/bold]", - (0, 4), - ) - ) - if arguments: - try: - console.print(Padding(JSON(arguments), (0, 8))) - except json.JSONDecodeError: - console.print("[red]Invalid JSON generated by the model:[/red]") - print(arguments) - console.print(Padding("[bold])[/bold]", (0, 4))) - if tool_calls: - console.print() + ] + + ">[/bold]" + ) + console.print( + Panel( + Group(*message_elements), + title=message_title, + title_align="left", + subtitle=message_subtitle, + subtitle_align="right", + border_style=message_style, + ) + ) return request_id - - -# --- SSE logging helpers, keeping for future use if we enable SSE logging --- -# from .sse import SSEEvent -# 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) diff --git a/app/common/recording.py b/app/common/recording.py index 0cb6c4e..16e9c30 100644 --- a/app/common/recording.py +++ b/app/common/recording.py @@ -12,7 +12,7 @@ import re from functools import wraps from typing import Any, Dict -from flask import current_app, has_app_context +from flask import current_app RECORDINGS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "recordings") @@ -25,9 +25,7 @@ def config_bypass(func): @wraps(func) def wrapper(*args, **kwargs): - enabled = False - if has_app_context(): - enabled = current_app.config["RECORD_TRAFFIC"] + enabled = current_app.config["RECORD_TRAFFIC"] if not enabled: return None return func(*args, **kwargs) diff --git a/app/common/sse.py b/app/common/sse.py index 843bbcd..08b5b1d 100644 --- a/app/common/sse.py +++ b/app/common/sse.py @@ -7,7 +7,7 @@ This module provides helpers to decode and encode SSE streams, including: """ import json -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, Iterable, Iterator, List, Optional from .recording import record_sse @@ -31,17 +31,6 @@ class SSEEvent: 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]: @@ -49,19 +38,9 @@ class SSEEvent: 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 + text = (self.data or "").strip() + val: Optional[Any] = json.loads(text) + return val class SSEDecoder: @@ -92,11 +71,8 @@ class SSEDecoder: .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) + else: + data_parts.append(line[5:].strip()) data_text = ( b"\n".join(data_parts).decode(self.encoding, errors="replace") @@ -107,8 +83,6 @@ class SSEDecoder: 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: @@ -119,11 +93,10 @@ class SSEDecoder: 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 + 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) @@ -157,13 +130,10 @@ def encode_sse_data(data: str) -> bytes: as per the SSE spec. Optionally include event and id. """ out = bytearray() - 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") + 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) @@ -174,9 +144,7 @@ def encode_sse_json(obj: Any) -> bytes: return encode_sse_data(payload) -def chunks_to_sse( - chunks: Iterable[Dict[str, Any]], *, add_done: bool = True -) -> Iterator[bytes]: +def chunks_to_sse(chunks: Iterable[Dict[str, Any]]) -> 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. @@ -188,48 +156,12 @@ def chunks_to_sse( buffer += sse yield sse finally: - if add_done: - sse = done_event_bytes() - buffer += sse - yield sse + 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]") - - -# Keeping for future use if we enable OpenAI backend -# 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) diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..e0397f4 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,25 @@ +"""Exceptions for the application.""" + + +class ConfigurationError(ValueError): + """Exception raised for configuration errors.""" + + preamble = None + + def get_response_content(self): + """Returns a formated error message inclduing the preamble and the message.""" + message = self.args[0].replace("\n", "\n\t") + + return f"{self.preamble}\n\n\t{message}" + + +class ServiceConfigurationError(ConfigurationError): + """Exception raised for configuration errors in the service configuration.""" + + preamble = "Service configuration error, check your .env file." + + +class CursorConfigurationError(ConfigurationError): + """Exception raised for configuration errors in Cursor configuration.""" + + preamble = "Cursor configuration error, check your Cursor settings." diff --git a/requirements/prod.txt b/requirements/prod.txt index 9839992..fc51573 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -6,7 +6,6 @@ Flask==3.1.2 # Logging rich==14.1.0 -loguru==0.7.3 # Requests requests==2.32.5 diff --git a/tests/recordings/sse_without_closing_new_lines/downstream_request.json b/tests/recordings/sse_without_closing_new_lines/downstream_request.json new file mode 100644 index 0000000..faa20ae --- /dev/null +++ b/tests/recordings/sse_without_closing_new_lines/downstream_request.json @@ -0,0 +1,396 @@ +{ + "model": "gpt-minimal", + "temperature": 0, + "user": "REDACTED", + "messages": [ + { + "role": "system", + "content": "REDACTED" + }, + { + "role": "user", + "content": "REDACTED" + }, + { + "role": "user", + "content": "REDACTED" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "explanation": { + "type": "string", + "description": "REDACTED" + }, + "query": { + "type": "string", + "description": "REDACTED" + }, + "target_directories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "REDACTED" + }, + "search_only_prs": { + "type": "boolean", + "description": "REDACTED" + } + }, + "required": [ + "explanation", + "query", + "target_directories" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "REDACTED" + }, + "is_background": { + "type": "boolean", + "description": "REDACTED" + }, + "explanation": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "command", + "is_background" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "REDACTED" + }, + "path": { + "type": "string", + "description": "REDACTED" + }, + "glob": { + "type": "string", + "description": "REDACTED" + }, + "output_mode": { + "type": "string", + "description": "REDACTED", + "enum": [ + "content", + "files_with_matches", + "count" + ] + }, + "-B": { + "type": "number", + "description": "REDACTED" + }, + "-A": { + "type": "number", + "description": "REDACTED" + }, + "-C": { + "type": "number", + "description": "REDACTED" + }, + "-i": { + "type": "boolean", + "description": "REDACTED" + }, + "type": { + "type": "string", + "description": "REDACTED" + }, + "head_limit": { + "type": "number", + "description": "REDACTED" + }, + "multiline": { + "type": "boolean", + "description": "REDACTED" + } + }, + "required": [ + "pattern" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_file": { + "type": "string", + "description": "REDACTED" + }, + "explanation": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "target_file" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "paths": { + "description": "REDACTED", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_notebook": { + "type": "string", + "description": "REDACTED" + }, + "cell_idx": { + "type": "number", + "description": "REDACTED" + }, + "is_new_cell": { + "type": "boolean", + "description": "REDACTED" + }, + "cell_language": { + "type": "string", + "description": "REDACTED" + }, + "old_string": { + "type": "string", + "description": "REDACTED" + }, + "new_string": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "target_notebook", + "cell_idx", + "is_new_cell", + "cell_language", + "old_string", + "new_string" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "merge": { + "type": "boolean", + "description": "REDACTED" + }, + "todos": { + "type": "array", + "description": "REDACTED", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "REDACTED" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "cancelled" + ], + "description": "REDACTED" + }, + "id": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "content", + "status", + "id" + ] + }, + "minItems": 2 + } + }, + "required": [ + "merge", + "todos" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_file": { + "type": "string", + "description": "REDACTED" + }, + "instructions": { + "type": "string", + "description": "REDACTED" + }, + "code_edit": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "target_file", + "instructions", + "code_edit" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_file": { + "type": "string", + "description": "REDACTED" + }, + "offset": { + "type": "integer", + "description": "REDACTED" + }, + "limit": { + "type": "integer", + "description": "REDACTED" + } + }, + "required": [ + "target_file" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_directory": { + "type": "string", + "description": "REDACTED" + }, + "ignore_globs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "REDACTED" + } + }, + "required": [ + "target_directory" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_directory": { + "type": "string", + "description": "REDACTED" + }, + "glob_pattern": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "glob_pattern" + ] + } + } + } + ], + "tool_choice": "auto", + "stream": true +} \ No newline at end of file diff --git a/tests/recordings/sse_without_closing_new_lines/downstream_response.sse b/tests/recordings/sse_without_closing_new_lines/downstream_response.sse new file mode 100644 index 0000000..e459820 --- /dev/null +++ b/tests/recordings/sse_without_closing_new_lines/downstream_response.sse @@ -0,0 +1,10 @@ +data: {"id":"chatcmpl-ERO7qkefORSgjuOmbThzZoG8","object":"chat.completion.chunk","created":1758093803,"model":"gpt-minimal","choices":[{"index":0,"delta":{"role":"assistant","content":"\n\n"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ERO7qkefORSgjuOmbThzZoG8","object":"chat.completion.chunk","created":1758093804,"model":"gpt-minimal","choices":[{"index":0,"delta":{"role":"assistant","content":"\n\n"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ERO7qkefORSgjuOmbThzZoG8","object":"chat.completion.chunk","created":1758093804,"model":"gpt-minimal","choices":[{"index":0,"delta":{"role":"assistant","content":"pong"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ERO7qkefORSgjuOmbThzZoG8","object":"chat.completion.chunk","created":1758093806,"model":"gpt-minimal","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: [DONE] + diff --git a/tests/recordings/sse_without_closing_new_lines/upstream_request.json b/tests/recordings/sse_without_closing_new_lines/upstream_request.json new file mode 100644 index 0000000..c6db862 --- /dev/null +++ b/tests/recordings/sse_without_closing_new_lines/upstream_request.json @@ -0,0 +1,400 @@ +{ + "instructions": "REDACTED", + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "REDACTED" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "REDACTED" + } + ] + } + ], + "model": "gpt-5", + "tools": [ + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "explanation": { + "type": "string", + "description": "REDACTED" + }, + "query": { + "type": "string", + "description": "REDACTED" + }, + "target_directories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "REDACTED" + }, + "search_only_prs": { + "type": "boolean", + "description": "REDACTED" + } + }, + "required": [ + "explanation", + "query", + "target_directories" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "REDACTED" + }, + "is_background": { + "type": "boolean", + "description": "REDACTED" + }, + "explanation": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "command", + "is_background" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "REDACTED" + }, + "path": { + "type": "string", + "description": "REDACTED" + }, + "glob": { + "type": "string", + "description": "REDACTED" + }, + "output_mode": { + "type": "string", + "description": "REDACTED", + "enum": [ + "content", + "files_with_matches", + "count" + ] + }, + "-B": { + "type": "number", + "description": "REDACTED" + }, + "-A": { + "type": "number", + "description": "REDACTED" + }, + "-C": { + "type": "number", + "description": "REDACTED" + }, + "-i": { + "type": "boolean", + "description": "REDACTED" + }, + "type": { + "type": "string", + "description": "REDACTED" + }, + "head_limit": { + "type": "number", + "description": "REDACTED" + }, + "multiline": { + "type": "boolean", + "description": "REDACTED" + } + }, + "required": [ + "pattern" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_file": { + "type": "string", + "description": "REDACTED" + }, + "explanation": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "target_file" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "paths": { + "description": "REDACTED", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_notebook": { + "type": "string", + "description": "REDACTED" + }, + "cell_idx": { + "type": "number", + "description": "REDACTED" + }, + "is_new_cell": { + "type": "boolean", + "description": "REDACTED" + }, + "cell_language": { + "type": "string", + "description": "REDACTED" + }, + "old_string": { + "type": "string", + "description": "REDACTED" + }, + "new_string": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "target_notebook", + "cell_idx", + "is_new_cell", + "cell_language", + "old_string", + "new_string" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "merge": { + "type": "boolean", + "description": "REDACTED" + }, + "todos": { + "type": "array", + "description": "REDACTED", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "REDACTED" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "cancelled" + ], + "description": "REDACTED" + }, + "id": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "content", + "status", + "id" + ] + }, + "minItems": 2 + } + }, + "required": [ + "merge", + "todos" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_file": { + "type": "string", + "description": "REDACTED" + }, + "instructions": { + "type": "string", + "description": "REDACTED" + }, + "code_edit": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "target_file", + "instructions", + "code_edit" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_file": { + "type": "string", + "description": "REDACTED" + }, + "offset": { + "type": "integer", + "description": "REDACTED" + }, + "limit": { + "type": "integer", + "description": "REDACTED" + } + }, + "required": [ + "target_file" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_directory": { + "type": "string", + "description": "REDACTED" + }, + "ignore_globs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "REDACTED" + } + }, + "required": [ + "target_directory" + ] + }, + "strict": false + }, + { + "type": "function", + "name": "REDACTED", + "description": "REDACTED", + "parameters": { + "type": "object", + "properties": { + "target_directory": { + "type": "string", + "description": "REDACTED" + }, + "glob_pattern": { + "type": "string", + "description": "REDACTED" + } + }, + "required": [ + "glob_pattern" + ] + }, + "strict": false + } + ], + "tool_choice": "auto", + "prompt_cache_key": "REDACTED", + "stream": true, + "reasoning": { + "effort": "minimal", + "summary": "detailed" + }, + "store": false, + "stream_options": { + "include_obfuscation": false + }, + "truncation": "auto" +} \ No newline at end of file diff --git a/tests/recordings/sse_without_closing_new_lines/upstream_response.sse b/tests/recordings/sse_without_closing_new_lines/upstream_response.sse new file mode 100644 index 0000000..4751b5c --- /dev/null +++ b/tests/recordings/sse_without_closing_new_lines/upstream_response.sse @@ -0,0 +1,32 @@ +event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68ca61ea133c8190a25851b67c2dc1fa000638fabbdbe241","object":"response","created_at":1758093802,"status":"in_progress","background":false,"content_filters":null,"error":null,"incomplete_details":null,"instructions":"REDACTED","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":"REDACTED","reasoning":{"effort":"minimal","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"explanation":{"type":"string","description":"REDACTED"},"query":{"type":"string","description":"REDACTED"},"target_directories":{"type":"array","items":{"type":"string"},"description":"REDACTED"},"search_only_prs":{"type":"boolean","description":"REDACTED"}},"required":["explanation","query","target_directories"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"command":{"type":"string","description":"REDACTED"},"is_background":{"type":"boolean","description":"REDACTED"},"explanation":{"type":"string","description":"REDACTED"}},"required":["command","is_background"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"pattern":{"type":"string","description":"REDACTED"},"path":{"type":"string","description":"REDACTED"},"glob":{"type":"string","description":"REDACTED"},"output_mode":{"type":"string","description":"REDACTED","enum":["content","files_with_matches","count"]},"-B":{"type":"number","description":"REDACTED"},"-A":{"type":"number","description":"REDACTED"},"-C":{"type":"number","description":"REDACTED"},"-i":{"type":"boolean","description":"REDACTED"},"type":{"type":"string","description":"REDACTED"},"head_limit":{"type":"number","description":"REDACTED"},"multiline":{"type":"boolean","description":"REDACTED"}},"required":["pattern"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"explanation":{"type":"string","description":"REDACTED"}},"required":["target_file"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"paths":{"description":"REDACTED","type":"array","items":{"type":"string"}}},"required":[]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_notebook":{"type":"string","description":"REDACTED"},"cell_idx":{"type":"number","description":"REDACTED"},"is_new_cell":{"type":"boolean","description":"REDACTED"},"cell_language":{"type":"string","description":"REDACTED"},"old_string":{"type":"string","description":"REDACTED"},"new_string":{"type":"string","description":"REDACTED"}},"required":["target_notebook","cell_idx","is_new_cell","cell_language","old_string","new_string"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"merge":{"type":"boolean","description":"REDACTED"},"todos":{"type":"array","description":"REDACTED","items":{"type":"object","properties":{"content":{"type":"string","description":"REDACTED"},"status":{"type":"string","enum":["pending","in_progress","completed","cancelled"],"description":"REDACTED"},"id":{"type":"string","description":"REDACTED"}},"required":["content","status","id"]},"minItems":2}},"required":["merge","todos"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"instructions":{"type":"string","description":"REDACTED"},"code_edit":{"type":"string","description":"REDACTED"}},"required":["target_file","instructions","code_edit"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"offset":{"type":"integer","description":"REDACTED"},"limit":{"type":"integer","description":"REDACTED"}},"required":["target_file"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_directory":{"type":"string","description":"REDACTED"},"ignore_globs":{"type":"array","items":{"type":"string"},"description":"REDACTED"}},"required":["target_directory"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_directory":{"type":"string","description":"REDACTED"},"glob_pattern":{"type":"string","description":"REDACTED"}},"required":["glob_pattern"]},"strict":false}],"top_p":1.0,"truncation":"auto","usage":null,"user":null,"metadata":{}}} + +event: response.in_progress +data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_68ca61ea133c8190a25851b67c2dc1fa000638fabbdbe241","object":"response","created_at":1758093802,"status":"in_progress","background":false,"content_filters":null,"error":null,"incomplete_details":null,"instructions":"REDACTED","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":"REDACTED","reasoning":{"effort":"minimal","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"explanation":{"type":"string","description":"REDACTED"},"query":{"type":"string","description":"REDACTED"},"target_directories":{"type":"array","items":{"type":"string"},"description":"REDACTED"},"search_only_prs":{"type":"boolean","description":"REDACTED"}},"required":["explanation","query","target_directories"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"command":{"type":"string","description":"REDACTED"},"is_background":{"type":"boolean","description":"REDACTED"},"explanation":{"type":"string","description":"REDACTED"}},"required":["command","is_background"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"pattern":{"type":"string","description":"REDACTED"},"path":{"type":"string","description":"REDACTED"},"glob":{"type":"string","description":"REDACTED"},"output_mode":{"type":"string","description":"REDACTED","enum":["content","files_with_matches","count"]},"-B":{"type":"number","description":"REDACTED"},"-A":{"type":"number","description":"REDACTED"},"-C":{"type":"number","description":"REDACTED"},"-i":{"type":"boolean","description":"REDACTED"},"type":{"type":"string","description":"REDACTED"},"head_limit":{"type":"number","description":"REDACTED"},"multiline":{"type":"boolean","description":"REDACTED"}},"required":["pattern"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"explanation":{"type":"string","description":"REDACTED"}},"required":["target_file"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"paths":{"description":"REDACTED","type":"array","items":{"type":"string"}}},"required":[]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_notebook":{"type":"string","description":"REDACTED"},"cell_idx":{"type":"number","description":"REDACTED"},"is_new_cell":{"type":"boolean","description":"REDACTED"},"cell_language":{"type":"string","description":"REDACTED"},"old_string":{"type":"string","description":"REDACTED"},"new_string":{"type":"string","description":"REDACTED"}},"required":["target_notebook","cell_idx","is_new_cell","cell_language","old_string","new_string"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"merge":{"type":"boolean","description":"REDACTED"},"todos":{"type":"array","description":"REDACTED","items":{"type":"object","properties":{"content":{"type":"string","description":"REDACTED"},"status":{"type":"string","enum":["pending","in_progress","completed","cancelled"],"description":"REDACTED"},"id":{"type":"string","description":"REDACTED"}},"required":["content","status","id"]},"minItems":2}},"required":["merge","todos"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"instructions":{"type":"string","description":"REDACTED"},"code_edit":{"type":"string","description":"REDACTED"}},"required":["target_file","instructions","code_edit"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"offset":{"type":"integer","description":"REDACTED"},"limit":{"type":"integer","description":"REDACTED"}},"required":["target_file"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_directory":{"type":"string","description":"REDACTED"},"ignore_globs":{"type":"array","items":{"type":"string"},"description":"REDACTED"}},"required":["target_directory"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_directory":{"type":"string","description":"REDACTED"},"glob_pattern":{"type":"string","description":"REDACTED"}},"required":["glob_pattern"]},"strict":false}],"top_p":1.0,"truncation":"auto","usage":null,"user":null,"metadata":{}}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"rs_68ca61eb7cf481909660df840351bf9d000638fabbdbe241","type":"reasoning","summary":[]}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":3,"output_index":0,"item":{"id":"rs_68ca61eb7cf481909660df840351bf9d000638fabbdbe241","type":"reasoning","summary":[]}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":4,"output_index":1,"item":{"id":"msg_68ca61ecbbe881909418900fa3ecd3e7000638fabbdbe241","type":"message","status":"in_progress","content":[],"role":"assistant"}} + +event: response.content_part.added +data: {"type":"response.content_part.added","sequence_number":5,"item_id":"msg_68ca61ecbbe881909418900fa3ecd3e7000638fabbdbe241","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"text":""}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":6,"item_id":"msg_68ca61ecbbe881909418900fa3ecd3e7000638fabbdbe241","output_index":1,"content_index":0,"delta":"pong"} + +event: response.output_text.done +data: {"type":"response.output_text.done","sequence_number":7,"item_id":"msg_68ca61ecbbe881909418900fa3ecd3e7000638fabbdbe241","output_index":1,"content_index":0,"text":"REDACTED"} + +event: response.content_part.done +data: {"type":"response.content_part.done","sequence_number":8,"item_id":"msg_68ca61ecbbe881909418900fa3ecd3e7000638fabbdbe241","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"text":"REDACTED"}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":9,"output_index":1,"item":{"id":"msg_68ca61ecbbe881909418900fa3ecd3e7000638fabbdbe241","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"REDACTED"}],"role":"assistant"}} + +event: response.completed +data: {"type":"response.completed","sequence_number":10,"response":{"id":"resp_68ca61ea133c8190a25851b67c2dc1fa000638fabbdbe241","object":"response","created_at":1758093802,"status":"completed","background":false,"content_filters":null,"error":null,"incomplete_details":null,"instructions":"REDACTED","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5","output":[{"id":"rs_68ca61eb7cf481909660df840351bf9d000638fabbdbe241","type":"reasoning","summary":[]},{"id":"msg_68ca61ecbbe881909418900fa3ecd3e7000638fabbdbe241","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"REDACTED"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":"REDACTED","reasoning":{"effort":"minimal","summary":"detailed"},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"explanation":{"type":"string","description":"REDACTED"},"query":{"type":"string","description":"REDACTED"},"target_directories":{"type":"array","items":{"type":"string"},"description":"REDACTED"},"search_only_prs":{"type":"boolean","description":"REDACTED"}},"required":["explanation","query","target_directories"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"command":{"type":"string","description":"REDACTED"},"is_background":{"type":"boolean","description":"REDACTED"},"explanation":{"type":"string","description":"REDACTED"}},"required":["command","is_background"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"pattern":{"type":"string","description":"REDACTED"},"path":{"type":"string","description":"REDACTED"},"glob":{"type":"string","description":"REDACTED"},"output_mode":{"type":"string","description":"REDACTED","enum":["content","files_with_matches","count"]},"-B":{"type":"number","description":"REDACTED"},"-A":{"type":"number","description":"REDACTED"},"-C":{"type":"number","description":"REDACTED"},"-i":{"type":"boolean","description":"REDACTED"},"type":{"type":"string","description":"REDACTED"},"head_limit":{"type":"number","description":"REDACTED"},"multiline":{"type":"boolean","description":"REDACTED"}},"required":["pattern"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"explanation":{"type":"string","description":"REDACTED"}},"required":["target_file"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"paths":{"description":"REDACTED","type":"array","items":{"type":"string"}}},"required":[]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_notebook":{"type":"string","description":"REDACTED"},"cell_idx":{"type":"number","description":"REDACTED"},"is_new_cell":{"type":"boolean","description":"REDACTED"},"cell_language":{"type":"string","description":"REDACTED"},"old_string":{"type":"string","description":"REDACTED"},"new_string":{"type":"string","description":"REDACTED"}},"required":["target_notebook","cell_idx","is_new_cell","cell_language","old_string","new_string"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"merge":{"type":"boolean","description":"REDACTED"},"todos":{"type":"array","description":"REDACTED","items":{"type":"object","properties":{"content":{"type":"string","description":"REDACTED"},"status":{"type":"string","enum":["pending","in_progress","completed","cancelled"],"description":"REDACTED"},"id":{"type":"string","description":"REDACTED"}},"required":["content","status","id"]},"minItems":2}},"required":["merge","todos"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"instructions":{"type":"string","description":"REDACTED"},"code_edit":{"type":"string","description":"REDACTED"}},"required":["target_file","instructions","code_edit"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_file":{"type":"string","description":"REDACTED"},"offset":{"type":"integer","description":"REDACTED"},"limit":{"type":"integer","description":"REDACTED"}},"required":["target_file"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_directory":{"type":"string","description":"REDACTED"},"ignore_globs":{"type":"array","items":{"type":"string"},"description":"REDACTED"}},"required":["target_directory"]},"strict":false},{"type":"function","description":"REDACTED","name":"REDACTED","parameters":{"type":"object","properties":{"target_directory":{"type":"string","description":"REDACTED"},"glob_pattern":{"type":"string","description":"REDACTED"}},"required":["glob_pattern"]},"strict":false}],"top_p":1.0,"truncation":"auto","usage":{"input_tokens":8510,"input_tokens_details":{"cached_tokens":0},"output_tokens":7,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":8517},"user":null,"metadata":{}}} \ No newline at end of file diff --git a/tests/replay_base.py b/tests/replay_base.py index 0d6383e..ba3f221 100644 --- a/tests/replay_base.py +++ b/tests/replay_base.py @@ -101,8 +101,13 @@ class ReplyBase: def assert_upstream_request(self, mock: Any) -> None: """Assert upstream request matches the recorded upstream request.""" - expected_upstream_request_json = json.loads(self.expected_upstream_request_body) - assert mock.last_request.json() == expected_upstream_request_json + if self.expected_upstream_request_body: + expected_upstream_request_json = json.loads( + self.expected_upstream_request_body + ) + assert mock.last_request.json() == expected_upstream_request_json + else: + assert mock.last_request is None def assert_downstream_response(self, response) -> None: """Assert downstream response matches the recorded downstream response.""" diff --git a/tests/test_azure_errors.py b/tests/test_azure_errors.py index f3f45c4..8811f07 100644 --- a/tests/test_azure_errors.py +++ b/tests/test_azure_errors.py @@ -21,7 +21,7 @@ Check "azure_response" for the error details: \t "foo": "bar" \t }, \t "request_body": { -\t "instructions": "REDACTE...", +\t "instructions": "REDACTED...", \t "input": "...redacted 2 input items...", \t "model": "gpt-5", \t "tools": "...redacted 11 tools...", @@ -59,7 +59,7 @@ Check "azure_response" for the error details: \t "error": "Bad API Key or whatever" \t }, \t "request_body": { -\t "instructions": "REDACTE...", +\t "instructions": "REDACTED...", \t "input": "...redacted 2 input items...", \t "model": "gpt-5", \t "tools": "...redacted 11 tools...", @@ -95,7 +95,7 @@ Check "azure_response" for the error details: \t "azure_status_code": 500, \t "azure_response": "Internal Server Error", \t "request_body": { -\t "instructions": "REDACTE...", +\t "instructions": "REDACTED...", \t "input": "...redacted 2 input items...", \t "model": "gpt-5", \t "tools": "...redacted 11 tools...", diff --git a/tests/test_configuration_errors.py b/tests/test_configuration_errors.py new file mode 100644 index 0000000..57579d8 --- /dev/null +++ b/tests/test_configuration_errors.py @@ -0,0 +1,39 @@ +"""Functional tests using WebTest. + +See: http://webtest.readthedocs.org/ +""" + +from .replay_base import ReplyBase + + +class TestBadSummaryLevel(ReplyBase): + """Test a single ping-pong interaction, no tool calls.""" + + expected_upstream_request_body = None + expected_downstream_status_code = 400 + expected_downstream_response_body = b"""Service configuration error, check your .env file. + +\tAZURE_SUMMARY_LEVEL must be either auto, detailed, or concise. +\t +\tGot: foo""" + + def modify_settings(self, app) -> None: + """Set invalid summary level in settings.""" + app.config["AZURE_SUMMARY_LEVEL"] = "foo" + + +class TestBadModelName(ReplyBase): + """Test a single ping-pong interaction, no tool calls.""" + + expected_upstream_request_body = None + expected_downstream_status_code = 400 + expected_downstream_response_body = b"""Cursor configuration error, check your Cursor settings. + +\tModel name must be either gpt-high, gpt-medium, gpt-low, or gpt-minimal. +\t +\tGot: foo-minimal""" + + @property + def downstream_request_body(self) -> str: + """Set invalid model name in request body.""" + return super().downstream_request_body.replace("gpt-", "foo-") diff --git a/tests/test_irregular_sse.py b/tests/test_irregular_sse.py new file mode 100644 index 0000000..9535b97 --- /dev/null +++ b/tests/test_irregular_sse.py @@ -0,0 +1,12 @@ +"""Functional tests using WebTest. + +See: http://webtest.readthedocs.org/ +""" + +from .replay_base import ReplyBase + + +class TestSSEWithoutClosingNewLines(ReplyBase): + """Test the replay of an SSE response without closing new lines.""" + + recording = "sse_without_closing_new_lines" diff --git a/tests/test_logging.py b/tests/test_logging.py index 371609b..e4b7d0f 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -15,11 +15,12 @@ class TestModelInvalidJson(ReplyBase): def test(self, testapp, requests_mock, mocker): """Test logging of context containing an invalid JSON tool call.""" - console_print = mocker.patch( - "rich.console.Console.print", return_value=mocker.Mock() - ) + mocker.patch("rich.console.Console.print") + console_print = mocker.patch("rich.padding.Padding.__init__", return_value=None) super().test(testapp, requests_mock) - console_print.assert_any_call("[red]Invalid JSON generated by the model:[/red]") + console_print.assert_any_call( + "[red]Invalid JSON generated by the model:[/red]", (0, 4) + ) def test_redact_headers(): diff --git a/tests/test_models.py b/tests/test_models.py index bdcd1aa..4682fa9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,11 +8,11 @@ class TestModels: """Models.""" def test_models_endpoint_returns_400(self, testapp): - """Ensure /models endpoint returns HTTP 400.""" + """Ensure /models endpoint returns HTTP 400 wihtout auth.""" testapp.get("/models", status=400) def test_models_endpoint_returns_200(self, testapp): - """Ensure /models endpoint returns HTTP 400.""" + """Ensure /models endpoint returns HTTP 400 with auth.""" response = testapp.get( "/models", status=200, @@ -25,5 +25,5 @@ class TestModels: assert '"gpt-minimal"' in content def test_health_endpoint_returns_200(self, testapp): - """Ensure /health endpoint returns HTTP 200.""" + """Ensure /health endpoint returns HTTP 200 without auth.""" testapp.get("/health", status=200) diff --git a/tests/test_recording.py b/tests/test_recording.py index a29ce61..32578c8 100644 --- a/tests/test_recording.py +++ b/tests/test_recording.py @@ -71,6 +71,9 @@ class TestRecording(ReplyBase): # Unrelated folder os.makedirs(os.path.join(tmp_path, "foo")) + # Smaller index than last recording index + os.makedirs(os.path.join(tmp_path, "-10")) + super().test(testapp, requests_mock) assert os.path.exists(os.path.join(tmp_path, "124"))