diff --git a/.ccproxy.codex.msaf.toml.example b/.ccproxy.codex.msaf.toml.example
new file mode 100644
index 00000000..2e890709
--- /dev/null
+++ b/.ccproxy.codex.msaf.toml.example
@@ -0,0 +1,64 @@
+# Example ccproxy config for Microsoft Agent Framework clients over Codex.
+
+enable_plugins = true
+enabled_plugins = ["oauth_codex", "codex"]
+
+[server]
+bypass_mode = false
+
+[llm]
+# Keep OpenAI-compatible responses free from ... blocks.
+openai_thinking_xml = false
+
+[plugins.codex]
+enabled = true
+name = "codex"
+base_url = "https://chatgpt.com/backend-api/codex"
+requires_auth = true
+auth_type = "oauth"
+supports_streaming = true
+preferred_upstream_mode = "streaming"
+buffer_non_streaming = true
+enable_format_registry = true
+
+# Microsoft Agent Framework sends its own instructions/reasoning payloads.
+# Do not prepend captured Codex CLI templates to generic OpenAI-compatible calls.
+inject_detection_payload = false
+
+supported_input_formats = [
+ "openai.responses",
+ "openai.chat_completions",
+ "anthropic.messages",
+]
+
+detection_home_mode = "temp"
+
+[[plugins.codex.models_endpoint]]
+id = "gpt-5.4"
+object = "model"
+created = 1735689600
+owned_by = "openai"
+root = "gpt-5.4"
+permission = []
+
+[plugins.codex.oauth]
+base_url = "https://auth.openai.com"
+client_id = "app_EMoamEEZ73f0CkXaXp7hrann"
+scopes = ["openid", "profile", "email", "offline_access"]
+
+[plugins.oauth_codex]
+enabled = true
+base_url = "https://auth.openai.com"
+authorize_url = "https://auth.openai.com/oauth/authorize"
+token_url = "https://auth.openai.com/oauth/token"
+profile_url = "https://api.openai.com/oauth/profile"
+client_id = "app_EMoamEEZ73f0CkXaXp7hrann"
+redirect_uri = "http://localhost:1455/auth/callback"
+callback_port = 1455
+scopes = ["openid", "profile", "email", "offline_access"]
+audience = "https://api.openai.com/v1"
+user_agent = "Codex-Code/1.0.43"
+headers = { User-Agent = "Codex-Code/1.0.43" }
+request_timeout = 30
+callback_timeout = 300
+use_pkce = true
diff --git a/ccproxy/core/plugins/factories.py b/ccproxy/core/plugins/factories.py
index d73f0e5b..35476d25 100644
--- a/ccproxy/core/plugins/factories.py
+++ b/ccproxy/core/plugins/factories.py
@@ -14,6 +14,7 @@
from ccproxy.models.provider import ProviderConfig
from ccproxy.services.adapters.base import BaseAdapter
from ccproxy.services.adapters.http_adapter import BaseHTTPAdapter
+from ccproxy.services.adapters.mock_adapter import MockAdapter
from ccproxy.services.interfaces import (
IMetricsCollector,
IRequestTracer,
@@ -215,6 +216,23 @@ async def create_adapter(self, context: PluginContext) -> BaseAdapter:
Returns:
Adapter instance
"""
+ settings = context.get("settings")
+ service_container = context.get("service_container")
+ if settings and getattr(settings.server, "bypass_mode", False):
+ if not service_container:
+ raise RuntimeError(
+ f"Cannot initialize plugin '{self.plugin_name}' in bypass mode: "
+ "service container is required to create mock adapter. "
+ "This is likely a configuration issue."
+ )
+ logger.warning(
+ "plugin_bypass_mode_enabled",
+ plugin=self.plugin_name,
+ adapter=self.adapter_class.__name__,
+ category="lifecycle",
+ )
+ return MockAdapter(service_container.get_mock_handler())
+
# Extract services from context (one-time extraction)
http_pool_manager: HTTPPoolManager | None = cast(
"HTTPPoolManager | None", context.get("http_pool_manager")
@@ -232,7 +250,6 @@ async def create_adapter(self, context: PluginContext) -> BaseAdapter:
config = context.get("config")
# Get all adapter dependencies from service container
- service_container = context.get("service_container")
if not service_container:
raise RuntimeError("Service container is required for adapter services")
diff --git a/ccproxy/llms/formatters/context.py b/ccproxy/llms/formatters/context.py
index 854505c6..5086fcce 100644
--- a/ccproxy/llms/formatters/context.py
+++ b/ccproxy/llms/formatters/context.py
@@ -11,6 +11,9 @@
"formatter_instructions", default=None
)
_TOOLS_VAR: ContextVar[list[Any] | None] = ContextVar("formatter_tools", default=None)
+_OPENAI_THINKING_XML_VAR: ContextVar[bool | None] = ContextVar(
+ "formatter_openai_thinking_xml", default=None
+)
def register_request(request: Any | None, instructions: str | None = None) -> None:
@@ -114,3 +117,24 @@ def get_last_request_tools() -> list[Any] | None:
cached = _TOOLS_VAR.get()
return list(cached) if cached else None
+
+
+def register_openai_thinking_xml(enabled: bool | None) -> None:
+ """Cache OpenAI thinking serialization preference for active conversions.
+
+ Args:
+ enabled: Whether thinking blocks should be serialized with XML wrappers.
+ ``None`` means downstream conversion logic should use its default.
+
+ Note:
+ The value is stored in a ``ContextVar``, so concurrent async requests
+ keep independent preferences without leaking into each other.
+ """
+
+ _OPENAI_THINKING_XML_VAR.set(enabled)
+
+
+def get_openai_thinking_xml() -> bool | None:
+ """Return the OpenAI thinking serialization preference for active conversions."""
+
+ return _OPENAI_THINKING_XML_VAR.get()
diff --git a/ccproxy/llms/formatters/openai_to_openai/responses.py b/ccproxy/llms/formatters/openai_to_openai/responses.py
index 7283c52c..d8ccd254 100644
--- a/ccproxy/llms/formatters/openai_to_openai/responses.py
+++ b/ccproxy/llms/formatters/openai_to_openai/responses.py
@@ -15,6 +15,7 @@
convert_openai_responses_usage_to_completion_usage,
merge_thinking_segments,
)
+from ccproxy.llms.formatters.context import get_openai_thinking_xml
from ccproxy.llms.models import openai as openai_models
from ._helpers import (
@@ -333,6 +334,10 @@ def convert__openai_responses_to_openai_chat__response(
response: openai_models.ResponseObject,
) -> openai_models.ChatCompletionResponse:
"""Convert an OpenAI ResponseObject to a ChatCompletionResponse."""
+ include_thinking = get_openai_thinking_xml()
+ if include_thinking is None:
+ include_thinking = True
+
text_segments: list[str] = []
added_reasoning: set[tuple[str, str]] = set()
tool_calls: list[openai_models.ToolCall] = []
@@ -353,7 +358,7 @@ def convert__openai_responses_to_openai_chat__response(
if thinking_text and len(thinking_text) > 30
else thinking_text,
)
- if thinking_text:
+ if include_thinking and thinking_text:
key = (signature or "", thinking_text)
if key not in added_reasoning:
text_segments.append(_wrap_thinking(signature, thinking_text))
diff --git a/ccproxy/llms/formatters/openai_to_openai/streams.py b/ccproxy/llms/formatters/openai_to_openai/streams.py
index b41187f9..8a50ee2d 100644
--- a/ccproxy/llms/formatters/openai_to_openai/streams.py
+++ b/ccproxy/llms/formatters/openai_to_openai/streams.py
@@ -27,16 +27,14 @@
get_last_instructions,
get_last_request,
get_last_request_tools,
+ get_openai_thinking_xml,
register_request,
register_request_tools,
)
from ccproxy.llms.models import openai as openai_models
from ccproxy.llms.streaming.accumulators import OpenAIAccumulator
-from ._helpers import (
- _convert_tools_chat_to_responses,
- _get_attr,
-)
+from ._helpers import _convert_tools_chat_to_responses, _get_attr
from .requests import _build_responses_payload_from_chat_request
from .responses import (
_collect_reasoning_segments,
@@ -61,6 +59,10 @@ def run(
async def generator() -> AsyncGenerator[
openai_models.ChatCompletionChunk, None
]:
+ include_thinking = get_openai_thinking_xml()
+ if include_thinking is None:
+ include_thinking = True
+
model_id = ""
role_sent = False
@@ -537,7 +539,7 @@ def create_text_chunk(
for entry in summary_list:
text = _get_attr(entry, "text")
signature = _get_attr(entry, "signature")
- if isinstance(text, str) and text:
+ if include_thinking and isinstance(text, str) and text:
chunk_text = _wrap_thinking(signature, text)
sequence_counter += 1
yield openai_models.ChatCompletionChunk(
diff --git a/ccproxy/plugins/codex/adapter.py b/ccproxy/plugins/codex/adapter.py
index 27ca606e..7c3af71f 100644
--- a/ccproxy/plugins/codex/adapter.py
+++ b/ccproxy/plugins/codex/adapter.py
@@ -262,26 +262,41 @@ async def prepare_provider_request(
# Parse body (format conversion is now handled by format chain)
body_data = json.loads(body.decode()) if body else {}
- body_data = self._apply_request_template(body_data)
+ if self._should_apply_detection_payload():
+ body_data = self._apply_request_template(body_data)
+ else:
+ body_data = self._normalize_input_messages(body_data)
- # Fetch detected instructions from detection service
- instructions = self._get_instructions()
+ detected_instructions = (
+ self._get_instructions() if self._should_apply_detection_payload() else ""
+ )
existing_instructions = body_data.get("instructions")
if isinstance(existing_instructions, str) and existing_instructions:
- if instructions:
- instructions = instructions + "\n" + existing_instructions
- else:
- instructions = existing_instructions
+ instructions = (
+ detected_instructions + "\n" + existing_instructions
+ if detected_instructions
+ else existing_instructions
+ )
+ else:
+ instructions = detected_instructions
- body_data["instructions"] = instructions
+ if instructions:
+ body_data["instructions"] = instructions
+ else:
+ body_data.pop("instructions", None)
# Codex backend requires stream=true, always override
body_data["stream"] = True
body_data["store"] = False
# Remove unsupported keys for Codex
- for key in ("max_output_tokens", "max_completion_tokens", "temperature"):
+ for key in (
+ "max_output_tokens",
+ "max_completion_tokens",
+ "max_tokens",
+ "temperature",
+ ):
body_data.pop(key, None)
list_input = body_data.get("input", [])
@@ -640,6 +655,9 @@ def _request_body_is_encoded(self, headers: dict[str, str]) -> bool:
encoding = headers.get("content-encoding", "").strip().lower()
return bool(encoding and encoding != "identity")
+ def _should_apply_detection_payload(self) -> bool:
+ return bool(getattr(self.config, "inject_detection_payload", True))
+
def _detect_streaming_intent(self, body: bytes, headers: dict[str, str]) -> bool:
if self._request_body_is_encoded(headers):
accept = headers.get("accept", "").lower()
diff --git a/ccproxy/plugins/codex/config.py b/ccproxy/plugins/codex/config.py
index df2af6c0..6d9d71b5 100644
--- a/ccproxy/plugins/codex/config.py
+++ b/ccproxy/plugins/codex/config.py
@@ -124,6 +124,13 @@ class CodexSettings(ProviderConfig):
enable_format_registry: bool = Field(
default=True, description="Whether to enable format adapter registry"
)
+ inject_detection_payload: bool = Field(
+ default=True,
+ description=(
+ "Whether to inject the captured Codex CLI instructions/template into "
+ "provider requests. Disable this for generic OpenAI-compatible API usage."
+ ),
+ )
# Detection configuration
detection_home_mode: Literal["temp", "home"] = Field(
diff --git a/ccproxy/services/adapters/format_adapter.py b/ccproxy/services/adapters/format_adapter.py
index a0af27e0..9ffdb7e6 100644
--- a/ccproxy/services/adapters/format_adapter.py
+++ b/ccproxy/services/adapters/format_adapter.py
@@ -6,6 +6,8 @@
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any, Protocol, runtime_checkable
+from ccproxy.llms.formatters.context import register_openai_thinking_xml
+
FormatDict = dict[str, Any]
@@ -63,6 +65,10 @@ def __init__(
self._error = error
self._stream = stream
self.name = name or self.__class__.__name__
+ self._openai_thinking_xml: bool | None = None
+
+ def configure_streaming(self, *, openai_thinking_xml: bool | None = None) -> None:
+ self._openai_thinking_xml = openai_thinking_xml
async def convert_request(self, data: FormatDict) -> FormatDict:
return await self._run_stage(self._request, data, stage="request")
@@ -92,6 +98,7 @@ async def _create_stream_iterator(
f"{self.name} does not implement stream conversion"
)
+ register_openai_thinking_xml(self._openai_thinking_xml)
handler = self._stream(stream)
handler = await _maybe_await(handler)
@@ -121,6 +128,7 @@ async def _run_stage(
f"{self.name} does not implement {stage} conversion"
)
+ register_openai_thinking_xml(self._openai_thinking_xml)
result = await _maybe_await(func(data))
if not isinstance(result, dict):
raise TypeError(
diff --git a/ccproxy/services/adapters/mock_adapter.py b/ccproxy/services/adapters/mock_adapter.py
index f335551a..8e2f5a73 100644
--- a/ccproxy/services/adapters/mock_adapter.py
+++ b/ccproxy/services/adapters/mock_adapter.py
@@ -10,6 +10,11 @@
from starlette.responses import StreamingResponse
from ccproxy.core import logging
+from ccproxy.core.constants import (
+ FORMAT_ANTHROPIC_MESSAGES,
+ FORMAT_OPENAI_CHAT,
+ FORMAT_OPENAI_RESPONSES,
+)
from ccproxy.core.request_context import RequestContext
from ccproxy.services.adapters.base import BaseAdapter
from ccproxy.services.mocking.mock_handler import MockResponseHandler
@@ -25,6 +30,44 @@ class MockAdapter(BaseAdapter):
def __init__(self, mock_handler: MockResponseHandler) -> None:
self.mock_handler = mock_handler
+ async def cleanup(self) -> None:
+ """Release adapter resources."""
+ return None
+
+ def _detect_format_from_endpoint(self, endpoint: str | None) -> str | None:
+ """Map known route patterns to the expected output format."""
+
+ if not endpoint:
+ return None
+
+ endpoint_lower = endpoint.lower()
+ if "chat/completions" in endpoint_lower:
+ return FORMAT_OPENAI_CHAT
+ if "responses" in endpoint_lower:
+ return FORMAT_OPENAI_RESPONSES
+ return None
+
+ def _resolve_target_format(self, request: Request, endpoint: str) -> str:
+ """Infer the response format expected by the current route."""
+
+ ctx = getattr(request.state, "context", None)
+ format_chain = getattr(ctx, "format_chain", None)
+ if isinstance(format_chain, list) and format_chain:
+ first: str = format_chain[0]
+ if first in {
+ FORMAT_OPENAI_CHAT,
+ FORMAT_OPENAI_RESPONSES,
+ FORMAT_ANTHROPIC_MESSAGES,
+ }:
+ return first
+
+ for candidate in (endpoint, getattr(request.url, "path", None)):
+ detected_format = self._detect_format_from_endpoint(candidate)
+ if detected_format:
+ return detected_format
+
+ return FORMAT_ANTHROPIC_MESSAGES
+
def _extract_stream_flag(self, body: bytes) -> bool:
"""Check if request asks for streaming."""
try:
@@ -37,7 +80,6 @@ def _extract_stream_flag(self, body: bytes) -> bool:
pass
except Exception as e:
logger.debug("stream_flag_extraction_error", error=str(e))
- pass
return False
async def handle_request(
@@ -46,6 +88,7 @@ async def handle_request(
"""Handle request using mock handler."""
body = await request.body()
message_type = self.mock_handler.extract_message_type(body)
+ prompt_text = self.mock_handler.extract_prompt_text(body)
# Get endpoint from context or request URL
endpoint = request.url.path
@@ -53,7 +96,7 @@ async def handle_request(
ctx = request.state.context
endpoint = ctx.metadata.get("endpoint", request.url.path)
- is_openai = "openai" in endpoint
+ target_format = self._resolve_target_format(request, endpoint)
model = "unknown"
try:
body_json = json.loads(body) if body else {}
@@ -63,8 +106,7 @@ async def handle_request(
except UnicodeDecodeError:
pass
except Exception as e:
- logger.debug("stream_flag_extraction_error", error=str(e))
- pass
+ logger.debug("model_extraction_error", error=str(e), endpoint=endpoint)
# Create request context
ctx = RequestContext(
@@ -75,7 +117,7 @@ async def handle_request(
if self._extract_stream_flag(body):
return await self.mock_handler.generate_streaming_response(
- model, is_openai, ctx, message_type
+ model, target_format, ctx, message_type, prompt_text
)
else:
(
@@ -83,7 +125,7 @@ async def handle_request(
headers,
response_body,
) = await self.mock_handler.generate_standard_response(
- model, is_openai, ctx, message_type
+ model, target_format, ctx, message_type, prompt_text
)
return Response(content=response_body, status_code=status, headers=headers)
@@ -93,7 +135,8 @@ async def handle_streaming(
"""Handle a streaming request."""
body = await request.body()
message_type = self.mock_handler.extract_message_type(body)
- is_openai = "openai" in endpoint
+ prompt_text = self.mock_handler.extract_prompt_text(body)
+ target_format = self._resolve_target_format(request, endpoint)
model = "unknown"
try:
body_json = json.loads(body) if body else {}
@@ -104,7 +147,6 @@ async def handle_streaming(
pass
except Exception as e:
logger.debug("stream_flag_extraction_error", error=str(e))
- pass
# Create request context
ctx = RequestContext(
@@ -114,5 +156,5 @@ async def handle_streaming(
)
return await self.mock_handler.generate_streaming_response(
- model, is_openai, ctx, message_type
+ model, target_format, ctx, message_type, prompt_text
)
diff --git a/ccproxy/services/factories.py b/ccproxy/services/factories.py
index ddf079b2..a2923d5e 100644
--- a/ccproxy/services/factories.py
+++ b/ccproxy/services/factories.py
@@ -23,6 +23,7 @@
from ccproxy.services.adapters.format_registry import FormatRegistry
from ccproxy.services.adapters.simple_converters import (
convert_anthropic_to_openai_response,
+ convert_anthropic_to_openai_responses_response,
)
from ccproxy.services.auth_registry import AuthManagerRegistry
from ccproxy.services.cache import ResponseCache
@@ -127,16 +128,25 @@ def create_mock_handler(self) -> MockResponseHandler:
response=convert_anthropic_to_openai_response,
name="mock_anthropic_to_openai",
)
+ openai_responses_adapter = DictFormatAdapter(
+ response=convert_anthropic_to_openai_responses_response,
+ name="mock_anthropic_to_openai_responses",
+ )
# Configure streaming settings if needed
openai_thinking_xml = getattr(
getattr(settings, "llm", object()), "openai_thinking_xml", True
)
if hasattr(openai_adapter, "configure_streaming"):
openai_adapter.configure_streaming(openai_thinking_xml=openai_thinking_xml)
+ if hasattr(openai_responses_adapter, "configure_streaming"):
+ openai_responses_adapter.configure_streaming(
+ openai_thinking_xml=openai_thinking_xml
+ )
handler = MockResponseHandler(
mock_generator=mock_generator,
openai_adapter=openai_adapter,
+ openai_responses_adapter=openai_responses_adapter,
error_rate=0.05,
latency_range=(0.5, 2.0),
)
@@ -342,6 +352,12 @@ def _register_core_format_adapters(
]
# Register each core adapter
+ openai_thinking_xml = True
+ if settings is not None:
+ openai_thinking_xml = getattr(
+ getattr(settings, "llm", object()), "openai_thinking_xml", True
+ )
+
for spec in core_adapter_specs:
adapter = DictFormatAdapter(
request=spec["request"],
@@ -350,6 +366,8 @@ def _register_core_format_adapters(
error=spec["error"],
name=spec["name"],
)
+ if hasattr(adapter, "configure_streaming"):
+ adapter.configure_streaming(openai_thinking_xml=openai_thinking_xml)
registry.register(
from_format=spec["from_format"],
to_format=spec["to_format"],
diff --git a/ccproxy/services/mocking/mock_handler.py b/ccproxy/services/mocking/mock_handler.py
index 8cdd1afb..8675242e 100644
--- a/ccproxy/services/mocking/mock_handler.py
+++ b/ccproxy/services/mocking/mock_handler.py
@@ -4,21 +4,33 @@
import json
import random
from collections.abc import AsyncGenerator
-from typing import Any
+from time import time
+from typing import Any, TypeAlias
import structlog
from fastapi.responses import StreamingResponse
+from ccproxy.core.constants import (
+ FORMAT_OPENAI_CHAT,
+ FORMAT_OPENAI_RESPONSES,
+)
from ccproxy.core.request_context import RequestContext
from ccproxy.services.adapters.format_adapter import DictFormatAdapter
from ccproxy.services.adapters.simple_converters import (
convert_anthropic_to_openai_response,
+ convert_anthropic_to_openai_responses_response,
)
from ccproxy.testing import RealisticMockResponseGenerator
logger = structlog.get_logger(__name__)
+PROMPT_EXTRACTION_KEYS = ("instructions", "content", "text", "input", "messages")
+MAX_PROMPT_EXTRACTION_DEPTH = 10
+
+TargetFormat: TypeAlias = str
+PromptValue: TypeAlias = str | list[Any] | dict[str, Any] | int | float | bool | None
+
class MockResponseHandler:
"""Handles bypass mode with realistic mock responses."""
@@ -27,6 +39,7 @@ def __init__(
self,
mock_generator: RealisticMockResponseGenerator,
openai_adapter: DictFormatAdapter | None = None,
+ openai_responses_adapter: DictFormatAdapter | None = None,
error_rate: float = 0.05,
latency_range: tuple[float, float] = (0.5, 2.0),
) -> None:
@@ -37,14 +50,32 @@ def __init__(
"""
self.mock_generator = mock_generator
if openai_adapter is None:
- openai_adapter = DictFormatAdapter(
- response=convert_anthropic_to_openai_response,
- name="mock_anthropic_to_openai",
- )
+ openai_adapter = self._create_openai_adapter()
self.openai_adapter = openai_adapter
+ if openai_responses_adapter is None:
+ openai_responses_adapter = self._create_openai_responses_adapter()
+ self.openai_responses_adapter = openai_responses_adapter
self.error_rate = error_rate
self.latency_range = latency_range
+ @staticmethod
+ def _create_openai_adapter() -> DictFormatAdapter:
+ """Create the adapter used for Anthropic -> OpenAI chat mocks."""
+
+ return DictFormatAdapter(
+ response=convert_anthropic_to_openai_response,
+ name="mock_anthropic_to_openai",
+ )
+
+ @staticmethod
+ def _create_openai_responses_adapter() -> DictFormatAdapter:
+ """Create the adapter used for Anthropic -> OpenAI responses mocks."""
+
+ return DictFormatAdapter(
+ response=convert_anthropic_to_openai_responses_response,
+ name="mock_anthropic_to_openai_responses",
+ )
+
def extract_message_type(self, body: bytes | None) -> str:
"""Analyze request body to determine response type.
@@ -89,12 +120,75 @@ def should_simulate_error(self) -> bool:
"""
return random.random() < self.error_rate
+ def extract_prompt_text(self, body: bytes | None) -> str:
+ """Extract a plain-text prompt summary from common request shapes."""
+
+ if not body:
+ return ""
+
+ try:
+ data: PromptValue = json.loads(body)
+ except (json.JSONDecodeError, TypeError):
+ return ""
+
+ parts: list[str] = []
+
+ seen: set[int] = set()
+
+ def collect(value: PromptValue, depth: int = 0) -> None:
+ if depth > MAX_PROMPT_EXTRACTION_DEPTH:
+ logger.debug(
+ "prompt_extraction_max_depth_reached",
+ depth=depth,
+ max_depth=MAX_PROMPT_EXTRACTION_DEPTH,
+ )
+ return
+
+ if isinstance(value, str):
+ text = value.strip()
+ if text:
+ parts.append(text)
+ return
+
+ if isinstance(value, list):
+ value_id = id(value)
+ if value_id in seen:
+ return
+ seen.add(value_id)
+ for item in value:
+ collect(item, depth + 1)
+ return
+
+ if not isinstance(value, dict):
+ return
+
+ value_id = id(value)
+ if value_id in seen:
+ return
+ seen.add(value_id)
+
+ for key in PROMPT_EXTRACTION_KEYS:
+ if key not in value:
+ continue
+ try:
+ collect(value[key], depth + 1)
+ except (KeyError, TypeError, AttributeError) as exc:
+ logger.debug(
+ "prompt_extraction_value_skipped",
+ key=key,
+ error=str(exc),
+ )
+
+ collect(data)
+ return "\n".join(parts)
+
async def generate_standard_response(
self,
model: str | None,
- is_openai_format: bool,
+ target_format: TargetFormat,
ctx: RequestContext,
message_type: str = "short",
+ prompt_text: str = "",
) -> tuple[int, dict[str, str], bytes]:
"""Generate non-streaming mock response.
@@ -109,10 +203,9 @@ async def generate_standard_response(
# Check if we should simulate an error
if self.should_simulate_error():
- error_response = self._generate_error_response(is_openai_format)
+ error_response = self._generate_error_response(target_format)
return 429, {"content-type": "application/json"}, error_response
- # Generate mock response based on type
if message_type == "tool_use":
mock_response = self.mock_generator.generate_tool_use_response(model=model)
elif message_type == "long":
@@ -123,9 +216,12 @@ async def generate_standard_response(
mock_response = self.mock_generator.generate_short_response(model=model)
# Convert to OpenAI format if needed
- if is_openai_format and message_type != "tool_use":
- # Use dict-based conversion
+ if target_format == FORMAT_OPENAI_CHAT and message_type != "tool_use":
mock_response = await self.openai_adapter.convert_response(mock_response)
+ elif target_format == FORMAT_OPENAI_RESPONSES:
+ mock_response = await self.openai_responses_adapter.convert_response(
+ mock_response
+ )
# Update context with metrics
if ctx:
@@ -142,9 +238,10 @@ async def generate_standard_response(
async def generate_streaming_response(
self,
model: str | None,
- is_openai_format: bool,
+ target_format: TargetFormat,
ctx: RequestContext,
message_type: str = "short",
+ prompt_text: str = "",
) -> StreamingResponse:
"""Generate SSE streaming mock response.
@@ -174,8 +271,14 @@ async def stream_generator() -> AsyncGenerator[bytes, None]:
words = text_content.split()
chunk_size = 3 # Words per chunk
+ response_id = f"resp_{ctx.request_id if ctx else 'mock'}"
+ msg_id = f"msg_{ctx.request_id if ctx else 'mock'}"
+ used_model = model or "claude-3-opus-20240229"
+ created_at = int(time())
+ sequence_number = 0
+
# Send initial event
- if is_openai_format:
+ if target_format == FORMAT_OPENAI_CHAT:
initial_event = {
"id": f"chatcmpl-{ctx.request_id if ctx else 'mock'}",
"object": "chat.completion.chunk",
@@ -190,14 +293,54 @@ async def stream_generator() -> AsyncGenerator[bytes, None]:
],
}
yield f"data: {json.dumps(initial_event)}\n\n".encode()
+ elif target_format == FORMAT_OPENAI_RESPONSES:
+ created_event = {
+ "type": "response.created",
+ "sequence_number": sequence_number,
+ "response": {
+ "id": response_id,
+ "object": "response",
+ "created_at": created_at,
+ "status": "in_progress",
+ "model": used_model,
+ "output": [],
+ "parallel_tool_calls": False,
+ },
+ }
+ yield f"data: {json.dumps(created_event)}\n\n".encode()
+ sequence_number += 1
+ item_added_event = {
+ "type": "response.output_item.added",
+ "sequence_number": sequence_number,
+ "output_index": 0,
+ "item": {
+ "type": "message",
+ "id": msg_id,
+ "status": "in_progress",
+ "role": "assistant",
+ "content": [],
+ },
+ }
+ yield f"data: {json.dumps(item_added_event)}\n\n".encode()
+ sequence_number += 1
+ part_added_event = {
+ "type": "response.content_part.added",
+ "sequence_number": sequence_number,
+ "item_id": msg_id,
+ "output_index": 0,
+ "content_index": 0,
+ "part": {"type": "output_text", "text": ""},
+ }
+ yield f"data: {json.dumps(part_added_event)}\n\n".encode()
+ sequence_number += 1
else:
initial_event = {
"type": "message_start",
"message": {
- "id": f"msg_{ctx.request_id if ctx else 'mock'}",
+ "id": msg_id,
"type": "message",
"role": "assistant",
- "model": model or "claude-3-opus-20240229",
+ "model": used_model,
"content": [],
"usage": {"input_tokens": 10, "output_tokens": 0},
},
@@ -213,7 +356,7 @@ async def stream_generator() -> AsyncGenerator[bytes, None]:
await asyncio.sleep(0.05) # Simulate token generation delay
- if is_openai_format:
+ if target_format == FORMAT_OPENAI_CHAT:
chunk_event = {
"id": f"chatcmpl-{ctx.request_id if ctx else 'mock'}",
"object": "chat.completion.chunk",
@@ -227,6 +370,16 @@ async def stream_generator() -> AsyncGenerator[bytes, None]:
}
],
}
+ elif target_format == FORMAT_OPENAI_RESPONSES:
+ chunk_event = {
+ "type": "response.output_text.delta",
+ "sequence_number": sequence_number,
+ "item_id": msg_id,
+ "output_index": 0,
+ "content_index": 0,
+ "delta": chunk_text,
+ }
+ sequence_number += 1
else:
chunk_event = {
"type": "content_block_delta",
@@ -237,7 +390,7 @@ async def stream_generator() -> AsyncGenerator[bytes, None]:
yield f"data: {json.dumps(chunk_event)}\n\n".encode()
# Send final event
- if is_openai_format:
+ if target_format == FORMAT_OPENAI_CHAT:
final_event = {
"id": f"chatcmpl-{ctx.request_id if ctx else 'mock'}",
"object": "chat.completion.chunk",
@@ -246,6 +399,62 @@ async def stream_generator() -> AsyncGenerator[bytes, None]:
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(final_event)}\n\n".encode()
+ elif target_format == FORMAT_OPENAI_RESPONSES:
+ output_tokens = len(text_content.split())
+ completed_message = {
+ "type": "message",
+ "id": msg_id,
+ "status": "completed",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": text_content}],
+ }
+ text_done_event = {
+ "type": "response.output_text.done",
+ "sequence_number": sequence_number,
+ "item_id": msg_id,
+ "output_index": 0,
+ "content_index": 0,
+ "text": text_content,
+ }
+ yield f"data: {json.dumps(text_done_event)}\n\n".encode()
+ sequence_number += 1
+ part_done_event = {
+ "type": "response.content_part.done",
+ "sequence_number": sequence_number,
+ "item_id": msg_id,
+ "output_index": 0,
+ "content_index": 0,
+ "part": {"type": "output_text", "text": text_content},
+ }
+ yield f"data: {json.dumps(part_done_event)}\n\n".encode()
+ sequence_number += 1
+ item_done_event = {
+ "type": "response.output_item.done",
+ "sequence_number": sequence_number,
+ "output_index": 0,
+ "item": completed_message,
+ }
+ yield f"data: {json.dumps(item_done_event)}\n\n".encode()
+ sequence_number += 1
+ completed_event = {
+ "type": "response.completed",
+ "sequence_number": sequence_number,
+ "response": {
+ "id": response_id,
+ "object": "response",
+ "created_at": created_at,
+ "status": "completed",
+ "model": used_model,
+ "output": [completed_message],
+ "parallel_tool_calls": False,
+ "usage": {
+ "input_tokens": 10,
+ "output_tokens": output_tokens,
+ "total_tokens": 10 + output_tokens,
+ },
+ },
+ }
+ yield f"data: {json.dumps(completed_event)}\n\n".encode()
else:
final_event = {
"type": "message_stop",
@@ -270,9 +479,9 @@ async def stream_generator() -> AsyncGenerator[bytes, None]:
},
)
- def _generate_error_response(self, is_openai_format: bool) -> bytes:
+ def _generate_error_response(self, target_format: TargetFormat) -> bytes:
"""Generate a mock error response."""
- if is_openai_format:
+ if target_format in {FORMAT_OPENAI_CHAT, FORMAT_OPENAI_RESPONSES}:
error: dict[str, Any] = {
"error": {
"message": "Rate limit exceeded (mock error)",
diff --git a/tests/plugins/codex/integration/test_codex_basic.py b/tests/plugins/codex/integration/test_codex_basic.py
index 06da0b73..efb1e1b7 100644
--- a/tests/plugins/codex/integration/test_codex_basic.py
+++ b/tests/plugins/codex/integration/test_codex_basic.py
@@ -1,3 +1,4 @@
+import json
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any
@@ -5,6 +6,7 @@
import pytest
import pytest_asyncio
+from pydantic import TypeAdapter
from tests.helpers.assertions import (
assert_codex_response_format,
assert_openai_responses_format,
@@ -14,6 +16,7 @@
STANDARD_OPENAI_REQUEST,
)
+from ccproxy.llms.models import openai as openai_models
from ccproxy.models.detection import DetectedHeaders, DetectedPrompts
from ccproxy.plugins.codex.models import CodexCacheData
@@ -112,6 +115,50 @@ async def test_openai_chat_completions_streaming(
assert any(chunk.startswith("data: ") for chunk in chunks)
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.codex
+async def test_codex_bypass_responses_streaming_emits_valid_openai_response_events(
+ codex_bypass_client: Any,
+) -> None:
+ resp = await codex_bypass_client.post(
+ "/codex/v1/responses",
+ json={
+ "model": "gpt-5",
+ "stream": True,
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "Reply with exactly OK"}
+ ],
+ }
+ ],
+ },
+ )
+ raw_body = await resp.aread()
+
+ assert resp.status_code == 200, raw_body
+ assert resp.headers["content-type"].startswith("text/event-stream")
+
+ body = raw_body.decode()
+ events: list[dict[str, Any]] = []
+ validator = TypeAdapter(openai_models.AnyStreamEvent)
+ for line in body.splitlines():
+ if not line.startswith("data: "):
+ continue
+ payload = line[6:].strip()
+ if not payload or payload == "[DONE]":
+ continue
+ event = json.loads(payload)
+ events.append(event)
+ validator.validate_python(event)
+
+ assert events[0]["type"] == "response.created"
+ assert events[-1]["type"] == "response.completed"
+ assert body.strip().endswith("data: [DONE]")
+
+
# Module-scoped client to avoid per-test startup cost
# Use module-level async loop for all tests here
pytestmark = pytest.mark.asyncio(loop_scope="module")
@@ -195,3 +242,65 @@ async def init_detection_stub(self): # type: ignore[no-untyped-def]
yield client
finally:
await client.aclose()
+
+
+@pytest_asyncio.fixture(scope="module", loop_scope="module")
+async def codex_bypass_client() -> Any: # type: ignore[misc]
+ from httpx import ASGITransport, AsyncClient
+
+ from ccproxy.api.app import create_app, initialize_plugins_startup
+ from ccproxy.api.bootstrap import create_service_container
+ from ccproxy.config.core import ServerSettings
+ from ccproxy.config.settings import Settings
+ from ccproxy.core.logging import setup_logging
+
+ setup_logging(json_logs=False, log_level_name="ERROR")
+ settings = Settings(
+ enable_plugins=True,
+ server=ServerSettings(bypass_mode=True),
+ plugins={
+ "codex": {"enabled": True},
+ "oauth_codex": {"enabled": True},
+ "duckdb_storage": {"enabled": False},
+ "analytics": {"enabled": False},
+ "metrics": {"enabled": False},
+ },
+ enabled_plugins=["codex", "oauth_codex"],
+ plugins_disable_local_discovery=False,
+ )
+ service_container = create_service_container(settings)
+ app = create_app(service_container)
+
+ prompts = DetectedPrompts.from_body(
+ {"instructions": "You are a helpful coding assistant."}
+ )
+ detection_data = CodexCacheData(
+ codex_version="fallback",
+ headers=DetectedHeaders({}),
+ prompts=prompts,
+ body_json=prompts.raw,
+ method="POST",
+ url="https://chatgpt.com/backend-codex/responses",
+ path="/api/backend-codex/responses",
+ query_params={},
+ )
+
+ async def init_detection_stub(self): # type: ignore[no-untyped-def]
+ self._cached_data = detection_data
+ return detection_data
+
+ detection_patch = patch(
+ "ccproxy.plugins.codex.detection_service.CodexDetectionService.initialize_detection",
+ new=init_detection_stub,
+ )
+ with detection_patch:
+ await initialize_plugins_startup(app, settings)
+
+ transport = ASGITransport(app=app)
+ runtime = app.state.plugin_registry.get_runtime("codex")
+ assert runtime and runtime.adapter, "Codex plugin failed to initialize"
+ client = AsyncClient(transport=transport, base_url="http://test")
+ try:
+ yield client
+ finally:
+ await client.aclose()
diff --git a/tests/plugins/codex/integration/test_msaf_compat.py b/tests/plugins/codex/integration/test_msaf_compat.py
new file mode 100644
index 00000000..a844aed9
--- /dev/null
+++ b/tests/plugins/codex/integration/test_msaf_compat.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+import json
+from collections.abc import AsyncGenerator
+from contextlib import AsyncExitStack
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, patch
+
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from pytest_httpx import HTTPXMock
+from tests.helpers.assertions import assert_openai_responses_format
+
+from ccproxy.api.app import create_app, initialize_plugins_startup, shutdown_plugins
+from ccproxy.api.bootstrap import create_service_container
+from ccproxy.config.settings import Settings
+from ccproxy.core.logging import setup_logging
+from ccproxy.models.detection import DetectedHeaders, DetectedPrompts
+from ccproxy.plugins.codex.models import CodexCacheData
+
+
+pytestmark = pytest.mark.asyncio(loop_scope="module")
+
+DETECTED_CLI_INSTRUCTIONS = "Detected Codex CLI instructions"
+MSAF_CHAT_COMPLETIONS_REQUEST: dict[str, Any] = {
+ "model": "gpt-5.4",
+ "messages": [
+ {
+ "role": "system",
+ "content": "You are part of a requirements workshop for a login form.",
+ },
+ {"role": "user", "content": "Составьте требования для формы логина."},
+ ],
+ "reasoning_effort": "medium",
+ "max_completion_tokens": 256,
+ "temperature": 0.1,
+}
+
+
+def _build_detection_data() -> CodexCacheData:
+ prompts = DetectedPrompts.from_body({"instructions": DETECTED_CLI_INSTRUCTIONS})
+ return CodexCacheData(
+ codex_version="fallback",
+ headers=DetectedHeaders({}),
+ prompts=prompts,
+ body_json=prompts.raw,
+ method="POST",
+ url="https://chatgpt.com/backend-api/codex/responses",
+ path="/backend-api/codex/responses",
+ query_params={},
+ )
+
+
+@pytest_asyncio.fixture
+async def codex_msaf_client() -> AsyncGenerator[AsyncClient, None]:
+ setup_logging(json_logs=False, log_level_name="ERROR")
+
+ settings = Settings(
+ enable_plugins=True,
+ plugins_disable_local_discovery=False,
+ enabled_plugins=["codex", "oauth_codex"],
+ plugins={
+ "codex": {
+ "enabled": True,
+ "inject_detection_payload": False,
+ },
+ "oauth_codex": {"enabled": True},
+ "duckdb_storage": {"enabled": False},
+ "analytics": {"enabled": False},
+ "metrics": {"enabled": False},
+ },
+ llm=Settings.LLMSettings(openai_thinking_xml=False),
+ )
+ service_container = create_service_container(settings)
+ app = create_app(service_container)
+
+ credentials_stub = SimpleNamespace(
+ access_token="test-codex-access-token",
+ expires_at=datetime.now(UTC) + timedelta(hours=1),
+ )
+ profile_stub = SimpleNamespace(chatgpt_account_id="test-account-id")
+ detection_data = _build_detection_data()
+
+ async def init_detection_stub(self): # type: ignore[no-untyped-def]
+ self._cached_data = detection_data
+ return detection_data
+
+ async with AsyncExitStack() as stack:
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.oauth_codex.manager.CodexTokenManager.load_credentials",
+ new=AsyncMock(return_value=credentials_stub),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.oauth_codex.manager.CodexTokenManager.get_access_token",
+ new=AsyncMock(return_value="test-codex-access-token"),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.oauth_codex.manager.CodexTokenManager.get_access_token_with_refresh",
+ new=AsyncMock(return_value="test-codex-access-token"),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.oauth_codex.manager.CodexTokenManager.get_profile_quick",
+ new=AsyncMock(return_value=profile_stub),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.codex.detection_service.CodexDetectionService.initialize_detection",
+ new=init_detection_stub,
+ )
+ )
+
+ await initialize_plugins_startup(app, settings)
+ transport = ASGITransport(app=app)
+ client = AsyncClient(transport=transport, base_url="http://test")
+ try:
+ yield client
+ finally:
+ await client.aclose()
+ await shutdown_plugins(app)
+ await service_container.close()
+
+
+@pytest.mark.integration
+@pytest.mark.codex
+async def test_msaf_chat_completions_request_reaches_codex_without_cli_injection(
+ codex_msaf_client: AsyncClient,
+ mock_external_codex_api: HTTPXMock,
+) -> None:
+ response = await codex_msaf_client.post(
+ "/codex/v1/chat/completions",
+ json=MSAF_CHAT_COMPLETIONS_REQUEST,
+ )
+
+ assert response.status_code == 200, response.text
+ data = response.json()
+ assert_openai_responses_format(data)
+
+ requests = mock_external_codex_api.get_requests()
+ assert len(requests) == 1
+
+ upstream_payload = json.loads(requests[0].read().decode())
+ assert (
+ upstream_payload["instructions"]
+ == MSAF_CHAT_COMPLETIONS_REQUEST["messages"][0]["content"]
+ )
+ assert DETECTED_CLI_INSTRUCTIONS not in upstream_payload["instructions"]
+ assert upstream_payload["reasoning"] == {"effort": "medium", "summary": "auto"}
+ assert upstream_payload["stream"] is True
+ assert upstream_payload["store"] is False
+ assert "max_tokens" not in upstream_payload
+ assert "max_output_tokens" not in upstream_payload
+ assert "temperature" not in upstream_payload
+ assert upstream_payload["input"][0]["type"] == "message"
+ assert (
+ upstream_payload["input"][0]["content"][0]["text"]
+ == "Составьте требования для формы логина."
+ )
+
+
+@pytest.mark.integration
+@pytest.mark.codex
+async def test_msaf_chat_completions_hides_thinking_xml_when_disabled(
+ codex_msaf_client: AsyncClient,
+ mock_external_codex_api: HTTPXMock,
+) -> None:
+ request_payload = {
+ **MSAF_CHAT_COMPLETIONS_REQUEST,
+ "reasoning_effort": "high",
+ }
+
+ response = await codex_msaf_client.post(
+ "/codex/v1/chat/completions",
+ json=request_payload,
+ )
+
+ assert response.status_code == 200, response.text
+ data = response.json()
+ assert_openai_responses_format(data)
+ assert "" not in json.dumps(data, ensure_ascii=False)
diff --git a/tests/plugins/codex/integration/test_msaf_real_library.py b/tests/plugins/codex/integration/test_msaf_real_library.py
new file mode 100644
index 00000000..14c0f7fd
--- /dev/null
+++ b/tests/plugins/codex/integration/test_msaf_real_library.py
@@ -0,0 +1,333 @@
+"""Tests for MSAF-style sequential agent workflows through the Codex proxy.
+
+Validates that multi-step agent patterns (analyst -> editor) work correctly
+without requiring the agent_framework library, using plain httpx calls to
+simulate the same request flow.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import AsyncGenerator
+from contextlib import AsyncExitStack
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+import pytest_asyncio
+from pytest_httpx import HTTPXMock
+from tests.helpers.assertions import assert_openai_responses_format
+
+from ccproxy.api.app import create_app, initialize_plugins_startup, shutdown_plugins
+from ccproxy.api.bootstrap import create_service_container
+from ccproxy.config.settings import Settings
+from ccproxy.core.logging import setup_logging
+from ccproxy.models.detection import DetectedHeaders, DetectedPrompts
+from ccproxy.plugins.codex.models import CodexCacheData
+
+
+pytestmark = [
+ pytest.mark.asyncio(loop_scope="module"),
+ pytest.mark.integration,
+ pytest.mark.codex,
+]
+
+DETECTED_CLI_INSTRUCTIONS = "Detected Codex CLI instructions"
+COMMON_INSTRUCTIONS = (
+ "You are part of a requirements workshop for a login form. "
+ "Reply in the same language as the user request. "
+ "Be concise and practical."
+)
+
+
+def _build_detection_data() -> CodexCacheData:
+ prompts = DetectedPrompts.from_body({"instructions": DETECTED_CLI_INSTRUCTIONS})
+ return CodexCacheData(
+ codex_version="fallback",
+ headers=DetectedHeaders({}),
+ prompts=prompts,
+ body_json=prompts.raw,
+ method="POST",
+ url="https://chatgpt.com/backend-api/codex/responses",
+ path="/backend-api/codex/responses",
+ query_params={},
+ )
+
+
+def _build_codex_response(
+ *,
+ response_id: str,
+ message_id: str,
+ text: str,
+ reasoning_text: str,
+) -> dict[str, Any]:
+ return {
+ "id": response_id,
+ "object": "response",
+ "created_at": 1773389433,
+ "status": "completed",
+ "model": "gpt-5-2025-08-07",
+ "output": [
+ {
+ "type": "reasoning",
+ "id": f"rs_{response_id}",
+ "status": "completed",
+ "summary": [{"type": "summary_text", "text": reasoning_text}],
+ },
+ {
+ "type": "message",
+ "id": message_id,
+ "role": "assistant",
+ "status": "completed",
+ "content": [{"type": "output_text", "text": text}],
+ },
+ ],
+ "parallel_tool_calls": False,
+ "usage": {
+ "input_tokens": 64,
+ "output_tokens": 32,
+ "total_tokens": 96,
+ "input_tokens_details": {"cached_tokens": 0},
+ "output_tokens_details": {"reasoning_tokens": 12},
+ },
+ }
+
+
+def _extract_message_text(data: dict[str, Any]) -> str:
+ """Extract assistant message text from an OpenAI chat completions response."""
+ choices = data.get("choices", [])
+ if choices:
+ message = choices[0].get("message", {})
+ content = message.get("content", "")
+ if isinstance(content, str):
+ return content
+ return ""
+
+
+@pytest_asyncio.fixture
+async def msaf_codex_client(
+ httpx_mock: HTTPXMock,
+) -> AsyncGenerator[tuple[httpx.AsyncClient, list[dict[str, Any]]], None]:
+ upstream_payloads: list[dict[str, Any]] = []
+ response_bodies = [
+ _build_codex_response(
+ response_id="resp_analyst",
+ message_id="msg_analyst",
+ text="- Email\n- Password\n- Remember me\n- Inline errors\n- Redirect after success",
+ reasoning_text="Hidden analyst reasoning",
+ ),
+ _build_codex_response(
+ response_id="resp_editor",
+ message_id="msg_editor",
+ text=(
+ "## Goal\n"
+ "Определить требования к форме логина.\n\n"
+ "## Functional Requirements\n"
+ "- Поля email и пароль.\n"
+ "- Кнопка входа и remember me.\n\n"
+ "## Validation Rules\n"
+ "- Оба поля обязательны.\n"
+ "- Email валидируется по формату.\n\n"
+ "## Acceptance Criteria\n"
+ "- Успешный вход ведет к редиректу."
+ ),
+ reasoning_text="Hidden editor reasoning",
+ ),
+ ]
+
+ def upstream_callback(request: httpx.Request) -> httpx.Response:
+ payload = json.loads(request.content.decode() or "{}")
+ upstream_payloads.append(payload)
+ index = min(len(upstream_payloads), len(response_bodies)) - 1
+ return httpx.Response(
+ status_code=200,
+ json=response_bodies[index],
+ headers={"content-type": "application/json"},
+ )
+
+ httpx_mock.add_callback(
+ upstream_callback,
+ url="https://chatgpt.com/backend-api/codex/responses",
+ is_reusable=True,
+ )
+
+ setup_logging(json_logs=False, log_level_name="ERROR")
+
+ settings = Settings(
+ enable_plugins=True,
+ plugins_disable_local_discovery=False,
+ enabled_plugins=["codex", "oauth_codex"],
+ plugins={
+ "codex": {"enabled": True, "inject_detection_payload": False},
+ "oauth_codex": {"enabled": True},
+ "duckdb_storage": {"enabled": False},
+ "analytics": {"enabled": False},
+ "metrics": {"enabled": False},
+ },
+ llm=Settings.LLMSettings(openai_thinking_xml=False),
+ )
+ service_container = create_service_container(settings)
+ app = create_app(service_container)
+
+ credentials_stub = SimpleNamespace(
+ access_token="test-codex-access-token",
+ expires_at=datetime.now(UTC) + timedelta(hours=1),
+ )
+ profile_stub = SimpleNamespace(chatgpt_account_id="test-account-id")
+ detection_data = _build_detection_data()
+
+ async def init_detection_stub(self: Any) -> CodexCacheData:
+ self._cached_data = detection_data
+ return detection_data
+
+ async with AsyncExitStack() as stack:
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.oauth_codex.manager.CodexTokenManager.load_credentials",
+ new=AsyncMock(return_value=credentials_stub),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.oauth_codex.manager.CodexTokenManager.get_access_token",
+ new=AsyncMock(return_value="test-codex-access-token"),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.oauth_codex.manager.CodexTokenManager.get_access_token_with_refresh",
+ new=AsyncMock(return_value="test-codex-access-token"),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.oauth_codex.manager.CodexTokenManager.get_profile_quick",
+ new=AsyncMock(return_value=profile_stub),
+ )
+ )
+ stack.enter_context(
+ patch(
+ "ccproxy.plugins.codex.detection_service.CodexDetectionService.initialize_detection",
+ new=init_detection_stub,
+ )
+ )
+ await initialize_plugins_startup(app, settings)
+ transport = httpx.ASGITransport(app=app)
+ client = httpx.AsyncClient(transport=transport, base_url="http://test")
+ try:
+ yield client, upstream_payloads
+ finally:
+ await client.aclose()
+ await shutdown_plugins(app)
+ await service_container.close()
+
+
+async def test_msaf_agent_runs_through_codex_proxy(
+ msaf_codex_client: tuple[httpx.AsyncClient, list[dict[str, Any]]],
+) -> None:
+ """Single agent-style call verifies no CLI injection, proper flags, no thinking XML."""
+ client, upstream_payloads = msaf_codex_client
+ response = await client.post(
+ "/codex/v1/chat/completions",
+ json={
+ "model": "gpt-5.4",
+ "messages": [
+ {
+ "role": "system",
+ "content": (
+ f"{COMMON_INSTRUCTIONS} "
+ "Focus on fields, validations, and success criteria. "
+ "Output at most 5 bullets."
+ ),
+ },
+ {"role": "user", "content": "Составьте требования для формы логина."},
+ ],
+ "reasoning_effort": "medium",
+ "max_completion_tokens": 256,
+ },
+ )
+
+ assert response.status_code == 200, response.text
+ data = response.json()
+ assert_openai_responses_format(data)
+
+ assert len(upstream_payloads) == 1
+ payload = upstream_payloads[0]
+ assert DETECTED_CLI_INSTRUCTIONS not in payload.get("instructions", "")
+ assert payload.get("stream") is True
+ assert payload.get("store") is False
+ assert "" not in json.dumps(data, ensure_ascii=False)
+
+ text = _extract_message_text(data)
+ assert "Email" in text
+ assert "Password" in text
+
+
+async def test_msaf_sequential_agents_keep_clean_messages(
+ msaf_codex_client: tuple[httpx.AsyncClient, list[dict[str, Any]]],
+) -> None:
+ """Two sequential agent calls (analyst -> editor) keep reasoning hidden and output clean."""
+ client, upstream_payloads = msaf_codex_client
+
+ # Step 1: analyst call
+ analyst_response = await client.post(
+ "/codex/v1/chat/completions",
+ json={
+ "model": "gpt-5.4",
+ "messages": [
+ {
+ "role": "system",
+ "content": (
+ f"{COMMON_INSTRUCTIONS} "
+ "Focus on fields, validations, and success criteria. "
+ "Output at most 5 bullets."
+ ),
+ },
+ {"role": "user", "content": "Составьте требования для формы логина."},
+ ],
+ "reasoning_effort": "medium",
+ },
+ )
+ assert analyst_response.status_code == 200, analyst_response.text
+ analyst_data = analyst_response.json()
+ analyst_text = _extract_message_text(analyst_data)
+
+ # Step 2: editor call, feeding analyst output as context
+ editor_response = await client.post(
+ "/codex/v1/chat/completions",
+ json={
+ "model": "gpt-5.4",
+ "messages": [
+ {
+ "role": "system",
+ "content": (
+ "You are the final editor for login form requirements. "
+ "Reply in the same language as the user request. "
+ "Produce one clean Markdown document with sections "
+ "Goal, Functional Requirements, Validation Rules, Acceptance Criteria."
+ ),
+ },
+ {"role": "user", "content": "Составьте требования для формы логина."},
+ {
+ "role": "assistant",
+ "content": analyst_text,
+ "name": "ProductAnalyst",
+ },
+ ],
+ "reasoning_effort": "medium",
+ },
+ )
+ assert editor_response.status_code == 200, editor_response.text
+ editor_data = editor_response.json()
+ editor_text = _extract_message_text(editor_data)
+
+ assert len(upstream_payloads) == 2
+ assert "Hidden analyst reasoning" not in analyst_text
+ assert "Hidden editor reasoning" not in editor_text
+ assert "" not in analyst_text
+ assert "" not in editor_text
+ assert "## Goal" in editor_text
+ assert "## Functional Requirements" in editor_text
diff --git a/tests/plugins/codex/unit/test_adapter.py b/tests/plugins/codex/unit/test_adapter.py
index 89705135..4eb7400c 100644
--- a/tests/plugins/codex/unit/test_adapter.py
+++ b/tests/plugins/codex/unit/test_adapter.py
@@ -90,6 +90,25 @@ def adapter(
http_pool_manager=mock_http_pool_manager,
)
+ @pytest.fixture
+ def adapter_with_disabled_detection(
+ self,
+ mock_detection_service: Mock,
+ mock_auth_manager: Mock,
+ mock_http_pool_manager: Mock,
+ ) -> CodexAdapter:
+ """Create CodexAdapter with detection payload injection disabled."""
+ mock_config = Mock()
+ mock_config.base_url = "https://chat.openai.com/backend-anon"
+ mock_config.inject_detection_payload = False
+
+ return CodexAdapter(
+ detection_service=mock_detection_service,
+ config=mock_config,
+ auth_manager=mock_auth_manager,
+ http_pool_manager=mock_http_pool_manager,
+ )
+
@pytest.mark.asyncio
async def test_get_target_url(self, adapter: CodexAdapter) -> None:
"""Test target URL generation."""
@@ -333,6 +352,95 @@ async def test_prepare_provider_request_applies_codex_template_defaults(
assert result_data["prompt_cache_key"] != "template-cache-key"
assert result_data["input"][0]["type"] == "message"
+ @pytest.mark.asyncio
+ async def test_prepare_provider_request_skips_detection_payload_when_disabled(
+ self,
+ adapter_with_disabled_detection: CodexAdapter,
+ mock_detection_service: Mock,
+ ) -> None:
+ """Verify that detection payload is not injected when disabled in config."""
+ template = {
+ "instructions": "You are a Python expert.",
+ "include": ["reasoning.encrypted_content"],
+ "parallel_tool_calls": True,
+ "reasoning": {"effort": "medium"},
+ "tool_choice": "auto",
+ "tools": [{"type": "function", "name": "exec_command"}],
+ }
+ prompts = DetectedPrompts.from_body(template)
+ mock_detection_service.get_detected_prompts = Mock(return_value=prompts)
+ mock_detection_service.get_system_prompt = Mock(
+ return_value=prompts.instructions_payload()
+ )
+
+ body = json.dumps(
+ {
+ "model": "gpt-5",
+ "instructions": "User supplied instructions",
+ "input": [{"role": "user", "content": [{"type": "input_text"}]}],
+ }
+ ).encode()
+
+ result_body, _ = await adapter_with_disabled_detection.prepare_provider_request(
+ body, {}, "/responses"
+ )
+ result_data = json.loads(result_body.decode())
+
+ # When detection is disabled, user instructions are preserved
+ assert result_data["instructions"] == "User supplied instructions"
+ # Template fields should not be injected
+ assert "include" not in result_data
+ assert "parallel_tool_calls" not in result_data
+ assert "reasoning" not in result_data
+ assert "tool_choice" not in result_data
+ assert "tools" not in result_data
+ # Input type normalization still occurs
+ assert result_data["input"][0]["type"] == "message"
+
+ @pytest.mark.asyncio
+ async def test_prepare_provider_request_keeps_msaf_reasoning_when_detection_disabled(
+ self, adapter_with_disabled_detection: CodexAdapter
+ ) -> None:
+ """Verify that user-supplied reasoning is preserved when detection is disabled.
+
+ This ensures that even with detection disabled, legitimate MSAF reasoning
+ parameters from the user are not stripped.
+ """
+ body = json.dumps(
+ {
+ "model": "gpt-5",
+ "instructions": "Workshop instructions",
+ "reasoning": {"effort": "medium", "summary": "auto"},
+ "temperature": 0.2,
+ "max_tokens": 128,
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "Draft login form requirements.",
+ }
+ ],
+ }
+ ],
+ }
+ ).encode()
+
+ result_body, _ = await adapter_with_disabled_detection.prepare_provider_request(
+ body, {}, "/responses"
+ )
+ result_data = json.loads(result_body.decode())
+
+ assert result_data["instructions"] == "Workshop instructions"
+ assert result_data["reasoning"] == {"effort": "medium", "summary": "auto"}
+ assert result_data["stream"] is True
+ assert result_data["store"] is False
+ # Provider-specific params are normalized/removed
+ assert "temperature" not in result_data
+ assert "max_tokens" not in result_data
+
@pytest.mark.asyncio
async def test_process_provider_response(self, adapter: CodexAdapter) -> None:
"""Test response processing and format conversion."""
diff --git a/tests/unit/core/test_provider_factory_bypass.py b/tests/unit/core/test_provider_factory_bypass.py
new file mode 100644
index 00000000..b0d6f840
--- /dev/null
+++ b/tests/unit/core/test_provider_factory_bypass.py
@@ -0,0 +1,48 @@
+"""Unit tests for provider factory bypass mode behaviour."""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from ccproxy.core.plugins import factories as plugin_factories
+from ccproxy.plugins.codex.plugin import CodexFactory
+from ccproxy.services.adapters.mock_adapter import MockAdapter
+
+
+@pytest.mark.asyncio
+async def test_create_adapter_logs_warning_in_bypass_mode() -> None:
+ factory = CodexFactory()
+ mock_handler = MagicMock()
+ service_container = MagicMock()
+ service_container.get_mock_handler.return_value = mock_handler
+ context = {
+ "settings": SimpleNamespace(server=SimpleNamespace(bypass_mode=True)),
+ "service_container": service_container,
+ }
+
+ with patch.object(plugin_factories.logger, "warning") as warning:
+ adapter = await factory.create_adapter(context) # type: ignore[arg-type]
+
+ assert isinstance(adapter, MockAdapter)
+ assert adapter.mock_handler is mock_handler
+ warning.assert_called_once_with(
+ "plugin_bypass_mode_enabled",
+ plugin="codex",
+ adapter="CodexAdapter",
+ category="lifecycle",
+ )
+
+
+@pytest.mark.asyncio
+async def test_create_adapter_raises_clear_error_without_service_container() -> None:
+ factory = CodexFactory()
+ context = {
+ "settings": SimpleNamespace(server=SimpleNamespace(bypass_mode=True)),
+ }
+
+ with pytest.raises(
+ RuntimeError,
+ match="Cannot initialize plugin 'codex' in bypass mode",
+ ):
+ await factory.create_adapter(context) # type: ignore[arg-type]
diff --git a/tests/unit/llms/test_llms_streaming_settings.py b/tests/unit/llms/test_llms_streaming_settings.py
index 9ba4961d..64f06807 100644
--- a/tests/unit/llms/test_llms_streaming_settings.py
+++ b/tests/unit/llms/test_llms_streaming_settings.py
@@ -1,5 +1,14 @@
+import asyncio
+
import pytest
+from ccproxy.api.bootstrap import create_service_container
+from ccproxy.config.settings import Settings
+from ccproxy.core.constants import FORMAT_OPENAI_CHAT, FORMAT_OPENAI_RESPONSES
+from ccproxy.llms.formatters.context import (
+ get_openai_thinking_xml,
+ register_openai_thinking_xml,
+)
from ccproxy.llms.streaming.processors import OpenAIStreamProcessor
@@ -43,3 +52,26 @@ async def test_llm_openai_thinking_xml_env_disables_thinking_serialization(monke
delta = c["choices"][0].get("delta") or {}
if isinstance(delta, dict) and "content" in delta:
assert " None:
+ settings = Settings(llm=Settings.LLMSettings(openai_thinking_xml=False))
+ container = create_service_container(settings)
+
+ registry = container.get_format_registry()
+ adapter = registry.get(FORMAT_OPENAI_RESPONSES, FORMAT_OPENAI_CHAT)
+
+ assert getattr(adapter, "_openai_thinking_xml", None) is False
+
+
+@pytest.mark.asyncio
+async def test_openai_thinking_xml_contextvar_is_isolated_per_task() -> None:
+ async def worker(value: bool) -> bool | None:
+ register_openai_thinking_xml(value)
+ await asyncio.sleep(0)
+ return get_openai_thinking_xml()
+
+ results = await asyncio.gather(worker(True), worker(False))
+
+ assert list(results) == [True, False]
+ assert get_openai_thinking_xml() is None
diff --git a/tests/unit/services/mocking/test_mock_handler.py b/tests/unit/services/mocking/test_mock_handler.py
index a4e13005..8165284f 100644
--- a/tests/unit/services/mocking/test_mock_handler.py
+++ b/tests/unit/services/mocking/test_mock_handler.py
@@ -1,10 +1,20 @@
"""Tests for the mock response handler."""
import asyncio
+import json
+from collections.abc import Sequence
+from unittest.mock import MagicMock
import pytest
+from pydantic import TypeAdapter
+from ccproxy.core.constants import (
+ FORMAT_ANTHROPIC_MESSAGES,
+ FORMAT_OPENAI_CHAT,
+ FORMAT_OPENAI_RESPONSES,
+)
from ccproxy.core.request_context import RequestContext
+from ccproxy.llms.models import openai as openai_models
from ccproxy.services.mocking.mock_handler import MockResponseHandler
@@ -22,6 +32,29 @@ def generate_short_response(self, model=None):
return {"content": [{"text": "short"}]}
+def _parse_sse_events(
+ chunks: Sequence[bytes | str | memoryview],
+) -> list[dict[str, object]]:
+ events: list[dict[str, object]] = []
+ for chunk in chunks:
+ if isinstance(chunk, memoryview):
+ decoded = chunk.tobytes().decode()
+ elif isinstance(chunk, bytes):
+ decoded = chunk.decode()
+ else:
+ decoded = chunk
+ for line in decoded.splitlines():
+ if not line.startswith("data: "):
+ continue
+ payload = line[6:].strip()
+ if not payload or payload == "[DONE]":
+ continue
+ event = json.loads(payload)
+ if isinstance(event, dict):
+ events.append(event)
+ return events
+
+
@pytest.mark.parametrize(
"body,expected",
[
@@ -36,12 +69,42 @@ def test_extract_message_type(body: bytes, expected: str) -> None:
assert handler.extract_message_type(body) == expected
+def test_extract_prompt_text_collects_nested_values() -> None:
+ handler = MockResponseHandler(DummyGenerator()) # type: ignore[arg-type]
+ body = json.dumps(
+ {
+ "instructions": "Top level instructions",
+ "input": [
+ {
+ "content": [
+ {"text": "First prompt"},
+ {"text": "Second prompt"},
+ ]
+ }
+ ],
+ }
+ ).encode()
+
+ assert handler.extract_prompt_text(body) == (
+ "Top level instructions\nFirst prompt\nSecond prompt"
+ )
+
+
+def test_extract_prompt_text_limits_deep_nesting() -> None:
+ handler = MockResponseHandler(DummyGenerator()) # type: ignore[arg-type]
+ nested: dict[str, object] = {"text": "too deep"}
+ for _ in range(12):
+ nested = {"input": [nested]}
+
+ body = json.dumps(nested).encode()
+
+ assert handler.extract_prompt_text(body) == ""
+
+
@pytest.mark.asyncio
async def test_generate_standard_response_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- from unittest.mock import MagicMock
-
handler = MockResponseHandler(DummyGenerator(), error_rate=0.0) # type: ignore[arg-type]
monkeypatch.setattr(handler, "should_simulate_error", lambda: False)
monkeypatch.setattr("random.uniform", lambda *args, **kwargs: 0)
@@ -54,7 +117,10 @@ async def fast_sleep(_: float) -> None:
mock_logger = MagicMock()
ctx = RequestContext(request_id="req", start_time=0, logger=mock_logger) # type: ignore[arg-type]
status, headers, body = await handler.generate_standard_response(
- model="m1", is_openai_format=False, ctx=ctx, message_type="short"
+ model="m1",
+ target_format=FORMAT_ANTHROPIC_MESSAGES,
+ ctx=ctx,
+ message_type="short",
)
assert status == 200
@@ -64,11 +130,37 @@ async def fast_sleep(_: float) -> None:
@pytest.mark.asyncio
-async def test_generate_standard_response_error(
+async def test_generate_standard_response_does_not_special_case_login_prompts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- from unittest.mock import MagicMock
+ handler = MockResponseHandler(DummyGenerator(), error_rate=0.0) # type: ignore[arg-type]
+ monkeypatch.setattr(handler, "should_simulate_error", lambda: False)
+ monkeypatch.setattr("random.uniform", lambda *args, **kwargs: 0)
+
+ async def fast_sleep(_: float) -> None:
+ return None
+
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
+
+ mock_logger = MagicMock()
+ ctx = RequestContext(request_id="req", start_time=0, logger=mock_logger) # type: ignore[arg-type]
+ status, _, body = await handler.generate_standard_response(
+ model="m1",
+ target_format=FORMAT_ANTHROPIC_MESSAGES,
+ ctx=ctx,
+ message_type="short",
+ prompt_text="Write requirements for a login form.",
+ )
+
+ assert status == 200
+ assert b"short" in body
+ assert b"Login Form Requirements" not in body
+
+@pytest.mark.asyncio
+async def test_generate_standard_response_error(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
handler = MockResponseHandler(DummyGenerator(), error_rate=1.0) # type: ignore[arg-type]
monkeypatch.setattr(handler, "should_simulate_error", lambda: True)
@@ -80,7 +172,10 @@ async def fast_sleep(_: float) -> None:
mock_logger = MagicMock()
mock_ctx = RequestContext(request_id="req", start_time=0, logger=mock_logger) # type: ignore[arg-type]
status, headers, body = await handler.generate_standard_response(
- model="m1", is_openai_format=True, ctx=mock_ctx, message_type="short"
+ model="m1",
+ target_format=FORMAT_OPENAI_CHAT,
+ ctx=mock_ctx,
+ message_type="short",
)
assert status == 429
@@ -89,14 +184,12 @@ async def fast_sleep(_: float) -> None:
@pytest.mark.asyncio
async def test_generate_streaming_response(monkeypatch: pytest.MonkeyPatch) -> None:
- from unittest.mock import MagicMock
-
handler = MockResponseHandler(DummyGenerator(), error_rate=0.0) # type: ignore[arg-type]
mock_logger = MagicMock()
ctx = RequestContext(request_id="req", start_time=0, logger=mock_logger) # type: ignore[arg-type]
stream = await handler.generate_streaming_response(
- model="m1", is_openai_format=True, ctx=ctx
+ model="m1", target_format=FORMAT_OPENAI_CHAT, ctx=ctx
)
chunks = []
@@ -104,3 +197,43 @@ async def test_generate_streaming_response(monkeypatch: pytest.MonkeyPatch) -> N
chunks.append(chunk)
assert any(b"[DONE]" in chunk for chunk in chunks)
+
+
+@pytest.mark.asyncio
+async def test_generate_responses_streaming_response_emits_valid_events(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ handler = MockResponseHandler(DummyGenerator(), error_rate=0.0) # type: ignore[arg-type]
+ mock_logger = MagicMock()
+ ctx = RequestContext(request_id="req", start_time=0, logger=mock_logger) # type: ignore[arg-type]
+
+ async def fast_sleep(_: float) -> None:
+ return None
+
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
+
+ stream = await handler.generate_streaming_response(
+ model="m1",
+ target_format=FORMAT_OPENAI_RESPONSES,
+ ctx=ctx,
+ )
+
+ chunks = []
+ async for chunk in stream.body_iterator:
+ chunks.append(chunk)
+
+ events = _parse_sse_events(chunks)
+ validator = TypeAdapter(openai_models.AnyStreamEvent)
+
+ assert [event["type"] for event in events] == [
+ "response.created",
+ "response.output_item.added",
+ "response.content_part.added",
+ "response.output_text.delta",
+ "response.output_text.done",
+ "response.content_part.done",
+ "response.output_item.done",
+ "response.completed",
+ ]
+ for event in events:
+ validator.validate_python(event)
diff --git a/tests/unit/services/test_mock_adapter.py b/tests/unit/services/test_mock_adapter.py
index c852fc65..594eb8ba 100644
--- a/tests/unit/services/test_mock_adapter.py
+++ b/tests/unit/services/test_mock_adapter.py
@@ -1,10 +1,16 @@
"""Tests for the mock adapter streaming behaviour."""
+import json
from types import SimpleNamespace
from typing import Any
import pytest
+from ccproxy.core.constants import (
+ FORMAT_ANTHROPIC_MESSAGES,
+ FORMAT_OPENAI_CHAT,
+ FORMAT_OPENAI_RESPONSES,
+)
from ccproxy.services.adapters.mock_adapter import MockAdapter
@@ -21,16 +27,32 @@ def extract_message_type(self, body: bytes) -> str:
self.calls.append(("extract", (body,)))
return "message"
+ def extract_prompt_text(self, body: bytes) -> str:
+ self.calls.append(("prompt", (body,)))
+ return "prompt"
+
async def generate_standard_response(
- self, model: Any, is_openai: Any, ctx: Any, message_type: Any
+ self,
+ model: Any,
+ target_format: Any,
+ ctx: Any,
+ message_type: Any,
+ prompt_text: Any,
) -> tuple[int, dict[str, str], bytes]:
- self.calls.append(("standard", (model, is_openai, message_type)))
- return 202, {"X-Test": "yes"}, b"standard"
+ self.calls.append(
+ ("standard", (model, target_format, message_type, prompt_text))
+ )
+ return 202, {"X-Test": "yes"}, json.dumps({"format": target_format}).encode()
async def generate_streaming_response(
- self, model: Any, is_openai: Any, ctx: Any, message_type: Any
+ self,
+ model: Any,
+ target_format: Any,
+ ctx: Any,
+ message_type: Any,
+ prompt_text: Any,
) -> str:
- self.calls.append(("stream", (model, is_openai, message_type)))
+ self.calls.append(("stream", (model, target_format, message_type, prompt_text)))
return "stream-object"
@@ -43,7 +65,8 @@ def __init__(
state_dict: dict[str, Any] = {}
if context_endpoint is not None:
state_dict["context"] = SimpleNamespace(
- metadata={"endpoint": context_endpoint}
+ metadata={"endpoint": context_endpoint},
+ format_chain=[],
)
self.state = SimpleNamespace(**state_dict)
@@ -57,14 +80,16 @@ async def test_handle_request_returns_standard_response() -> None:
adapter = _TestableMockAdapter(handler)
request: Any = FakeRequest(
- b'{"model": "gpt", "stream": false}', "/openai/v1/messages"
+ b'{"model": "gpt", "stream": false}', "/codex/v1/responses"
)
response = await adapter.handle_request(request)
assert response.status_code == 202
assert response.headers["X-Test"] == "yes"
+ assert json.loads(bytes(response.body))["format"] == FORMAT_OPENAI_RESPONSES
assert handler.calls[0][0] == "extract"
- assert handler.calls[1][0] == "standard"
+ assert handler.calls[1][0] == "prompt"
+ assert handler.calls[2][0] == "standard"
@pytest.mark.asyncio
@@ -82,6 +107,83 @@ async def test_handle_request_streaming_path() -> None:
assert handler.calls[-1][0] == "stream"
+@pytest.mark.asyncio
+async def test_handle_request_prefers_context_format_chain() -> None:
+ handler: Any = StubHandler()
+ adapter = _TestableMockAdapter(handler)
+
+ request: Any = FakeRequest(
+ b'{"model": "gpt"}', "/codex/v1/chat/completions", "/v1/chat/completions"
+ )
+ request.state.context.format_chain = [FORMAT_OPENAI_CHAT]
+
+ response = await adapter.handle_request(request)
+
+ assert response.status_code == 202
+ assert json.loads(bytes(response.body))["format"] == FORMAT_OPENAI_CHAT
+
+
+@pytest.mark.asyncio
+async def test_handle_request_falls_back_to_chat_endpoint_detection() -> None:
+ handler: Any = StubHandler()
+ adapter = _TestableMockAdapter(handler)
+
+ request: Any = FakeRequest(
+ b'{"model": "gpt"}', "/codex/v1/chat/completions", "/internal/unknown"
+ )
+
+ response = await adapter.handle_request(request)
+
+ assert response.status_code == 202
+ assert json.loads(bytes(response.body))["format"] == FORMAT_OPENAI_CHAT
+
+
+@pytest.mark.asyncio
+async def test_handle_request_falls_back_to_responses_endpoint_detection() -> None:
+ handler: Any = StubHandler()
+ adapter = _TestableMockAdapter(handler)
+
+ request: Any = FakeRequest(
+ b'{"model": "gpt"}', "/codex/v1/responses", "/internal/unknown"
+ )
+
+ response = await adapter.handle_request(request)
+
+ assert response.status_code == 202
+ assert json.loads(bytes(response.body))["format"] == FORMAT_OPENAI_RESPONSES
+
+
+@pytest.mark.asyncio
+async def test_handle_request_ignores_unknown_format_chain_and_uses_endpoint() -> None:
+ handler: Any = StubHandler()
+ adapter = _TestableMockAdapter(handler)
+
+ request: Any = FakeRequest(
+ b'{"model": "gpt"}', "/codex/v1/chat/completions", "/codex/v1/chat/completions"
+ )
+ request.state.context.format_chain = ["unsupported.format"]
+
+ response = await adapter.handle_request(request)
+
+ assert response.status_code == 202
+ assert json.loads(bytes(response.body))["format"] == FORMAT_OPENAI_CHAT
+
+
+@pytest.mark.asyncio
+async def test_handle_request_defaults_to_anthropic_for_unknown_endpoint() -> None:
+ handler: Any = StubHandler()
+ adapter = _TestableMockAdapter(handler)
+
+ request: Any = FakeRequest(
+ b'{"model": "claude"}', "/provider/v1/messages", "/provider/v1/messages"
+ )
+
+ response = await adapter.handle_request(request)
+
+ assert response.status_code == 202
+ assert json.loads(bytes(response.body))["format"] == FORMAT_ANTHROPIC_MESSAGES
+
+
@pytest.mark.asyncio
async def test_handle_streaming_uses_endpoint_kwarg() -> None:
handler: Any = StubHandler()
diff --git a/uv.lock b/uv.lock
index 975545cf..293c10a6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -389,6 +389,8 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
@@ -397,6 +399,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
@@ -404,6 +411,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
@@ -411,18 +423,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
@@ -651,6 +676,7 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" },
{ url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" },
{ url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" },
{ url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" },
@@ -662,6 +688,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" },
{ url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" },
{ url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" },
@@ -673,6 +702,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" },
{ url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" },
+ { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" },
+ { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" },
{ url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" },
{ url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" },
@@ -684,10 +716,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" },
{ url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" },
{ url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" },
+ { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" },
{ url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" },
{ url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" },
+ { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" },
]
[[package]]
@@ -1357,7 +1393,7 @@ wheels = [
[[package]]
name = "mcp"
-version = "1.17.0"
+version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1366,15 +1402,18 @@ dependencies = [
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5a/79/5724a540df19e192e8606c543cdcf162de8eb435077520cca150f7365ec0/mcp-1.17.0.tar.gz", hash = "sha256:1b57fabf3203240ccc48e39859faf3ae1ccb0b571ff798bbedae800c73c6df90", size = 477951, upload-time = "2025-10-10T12:16:44.519Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1c/72/3751feae343a5ad07959df713907b5c3fbaed269d697a14b0c449080cf2e/mcp-1.17.0-py3-none-any.whl", hash = "sha256:0660ef275cada7a545af154db3082f176cf1d2681d5e35ae63e014faf0a35d40", size = 167737, upload-time = "2025-10-10T12:16:42.863Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
@@ -1932,6 +1971,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
+[package.optional-dependencies]
+crypto = [
+ { name = "cryptography" },
+]
+
[[package]]
name = "pymdown-extensions"
version = "10.16.1"