Ctrl K
ComunidadGuías…

app/tests/test_provider_adapters.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
from __future__ import annotations

import pytest

from app.privacy.ir import (
    CanonicalRole,
    ErrorEvent,
    FinishEvent,
    StructuredDataDelta,
    TextDelta,
    ToolArgumentsDelta,
    ToolCall,
    ToolResult,
)
from app.providers import (
    AdapterPolicy,
    AnthropicMessagesAdapter,
    GeminiGenerateContentAdapter,
    GeminiInteractionsAdapter,
    OpenAIChatCompletionsAdapter,
    OpenAIResponsesAdapter,
    ProviderAdapterError,
)

SCHEMA = {
    "type": "object",
    "properties": {"city": {"type": "string"}},
    "required": ["city"],
    "additionalProperties": False,
}


@pytest.mark.parametrize(
    ("adapter", "payload"),
    [
        (
            OpenAIChatCompletionsAdapter(),
            {
                "model": "gpt-test",
                "messages": [{"role": "user", "content": "hello"}],
                "unknown_payload": "must-not-disappear",
            },
        ),
        (
            OpenAIResponsesAdapter(),
            {"model": "gpt-test", "input": "hello", "unknown_payload": "must-not-disappear"},
        ),
        (
            AnthropicMessagesAdapter(),
            {
                "model": "claude-test",
                "max_tokens": 128,
                "messages": [{"role": "user", "content": "hello"}],
                "unknown_payload": "must-not-disappear",
            },
        ),
        (
            GeminiGenerateContentAdapter(),
            {
                "model": "gemini-test",
                "contents": [{"role": "user", "parts": [{"text": "hello"}]}],
                "unknownPayload": "must-not-disappear",
            },
        ),
        (
            GeminiInteractionsAdapter(),
            {"model": "gemini-test", "input": "hello", "unknown_payload": "must-not-disappear"},
        ),
    ],
)
def test_unknown_wire_fields_fail_closed(adapter: object, payload: dict[str, object]) -> None:
    with pytest.raises(ProviderAdapterError, match="unknown"):
        adapter.from_wire(payload)  # type: ignore[attr-defined]


@pytest.mark.parametrize(
    ("adapter", "payload"),
    [
        (
            OpenAIChatCompletionsAdapter(),
            {"model": "gpt-test", "messages": [], "store": True},
        ),
        (
            OpenAIResponsesAdapter(),
            {"model": "gpt-test", "input": "hello", "store": True},
        ),
        (
            GeminiInteractionsAdapter(),
            {"model": "gemini-test", "input": "hello", "store": True},
        ),
    ],
)
def test_provider_storage_is_disabled_by_default(
    adapter: object, payload: dict[str, object]
) -> None:
    with pytest.raises(ProviderAdapterError, match="storage"):
        adapter.from_wire(payload)  # type: ignore[attr-defined]


def test_gemini_interactions_server_state_requires_two_explicit_policy_controls() -> None:
    payload = {
        "model": "gemini-test",
        "input": "hello",
        "store": True,
        "previous_interaction_id": "interaction-1",
    }
    with pytest.raises(ProviderAdapterError, match="storage"):
        GeminiInteractionsAdapter().from_wire(payload)

    storage_only = GeminiInteractionsAdapter(AdapterPolicy(allow_provider_storage=True))
    with pytest.raises(ProviderAdapterError, match="reviewed storage policy"):
        storage_only.from_wire(payload)

    reviewed = GeminiInteractionsAdapter(
        AdapterPolicy(
            allow_provider_storage=True,
            safe_extension_fields=frozenset({"previous_interaction_id"}),
        )
    )
    request = reviewed.from_wire(payload)
    assert reviewed.to_wire(request)["previous_interaction_id"] == "interaction-1"


def test_gemini_interactions_labels_and_media_fail_closed_by_default() -> None:
    adapter = GeminiInteractionsAdapter()
    with pytest.raises(ProviderAdapterError, match="unknown unsafe field"):
        adapter.from_wire({"model": "gemini-test", "input": "hello", "labels": {"team": "red"}})
    with pytest.raises(ProviderAdapterError, match="media"):
        adapter.from_wire(
            {
                "model": "gemini-test",
                "input": [
                    {
                        "type": "user_input",
                        "content": [
                            {"type": "image", "mime_type": "image/png", "data": "opaque-base64"}
                        ],
                    }
                ],
            }
        )


def test_gemini_interactions_labels_require_explicit_safe_extension_policy() -> None:
    adapter = GeminiInteractionsAdapter(AdapterPolicy(safe_extension_fields=frozenset({"labels"})))
    request = adapter.from_wire(
        {"model": "gemini-test", "input": "hello", "labels": {"team": "synthetic"}}
    )
    assert adapter.to_wire(request)["labels"] == {"team": "synthetic"}


@pytest.mark.parametrize(
    ("adapter", "payload"),
    [
        (
            OpenAIResponsesAdapter(),
            {"model": "gpt-test", "input": "hello", "tools": [{"type": "web_search"}]},
        ),
        (
            AnthropicMessagesAdapter(),
            {
                "model": "claude-test",
                "max_tokens": 128,
                "messages": [{"role": "user", "content": "hello"}],
                "tools": [{"type": "web_search_20250305", "name": "web_search"}],
            },
        ),
        (
            GeminiGenerateContentAdapter(),
            {
                "model": "gemini-test",
                "contents": [{"role": "user", "parts": [{"text": "hello"}]}],
                "tools": [{"googleSearch": {}}],
            },
        ),
        (
            GeminiInteractionsAdapter(),
            {"model": "gemini-test", "input": "hello", "tools": [{"type": "google_search"}]},
        ),
    ],
)
def test_remote_provider_tools_are_blocked_without_trusted_policy(
    adapter: object, payload: dict[str, object]
) -> None:
    with pytest.raises(ProviderAdapterError, match="remote tool"):
        adapter.from_wire(payload)  # type: ignore[attr-defined]


def test_remote_provider_tool_requires_and_preserves_explicit_trust() -> None:
    adapter = OpenAIResponsesAdapter(AdapterPolicy(allow_remote_tools=True))
    request = adapter.from_wire(
        {
            "model": "gpt-test",
            "input": "hello",
            "tools": [{"type": "web_search", "search_context_size": "low"}],
        }
    )
    assert adapter.to_wire(request)["tools"] == [
        {"type": "web_search", "search_context_size": "low"}
    ]


def test_openai_chat_unsupported_media_fails_closed() -> None:
    with pytest.raises(ProviderAdapterError, match="unsupported content block"):
        OpenAIChatCompletionsAdapter().from_wire(
            {
                "model": "gpt-test",
                "messages": [
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "input_audio",
                                "input_audio": {"format": "wav", "data": "opaque-base64"},
                            }
                        ],
                    }
                ],
            }
        )


def test_explicitly_safe_opaque_extension_round_trips() -> None:
    adapter = OpenAIResponsesAdapter(AdapterPolicy(safe_extension_fields=frozenset({"metadata"})))
    request = adapter.from_wire(
        {"model": "gpt-test", "input": "hello", "metadata": {"trace": "synthetic"}}
    )
    assert request.extensions[0].name == "metadata"
    assert adapter.to_wire(request)["metadata"] == {"trace": "synthetic"}


def test_provider_specific_extension_cannot_be_silently_dropped_cross_provider() -> None:
    ingress = OpenAIChatCompletionsAdapter(
        AdapterPolicy(safe_extension_fields=frozenset({"metadata"}))
    )
    request = ingress.from_wire(
        {
            "model": "gemini-test",
            "messages": [{"role": "user", "content": "hello"}],
            "metadata": {"trace": "synthetic"},
        }
    )

    with pytest.raises(ProviderAdapterError, match="cannot cross providers"):
        GeminiGenerateContentAdapter().to_wire(request)


def test_openai_chat_round_trip_preserves_tools_calls_results_and_schema() -> None:
    adapter = OpenAIChatCompletionsAdapter()
    wire = {
        "model": "gpt-test",
        "messages": [
            {"role": "system", "content": "Keep answers short"},
            {"role": "user", "content": "Weather in Paris"},
            {
                "role": "assistant",
                "content": None,
                "tool_calls": [
                    {
                        "id": "call-1",
                        "type": "function",
                        "function": {"name": "weather", "arguments": '{"city":"Paris"}'},
                    }
                ],
            },
            {"role": "tool", "tool_call_id": "call-1", "content": '{"temperature":21}'},
        ],
        "tools": [
            {
                "type": "function",
                "function": {
                    "name": "weather",
                    "description": "Read weather",
                    "parameters": SCHEMA,
                    "strict": True,
                },
            }
        ],
        "response_format": {
            "type": "json_schema",
            "json_schema": {"name": "forecast", "strict": True, "schema": SCHEMA},
        },
        "max_completion_tokens": 256,
        "stream": True,
    }
    request = adapter.from_wire(wire)
    outbound = adapter.to_wire(request)

    assert isinstance(request.turns[2].content[0], ToolCall)
    assert isinstance(request.turns[3].content[0], ToolResult)
    assert outbound["messages"][2]["tool_calls"][0]["function"]["arguments"] == ('{"city":"Paris"}')
    assert outbound["messages"][3]["tool_call_id"] == "call-1"
    assert outbound["response_format"]["json_schema"]["schema"] == SCHEMA
    assert outbound["store"] is False


def test_openai_responses_round_trip_preserves_item_semantics() -> None:
    adapter = OpenAIResponsesAdapter()
    wire = {
        "model": "gpt-test",
        "instructions": "Keep answers short",
        "input": [
            {
                "type": "message",
                "role": "user",
                "content": [{"type": "input_text", "text": "Weather in Paris"}],
            },
            {
                "type": "function_call",
                "call_id": "call-1",
                "name": "weather",
                "arguments": '{"city":"Paris"}',
            },
            {
                "type": "function_call_output",
                "call_id": "call-1",
                "output": '{"temperature":21}',
            },
        ],
        "tools": [
            {
                "type": "function",
                "name": "weather",
                "description": "Read weather",
                "parameters": SCHEMA,
                "strict": True,
            }
        ],
        "text": {
            "format": {"type": "json_schema", "name": "forecast", "strict": True, "schema": SCHEMA}
        },
        "max_output_tokens": 256,
    }
    request = adapter.from_wire(wire)
    outbound = adapter.to_wire(request)

    assert request.turns[0].role is CanonicalRole.SYSTEM
    assert isinstance(request.turns[2].content[0], ToolCall)
    assert isinstance(request.turns[3].content[0], ToolResult)
    assert outbound["input"][1]["type"] == "function_call"
    assert outbound["input"][2]["type"] == "function_call_output"
    assert outbound["text"]["format"]["schema"] == SCHEMA
    assert outbound["store"] is False


def test_anthropic_round_trip_preserves_tools_calls_results_and_schema() -> None:
    adapter = AnthropicMessagesAdapter()
    wire = {
        "model": "claude-test",
        "max_tokens": 256,
        "system": "Keep answers short",
        "messages": [
            {"role": "user", "content": "Weather in Paris"},
            {
                "role": "assistant",
                "content": [
                    {
                        "type": "tool_use",
                        "id": "call-1",
                        "name": "weather",
                        "input": {"city": "Paris"},
                    }
                ],
            },
            {
                "role": "user",
                "content": [
                    {
                        "type": "tool_result",
                        "tool_use_id": "call-1",
                        "content": '{"temperature":21}',
                    }
                ],
            },
        ],
        "tools": [
            {
                "name": "weather",
                "description": "Read weather",
                "input_schema": SCHEMA,
                "strict": True,
            }
        ],
        "output_config": {"format": {"type": "json_schema", "schema": SCHEMA}},
        "stream": True,
    }
    request = adapter.from_wire(wire)
    outbound = adapter.to_wire(request)

    assert isinstance(request.turns[2].content[0], ToolCall)
    assert isinstance(request.turns[3].content[0], ToolResult)
    assert outbound["messages"][1]["content"][0]["type"] == "tool_use"
    assert outbound["messages"][2]["content"][0]["type"] == "tool_result"
    assert outbound["output_config"]["format"]["schema"] == SCHEMA


def test_gemini_generate_content_round_trip_preserves_function_parts() -> None:
    adapter = GeminiGenerateContentAdapter()
    wire = {
        "model": "gemini-test",
        "systemInstruction": {"parts": [{"text": "Keep answers short"}]},
        "contents": [
            {"role": "user", "parts": [{"text": "Weather in Paris"}]},
            {
                "role": "model",
                "parts": [
                    {"functionCall": {"id": "call-1", "name": "weather", "args": {"city": "Paris"}}}
                ],
            },
            {
                "role": "user",
                "parts": [
                    {
                        "functionResponse": {
                            "id": "call-1",
                            "name": "weather",
                            "response": {"temperature": 21},
                        }
                    }
                ],
            },
        ],
        "tools": [
            {
                "functionDeclarations": [
                    {"name": "weather", "description": "Read weather", "parameters": SCHEMA}
                ]
            }
        ],
        "generationConfig": {
            "maxOutputTokens": 256,
            "responseMimeType": "application/json",
            "responseSchema": SCHEMA,
        },
    }
    request = adapter.from_wire(wire)
    outbound = adapter.to_wire(request)

    assert isinstance(request.turns[2].content[0], ToolCall)
    assert isinstance(request.turns[3].content[0], ToolResult)
    assert outbound["contents"][1]["parts"][0]["functionCall"]["id"] == "call-1"
    assert outbound["contents"][2]["parts"][0]["functionResponse"]["id"] == "call-1"
    assert outbound["generationConfig"]["responseSchema"] == SCHEMA
    assert "store" not in outbound
    assert "model" not in outbound
    assert "stream" not in outbound
    assert adapter.target_for(request) == ("/v1beta/models/gemini-test:generateContent")


def test_experimental_gemini_interactions_round_trip_is_stateless_and_typed() -> None:
    adapter = GeminiInteractionsAdapter()
    assert adapter.experimental is True
    wire = {
        "model": "gemini-test",
        "system_instruction": "Keep answers short",
        "input": [
            {"type": "user_input", "content": [{"type": "text", "text": "Weather in Paris"}]},
            {
                "type": "function_call",
                "id": "call-1",
                "name": "weather",
                "arguments": {"city": "Paris"},
            },
            {
                "type": "function_result",
                "call_id": "call-1",
                "name": "weather",
                "result": [{"type": "text", "text": '{"temperature":21}'}],
            },
        ],
        "tools": [
            {
                "type": "function",
                "name": "weather",
                "description": "Read weather",
                "parameters": SCHEMA,
            }
        ],
        "response_format": {"type": "text", "mime_type": "application/json", "schema": SCHEMA},
        "generation_config": {"max_output_tokens": 256, "temperature": 0.2},
        "stream": True,
    }
    request = adapter.from_wire(wire)
    outbound = adapter.to_wire(request)

    assert isinstance(request.turns[2].content[0], ToolCall)
    assert isinstance(request.turns[3].content[0], ToolResult)
    assert outbound["input"][1]["type"] == "function_call"
    assert outbound["input"][2]["type"] == "function_result"
    assert outbound["response_format"]["schema"] == SCHEMA
    assert outbound["store"] is False


def test_openai_chat_stream_parser_covers_all_choices_and_tool_arguments() -> None:
    events = OpenAIChatCompletionsAdapter().parse_stream_event(
        {
            "choices": [
                {"index": 0, "delta": {"content": "first"}},
                {
                    "index": 1,
                    "delta": {
                        "tool_calls": [
                            {"index": 0, "id": "call-1", "function": {"arguments": '{"city":'}}
                        ]
                    },
                    "finish_reason": "tool_calls",
                },
            ]
        }
    )
    assert isinstance(events[0], TextDelta)
    assert events[0].path[1] == 0
    assert isinstance(events[1], ToolArgumentsDelta)
    assert events[1].call_id == "call-1"
    assert isinstance(events[2], FinishEvent)


def test_openai_chat_stream_parser_handles_refusal_and_legacy_function_call() -> None:
    events = OpenAIChatCompletionsAdapter().parse_stream_event(
        {
            "choices": [
                {
                    "index": 0,
                    "delta": {
                        "refusal": "cannot comply",
                        "function_call": {"name": "legacy", "arguments": '{"x":'},
                    },
                }
            ]
        }
    )
    assert isinstance(events[0], TextDelta)
    assert events[0].path[-1] == "refusal"
    assert isinstance(events[1], ToolArgumentsDelta)
    assert events[1].path[-2:] == ("function_call", "arguments")

    with pytest.raises(ProviderAdapterError, match="unknown stream delta"):
        OpenAIChatCompletionsAdapter().parse_stream_event(
            {"choices": [{"index": 0, "delta": {"new_opaque_field": "unsafe"}}]}
        )


def test_typed_stream_parsers_cover_structured_finish_and_error_events() -> None:
    responses = OpenAIResponsesAdapter()
    structured = responses.parse_stream_event(
        {
            "type": "response.output_text.delta",
            "output_index": 0,
            "content_index": 0,
            "delta": '{"answer":',
        },
        structured_output=True,
    )
    assert isinstance(structured[0], StructuredDataDelta)

    anthropic = AnthropicMessagesAdapter()
    arguments = anthropic.parse_stream_event(
        {
            "type": "content_block_delta",
            "index": 1,
            "delta": {"type": "input_json_delta", "partial_json": '{"city":'},
        }
    )
    assert isinstance(arguments[0], ToolArgumentsDelta)

    interactions = GeminiInteractionsAdapter()
    finish = interactions.parse_stream_event(
        {"event_type": "interaction.completed", "interaction": {"status": "completed"}}
    )
    error = interactions.parse_stream_event(
        {"event_type": "error", "error": {"code": "bad_request", "message": "bad"}}
    )
    assert isinstance(finish[0], FinishEvent)
    assert isinstance(error[0], ErrorEvent)