diff --git a/agent/transports/__init__.py b/agent/transports/__init__.py index 6ee1c51174..6cd3a277a1 100644 --- a/agent/transports/__init__.py +++ b/agent/transports/__init__.py @@ -1 +1,39 @@ -"""Transport layer types for provider response normalization.""" +"""Transport layer types and registry for provider response normalization. + +Usage: + from agent.transports import get_transport + transport = get_transport("anthropic_messages") + result = transport.normalize_response(raw_response) +""" + +from agent.transports.types import NormalizedResponse, ToolCall, Usage, build_tool_call, map_finish_reason # noqa: F401 + +_REGISTRY: dict = {} + + +def register_transport(api_mode: str, transport_cls: type) -> None: + """Register a transport class for an api_mode string.""" + _REGISTRY[api_mode] = transport_cls + + +def get_transport(api_mode: str): + """Get a transport instance for the given api_mode. + + Returns None if no transport is registered for this api_mode. + This allows gradual migration — call sites can check for None + and fall back to the legacy code path. + """ + if not _REGISTRY: + _discover_transports() + cls = _REGISTRY.get(api_mode) + if cls is None: + return None + return cls() + + +def _discover_transports() -> None: + """Import all transport modules to trigger auto-registration.""" + try: + import agent.transports.anthropic # noqa: F401 + except ImportError: + pass diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py new file mode 100644 index 0000000000..7ffa71a6f9 --- /dev/null +++ b/agent/transports/anthropic.py @@ -0,0 +1,129 @@ +"""Anthropic Messages API transport. + +Delegates to the existing adapter functions in agent/anthropic_adapter.py. +This transport owns format conversion and normalization — NOT client lifecycle. +""" + +from typing import Any, Dict, List, Optional + +from agent.transports.base import ProviderTransport +from agent.transports.types import NormalizedResponse + + +class AnthropicTransport(ProviderTransport): + """Transport for api_mode='anthropic_messages'. + + Wraps the existing functions in anthropic_adapter.py behind the + ProviderTransport ABC. Each method delegates — no logic is duplicated. + """ + + @property + def api_mode(self) -> str: + return "anthropic_messages" + + def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any: + """Convert OpenAI messages to Anthropic (system, messages) tuple. + + kwargs: + base_url: Optional[str] — affects thinking signature handling. + """ + from agent.anthropic_adapter import convert_messages_to_anthropic + + base_url = kwargs.get("base_url") + return convert_messages_to_anthropic(messages, base_url=base_url) + + def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: + """Convert OpenAI tool schemas to Anthropic input_schema format.""" + from agent.anthropic_adapter import convert_tools_to_anthropic + + return convert_tools_to_anthropic(tools) + + def build_kwargs( + self, + model: str, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **params, + ) -> Dict[str, Any]: + """Build Anthropic messages.create() kwargs. + + Calls convert_messages and convert_tools internally. + + params (all optional): + max_tokens: int + reasoning_config: dict | None + tool_choice: str | None + is_oauth: bool + preserve_dots: bool + context_length: int | None + base_url: str | None + fast_mode: bool + """ + from agent.anthropic_adapter import build_anthropic_kwargs + + return build_anthropic_kwargs( + model=model, + messages=messages, + tools=tools, + max_tokens=params.get("max_tokens", 16384), + reasoning_config=params.get("reasoning_config"), + tool_choice=params.get("tool_choice"), + is_oauth=params.get("is_oauth", False), + preserve_dots=params.get("preserve_dots", False), + context_length=params.get("context_length"), + base_url=params.get("base_url"), + fast_mode=params.get("fast_mode", False), + ) + + def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: + """Normalize Anthropic response to NormalizedResponse. + + kwargs: + strip_tool_prefix: bool — strip 'mcp_mcp_' prefixes from tool names. + """ + from agent.anthropic_adapter import normalize_anthropic_response_v2 + + strip_tool_prefix = kwargs.get("strip_tool_prefix", False) + return normalize_anthropic_response_v2(response, strip_tool_prefix=strip_tool_prefix) + + def validate_response(self, response: Any) -> bool: + """Check Anthropic response structure is valid.""" + if response is None: + return False + content_blocks = getattr(response, "content", None) + if not isinstance(content_blocks, list): + return False + if not content_blocks: + return False + return True + + def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]: + """Extract Anthropic cache_read and cache_creation token counts.""" + usage = getattr(response, "usage", None) + if usage is None: + return None + cached = getattr(usage, "cache_read_input_tokens", 0) or 0 + written = getattr(usage, "cache_creation_input_tokens", 0) or 0 + if cached or written: + return {"cached_tokens": cached, "creation_tokens": written} + return None + + # Promote the adapter's canonical mapping to module level so it's shared + _STOP_REASON_MAP = { + "end_turn": "stop", + "tool_use": "tool_calls", + "max_tokens": "length", + "stop_sequence": "stop", + "refusal": "content_filter", + "model_context_window_exceeded": "length", + } + + def map_finish_reason(self, raw_reason: str) -> str: + """Map Anthropic stop_reason to OpenAI finish_reason.""" + return self._STOP_REASON_MAP.get(raw_reason, "stop") + + +# Auto-register on import +from agent.transports import register_transport # noqa: E402 + +register_transport("anthropic_messages", AnthropicTransport) diff --git a/agent/transports/base.py b/agent/transports/base.py new file mode 100644 index 0000000000..b516967b6a --- /dev/null +++ b/agent/transports/base.py @@ -0,0 +1,89 @@ +"""Abstract base for provider transports. + +A transport owns the data path for one api_mode: + convert_messages → convert_tools → build_kwargs → normalize_response + +It does NOT own: client construction, streaming, credential refresh, +prompt caching, interrupt handling, or retry logic. Those stay on AIAgent. +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +from agent.transports.types import NormalizedResponse + + +class ProviderTransport(ABC): + """Base class for provider-specific format conversion and normalization.""" + + @property + @abstractmethod + def api_mode(self) -> str: + """The api_mode string this transport handles (e.g. 'anthropic_messages').""" + ... + + @abstractmethod + def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any: + """Convert OpenAI-format messages to provider-native format. + + Returns provider-specific structure (e.g. (system, messages) for Anthropic, + or the messages list unchanged for chat_completions). + """ + ... + + @abstractmethod + def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: + """Convert OpenAI-format tool definitions to provider-native format. + + Returns provider-specific tool list (e.g. Anthropic input_schema format). + """ + ... + + @abstractmethod + def build_kwargs( + self, + model: str, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + **params, + ) -> Dict[str, Any]: + """Build the complete API call kwargs dict. + + This is the primary entry point — it typically calls convert_messages() + and convert_tools() internally, then adds model-specific config. + + Returns a dict ready to be passed to the provider's SDK client. + """ + ... + + @abstractmethod + def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: + """Normalize a raw provider response to the shared NormalizedResponse type. + + This is the only method that returns a transport-layer type. + """ + ... + + def validate_response(self, response: Any) -> bool: + """Optional: check if the raw response is structurally valid. + + Returns True if valid, False if the response should be treated as invalid. + Default implementation always returns True. + """ + return True + + def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]: + """Optional: extract provider-specific cache hit/creation stats. + + Returns dict with 'cached_tokens' and 'creation_tokens', or None. + Default returns None. + """ + return None + + def map_finish_reason(self, raw_reason: str) -> str: + """Optional: map provider-specific stop reason to OpenAI equivalent. + + Default returns the raw reason unchanged. Override for providers + with different stop reason vocabularies. + """ + return raw_reason diff --git a/run_agent.py b/run_agent.py index 9c6e9d7b92..722f7cea4b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6545,6 +6545,15 @@ class AIAgent: return suffix return "[A multimodal message was converted to text for Anthropic compatibility.]" + def _get_anthropic_transport(self): + """Return the cached AnthropicTransport instance (lazy singleton).""" + t = getattr(self, "_anthropic_transport", None) + if t is None: + from agent.transports import get_transport + t = get_transport("anthropic_messages") + self._anthropic_transport = t + return t + def _prepare_anthropic_messages_for_api(self, api_messages: list) -> list: if not any( isinstance(msg, dict) and self._content_has_image_parts(msg.get("content")) @@ -6661,20 +6670,14 @@ class AIAgent: def _build_api_kwargs(self, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_kwargs + _transport = self._get_anthropic_transport() anthropic_messages = self._prepare_anthropic_messages_for_api(api_messages) - # Pass context_length (total input+output window) so the adapter can - # clamp max_tokens (output cap) when the user configured a smaller - # context window than the model's native output limit. ctx_len = getattr(self, "context_compressor", None) ctx_len = ctx_len.context_length if ctx_len else None - # _ephemeral_max_output_tokens is set for one call when the API - # returns "max_tokens too large given prompt" — it caps output to - # the available window space without touching context_length. ephemeral_out = getattr(self, "_ephemeral_max_output_tokens", None) if ephemeral_out is not None: self._ephemeral_max_output_tokens = None # consume immediately - return build_anthropic_kwargs( + return _transport.build_kwargs( model=self.model, messages=anthropic_messages, tools=self.tools, @@ -7356,9 +7359,9 @@ class AIAgent: codex_kwargs["max_output_tokens"] = 5120 response = self._run_codex_stream(codex_kwargs) elif not _aux_available and self.api_mode == "anthropic_messages": - # Native Anthropic — use the Anthropic client directly - from agent.anthropic_adapter import build_anthropic_kwargs as _build_ant_kwargs - ant_kwargs = _build_ant_kwargs( + # Native Anthropic — use the transport for kwargs + _tflush = self._get_anthropic_transport() + ant_kwargs = _tflush.build_kwargs( model=self.model, messages=api_messages, tools=[memory_tool_def], max_tokens=5120, reasoning_config=None, @@ -7386,10 +7389,15 @@ class AIAgent: if assistant_msg and assistant_msg.tool_calls: tool_calls = assistant_msg.tool_calls elif self.api_mode == "anthropic_messages" and not _aux_available: - from agent.anthropic_adapter import normalize_anthropic_response as _nar_flush - _flush_msg, _ = _nar_flush(response, strip_tool_prefix=self._is_anthropic_oauth) - if _flush_msg and _flush_msg.tool_calls: - tool_calls = _flush_msg.tool_calls + _tfn = self._get_anthropic_transport() + _flush_nr = _tfn.normalize_response(response, strip_tool_prefix=self._is_anthropic_oauth) + if _flush_nr and _flush_nr.tool_calls: + tool_calls = [ + SimpleNamespace( + id=tc.id, type="function", + function=SimpleNamespace(name=tc.name, arguments=tc.arguments), + ) for tc in _flush_nr.tool_calls + ] elif hasattr(response, "choices") and response.choices: assistant_message = response.choices[0].message if assistant_message.tool_calls: @@ -8449,14 +8457,14 @@ class AIAgent: summary_kwargs["extra_body"] = summary_extra_body if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_kwargs as _bak, normalize_anthropic_response as _nar - _ant_kw = _bak(model=self.model, messages=api_messages, tools=None, + _tsum = self._get_anthropic_transport() + _ant_kw = _tsum.build_kwargs(model=self.model, messages=api_messages, tools=None, max_tokens=self.max_tokens, reasoning_config=self.reasoning_config, is_oauth=self._is_anthropic_oauth, preserve_dots=self._anthropic_preserve_dots()) summary_response = self._anthropic_messages_create(_ant_kw) - _msg, _ = _nar(summary_response, strip_tool_prefix=self._is_anthropic_oauth) - final_response = (_msg.content or "").strip() + _sum_nr = _tsum.normalize_response(summary_response, strip_tool_prefix=self._is_anthropic_oauth) + final_response = (_sum_nr.content or "").strip() else: summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary").chat.completions.create(**summary_kwargs) @@ -8481,14 +8489,14 @@ class AIAgent: retry_msg, _ = self._normalize_codex_response(retry_response) final_response = (retry_msg.content or "").strip() if retry_msg else "" elif self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_kwargs as _bak2, normalize_anthropic_response as _nar2 - _ant_kw2 = _bak2(model=self.model, messages=api_messages, tools=None, + _tretry = self._get_anthropic_transport() + _ant_kw2 = _tretry.build_kwargs(model=self.model, messages=api_messages, tools=None, is_oauth=self._is_anthropic_oauth, max_tokens=self.max_tokens, reasoning_config=self.reasoning_config, preserve_dots=self._anthropic_preserve_dots()) retry_response = self._anthropic_messages_create(_ant_kw2) - _retry_msg, _ = _nar2(retry_response, strip_tool_prefix=self._is_anthropic_oauth) - final_response = (_retry_msg.content or "").strip() + _retry_nr = _tretry.normalize_response(retry_response, strip_tool_prefix=self._is_anthropic_oauth) + final_response = (_retry_nr.content or "").strip() else: summary_kwargs = { "model": self.model, @@ -9357,16 +9365,13 @@ class AIAgent: response_invalid = True error_details.append("response.output is empty") elif self.api_mode == "anthropic_messages": - content_blocks = getattr(response, "content", None) if response is not None else None - if response is None: + _tv = self._get_anthropic_transport() + if not _tv.validate_response(response): response_invalid = True - error_details.append("response is None") - elif not isinstance(content_blocks, list): - response_invalid = True - error_details.append("response.content is not a list") - elif not content_blocks: - response_invalid = True - error_details.append("response.content is empty") + if response is None: + error_details.append("response is None") + else: + error_details.append("response.content invalid (not a non-empty list)") else: if response is None or not hasattr(response, 'choices') or response.choices is None or not response.choices: response_invalid = True @@ -9527,8 +9532,8 @@ class AIAgent: else: finish_reason = "stop" elif self.api_mode == "anthropic_messages": - stop_reason_map = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop"} - finish_reason = stop_reason_map.get(response.stop_reason, "stop") + _tfr = self._get_anthropic_transport() + finish_reason = _tfr.map_finish_reason(response.stop_reason) else: finish_reason = response.choices[0].finish_reason assistant_message = response.choices[0].message @@ -9557,10 +9562,24 @@ class AIAgent: if self.api_mode in ("chat_completions", "bedrock_converse"): _trunc_msg = response.choices[0].message if (hasattr(response, "choices") and response.choices) else None elif self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import normalize_anthropic_response - _trunc_msg, _ = normalize_anthropic_response( + _trunc_nr = self._get_anthropic_transport().normalize_response( response, strip_tool_prefix=self._is_anthropic_oauth ) + _trunc_msg = SimpleNamespace( + content=_trunc_nr.content, + tool_calls=[ + SimpleNamespace( + id=tc.id, type="function", + function=SimpleNamespace(name=tc.name, arguments=tc.arguments), + ) for tc in (_trunc_nr.tool_calls or []) + ] or None, + reasoning=_trunc_nr.reasoning, + reasoning_content=None, + reasoning_details=( + _trunc_nr.provider_data.get("reasoning_details") + if _trunc_nr.provider_data else None + ), + ) _trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None _trunc_has_tool_calls = bool(getattr(_trunc_msg, "tool_calls", None)) if _trunc_msg else False @@ -9819,9 +9838,10 @@ class AIAgent: # Log cache hit stats when prompt caching is active if self._use_prompt_caching: if self.api_mode == "anthropic_messages": - # Anthropic uses cache_read_input_tokens / cache_creation_input_tokens - cached = getattr(response.usage, 'cache_read_input_tokens', 0) or 0 - written = getattr(response.usage, 'cache_creation_input_tokens', 0) or 0 + _tcs = self._get_anthropic_transport() + _cache = _tcs.extract_cache_stats(response) + cached = _cache["cached_tokens"] if _cache else 0 + written = _cache["creation_tokens"] if _cache else 0 else: # OpenRouter uses prompt_tokens_details.cached_tokens details = getattr(response.usage, 'prompt_tokens_details', None) @@ -10766,15 +10786,13 @@ class AIAgent: if self.api_mode == "codex_responses": assistant_message, finish_reason = self._normalize_codex_response(response) elif self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import normalize_anthropic_response_v2 - _nr = normalize_anthropic_response_v2( + _transport = self._get_anthropic_transport() + _nr = _transport.normalize_response( response, strip_tool_prefix=self._is_anthropic_oauth ) # Back-compat shim: downstream code expects SimpleNamespace with # .content, .tool_calls, .reasoning, .reasoning_content, - # .reasoning_details attributes. This shim makes the cost of the - # old interface visible — it vanishes when the full transport - # wiring lands (PR 3+). + # .reasoning_details attributes. assistant_message = SimpleNamespace( content=_nr.content, tool_calls=[ diff --git a/tests/agent/transports/test_transport.py b/tests/agent/transports/test_transport.py new file mode 100644 index 0000000000..b51336d962 --- /dev/null +++ b/tests/agent/transports/test_transport.py @@ -0,0 +1,220 @@ +"""Tests for the transport ABC, registry, and AnthropicTransport.""" + +import pytest +from types import SimpleNamespace +from unittest.mock import MagicMock + +from agent.transports.base import ProviderTransport +from agent.transports.types import NormalizedResponse, ToolCall, Usage +from agent.transports import get_transport, register_transport, _REGISTRY + + +# ── ABC contract tests ────────────────────────────────────────────────── + +class TestProviderTransportABC: + """Verify the ABC contract is enforceable.""" + + def test_cannot_instantiate_abc(self): + with pytest.raises(TypeError): + ProviderTransport() + + def test_concrete_must_implement_all_abstract(self): + class Incomplete(ProviderTransport): + @property + def api_mode(self): + return "test" + with pytest.raises(TypeError): + Incomplete() + + def test_minimal_concrete(self): + class Minimal(ProviderTransport): + @property + def api_mode(self): + return "test_minimal" + def convert_messages(self, messages, **kw): + return messages + def convert_tools(self, tools): + return tools + def build_kwargs(self, model, messages, tools=None, **params): + return {"model": model, "messages": messages} + def normalize_response(self, response, **kw): + return NormalizedResponse(content="ok", tool_calls=None, finish_reason="stop") + + t = Minimal() + assert t.api_mode == "test_minimal" + assert t.validate_response(None) is True # default + assert t.extract_cache_stats(None) is None # default + assert t.map_finish_reason("end_turn") == "end_turn" # default passthrough + + +# ── Registry tests ─────────────────────────────────────────────────────── + +class TestTransportRegistry: + + def test_get_unregistered_returns_none(self): + assert get_transport("nonexistent_mode") is None + + def test_anthropic_registered_on_import(self): + import agent.transports.anthropic # noqa: F401 + t = get_transport("anthropic_messages") + assert t is not None + assert t.api_mode == "anthropic_messages" + + def test_register_and_get(self): + class DummyTransport(ProviderTransport): + @property + def api_mode(self): + return "dummy_test" + def convert_messages(self, messages, **kw): + return messages + def convert_tools(self, tools): + return tools + def build_kwargs(self, model, messages, tools=None, **params): + return {} + def normalize_response(self, response, **kw): + return NormalizedResponse(content=None, tool_calls=None, finish_reason="stop") + + register_transport("dummy_test", DummyTransport) + t = get_transport("dummy_test") + assert t.api_mode == "dummy_test" + # Cleanup + _REGISTRY.pop("dummy_test", None) + + +# ── AnthropicTransport tests ──────────────────────────────────────────── + +class TestAnthropicTransport: + + @pytest.fixture + def transport(self): + import agent.transports.anthropic # noqa: F401 + return get_transport("anthropic_messages") + + def test_api_mode(self, transport): + assert transport.api_mode == "anthropic_messages" + + def test_convert_tools_simple(self, transport): + tools = [{ + "type": "function", + "function": { + "name": "test_tool", + "description": "A test", + "parameters": {"type": "object", "properties": {}}, + } + }] + result = transport.convert_tools(tools) + assert len(result) == 1 + assert result[0]["name"] == "test_tool" + assert "input_schema" in result[0] + + def test_validate_response_none(self, transport): + assert transport.validate_response(None) is False + + def test_validate_response_empty_content(self, transport): + r = SimpleNamespace(content=[]) + assert transport.validate_response(r) is False + + def test_validate_response_valid(self, transport): + r = SimpleNamespace(content=[SimpleNamespace(type="text", text="hello")]) + assert transport.validate_response(r) is True + + def test_map_finish_reason(self, transport): + assert transport.map_finish_reason("end_turn") == "stop" + assert transport.map_finish_reason("tool_use") == "tool_calls" + assert transport.map_finish_reason("max_tokens") == "length" + assert transport.map_finish_reason("stop_sequence") == "stop" + assert transport.map_finish_reason("refusal") == "content_filter" + assert transport.map_finish_reason("model_context_window_exceeded") == "length" + assert transport.map_finish_reason("unknown") == "stop" + + def test_extract_cache_stats_none_usage(self, transport): + r = SimpleNamespace(usage=None) + assert transport.extract_cache_stats(r) is None + + def test_extract_cache_stats_with_cache(self, transport): + usage = SimpleNamespace(cache_read_input_tokens=100, cache_creation_input_tokens=50) + r = SimpleNamespace(usage=usage) + result = transport.extract_cache_stats(r) + assert result == {"cached_tokens": 100, "creation_tokens": 50} + + def test_extract_cache_stats_zero(self, transport): + usage = SimpleNamespace(cache_read_input_tokens=0, cache_creation_input_tokens=0) + r = SimpleNamespace(usage=usage) + assert transport.extract_cache_stats(r) is None + + def test_normalize_response_text(self, transport): + """Test normalization of a simple text response.""" + r = SimpleNamespace( + content=[SimpleNamespace(type="text", text="Hello world")], + stop_reason="end_turn", + usage=SimpleNamespace(input_tokens=10, output_tokens=5), + model="claude-sonnet-4-6", + ) + nr = transport.normalize_response(r) + assert isinstance(nr, NormalizedResponse) + assert nr.content == "Hello world" + assert nr.tool_calls is None or nr.tool_calls == [] + assert nr.finish_reason == "stop" + + def test_normalize_response_tool_calls(self, transport): + """Test normalization of a tool-use response.""" + r = SimpleNamespace( + content=[ + SimpleNamespace( + type="tool_use", + id="toolu_123", + name="terminal", + input={"command": "ls"}, + ), + ], + stop_reason="tool_use", + usage=SimpleNamespace(input_tokens=10, output_tokens=20), + model="claude-sonnet-4-6", + ) + nr = transport.normalize_response(r) + assert nr.finish_reason == "tool_calls" + assert len(nr.tool_calls) == 1 + tc = nr.tool_calls[0] + assert tc.name == "terminal" + assert tc.id == "toolu_123" + assert '"command"' in tc.arguments + + def test_normalize_response_thinking(self, transport): + """Test normalization preserves thinking content.""" + r = SimpleNamespace( + content=[ + SimpleNamespace(type="thinking", thinking="Let me think..."), + SimpleNamespace(type="text", text="The answer is 42"), + ], + stop_reason="end_turn", + usage=SimpleNamespace(input_tokens=10, output_tokens=15), + model="claude-sonnet-4-6", + ) + nr = transport.normalize_response(r) + assert nr.content == "The answer is 42" + assert nr.reasoning == "Let me think..." + + def test_build_kwargs_returns_dict(self, transport): + """Test build_kwargs produces a usable kwargs dict.""" + messages = [{"role": "user", "content": "Hello"}] + kw = transport.build_kwargs( + model="claude-sonnet-4-6", + messages=messages, + max_tokens=1024, + ) + assert isinstance(kw, dict) + assert "model" in kw + assert "max_tokens" in kw + assert "messages" in kw + + def test_convert_messages_extracts_system(self, transport): + """Test convert_messages separates system from messages.""" + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hi"}, + ] + system, msgs = transport.convert_messages(messages) + # System should be extracted + assert system is not None + # Messages should only have user + assert len(msgs) >= 1