# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Unit tests for the native Gemini API translation layer.

Gemini does NOT speak OpenAI Chat Completions on its primary endpoint
(`streamGenerateContent`). `_stream_gemini` in
`core/inference/external_provider.py` translates between the two shapes:

  Request:
    OpenAI messages [{role, content}]
      -> Gemini contents [{role, parts: [{text}|{inlineData}|{functionCall}|...]}]
        + systemInstruction.parts[].text for role=system messages
        + generationConfig.{temperature,topP,topK,maxOutputTokens}
        + tools[{googleSearch:{}}] for web_search
        + tools[{codeExecution:{}}] for code_execution
        + responseModalities=[TEXT,IMAGE] for Nano Banana (gemini-2.5-flash-image)
        + cachedContent for prompt caching

  Response:
    Gemini SSE chunks { candidates:[{content:{parts:[...]}, finishReason}],
                        usageMetadata:{promptTokenCount, candidatesTokenCount} }
      -> OpenAI chat.completion.chunk frames
        (delta.content for text, delta.tool_calls for functionCall,
         _toolEvent for image_b64/web_search, usage block before [DONE])

These tests pin the outbound body shape AND the inbound translation via
httpx.MockTransport (no live network). Mirrors test_anthropic_cache_ttl.py
and test_openai_image_generation.py.
"""

import asyncio
import base64
import json

import httpx
import pytest

from core.inference import external_provider as ep_mod
from core.inference.external_provider import ExternalProviderClient


_active_mock_clients: list[httpx.AsyncClient] = []


def _drive(coro):
    # Fresh loop per drive so tests don't share asyncio state. Close mocked
    # clients + shutdown async-generators inside this loop so Python 3.13
    # doesn't emit `Response.aiter_*.aclose was never awaited` on GC.
    loop = asyncio.new_event_loop()
    try:
        result = loop.run_until_complete(coro)
        while _active_mock_clients:
            mc = _active_mock_clients.pop()
            loop.run_until_complete(mc.aclose())
        return result
    finally:
        try:
            loop.run_until_complete(loop.shutdown_asyncgens())
        finally:
            loop.close()


def _make_gemini_client(
    base_url: str = "https://generativelanguage.googleapis.com/v1beta",
) -> ExternalProviderClient:
    return ExternalProviderClient(
        provider_type = "gemini",
        base_url = base_url,
        api_key = "AIza-test-key",
    )


def _mock_http(monkeypatch, handler):
    mock_client = httpx.AsyncClient(transport = httpx.MockTransport(handler))
    monkeypatch.setattr(ep_mod, "_http_client", mock_client)
    # `_drive` acloses this at end of run inside the same event loop, so we
    # don't leak an unawaited aclose() coroutine.
    _active_mock_clients.append(mock_client)


def _gemini_sse(events: list[dict]) -> bytes:
    """Encode a list of dicts as Gemini-style SSE frames (`data:` lines)."""
    chunks: list[str] = []
    for event in events:
        chunks.append(f"data: {json.dumps(event)}")
        chunks.append("")
    return ("\n".join(chunks) + "\n").encode("utf-8")


def _capture_body(monkeypatch, **kwargs) -> dict:
    """Drive a single stream and return the captured outbound request body."""
    captured: dict = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["body"] = json.loads(request.content.decode("utf-8"))
        captured["headers"] = dict(request.headers)
        captured["url"] = str(request.url)
        captured["method"] = request.method
        # Minimal valid Gemini stream so the helper completes.
        return httpx.Response(
            200,
            content = _gemini_sse(
                [
                    {
                        "candidates": [
                            {
                                "content": {
                                    "role": "model",
                                    "parts": [{"text": "ok"}],
                                },
                                "finishReason": "STOP",
                            }
                        ],
                        "usageMetadata": {
                            "promptTokenCount": 1,
                            "candidatesTokenCount": 1,
                        },
                    }
                ]
            ),
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    messages = kwargs.pop("messages", [{"role": "user", "content": "hi"}])
    model = kwargs.pop("model", "gemini-2.5-flash")
    temperature = kwargs.pop("temperature", 0.7)
    top_p = kwargs.pop("top_p", 0.95)
    max_tokens = kwargs.pop("max_tokens", 64)

    async def run():
        client = _make_gemini_client()
        async for _ in client.stream_chat_completion(
            messages = messages,
            model = model,
            temperature = temperature,
            top_p = top_p,
            max_tokens = max_tokens,
            **kwargs,
        ):
            pass
        await client.close()

    _drive(run())
    return captured


def _collect(monkeypatch, sse_events, **kwargs) -> list[str]:
    """Drive a stream with a custom set of SSE events and return raw lines."""

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            content = _gemini_sse(sse_events),
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    messages = kwargs.pop("messages", [{"role": "user", "content": "hi"}])
    model = kwargs.pop("model", "gemini-2.5-flash")
    temperature = kwargs.pop("temperature", 0.7)
    top_p = kwargs.pop("top_p", 0.95)
    max_tokens = kwargs.pop("max_tokens", 64)

    out: list[str] = []

    async def run():
        client = _make_gemini_client()
        async for line in client.stream_chat_completion(
            messages = messages,
            model = model,
            temperature = temperature,
            top_p = top_p,
            max_tokens = max_tokens,
            **kwargs,
        ):
            out.append(line)
        await client.close()

    _drive(run())
    return out


def _parse_chunks(lines: list[str]) -> list[dict]:
    out: list[dict] = []
    for raw in lines:
        if not raw.startswith("data:"):
            continue
        payload = raw[len("data:") :].strip()
        if not payload or payload == "[DONE]":
            continue
        try:
            out.append(json.loads(payload))
        except json.JSONDecodeError:
            continue
    return out


# ── request body translation ─────────────────────────────────────────


def test_request_body_uses_contents_and_parts_shape(monkeypatch):
    """OpenAI messages must be translated to Gemini's `contents` shape."""
    captured = _capture_body(
        monkeypatch,
        messages = [
            {"role": "system", "content": "Be brief."},
            {"role": "user", "content": "Hello"},
            {"role": "assistant", "content": "Hi there"},
            {"role": "user", "content": "Follow up"},
        ],
    )
    body = captured["body"]
    # system -> systemInstruction
    assert body["systemInstruction"] == {"parts": [{"text": "Be brief."}]}, body
    # user/assistant -> contents with role user/model
    assert body["contents"] == [
        {"role": "user", "parts": [{"text": "Hello"}]},
        {"role": "model", "parts": [{"text": "Hi there"}]},
        {"role": "user", "parts": [{"text": "Follow up"}]},
    ], body["contents"]
    # generationConfig fields map across with Google's casing.
    gc = body["generationConfig"]
    assert gc["temperature"] == 0.7
    assert gc["topP"] == 0.95
    assert gc["maxOutputTokens"] == 64


def test_request_url_targets_stream_generate_content(monkeypatch):
    """Helper must POST to /v1beta/models/{model}:streamGenerateContent?alt=sse."""
    captured = _capture_body(monkeypatch, model = "gemini-2.5-pro")
    url = captured["url"]
    assert ":streamGenerateContent" in url, url
    assert "alt=sse" in url, url
    assert "/v1beta/models/gemini-2.5-pro" in url, url
    assert captured["method"] == "POST"


def test_request_auth_header_uses_x_goog_api_key(monkeypatch):
    """API key must be sent on `x-goog-api-key`, not Authorization."""
    captured = _capture_body(monkeypatch)
    hdrs = captured["headers"]
    assert hdrs.get("x-goog-api-key") == "AIza-test-key", hdrs
    assert "authorization" not in {k.lower() for k in hdrs}, hdrs


def test_top_k_forwarded_only_when_positive(monkeypatch):
    """top_k is opt-in; only positive integers reach the wire."""
    captured = _capture_body(monkeypatch, top_k = 40)
    assert captured["body"]["generationConfig"]["topK"] == 40

    captured = _capture_body(monkeypatch, top_k = 0)
    assert "topK" not in captured["body"]["generationConfig"]


def test_presence_penalty_forwarded_to_generation_config(monkeypatch):
    """A non-zero presence_penalty reaches generationConfig.presencePenalty."""
    captured = _capture_body(monkeypatch, presence_penalty = 0.7)
    assert captured["body"]["generationConfig"]["presencePenalty"] == 0.7

    # Default zero is omitted, matching top_k semantics.
    captured = _capture_body(monkeypatch, presence_penalty = 0.0)
    assert "presencePenalty" not in captured["body"]["generationConfig"]


# ── thinkingConfig translation ────────────────────────────────────────


def test_gemini25_flash_thinking_disabled_sets_budget_zero(monkeypatch):
    """Gemini 2.5 Flash still uses thinkingBudget; 0 = off."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash",
        enable_thinking = False,
    )
    tc = captured["body"]["generationConfig"].get("thinkingConfig")
    assert tc == {"thinkingBudget": 0}, tc


def test_gemini3_flash_thinking_disabled_uses_minimal_level(monkeypatch):
    """Gemini 3 Flash uses thinkingLevel; "off" maps to minimal
    (Gemini 3 cannot turn thinking fully off)."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-3.5-flash",
        enable_thinking = False,
    )
    tc = captured["body"]["generationConfig"].get("thinkingConfig")
    assert tc == {"thinkingLevel": "minimal"}, tc


def test_gemini25_pro_thinking_disabled_uses_small_budget(monkeypatch):
    """Gemini 2.5 Pro 400s on thinkingBudget=0 ("only works in thinking
    mode"); coerce to a small positive budget."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-pro",
        enable_thinking = False,
    )
    tc = captured["body"]["generationConfig"].get("thinkingConfig")
    assert tc is not None and tc.get("thinkingBudget", 0) > 0, tc


def test_gemini3_pro_thinking_disabled_uses_low_level(monkeypatch):
    """Gemini 3 Pro uses thinkingLevel and rejects 'minimal' (Pro tier), so
    'off' coerces to 'low' (lowest the API accepts)."""
    for model in (
        "gemini-3.1-pro-preview",
        "gemini-3-pro-preview",
        "gemini-3.5-pro",
        "gemini-pro-latest",
    ):
        captured = _capture_body(
            monkeypatch,
            model = model,
            enable_thinking = False,
        )
        tc = captured["body"]["generationConfig"].get("thinkingConfig")
        assert tc == {"thinkingLevel": "low"}, (model, tc)


def test_gemini25_flash_effort_levels_map_to_budgets(monkeypatch):
    """Gemini 2.5 Flash retains the integer thinkingBudget ladder."""
    cases = {
        "minimal": 512,
        "low": 2048,
        "medium": 8192,
        "high": 24576,
        "max": -1,
        "xhigh": -1,
    }
    for effort, expected in cases.items():
        captured = _capture_body(
            monkeypatch,
            model = "gemini-2.5-flash",
            reasoning_effort = effort,
        )
        tc = captured["body"]["generationConfig"].get("thinkingConfig")
        assert tc == {"thinkingBudget": expected}, (effort, tc)


def test_gemini3_flash_effort_levels_map_to_thinking_level(monkeypatch):
    """Gemini 3 Flash thinkingLevel ladder: minimal/low/medium/high."""
    cases = {
        "minimal": "minimal",
        "low": "low",
        "medium": "medium",
        "high": "high",
        "max": "high",
    }
    for effort, expected in cases.items():
        captured = _capture_body(
            monkeypatch,
            model = "gemini-3.5-flash",
            reasoning_effort = effort,
        )
        tc = captured["body"]["generationConfig"].get("thinkingConfig")
        assert tc == {"thinkingLevel": expected}, (effort, tc)


def test_gemini3_pro_passes_medium_through(monkeypatch):
    """Gemini 3.1+ Pro accepts thinkingLevel="medium" per
    https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro;
    forward as-is (medium is the documented mid-tier on Gemini 3.1)."""
    for model in (
        "gemini-3.1-pro-preview",
        "gemini-pro-latest",
    ):
        captured = _capture_body(
            monkeypatch,
            model = model,
            reasoning_effort = "medium",
        )
        tc = captured["body"]["generationConfig"].get("thinkingConfig")
        assert tc == {"thinkingLevel": "medium"}, (model, tc)


def test_gemini3_pro_minimal_effort_coerces_to_low(monkeypatch):
    """Gemini 3 Pro rejects thinkingLevel="minimal"; coerce to "low"."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-3.1-pro-preview",
        reasoning_effort = "minimal",
    )
    tc = captured["body"]["generationConfig"].get("thinkingConfig")
    assert tc == {"thinkingLevel": "low"}, tc


def test_gemini3_flash_effort_none_maps_to_minimal(monkeypatch):
    """reasoning_effort='none' on Gemini 3 Flash -> thinkingLevel=minimal."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-3.5-flash",
        reasoning_effort = "none",
    )
    tc = captured["body"]["generationConfig"].get("thinkingConfig")
    assert tc == {"thinkingLevel": "minimal"}, tc


def test_thinking_default_omits_thinking_config(monkeypatch):
    """When neither knob is supplied, thinkingConfig is omitted (Google's
    server-side default applies)."""
    captured = _capture_body(monkeypatch, model = "gemini-3.5-flash")
    gc = captured["body"]["generationConfig"]
    assert "thinkingConfig" not in gc, gc


def test_nano_banana_alias_routes_through_image_modalities(monkeypatch):
    """`nano-banana-pro-preview` aliases the Pro image model; must set
    responseModalities=[TEXT,IMAGE] when the Images pill is on
    (enabled_tools includes "image_generation")."""
    captured = _capture_body(
        monkeypatch,
        model = "nano-banana-pro-preview",
        enabled_tools = ["image_generation"],
    )
    gc = captured["body"]["generationConfig"]
    assert gc.get("responseModalities") == ["TEXT", "IMAGE"], gc


def test_image_capable_model_without_image_pill_stays_text_only(monkeypatch):
    """When the Images pill is off (no image_generation in enabled_tools), an
    image-capable model id (gemini-2.5-flash-image) must force
    responseModalities=["TEXT"]. Google's image models default to text+image
    when responseModalities is omitted, so omitting it would silently bill
    image output the UI says is disabled."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = [],
    )
    gc = captured["body"]["generationConfig"]
    assert gc.get("responseModalities") == ["TEXT"], gc


def test_image_models_skip_thinking_config(monkeypatch):
    """Image-tier ids have no visible thinking knob and must NOT forward
    thinkingConfig even when stale UI state still sends `reasoning_effort` or
    `enable_thinking=False`."""
    for model in (
        "gemini-2.5-flash-image",
        "gemini-3.1-flash-image-preview",
        "gemini-3-pro-image-preview",
        "nano-banana-pro-preview",
    ):
        captured = _capture_body(
            monkeypatch,
            model = model,
            reasoning_effort = "high",
            enable_thinking = False,
            enabled_tools = ["image_generation"],
        )
        gc = captured["body"]["generationConfig"]
        assert "thinkingConfig" not in gc, (model, gc)


def test_image_models_drop_code_execution(monkeypatch):
    """All image-tier ids reject `tools: [{codeExecution: {}}]`; drop
    silently. (Gemini 3 image models DO accept googleSearch -- see
    test_gemini3_image_models_allow_google_search; older ones drop
    everything.)"""
    for model in (
        "gemini-2.5-flash-image",
        "gemini-3.1-flash-image-preview",
        "gemini-3-pro-image-preview",
        "nano-banana-pro-preview",
    ):
        captured = _capture_body(
            monkeypatch,
            model = model,
            enabled_tools = ["image_generation", "code_execution"],
        )
        tools_arr = captured["body"].get("tools") or []
        names = [list(t.keys())[0] for t in tools_arr]
        assert "codeExecution" not in names, (model, tools_arr)


def test_gemini_35_pro_uses_thinking_level(monkeypatch):
    """`gemini-3.5-pro` is Gemini 3 family and uses thinkingLevel (not
    thinkingBudget). "Off" maps to "low" since Pro tier rejects "minimal"."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-3.5-pro",
        enable_thinking = False,
    )
    tc = captured["body"]["generationConfig"].get("thinkingConfig")
    assert tc == {"thinkingLevel": "low"}, tc


def test_gemini3_image_models_allow_google_search(monkeypatch):
    """Google documents Search grounding on the Gemini 3 image family
    (gemini-3-pro-image-preview, gemini-3.1-flash-image-preview,
    nano-banana-pro). codeExecution stays blocked on image mode."""
    for model in (
        "gemini-3-pro-image-preview",
        "gemini-3.1-flash-image-preview",
        "nano-banana-pro-preview",
    ):
        captured = _capture_body(
            monkeypatch,
            model = model,
            enabled_tools = ["image_generation", "web_search", "code_execution"],
        )
        tools_arr = captured["body"].get("tools") or []
        names = [list(t.keys())[0] for t in tools_arr]
        assert "googleSearch" in names, (model, tools_arr)
        assert "codeExecution" not in names, (model, tools_arr)


def test_legacy_image_models_block_google_search(monkeypatch):
    """Older Gemini image ids (gemini-2.5-flash-image) still 400 on
    `tools: [{googleSearch: {}}]`; backend keeps stripping it."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = ["image_generation", "web_search", "code_execution"],
    )
    assert "tools" not in captured["body"], captured["body"].get("tools")


def test_legacy_openai_base_url_normalized(monkeypatch):
    """Saved Gemini providers with the legacy `/v1beta/openai` base (from
    pre-PR OpenAI-compat plumbing) now point at the native endpoint without
    the user re-saving the connection."""
    client = ExternalProviderClient(
        provider_type = "gemini",
        base_url = "https://generativelanguage.googleapis.com/v1beta/openai",
        api_key = "AIza-test-key",
    )
    assert client.base_url == "https://generativelanguage.googleapis.com/v1beta"


def test_finish_reason_swaps_to_tool_calls_when_function_call_emitted(monkeypatch):
    """Gemini emits finishReason="STOP" even for pure functionCall turns;
    surface as `tool_calls` so OAI clients run the tool."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [{"functionCall": {"name": "lookup", "args": {"k": "v"}}}],
                    },
                    "finishReason": "STOP",
                }
            ]
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    finish_chunks = [
        c for c in chunks if c.get("choices", [{}])[0].get("finish_reason") is not None
    ]
    assert finish_chunks, chunks
    assert finish_chunks[-1]["choices"][0]["finish_reason"] == "tool_calls", chunks


def test_thought_signature_round_trips_into_gemini_function_call(monkeypatch):
    """An assistant tool_call carrying `extra_content.google.thought_signature`
    must echo it back as a sibling of the Gemini functionCall part."""
    captured = _capture_body(
        monkeypatch,
        messages = [
            {"role": "user", "content": "lookup x"},
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_0",
                        "type": "function",
                        "function": {"name": "lookup", "arguments": "{}"},
                        "extra_content": {"google": {"thought_signature": "SIG-ABC"}},
                    }
                ],
            },
            {
                "role": "tool",
                "tool_call_id": "call_0",
                "name": "lookup",
                "content": "{}",
            },
        ],
    )
    contents = captured["body"]["contents"]
    fc_turn = next((c for c in contents if c["role"] == "model"), None)
    assert fc_turn is not None, contents
    fc_part = next(
        (p for p in fc_turn["parts"] if "functionCall" in p),
        None,
    )
    assert fc_part is not None, fc_turn
    assert fc_part.get("thoughtSignature") == "SIG-ABC", fc_part


def test_thought_signature_emitted_in_tool_call_delta(monkeypatch):
    """A Gemini functionCall part with `thoughtSignature` must surface it on
    the outbound OpenAI tool_calls delta via
    `extra_content.google.thought_signature`."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "functionCall": {
                                    "name": "lookup",
                                    "args": {"k": "v"},
                                    "id": "call_xyz",
                                },
                                "thoughtSignature": "SIG-FROM-GEMINI",
                            }
                        ],
                    },
                    "finishReason": "STOP",
                }
            ]
        }
    ]
    chunks = _parse_chunks(_collect(monkeypatch, sse))
    deltas = [
        tc
        for c in chunks
        for tc in (c.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", [])
    ]
    assert deltas, chunks
    sig = deltas[0].get("extra_content", {}).get("google", {}).get("thought_signature")
    assert sig == "SIG-FROM-GEMINI", deltas


def test_image_models_suppress_phantom_web_search_card(monkeypatch):
    """When the image guard filters googleSearch out of the request, the
    inbound stream must NOT emit web_search tool_start / tool_end (else the UI
    shows a misleading 'Search complete' card on a turn Gemini never
    searched)."""
    sse = [
        {
            "candidates": [
                {
                    "content": {"role": "model", "parts": [{"text": "drawn"}]},
                    "finishReason": "STOP",
                }
            ]
        }
    ]
    lines = _collect(
        monkeypatch,
        sse,
        model = "gemini-2.5-flash-image",
        enabled_tools = ["image_generation", "web_search", "code_execution"],
    )
    chunks = _parse_chunks(lines)
    tool_evs = [
        ev
        for c in chunks
        for ev in [c.get("_toolEvent")]
        if isinstance(ev, dict) and ev.get("tool_name") == "web_search"
    ]
    assert tool_evs == [], tool_evs


def test_image_generation_tool_on_image_model_drops_text_tools(monkeypatch):
    """`enabled_tools=["image_generation", "web_search", "code_execution"]`
    on a Gemini IMAGE model flips responseModalities to TEXT+IMAGE; in that
    mode codeExecution must NOT be forwarded (Gemini rejects text code tools
    alongside image responseModalities). Older image families also drop
    googleSearch."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = [
            "image_generation",
            "web_search",
            "code_execution",
        ],
    )
    assert "tools" not in captured["body"], captured["body"]
    assert captured["body"]["generationConfig"].get("responseModalities") == ["TEXT", "IMAGE"]


def test_prompt_feedback_block_reason_surfaces_as_error(monkeypatch):
    """`promptFeedback.blockReason` with zero candidates must produce an error
    chunk, not a silent empty assistant reply."""
    sse = [
        {
            "promptFeedback": {"blockReason": "SAFETY"},
        }
    ]
    chunks = _parse_chunks(_collect(monkeypatch, sse))
    error_chunks = [c for c in chunks if "error" in c]
    assert error_chunks, chunks
    assert "SAFETY" in (error_chunks[0].get("error", {}).get("message") or ""), error_chunks


def test_usage_chunk_includes_thoughts_tokens(monkeypatch):
    """`thoughtsTokenCount` is the hidden-reasoning slice of output; roll it
    into `output_tokens` AND surface it on
    `output_tokens_details.reasoning_tokens` so total_tokens reflects the full
    billable spend."""
    sse = [
        {
            "candidates": [
                {
                    "content": {"role": "model", "parts": [{"text": "ok"}]},
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 10,
                "candidatesTokenCount": 5,
                "thoughtsTokenCount": 20,
                "totalTokenCount": 35,
            },
        }
    ]
    chunks = _parse_chunks(_collect(monkeypatch, sse))
    usage_chunk = next((c for c in chunks if isinstance(c.get("usage"), dict)), None)
    assert usage_chunk is not None, chunks
    usage = usage_chunk["usage"]
    assert usage.get("prompt_tokens") == 10, usage
    # candidates 5 + thoughts 20 = 25 output tokens; total = 35.
    assert usage.get("completion_tokens") == 25, usage
    assert usage.get("total_tokens") == 35, usage


# ── web_search forwarded as googleSearch tool ────────────────────────


def test_web_search_forwarded_as_google_search_tool(monkeypatch):
    captured = _capture_body(
        monkeypatch,
        enabled_tools = ["web_search"],
    )
    tools = captured["body"].get("tools") or []
    assert {"googleSearch": {}} in tools, tools


def test_code_execution_forwarded_as_code_execution_tool(monkeypatch):
    captured = _capture_body(
        monkeypatch,
        enabled_tools = ["code_execution"],
    )
    tools = captured["body"].get("tools") or []
    assert {"codeExecution": {}} in tools, tools


def test_omitted_tools_leaves_body_untouched(monkeypatch):
    captured = _capture_body(monkeypatch, enabled_tools = [])
    assert "tools" not in captured["body"], captured["body"]


# ── prompt caching passthrough ───────────────────────────────────────


def test_cached_content_pass_through(monkeypatch):
    """A string cache id on enable_prompt_caching is forwarded verbatim."""
    cache_name = "cachedContents/abc123"
    captured = _capture_body(
        monkeypatch,
        enable_prompt_caching = cache_name,
    )
    assert captured["body"].get("cachedContent") == cache_name


def test_boolean_caching_does_not_set_cached_content(monkeypatch):
    """Studio's existing True/False signals shouldn't fabricate a cache id."""
    captured = _capture_body(monkeypatch, enable_prompt_caching = True)
    assert "cachedContent" not in captured["body"]


# ── image generation: request modalities + response translation ──────


def test_image_model_sets_response_modalities(monkeypatch):
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = ["image_generation"],
    )
    assert captured["body"]["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]


def test_image_generation_tool_sets_response_modalities_on_image_model(monkeypatch):
    """`enabled_tools=["image_generation"]` flips responseModalities
    only when the selected model is image-capable; otherwise the
    request stays plain text (text-only models 400 on
    responseModalities)."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = ["image_generation"],
    )
    assert captured["body"]["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]


def test_image_response_emits_image_b64_tool_event(monkeypatch):
    """`inlineData` parts become a tool_end with image_b64 + image_mime."""
    fake_b64 = base64.b64encode(b"PNG-BYTES").decode()
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "inlineData": {
                                    "mimeType": "image/png",
                                    "data": fake_b64,
                                }
                            }
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 5,
                "candidatesTokenCount": 0,
            },
        }
    ]
    lines = _collect(
        monkeypatch,
        sse,
        model = "gemini-2.5-flash-image",
    )
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    starts = [e for e in tool_events if e.get("type") == "tool_start"]
    ends = [e for e in tool_events if e.get("type") == "tool_end"]
    image_starts = [e for e in starts if e.get("tool_name") == "image_generation"]
    image_ends = [e for e in ends if e.get("image_b64")]
    assert len(image_starts) == 1, tool_events
    assert len(image_ends) == 1, tool_events
    assert image_ends[0]["image_b64"] == fake_b64
    assert image_ends[0]["image_mime"] == "image/png"


# ── function calling round-trips both directions ─────────────────────


def test_function_call_response_translates_to_tool_calls_delta(monkeypatch):
    """Gemini `functionCall` parts become OpenAI `tool_calls` delta chunks."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "functionCall": {
                                    "name": "get_weather",
                                    "args": {"location": "Paris"},
                                }
                            }
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 12,
                "candidatesTokenCount": 4,
            },
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    tool_call_chunks = [
        c
        for c in chunks
        if "_toolEvent" not in c
        and any(
            (isinstance(ch.get("delta"), dict) and "tool_calls" in ch["delta"])
            for ch in c.get("choices", [])
        )
    ]
    assert len(tool_call_chunks) == 1, chunks
    tc = tool_call_chunks[0]["choices"][0]["delta"]["tool_calls"][0]
    assert tc["function"]["name"] == "get_weather"
    args = json.loads(tc["function"]["arguments"])
    assert args == {"location": "Paris"}


def test_tool_message_translates_to_function_response_part(monkeypatch):
    """role=tool follow-ups are rewritten to functionResponse parts."""
    messages = [
        {"role": "user", "content": "Weather?"},
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "call_1",
                    "type": "function",
                    "function": {
                        "name": "get_weather",
                        "arguments": json.dumps({"location": "Paris"}),
                    },
                }
            ],
        },
        {
            "role": "tool",
            "name": "get_weather",
            "content": json.dumps({"temp_c": 18, "summary": "Sunny"}),
        },
    ]
    captured = _capture_body(monkeypatch, messages = messages)
    contents = captured["body"]["contents"]
    # Last turn must be a functionResponse part (Gemini wraps it as a role=user
    # turn carrying the result).
    last = contents[-1]
    assert last["role"] == "user", last
    fr = last["parts"][0].get("functionResponse")
    assert fr is not None, last
    assert fr["name"] == "get_weather"
    assert fr["response"] == {"temp_c": 18, "summary": "Sunny"}
    # And the assistant turn carries the original functionCall so the model
    # sees the round-trip context.
    assistant_turn = [c for c in contents if c["role"] == "model"][0]
    fc_part = next(
        (p for p in assistant_turn["parts"] if "functionCall" in p),
        None,
    )
    assert fc_part is not None, assistant_turn
    assert fc_part["functionCall"]["name"] == "get_weather"
    assert fc_part["functionCall"]["args"] == {"location": "Paris"}


def test_parallel_function_calls_get_distinct_tool_call_indices(monkeypatch):
    """Each emitted functionCall in one assistant turn needs its own
    tool_calls[*].index. Hardcoding index=0 collapses parallel calls onto one
    slot in OpenAI-style reassemblers."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "functionCall": {
                                    "id": "call_alpha",
                                    "name": "search",
                                    "args": {"q": "alpha"},
                                }
                            },
                            {
                                "functionCall": {
                                    "id": "call_beta",
                                    "name": "search",
                                    "args": {"q": "beta"},
                                }
                            },
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 8,
                "candidatesTokenCount": 4,
            },
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    tool_call_chunks = [
        c
        for c in chunks
        if "_toolEvent" not in c
        and any(
            (isinstance(ch.get("delta"), dict) and "tool_calls" in ch["delta"])
            for ch in c.get("choices", [])
        )
    ]
    assert len(tool_call_chunks) == 2, tool_call_chunks
    indices = [c["choices"][0]["delta"]["tool_calls"][0]["index"] for c in tool_call_chunks]
    assert indices == [0, 1], indices


def test_function_call_ids_forwarded_into_gemini_function_call_part(monkeypatch):
    """OpenAI tool_call id rides functionCall.id so parallel calls disambiguate."""
    messages = [
        {"role": "user", "content": "x"},
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "call_alpha",
                    "type": "function",
                    "function": {
                        "name": "search",
                        "arguments": json.dumps({"q": "a"}),
                    },
                },
                {
                    "id": "call_beta",
                    "type": "function",
                    "function": {
                        "name": "search",
                        "arguments": json.dumps({"q": "b"}),
                    },
                },
            ],
        },
        {
            "role": "tool",
            "tool_call_id": "call_alpha",
            "content": json.dumps({"hits": ["A"]}),
        },
        {
            "role": "tool",
            "tool_call_id": "call_beta",
            "content": json.dumps({"hits": ["B"]}),
        },
    ]
    captured = _capture_body(monkeypatch, messages = messages)
    contents = captured["body"]["contents"]
    assistant_parts = next(c for c in contents if c["role"] == "model")["parts"]
    call_ids = [p["functionCall"]["id"] for p in assistant_parts if "functionCall" in p]
    assert call_ids == ["call_alpha", "call_beta"], assistant_parts
    response_ids = [
        p["functionResponse"]["id"] for c in contents for p in c["parts"] if "functionResponse" in p
    ]
    assert response_ids == ["call_alpha", "call_beta"], contents


def test_parse_gemini_models_translates_native_catalog():
    """Gemini's native /v1beta/models payload becomes OpenAI-shape entries."""
    payload = {
        "models": [
            {
                "name": "models/gemini-2.5-flash",
                "baseModelId": "gemini-2.5-flash",
                "displayName": "Gemini 2.5 Flash",
                "supportedGenerationMethods": [
                    "generateContent",
                    "streamGenerateContent",
                ],
            },
            {
                "name": "models/embedding-001",
                "supportedGenerationMethods": ["embedContent"],
            },
            {
                "name": "models/gemini-2.5-pro",
            },
        ]
    }
    out = ExternalProviderClient._parse_gemini_models(payload)
    ids = [m["id"] for m in out]
    assert "gemini-2.5-flash" in ids
    assert "gemini-2.5-pro" in ids
    assert "embedding-001" not in ids
    flash = next(m for m in out if m["id"] == "gemini-2.5-flash")
    assert flash["display_name"] == "Gemini 2.5 Flash"
    assert flash["owned_by"] == "google"


def test_code_execution_parts_translate_to_code_execution_tool_events(monkeypatch):
    """executableCode + codeExecutionResult parts emit code_execution events."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "executableCode": {
                                    "language": "PYTHON",
                                    "code": "print(2+2)",
                                }
                            },
                            {
                                "codeExecutionResult": {
                                    "outcome": "OUTCOME_OK",
                                    "output": "4\n",
                                }
                            },
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 8,
                "candidatesTokenCount": 4,
            },
        }
    ]
    lines = _collect(monkeypatch, sse, enabled_tools = ["code_execution"])
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    code_starts = [
        e
        for e in tool_events
        if e.get("type") == "tool_start" and e.get("tool_name") == "code_execution"
    ]
    code_ends = [
        e for e in tool_events if e.get("type") == "tool_end" and "4" in str(e.get("result", ""))
    ]
    assert len(code_starts) == 1, tool_events
    assert code_starts[0]["arguments"]["code"] == "print(2+2)"
    assert code_starts[0]["arguments"]["language"] == "python"
    assert len(code_ends) == 1, tool_events
    # tool_start and tool_end must share a tool_call_id so the frontend pairs
    # them onto one CodeExecutionToolUI block.
    assert code_starts[0]["tool_call_id"] == code_ends[0]["tool_call_id"]


def test_code_execution_failure_outcome_surfaces_in_result(monkeypatch):
    """OUTCOME_FAILED is prefixed onto the result text so the UI shows it."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "executableCode": {
                                    "language": "PYTHON",
                                    "code": "1/0",
                                }
                            },
                            {
                                "codeExecutionResult": {
                                    "outcome": "OUTCOME_FAILED",
                                    "output": "ZeroDivisionError",
                                }
                            },
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 5,
                "candidatesTokenCount": 2,
            },
        }
    ]
    lines = _collect(monkeypatch, sse, enabled_tools = ["code_execution"])
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    result_text = next(
        (e["result"] for e in tool_events if e.get("type") == "tool_end"),
        "",
    )
    assert "OUTCOME_FAILED" in result_text
    assert "ZeroDivisionError" in result_text


def test_tool_message_recovers_name_from_tool_call_id(monkeypatch):
    """When name is omitted, recover it from the matching tool_call_id."""
    messages = [
        {"role": "user", "content": "Weather?"},
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "call_xyz",
                    "type": "function",
                    "function": {
                        "name": "get_weather",
                        "arguments": json.dumps({"location": "Paris"}),
                    },
                }
            ],
        },
        {
            "role": "tool",
            "tool_call_id": "call_xyz",
            "content": json.dumps({"temp_c": 18}),
        },
    ]
    captured = _capture_body(monkeypatch, messages = messages)
    contents = captured["body"]["contents"]
    last = contents[-1]
    fr = last["parts"][0].get("functionResponse")
    assert fr is not None, last
    assert (
        fr["name"] == "get_weather"
    ), "name should fall back to the prior tool_call's function name"


# ── usage chunk surfaces promptTokenCount / candidatesTokenCount ─────


def test_usage_chunk_translates_gemini_token_counts(monkeypatch):
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [{"text": "ok"}],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 1234,
                "candidatesTokenCount": 56,
                "cachedContentTokenCount": 1000,
            },
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    usage_chunks = [c for c in chunks if c.get("choices") == [] and "usage" in c]
    assert len(usage_chunks) == 1, chunks
    usage = usage_chunks[0]["usage"]
    assert usage["prompt_tokens"] == 1234
    assert usage["completion_tokens"] == 56
    assert usage["total_tokens"] == 1290
    assert usage["prompt_tokens_details"]["cached_tokens"] == 1000


# ── multimodal: vision image -> inlineData ───────────────────────────


def test_vision_data_url_translates_to_inline_data(monkeypatch):
    fake = base64.b64encode(b"JPGBYTES").decode()
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "What is this?"},
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{fake}",
                    },
                },
            ],
        }
    ]
    captured = _capture_body(monkeypatch, messages = messages)
    parts = captured["body"]["contents"][0]["parts"]
    inline_parts = [p for p in parts if "inlineData" in p]
    assert len(inline_parts) == 1, parts
    assert inline_parts[0]["inlineData"] == {"mimeType": "image/jpeg", "data": fake}


# ── finish reason mapping ────────────────────────────────────────────


@pytest.mark.parametrize(
    "gemini_reason, openai_reason",
    [
        ("STOP", "stop"),
        ("MAX_TOKENS", "length"),
        ("SAFETY", "content_filter"),
        ("PROHIBITED_CONTENT", "content_filter"),
    ],
)
def test_finish_reason_translation(monkeypatch, gemini_reason, openai_reason):
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [{"text": "x"}],
                    },
                    "finishReason": gemini_reason,
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 1,
                "candidatesTokenCount": 1,
            },
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    finish_chunks = [
        c for c in chunks if any(ch.get("finish_reason") for ch in c.get("choices", []))
    ]
    assert any(
        ch["choices"][0]["finish_reason"] == openai_reason for ch in finish_chunks
    ), finish_chunks


# ── grounding citations surface as web_search tool_end ───────────────


def test_grounding_metadata_surfaces_as_tool_end_citations(monkeypatch):
    """`groundingMetadata.groundingChunks[].web` -> tool_end result block."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [{"text": "Answer with sources."}],
                    },
                    "groundingMetadata": {
                        "groundingChunks": [
                            {
                                "web": {
                                    "uri": "https://example.com/a",
                                    "title": "Example A",
                                }
                            },
                            {
                                "web": {
                                    "uri": "https://example.com/b",
                                    "title": "Example B",
                                }
                            },
                        ]
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 7,
                "candidatesTokenCount": 3,
            },
        }
    ]
    lines = _collect(
        monkeypatch,
        sse,
        enabled_tools = ["web_search"],
    )
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    web_search_ends = [
        e
        for e in tool_events
        if e.get("type") == "tool_end" and e.get("tool_call_id") == "gemini_web_search"
    ]
    assert len(web_search_ends) == 1, tool_events
    result = web_search_ends[0]["result"]
    assert "https://example.com/a" in result
    assert "https://example.com/b" in result
    assert "Example A" in result
    assert "Example B" in result


# ── round 3 review follow-ups ─────────────────────────────────────────


def test_custom_gemini_proxy_base_url_not_rewritten():
    """Only the Google-hosted /v1beta/openai base is normalized; a custom
    gateway whose path ends in /openai must be left alone."""
    client = ExternalProviderClient(
        provider_type = "gemini",
        base_url = "https://proxy.example.com/team/openai",
        api_key = "AIza-test-key",
    )
    assert client.base_url == "https://proxy.example.com/team/openai"


def test_custom_gemini_proxy_uses_openai_dispatch():
    """Any non-Google Gemini base (LiteLLM, custom OpenAI-compat routers) must
    route through the OpenAI-compatible forwarder, not the native translator.
    Auth uses Authorization: Bearer ..., not x-goog-api-key."""
    for base in (
        "https://proxy.example.com/team/openai",
        "https://proxy.example.com/v1",
        "https://litellm.internal.example/v1",
    ):
        client = ExternalProviderClient(
            provider_type = "gemini",
            base_url = base,
            api_key = "AIza-test-key",
        )
        assert client._is_openai_compatible() is True, base
        headers = client._auth_headers()
        assert "x-goog-api-key" not in {k.lower() for k in headers}, (base, headers)
        assert headers["Authorization"] == "Bearer AIza-test-key", (base, headers)


def test_google_hosted_gemini_still_uses_native_dispatch():
    """Google-hosted Gemini keeps native dispatch + x-goog-api-key auth."""
    client = ExternalProviderClient(
        provider_type = "gemini",
        base_url = "https://generativelanguage.googleapis.com/v1beta",
        api_key = "AIza-test-key",
    )
    assert client._is_openai_compatible() is False
    headers = client._auth_headers()
    assert headers.get("x-goog-api-key") == "AIza-test-key", headers


def test_invalid_gemini_model_id_rejected_before_request(monkeypatch):
    """Path-traversal model ids must be rejected before the URL is
    interpolated, so the configured API key isn't sent to unintended Gemini
    endpoints."""

    captured: list[httpx.Request] = []

    def handler(request: httpx.Request) -> httpx.Response:
        captured.append(request)
        return httpx.Response(
            200,
            content = _gemini_sse([]),
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    out: list[str] = []

    async def run():
        client = _make_gemini_client()
        async for line in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "../cachedContents/leak",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
        ):
            out.append(line)
        await client.close()

    _drive(run())
    # No outbound request should have been issued.
    assert captured == [], captured
    error_lines = [line for line in out if '"error"' in line]
    assert error_lines, out


def test_top_k_omitted_when_not_explicit_default_for_gemini(monkeypatch):
    """top_k=None means "use provider default"; helper must not emit `topK` in
    generationConfig when the caller didn't pass it."""
    captured = _capture_body(monkeypatch, top_k = None)
    assert "topK" not in captured["body"]["generationConfig"], captured["body"]


def test_text_model_image_generation_tool_silently_dropped(monkeypatch):
    """A stale `enabled_tools=["image_generation"]` on a text-only Gemini
    model (e.g. gemini-2.5-flash) must NOT switch the request into image mode
    -- Google's API 400s on responseModalities for text models."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash",
        enabled_tools = ["image_generation"],
    )
    gc = captured["body"]["generationConfig"]
    assert "responseModalities" not in gc, gc


def test_empty_text_part_with_thought_signature_emits_extra_content(monkeypatch):
    """Gemini 3 can ship a content-free fragment whose only payload is
    `thoughtSignature`. The translator must still surface it on a
    delta.extra_content envelope so the next turn can replay it."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {"text": "answer"},
                            {"thoughtSignature": "SIG-FINAL"},
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 2,
                "candidatesTokenCount": 1,
            },
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    extra_carriers = [
        c
        for c in chunks
        if c.get("choices")
        and c["choices"][0]["delta"].get("extra_content")
        == {"google": {"thought_signature": "SIG-FINAL"}}
    ]
    assert extra_carriers, chunks


def test_enable_prompt_caching_false_string_coerces_to_bool():
    """Pre-PR the field was Optional[bool]; widening to Union[bool,str] must
    preserve historical coercion so callers sending `"false"` still opt out of
    caching."""
    from models.inference import ChatCompletionRequest

    msg = {"role": "user", "content": "hi"}
    req = ChatCompletionRequest.model_validate(
        {
            "model": "gemini-2.5-flash",
            "messages": [msg],
            "enable_prompt_caching": "false",
        }
    )
    assert req.enable_prompt_caching is False, req.enable_prompt_caching

    req = ChatCompletionRequest.model_validate(
        {
            "model": "gemini-2.5-flash",
            "messages": [msg],
            "enable_prompt_caching": "true",
        }
    )
    assert req.enable_prompt_caching is True

    # An actual cache resource name passes through untouched.
    req = ChatCompletionRequest.model_validate(
        {
            "model": "gemini-2.5-flash",
            "messages": [msg],
            "enable_prompt_caching": "cachedContents/abc123",
        }
    )
    assert req.enable_prompt_caching == "cachedContents/abc123"


def test_legacy_google_openai_base_url_is_rewritten():
    """The Google-hosted /v1beta/openai legacy base IS still rewritten."""
    client = ExternalProviderClient(
        provider_type = "gemini",
        base_url = "https://generativelanguage.googleapis.com/v1beta/openai",
        api_key = "AIza-test-key",
    )
    assert client.base_url == "https://generativelanguage.googleapis.com/v1beta"


def test_remote_image_url_downloads_and_inlines_as_base64(monkeypatch):
    """Round 14: arbitrary public HTTPS image URLs cannot be sent as Gemini
    fileData (reserved for Files API URIs and YouTube). The translator must
    fetch the bytes server-side and inline them as base64 inlineData."""
    image_bytes = b"FAKEPNGBYTES"

    async def fake_fetch(
        url,
        fallback_mime,
        max_bytes = None,
    ):
        assert url == "https://cdn.example.com/diagram.png"
        return ("image/png", base64.b64encode(image_bytes).decode("ascii"))

    monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch)
    captured = _capture_body(
        monkeypatch,
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "what is this?"},
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": "https://cdn.example.com/diagram.png",
                        },
                    },
                ],
            }
        ],
    )
    parts = captured["body"]["contents"][-1]["parts"]
    inline = next((p for p in parts if "inlineData" in p), None)
    assert inline is not None, parts
    assert inline["inlineData"]["mimeType"] == "image/png"
    assert inline["inlineData"]["data"] == base64.b64encode(image_bytes).decode()
    assert not any("fileData" in p for p in parts), parts


def test_remote_image_url_dropped_when_fetch_returns_none(monkeypatch):
    """Round 15: if the SSRF guard rejects the URL (private host, non-https,
    oversize, non-image), the helper returns None and the image part is
    silently dropped, not forwarded as raw bytes or a fileData fallback."""

    async def fake_fetch_reject(
        url,
        fallback_mime,
        max_bytes = None,
    ):
        return None

    monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch_reject)
    captured = _capture_body(
        monkeypatch,
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "what is this?"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "http://10.0.0.5/private.png"},
                    },
                ],
            }
        ],
    )
    parts = captured["body"]["contents"][-1]["parts"]
    assert not any("inlineData" in p for p in parts), parts
    assert not any("fileData" in p for p in parts), parts


def test_safe_fetch_image_rejects_non_https():
    """SSRF guard: only https URLs may be fetched."""
    res = asyncio.new_event_loop().run_until_complete(
        ep_mod._safe_fetch_image_for_gemini("http://cdn.example.com/x.png", "image/png")
    )
    assert res is None


def test_safe_fetch_image_rejects_loopback_ip_literal():
    """SSRF guard: refuse loopback / private IP literals before any network
    call."""
    for url in (
        "https://127.0.0.1/x.png",
        "https://[::1]/x.png",
        "https://169.254.169.254/latest/meta-data",
        "https://10.0.0.5/x.png",
        "https://192.168.1.1/x.png",
    ):
        res = asyncio.new_event_loop().run_until_complete(
            ep_mod._safe_fetch_image_for_gemini(url, "image/png")
        )
        assert res is None, url


def test_safe_fetch_image_rejects_resolved_private_host(monkeypatch):
    """SSRF guard: if a hostname resolves to a private IP, refuse."""
    import socket

    def fake_getaddrinfo(host, *_args, **_kwargs):
        return [(socket.AF_INET, None, None, "", ("10.0.0.5", 0))]

    monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
    res = asyncio.new_event_loop().run_until_complete(
        ep_mod._safe_fetch_image_for_gemini("https://internal.example/x.png", "image/png")
    )
    assert res is None


def test_youtube_and_files_api_uris_stay_as_file_data(monkeypatch):
    """Round 14: YouTube URLs and generativelanguage.googleapis.com Files API
    URIs are the documented `fileData.fileUri` paths and must NOT be
    downloaded; arbitrary public URLs do get fetched."""
    captured: dict = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["body"] = json.loads(request.content.decode("utf-8"))
        return httpx.Response(
            200,
            content = _gemini_sse(
                [
                    {
                        "candidates": [
                            {
                                "content": {
                                    "role": "model",
                                    "parts": [{"text": "ok"}],
                                },
                                "finishReason": "STOP",
                            }
                        ],
                        "usageMetadata": {
                            "promptTokenCount": 1,
                            "candidatesTokenCount": 1,
                        },
                    }
                ]
            ),
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = _make_gemini_client()
        async for _ in client.stream_chat_completion(
            messages = [
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "explain"},
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": "https://www.youtube.com/watch?v=abc123",
                            },
                        },
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": "https://generativelanguage.googleapis.com/v1beta/files/abc",
                            },
                        },
                    ],
                }
            ],
            model = "gemini-2.5-flash",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 64,
        ):
            pass
        await client.close()

    _drive(run())
    parts = captured["body"]["contents"][-1]["parts"]
    file_uris = [p["fileData"]["fileUri"] for p in parts if "fileData" in p]
    assert "https://www.youtube.com/watch?v=abc123" in file_uris, parts
    assert "https://generativelanguage.googleapis.com/v1beta/files/abc" in file_uris, parts


def test_tool_use_prompt_tokens_added_to_input_tokens(monkeypatch):
    """`toolUsePromptTokenCount` must roll into the OpenAI prompt total --
    else tool turns silently undercount input tokens."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [{"text": "result"}],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 10,
                "toolUsePromptTokenCount": 100,
                "candidatesTokenCount": 5,
                "thoughtsTokenCount": 2,
            },
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    usage_chunks = [c for c in chunks if c.get("usage")]
    assert len(usage_chunks) == 1, chunks
    usage = usage_chunks[0]["usage"]
    assert usage["prompt_tokens"] == 110, usage
    assert usage["completion_tokens"] == 7, usage
    assert usage["total_tokens"] == 117, usage
    assert usage["completion_tokens_details"]["reasoning_tokens"] == 2, usage


def test_usage_chunk_reasoning_tokens_surfaced(monkeypatch):
    """thoughtsTokenCount must surface as
    completion_tokens_details.reasoning_tokens in the emitted OpenAI usage
    chunk."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [{"text": "ok"}],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 8,
                "candidatesTokenCount": 5,
                "thoughtsTokenCount": 20,
            },
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    usage_chunks = [c for c in chunks if c.get("usage")]
    assert len(usage_chunks) == 1, chunks
    usage = usage_chunks[0]["usage"]
    assert usage["completion_tokens"] == 25, usage
    assert usage["completion_tokens_details"]["reasoning_tokens"] == 20, usage


def test_prompt_block_pairs_web_search_tool_end(monkeypatch):
    """When `promptFeedback.blockReason` triggers after the synthetic
    web_search tool_start, the helper must emit a matching tool_end so the UI
    doesn't leave a "searching..." spinner stuck on screen."""
    sse = [
        {"promptFeedback": {"blockReason": "SAFETY"}},
    ]
    lines = _collect(
        monkeypatch,
        sse,
        enabled_tools = ["web_search"],
    )
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    starts = [e for e in tool_events if e.get("type") == "tool_start"]
    ends = [e for e in tool_events if e.get("type") == "tool_end"]
    assert len(starts) == 1, tool_events
    assert len(ends) == 1, tool_events
    assert ends[0]["tool_call_id"] == "gemini_web_search"
    assert "aborted" in ends[0]["result"]
    error_chunks = [c for c in chunks if c.get("error")]
    assert error_chunks, chunks


def test_code_execution_tool_events_stow_native_part(monkeypatch):
    """executableCode / codeExecutionResult must round-trip native ids and
    thoughtSignature in google.native_part so follow-up turns can replay
    Gemini's required history shape."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "executableCode": {
                                    "id": "code_a",
                                    "language": "PYTHON",
                                    "code": "print(1+1)",
                                },
                                "thoughtSignature": "SIG-CODE",
                            },
                            {
                                "codeExecutionResult": {
                                    "id": "result_a",
                                    "outcome": "OUTCOME_OK",
                                    "output": "2\n",
                                },
                            },
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 5,
                "candidatesTokenCount": 4,
            },
        }
    ]
    lines = _collect(
        monkeypatch,
        sse,
        enabled_tools = ["code_execution"],
    )
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    starts = [e for e in tool_events if e.get("type") == "tool_start"]
    ends = [e for e in tool_events if e.get("type") == "tool_end"]
    code_start = next(
        (e for e in starts if e.get("tool_name") == "code_execution"),
        None,
    )
    code_end = next(iter(ends), None)
    assert code_start is not None, starts
    assert code_start["tool_call_id"] == "code_a", code_start
    native = code_start["arguments"]["google"]["native_part"]
    # Round 21: native_part uses an ordered `parts` list so per-part
    # `thoughtSignature` survives a frontend merge of executableCode +
    # codeExecutionResult into one tool-call card.
    start_parts = native["parts"]
    assert start_parts[0]["executableCode"]["id"] == "code_a"
    assert start_parts[0]["thoughtSignature"] == "SIG-CODE"
    assert code_end is not None, ends
    assert code_end["tool_call_id"] == "code_a", code_end
    native_end = code_end["google"]["native_part"]
    end_parts = native_end["parts"]
    assert end_parts[0]["codeExecutionResult"]["id"] == "result_a"


def test_inline_image_tool_end_carries_thought_signature(monkeypatch):
    """Inline image parts with thoughtSignature must persist it on the emitted
    tool_end so Gemini 3 image editing can echo it back."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "inlineData": {
                                    "mimeType": "image/png",
                                    "data": base64.b64encode(b"PNG").decode(),
                                },
                                "thoughtSignature": "SIG-IMG",
                            }
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 4,
                "candidatesTokenCount": 1,
            },
        }
    ]
    lines = _collect(
        monkeypatch,
        sse,
        model = "gemini-2.5-flash-image",
    )
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    image_ends = [e for e in tool_events if e.get("type") == "tool_end" and e.get("image_b64")]
    assert image_ends, tool_events
    assert image_ends[0]["google"]["thought_signature"] == "SIG-IMG"
    # Multi-turn image edit must replay the original inlineData part with its
    # thoughtSignature; the outbound translator reads
    # google.native_part.parts[].inlineData, so stow it on the tool_end too.
    # Round 21 made native_part an ordered parts list so a per-part signature
    # stays attached to inlineData only.
    native = image_ends[0]["google"]["native_part"]
    image_parts = native["parts"]
    assert image_parts[0]["inlineData"]["mimeType"] == "image/png"
    assert image_parts[0]["inlineData"]["data"] == base64.b64encode(b"PNG").decode()
    assert image_parts[0]["thoughtSignature"] == "SIG-IMG"


def test_code_execution_plot_attaches_inline_image_native_part(monkeypatch):
    """A code_execution turn that returns a matplotlib plot must stow the
    plot's inlineData on the secondary tool_end so the follow-up turn can
    replay the image alongside executableCode and codeExecutionResult."""
    plot_data = base64.b64encode(b"PLOT").decode()
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "executableCode": {
                                    "id": "code_a",
                                    "language": "PYTHON",
                                    "code": "plt.plot([0,1])",
                                },
                            },
                            {
                                "codeExecutionResult": {
                                    "id": "result_a",
                                    "outcome": "OUTCOME_OK",
                                    "output": "",
                                },
                            },
                            {
                                "inlineData": {
                                    "mimeType": "image/png",
                                    "data": plot_data,
                                },
                            },
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 5,
                "candidatesTokenCount": 4,
            },
        }
    ]
    lines = _collect(
        monkeypatch,
        sse,
        enabled_tools = ["code_execution"],
    )
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    code_ends = [
        e for e in tool_events if e.get("type") == "tool_end" and e.get("tool_call_id") == "code_a"
    ]
    # Two tool_end events on the same id: one for codeExecutionResult, one
    # merging in the inlineData plot. The plot one must carry the native
    # inlineData under google.native_part so the frontend tool_end merge union
    # joins it with the prior executableCode and codeExecutionResult parts on
    # the same card.
    assert len(code_ends) == 2, code_ends
    image_end = next(
        (e for e in code_ends if "__IMAGES__:" in (e.get("result") or "")),
        None,
    )
    assert image_end is not None, code_ends
    native = image_end["google"]["native_part"]
    plot_parts = native["parts"]
    assert plot_parts[0]["inlineData"]["mimeType"] == "image/png"
    assert plot_parts[0]["inlineData"]["data"] == plot_data


def test_text_chunk_carries_thought_signature(monkeypatch):
    """Text parts with thoughtSignature surface it on delta.extra_content so
    frontend persistence can replay it on the follow-up turn."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "text": "hello",
                                "thoughtSignature": "SIG-TEXT",
                            }
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 2,
                "candidatesTokenCount": 1,
            },
        }
    ]
    lines = _collect(monkeypatch, sse)
    chunks = _parse_chunks(lines)
    text_chunks = [
        c for c in chunks if c.get("choices") and c["choices"][0]["delta"].get("content") == "hello"
    ]
    assert text_chunks, chunks
    extra = text_chunks[0]["choices"][0]["delta"].get("extra_content")
    assert extra == {"google": {"thought_signature": "SIG-TEXT"}}, text_chunks


def test_openai_tools_translated_into_function_declarations(monkeypatch):
    """Standard ChatCompletionRequest.tools must be forwarded into Gemini's
    tools[].functionDeclarations envelope."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "get_weather",
                    "description": "Look up the weather for a city.",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "city": {"type": "string"},
                        },
                        "required": ["city"],
                    },
                },
            }
        ],
        tool_choice = {"type": "function", "function": {"name": "get_weather"}},
    )
    tools_arr = captured["body"].get("tools") or []
    fn_decls = [t for t in tools_arr if "functionDeclarations" in t]
    assert fn_decls, captured["body"]
    decls = fn_decls[0]["functionDeclarations"]
    assert decls[0]["name"] == "get_weather"
    assert decls[0]["parameters"]["properties"]["city"]["type"] == "string"
    tool_config = captured["body"].get("toolConfig")
    assert tool_config is not None, captured["body"]
    fcc = tool_config["functionCallingConfig"]
    assert fcc["mode"] == "ANY"
    assert fcc["allowedFunctionNames"] == ["get_weather"]


def test_tool_choice_auto_maps_to_function_calling_mode_auto(monkeypatch):
    """tool_choice="auto" maps to toolConfig.functionCallingConfig.mode."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {"name": "noop", "parameters": {"type": "object"}},
            }
        ],
        tool_choice = "auto",
    )
    fcc = captured["body"]["toolConfig"]["functionCallingConfig"]
    assert fcc["mode"] == "AUTO"
    assert "allowedFunctionNames" not in fcc


def test_code_exec_inline_image_attaches_to_code_execution_card(monkeypatch):
    """A codeExecution sandbox plot (matplotlib) ships as an inline image part
    right after the codeExecutionResult. Instead of a separate empty
    image_generation card, attach to the same code_execution tool_end via the
    `__IMAGES__:` marker the chat adapter already understands."""
    sse = [
        {
            "candidates": [
                {
                    "content": {
                        "role": "model",
                        "parts": [
                            {
                                "executableCode": {
                                    "id": "code_plot",
                                    "language": "PYTHON",
                                    "code": "import matplotlib.pyplot as plt; plt.plot([1,2,3]); plt.savefig('out.png')",
                                },
                            },
                            {
                                "codeExecutionResult": {
                                    "outcome": "OUTCOME_OK",
                                    "output": "saved",
                                },
                            },
                            {
                                "inlineData": {
                                    "mimeType": "image/png",
                                    "data": base64.b64encode(b"PNGDATA").decode(),
                                },
                            },
                        ],
                    },
                    "finishReason": "STOP",
                }
            ],
            "usageMetadata": {
                "promptTokenCount": 5,
                "candidatesTokenCount": 4,
            },
        }
    ]
    lines = _collect(
        monkeypatch,
        sse,
        enabled_tools = ["code_execution"],
    )
    chunks = _parse_chunks(lines)
    tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
    # No standalone image_generation card should have been emitted.
    image_starts = [
        e
        for e in tool_events
        if e.get("type") == "tool_start" and e.get("tool_name") == "image_generation"
    ]
    assert not image_starts, tool_events
    # The code_execution tool_end should now carry the inline image
    # via the `__IMAGES__:` marker.
    code_ends = [
        e
        for e in tool_events
        if e.get("type") == "tool_end" and e.get("tool_call_id") == "code_plot"
    ]
    assert code_ends, tool_events
    final_result = code_ends[-1]["result"]
    assert "__IMAGES__:" in final_result, code_ends
    assert "data:image/png;base64," in final_result, code_ends


def test_code_execution_tool_call_replays_native_executable_code(monkeypatch):
    """An assistant tool_call with toolName=code_execution and
    extra_content.google.native_part holding the originally-emitted
    `executableCode` + `codeExecutionResult` must round-trip as native Gemini
    parts (not a generic functionCall) on the next turn."""
    captured = _capture_body(
        monkeypatch,
        messages = [
            {"role": "user", "content": "compute 2+2"},
            {
                "role": "assistant",
                "content": None,
                "tool_calls": [
                    {
                        "id": "code_a",
                        "type": "function",
                        "function": {
                            "name": "code_execution",
                            "arguments": "{}",
                        },
                        "extra_content": {
                            "google": {
                                "native_part": {
                                    "executableCode": {
                                        "id": "code_a",
                                        "language": "PYTHON",
                                        "code": "print(2+2)",
                                    },
                                    "codeExecutionResult": {
                                        "outcome": "OUTCOME_OK",
                                        "output": "4\n",
                                    },
                                    "thoughtSignature": "SIG-CODE",
                                },
                            },
                        },
                    },
                ],
            },
            {"role": "user", "content": "what was that result"},
        ],
    )
    assistant_turn = captured["body"]["contents"][1]
    assert assistant_turn["role"] == "model"
    parts = assistant_turn["parts"]
    native_keys = [list(p.keys())[0] for p in parts if isinstance(p, dict)]
    assert "executableCode" in native_keys, parts
    assert "codeExecutionResult" in native_keys, parts
    assert not any(
        "functionCall" in p and (p["functionCall"] or {}).get("name") == "code_execution"
        for p in parts
    ), parts
    exec_part = next(p for p in parts if "executableCode" in p)
    assert exec_part.get("thoughtSignature") == "SIG-CODE", exec_part


def test_image_generation_tool_call_replays_native_inline_data(monkeypatch):
    """An assistant tool_call with toolName=image_generation and
    extra_content.google.native_part.inlineData must replay the prior image as
    a native Gemini inlineData part (not a generic functionCall) so multi-turn
    image editing keeps the image context."""
    pixel = base64.b64encode(b"PNG").decode()
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        messages = [
            {"role": "user", "content": "make a circle"},
            {
                "role": "assistant",
                "content": None,
                "tool_calls": [
                    {
                        "id": "img_a",
                        "type": "function",
                        "function": {
                            "name": "image_generation",
                            "arguments": "{}",
                        },
                        "extra_content": {
                            "google": {
                                "native_part": {
                                    "inlineData": {
                                        "mimeType": "image/png",
                                        "data": pixel,
                                    },
                                    "thoughtSignature": "SIG-IMG",
                                },
                            },
                        },
                    },
                ],
            },
            {"role": "user", "content": "now make it blue"},
        ],
    )
    assistant_turn = captured["body"]["contents"][1]
    assert assistant_turn["role"] == "model"
    parts = assistant_turn["parts"]
    inline_parts = [p for p in parts if "inlineData" in p]
    assert inline_parts, parts
    assert inline_parts[0]["inlineData"]["mimeType"] == "image/png"
    assert inline_parts[0]["inlineData"]["data"] == pixel
    assert inline_parts[0].get("thoughtSignature") == "SIG-IMG", inline_parts
    assert not any(
        "functionCall" in p and (p["functionCall"] or {}).get("name") == "image_generation"
        for p in parts
    ), parts


def test_assistant_text_thought_signature_replays_on_outbound_text_part(monkeypatch):
    """Assistant text with extra_content.google.thought_signature must attach
    `thoughtSignature` to the LAST text part of the replayed Gemini history.
    Gemini 3 strict function-calling rejects history that drops returned
    signatures, so the frontend stows the latest signed-text signature and the
    backend pins it on the next turn."""
    captured = _capture_body(
        monkeypatch,
        messages = [
            {"role": "user", "content": "hi"},
            {
                "role": "assistant",
                "content": [
                    {"type": "text", "text": "hello"},
                ],
                "extra_content": {
                    "google": {"thought_signature": "SIG-TEXT"},
                },
            },
            {"role": "user", "content": "again"},
        ],
    )
    assistant_turn = captured["body"]["contents"][1]
    assert assistant_turn["role"] == "model"
    parts = assistant_turn["parts"]
    text_parts = [p for p in parts if "text" in p]
    assert text_parts, parts
    assert text_parts[-1].get("thoughtSignature") == "SIG-TEXT", text_parts


def test_function_declarations_strip_openai_only_schema_keys(monkeypatch):
    """OpenAI strict tools commonly include `additionalProperties`, `$schema`,
    `$defs`, `strict`, etc. Gemini's Schema rejects those with
    INVALID_ARGUMENT, so the translator must strip them while keeping
    properties.<field>.type intact."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "lookup",
                    "description": "Look up a value.",
                    "parameters": {
                        "type": "object",
                        "$schema": "http://json-schema.org/draft-07/schema#",
                        "additionalProperties": False,
                        "strict": True,
                        "properties": {
                            "key": {
                                "type": "string",
                                "additionalProperties": False,
                            },
                        },
                        "required": ["key"],
                    },
                },
            }
        ],
    )
    tools_arr = captured["body"].get("tools") or []
    decls = next(
        (t.get("functionDeclarations") for t in tools_arr if "functionDeclarations" in t),
        None,
    )
    assert decls is not None, captured["body"]
    params = decls[0]["parameters"]
    assert "additionalProperties" not in params
    assert "$schema" not in params
    assert "strict" not in params
    assert params["type"] == "object"
    assert params["properties"]["key"]["type"] == "string"
    assert "additionalProperties" not in params["properties"]["key"]
    assert params["required"] == ["key"]


def test_function_declarations_inline_local_refs_into_gemini_schema(monkeypatch):
    """Round 25: Pydantic-generated tool schemas hoist nested object shapes
    into `$defs` and reference them with `{"$ref": "#/$defs/..."}`. Gemini's
    OpenAPI subset has no $ref, so a naive allowlist sanitizer drops the
    reference and reduces the nested property to `{}`, losing its type, fields,
    and required keys. The sanitizer must resolve local `#/...` pointers and
    inline the referenced schema."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "set_user",
                    "description": "Persist a user.",
                    "parameters": {
                        "type": "object",
                        "$defs": {
                            "Address": {
                                "type": "object",
                                "properties": {
                                    "street": {"type": "string"},
                                    "zip": {"type": "string"},
                                },
                                "required": ["street", "zip"],
                            },
                        },
                        "properties": {
                            "name": {"type": "string"},
                            "address": {"$ref": "#/$defs/Address"},
                        },
                        "required": ["name", "address"],
                    },
                },
            }
        ],
    )
    tools_arr = captured["body"].get("tools") or []
    decls = next(
        (t.get("functionDeclarations") for t in tools_arr if "functionDeclarations" in t),
        None,
    )
    assert decls is not None, captured["body"]
    params = decls[0]["parameters"]
    assert "$defs" not in params
    address = params["properties"]["address"]
    assert address.get("type") == "object", address
    assert address.get("properties", {}).get("street", {}).get("type") == "string"
    assert address.get("properties", {}).get("zip", {}).get("type") == "string"
    assert address.get("required") == ["street", "zip"]


def test_function_declarations_inline_local_refs_in_anyof_and_items(monkeypatch):
    """The recursive inliner must reach through `anyOf` branches and `items`
    (array element schemas), not just top-level property refs."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "bulk_set",
                    "parameters": {
                        "type": "object",
                        "$defs": {
                            "Address": {
                                "type": "object",
                                "properties": {"zip": {"type": "string"}},
                                "required": ["zip"],
                            },
                        },
                        "properties": {
                            "primary": {
                                "anyOf": [
                                    {"$ref": "#/$defs/Address"},
                                    {"type": "null"},
                                ],
                            },
                            "extras": {
                                "type": "array",
                                "items": {"$ref": "#/$defs/Address"},
                            },
                        },
                    },
                },
            }
        ],
    )
    tools_arr = captured["body"].get("tools") or []
    decls = next(
        (t.get("functionDeclarations") for t in tools_arr if "functionDeclarations" in t),
        None,
    )
    assert decls is not None
    params = decls[0]["parameters"]
    primary = params["properties"]["primary"]
    # anyOf with single non-null branch + null collapses to inline +
    # nullable: true; the inlined branch must contain the resolved Address
    # shape.
    assert primary.get("nullable") is True
    assert primary.get("type") == "object"
    assert primary.get("properties", {}).get("zip", {}).get("type") == "string"
    extras = params["properties"]["extras"]
    assert extras.get("type") == "array"
    assert extras.get("items", {}).get("type") == "object"
    assert extras.get("items", {}).get("properties", {}).get("zip", {}).get("type") == "string"


def test_function_declarations_self_referential_schema_terminates(monkeypatch):
    """Self-referential / cyclic JSON Schemas (a `Node` with `children:
    [Node]`) must not infinite-loop. The inliner tracks the set of refs in
    flight and short-circuits to `{}` on a cycle."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "set_tree",
                    "parameters": {
                        "type": "object",
                        "$defs": {
                            "Node": {
                                "type": "object",
                                "properties": {
                                    "value": {"type": "string"},
                                    "children": {
                                        "type": "array",
                                        "items": {"$ref": "#/$defs/Node"},
                                    },
                                },
                            },
                        },
                        "properties": {
                            "root": {"$ref": "#/$defs/Node"},
                        },
                    },
                },
            }
        ],
    )
    tools_arr = captured["body"].get("tools") or []
    decls = next(
        (t.get("functionDeclarations") for t in tools_arr if "functionDeclarations" in t),
        None,
    )
    assert decls is not None
    root = decls[0]["parameters"]["properties"]["root"]
    assert root.get("type") == "object"
    assert root.get("properties", {}).get("value", {}).get("type") == "string"


def test_gemini_native_skips_orphan_function_response_for_dropped_builtin(monkeypatch):
    """Round 26: when the assistant-side synthetic web_search/web_fetch
    tool_call is dropped from native Gemini history, the matching role="tool"
    follow-up must also be dropped. Otherwise the outbound body carries an
    orphan functionResponse with no preceding functionCall, which 400s the
    Gemini turn."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    req = ChatCompletionRequest.model_validate(
        {
            "model": "gemini-2.5-flash",
            "messages": [
                {"role": "user", "content": "search please"},
                {
                    "role": "assistant",
                    "content": "",
                    "tool_calls": [
                        {
                            "id": "call_s",
                            "type": "function",
                            "function": {
                                "name": "web_search",
                                "arguments": ('{"_server_tool": true, "query": "x"}'),
                            },
                        }
                    ],
                },
                {
                    "role": "tool",
                    "tool_call_id": "call_s",
                    "content": "[search result]",
                },
                {"role": "user", "content": "again"},
            ],
            "max_tokens": 64,
            "stream": True,
        }
    )
    built = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "gemini",
        base_url = "https://generativelanguage.googleapis.com/v1beta",
    )
    captured = _capture_body(monkeypatch, messages = built)
    contents = captured["body"].get("contents") or []
    for entry in contents:
        for part in entry.get("parts", []):
            fr = part.get("functionResponse")
            if isinstance(fr, dict):
                assert fr.get("name") != "web_search", contents


def test_gemini_native_skips_orphan_function_response_for_native_part_replay(monkeypatch):
    """Round 26: code_execution / image_generation tool_calls are replayed as
    Gemini-native executableCode / codeExecutionResult / inlineData parts. The
    matching role="tool" follow-up must NOT then be emitted as a
    functionResponse named code_execution -- there is no declared user
    function with that name, and Gemini's history rules already attribute the
    result to the native parts above."""
    captured = _capture_body(
        monkeypatch,
        messages = [
            {"role": "user", "content": "plot something"},
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_a",
                        "type": "function",
                        "function": {
                            "name": "code_execution",
                            "arguments": "{}",
                        },
                        "extra_content": {
                            "google": {
                                "native_part": {
                                    "parts": [
                                        {
                                            "executableCode": {
                                                "language": "PYTHON",
                                                "code": "print(2)",
                                            }
                                        },
                                        {
                                            "codeExecutionResult": {
                                                "outcome": "OUTCOME_OK",
                                                "output": "2\n",
                                            }
                                        },
                                    ]
                                }
                            }
                        },
                    }
                ],
            },
            {
                "role": "tool",
                "tool_call_id": "call_a",
                "name": "code_execution",
                "content": "2",
            },
            {"role": "user", "content": "next"},
        ],
    )
    contents = captured["body"].get("contents") or []
    saw_native = False
    for entry in contents:
        for part in entry.get("parts", []):
            if "executableCode" in part or "codeExecutionResult" in part:
                saw_native = True
            fr = part.get("functionResponse")
            if isinstance(fr, dict):
                assert fr.get("name") != "code_execution", contents
    assert saw_native, contents


def test_gemini_native_part_falls_back_to_args_google(monkeypatch):
    """Round 27: a direct OpenAI-compat API caller (or imported third-party
    thread) cannot use Studio's non-standard `tool_calls[].extra_content`
    field, so the native_part payload round-trips through `function.arguments`
    as `{"google": {"native_part": {...}}}`. The synthetic-builtin detector
    recognizes that location, but the replay branch was only reading from
    `tc.extra_content.google.native_part`. Result: the round-25 guard saw a
    synthetic builtin with no _native_part and dropped the entire assistant
    turn, losing the prior code/image context. The translator must fall back
    to args.google.native_part and still emit the native executableCode /
    inlineData parts."""
    import json as _json

    captured = _capture_body(
        monkeypatch,
        messages = [
            {"role": "user", "content": "draw a cat"},
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_img",
                        "type": "function",
                        "function": {
                            "name": "image_generation",
                            "arguments": _json.dumps(
                                {
                                    "google": {
                                        "native_part": {
                                            "parts": [
                                                {
                                                    "inlineData": {
                                                        "mimeType": "image/png",
                                                        "data": "AAAA",
                                                    }
                                                }
                                            ]
                                        }
                                    }
                                }
                            ),
                        },
                    }
                ],
            },
            {"role": "user", "content": "now make it a dog"},
        ],
    )
    contents = captured["body"].get("contents") or []
    saw_inline = False
    for entry in contents:
        for part in entry.get("parts", []):
            if "inlineData" in part:
                saw_inline = True
    assert saw_inline, contents


def test_gemini_native_skips_synthetic_server_builtin_replay(monkeypatch):
    """Round 25: Marked server-side builtin tool_calls (web_search /
    web_fetch with `_server_tool` or `args.google.native_part`) must not fall
    through to the generic Gemini `functionCall` replay path when no replayable
    native part exists. Without this guard the outbound body contains a fake
    `functionCall` whose name isn't a declared user function, and the Gemini
    turn 400s."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    req = ChatCompletionRequest.model_validate(
        {
            "model": "gemini-2.5-flash",
            "messages": [
                {"role": "user", "content": "search please"},
                {
                    "role": "assistant",
                    "content": "",
                    "tool_calls": [
                        {
                            "id": "call_s",
                            "type": "function",
                            "function": {
                                "name": "web_search",
                                "arguments": ('{"_server_tool": true, "query": "x"}'),
                            },
                        }
                    ],
                },
                {
                    "role": "tool",
                    "tool_call_id": "call_s",
                    "content": "[search result]",
                },
                {"role": "user", "content": "again"},
            ],
            "max_tokens": 64,
            "stream": True,
        }
    )
    built = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "gemini",
        base_url = "https://generativelanguage.googleapis.com/v1beta",
    )
    captured = _capture_body(monkeypatch, messages = built)
    contents = captured["body"].get("contents") or []
    for entry in contents:
        for part in entry.get("parts", []):
            fc = part.get("functionCall")
            if isinstance(fc, dict):
                assert fc.get("name") != "web_search", contents


def test_chat_message_extra_content_round_trips_through_validation():
    """Round 9: ChatMessage was missing `extra_content`, so Pydantic discarded
    it during request validation and the text-part signature replay path read
    nothing. The field must survive model_validate and pass through
    _build_external_messages."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    req = ChatCompletionRequest.model_validate(
        {
            "model": "gemini-2.5-flash",
            "messages": [
                {"role": "user", "content": "hi"},
                {
                    "role": "assistant",
                    "content": [
                        {"type": "text", "text": "hello"},
                    ],
                    "extra_content": {
                        "google": {"thought_signature": "SIG-TEXT"},
                    },
                },
                {"role": "user", "content": "again"},
            ],
            "max_tokens": 64,
            "stream": True,
        }
    )
    assistant_msg = req.messages[1]
    assert assistant_msg.extra_content == {"google": {"thought_signature": "SIG-TEXT"}}
    built = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "gemini",
        base_url = "https://generativelanguage.googleapis.com/v1beta",
    )
    assistant_out = built[1]
    assert assistant_out["extra_content"] == {"google": {"thought_signature": "SIG-TEXT"}}
    # Non-Gemini providers must NOT receive extra_content; Google's
    # thought_signature is unknown to OpenAI / Mistral / etc.
    built_openai = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "openai",
    )
    assert "extra_content" not in built_openai[1], built_openai[1]
    # Custom non-Google Gemini bases (LiteLLM / OAI-compat gateways) also must
    # not receive Gemini-only extra_content -- the backend dispatches them
    # through /chat/completions.
    built_custom = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "gemini",
        base_url = "https://litellm.example/v1",
    )
    assert "extra_content" not in built_custom[1], built_custom[1]


def test_parallel_tool_results_group_into_one_user_block(monkeypatch):
    """Round 14: Gemini docs group parallel functionResponses in a single
    subsequent user content with multiple functionResponse parts. Consecutive
    OpenAI role="tool" messages must merge into one Gemini user block, not
    split into separate user turns."""
    captured = _capture_body(
        monkeypatch,
        messages = [
            {"role": "user", "content": "compute"},
            {
                "role": "assistant",
                "content": None,
                "tool_calls": [
                    {
                        "id": "call_a",
                        "type": "function",
                        "function": {"name": "add", "arguments": '{"x":1}'},
                    },
                    {
                        "id": "call_b",
                        "type": "function",
                        "function": {"name": "mul", "arguments": '{"x":2}'},
                    },
                ],
            },
            {
                "role": "tool",
                "tool_call_id": "call_a",
                "name": "add",
                "content": "2",
            },
            {
                "role": "tool",
                "tool_call_id": "call_b",
                "name": "mul",
                "content": "4",
            },
        ],
    )
    contents = captured["body"]["contents"]
    # Initial user, model with two functionCalls, ONE user with two
    # functionResponses.
    tool_result_users = [
        c
        for c in contents
        if c.get("role") == "user"
        and all(isinstance(p, dict) and "functionResponse" in p for p in (c.get("parts") or []))
    ]
    assert len(tool_result_users) == 1, contents
    fr_parts = tool_result_users[0]["parts"]
    assert len(fr_parts) == 2, fr_parts
    names = [p["functionResponse"]["name"] for p in fr_parts]
    assert names == ["add", "mul"], names


def test_function_schema_nullable_type_array_flattens(monkeypatch):
    """Round 14: OpenAI strict tools commonly use `"type": ["string", "null"]`
    for optional fields. Gemini's OpenAPI-style Schema rejects union types and
    expects `"type": "string"` with `"nullable": true`. The sanitizer must
    translate the union form."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "lookup",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "city": {"type": ["string", "null"]},
                            "score": {"type": ["number", "null"]},
                        },
                    },
                },
            }
        ],
    )
    decls = next(
        t["functionDeclarations"]
        for t in captured["body"].get("tools") or []
        if "functionDeclarations" in t
    )
    params = decls[0]["parameters"]["properties"]
    assert params["city"]["type"] == "string"
    assert params["city"]["nullable"] is True
    assert params["score"]["type"] == "number"
    assert params["score"]["nullable"] is True


def test_image_picker_model_with_search_off_pill_strips_text_tools(monkeypatch):
    """Round 11: image-tier model ids reject text-only tools and
    thinkingConfig at the model level regardless of the Images pill. Selecting
    gemini-2.5-flash-image + enabled_tools=["web_search"] with no
    image_generation must NOT forward googleSearch or thinkingConfig (Gemini
    400s on text tools for legacy image ids)."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = ["web_search"],
        reasoning_effort = "high",
    )
    body = captured["body"]
    assert "tools" not in body, body.get("tools")
    assert "thinkingConfig" not in body.get("generationConfig", {}), body["generationConfig"]


def test_image_models_drop_function_declarations(monkeypatch):
    """Image-mode requests cannot mix tools with responseModalities, so
    user-supplied function declarations must be dropped."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = ["image_generation"],
        tools = [
            {
                "type": "function",
                "function": {"name": "noop", "parameters": {"type": "object"}},
            }
        ],
    )
    assert captured["body"].get("tools") is None
    assert captured["body"]["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]


def test_safe_fetch_image_rejects_malformed_bracketed_url():
    """Round 17: bracketed IPv6 garbage like `https://[bad/x.png` makes
    urlparse raise ValueError. The fetch helper must catch it and drop the
    image rather than crashing the request mid-build."""
    res = _drive(ep_mod._safe_fetch_image_for_gemini("https://[bad/x.png", "image/png"))
    assert res is None


def test_safe_fetch_image_pins_validated_ip_no_hostname_in_request(monkeypatch):
    """Round 17: the fetch helper must pin the validated IP into the outgoing
    request URL (with a Host header carrying the original hostname). A second
    hostname-style getaddrinfo after validate would be a DNS-rebinding gap, so
    we assert the urllib opener is called with an IP-rewritten URL."""
    import socket

    captured: dict = {"requests": []}

    # Public IP during validate; record every getaddrinfo call.
    original_getaddrinfo = socket.getaddrinfo

    def fake_getaddrinfo(host, *args, **kwargs):
        captured.setdefault("dns", []).append(host)
        if host == "cdn.example.com":
            return [
                (
                    socket.AF_INET,
                    socket.SOCK_STREAM,
                    0,
                    "",
                    ("8.8.8.8", 0),
                )
            ]
        return original_getaddrinfo(host, *args, **kwargs)

    monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)

    class _StubResp:
        status = 200
        headers = {"content-type": "image/png", "content-length": "3"}

        def __enter__(self):
            return self

        def __exit__(self, *a):
            return False

        def read(self, _n = None):
            return b"PNG"

    class _StubOpener:
        def open(
            self,
            req,
            timeout = None,
        ):
            captured["requests"].append(
                {
                    "url": req.full_url,
                    "host_header": req.get_header("Host"),
                }
            )
            return _StubResp()

    monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())

    res = _drive(ep_mod._safe_fetch_image_for_gemini("https://cdn.example.com/x.png", "image/png"))
    assert res is not None
    assert res[0] == "image/png"
    # Outgoing URL must use the pinned IP literal, not the hostname.
    assert any("8.8.8.8" in r["url"] for r in captured["requests"]), captured
    assert all("cdn.example.com" not in r["url"] for r in captured["requests"]), captured
    # Host header still carries the original hostname for vhost/SNI.
    assert captured["requests"][0]["host_header"] == "cdn.example.com"


def test_safe_fetch_image_redirect_to_private_host_rejected(monkeypatch):
    """Round 17: each redirect hop must re-validate the new host. A public hop
    that redirects to an internal address must be dropped."""
    import socket
    import urllib.error

    original_getaddrinfo = socket.getaddrinfo

    def fake_getaddrinfo(host, *args, **kwargs):
        if host == "cdn.example.com":
            return [
                (
                    socket.AF_INET,
                    socket.SOCK_STREAM,
                    0,
                    "",
                    ("1.1.1.1", 0),
                )
            ]
        if host == "internal.bad":
            return [
                (
                    socket.AF_INET,
                    socket.SOCK_STREAM,
                    0,
                    "",
                    ("10.0.0.5", 0),
                )
            ]
        return original_getaddrinfo(host, *args, **kwargs)

    monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)

    class _StubOpener:
        def open(
            self,
            req,
            timeout = None,
        ):
            # Simulate a 302 to a private host.
            raise urllib.error.HTTPError(
                req.full_url,
                302,
                "Found",
                {"Location": "https://internal.bad/secret.png"},
                None,
            )

    monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())

    res = _drive(ep_mod._safe_fetch_image_for_gemini("https://cdn.example.com/x.png", "image/png"))
    assert res is None


def test_files_api_substring_url_not_misclassified_as_filedata(monkeypatch):
    """Round 17: a CDN URL whose path/query merely contains the Files API
    substring must NOT be sent as `fileData.fileUri`; route it through the
    safe-fetch path. The old substring check
    `"generativelanguage.googleapis.com/" in url.lower()` matched any URL
    carrying that text anywhere."""
    captured_outbound: dict = {}
    fetch_calls: list[str] = []

    async def fake_fetch(
        url,
        fallback_mime,
        max_bytes = None,
    ):
        fetch_calls.append(url)
        return "image/png", base64.b64encode(b"DATA").decode("ascii")

    monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch)

    def handler(request: httpx.Request) -> httpx.Response:
        captured_outbound["body"] = json.loads(request.content.decode("utf-8"))
        return httpx.Response(
            200,
            content = _gemini_sse(
                [
                    {
                        "candidates": [
                            {
                                "content": {
                                    "role": "model",
                                    "parts": [{"text": "ok"}],
                                },
                                "finishReason": "STOP",
                            }
                        ],
                        "usageMetadata": {
                            "promptTokenCount": 1,
                            "candidatesTokenCount": 1,
                        },
                    }
                ]
            ),
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = _make_gemini_client()
        async for _ in client.stream_chat_completion(
            messages = [
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "describe"},
                        {
                            "type": "image_url",
                            "image_url": {
                                # Files-API-looking path, but host is an
                                # attacker CDN.
                                "url": "https://evil.example/path/generativelanguage.googleapis.com/v1beta/files/abc.png",
                            },
                        },
                        {
                            "type": "image_url",
                            "image_url": {
                                # Looks YouTube-ish in the path.
                                "url": "https://cdn.example.com/youtube.com/cat.png",
                            },
                        },
                    ],
                }
            ],
            model = "gemini-2.5-flash",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 64,
        ):
            pass
        await client.close()

    _drive(run())

    parts = captured_outbound["body"]["contents"][-1]["parts"]
    assert not any("fileData" in p for p in parts), parts
    inline_count = sum(1 for p in parts if "inlineData" in p)
    assert inline_count == 2, parts
    assert len(fetch_calls) == 2, fetch_calls


def test_function_schema_anyof_null_variant_flattens_to_nullable(monkeypatch):
    """Round 17: OpenAI/Pydantic emit `anyOf: [{X}, {"type":"null"}]` for
    Optional[X]. Gemini's OpenAPI subset rejects `"type":"null"` inside anyOf.
    The sanitizer must collapse a singleton-plus-null union back to the
    non-null branch with `nullable: true`."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "lookup",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "label": {
                                "anyOf": [
                                    {"type": "string"},
                                    {"type": "null"},
                                ]
                            },
                            "count": {
                                "anyOf": [
                                    {"type": "integer"},
                                    {"type": "null"},
                                ]
                            },
                        },
                    },
                },
            }
        ],
    )
    decls = next(
        t["functionDeclarations"]
        for t in captured["body"].get("tools") or []
        if "functionDeclarations" in t
    )
    params = decls[0]["parameters"]["properties"]
    assert params["label"]["type"] == "string"
    assert params["label"]["nullable"] is True
    assert "anyOf" not in params["label"]
    assert params["count"]["type"] == "integer"
    assert params["count"]["nullable"] is True


def test_legacy_gemini3_pro_medium_coerced_to_high(monkeypatch):
    """Round 17: legacy `gemini-3-pro*` (incl. `-preview`, shut down
    2026-03-09) only accepted low/high. 3.1+ Pro added medium. The backend
    must coerce medium → high for the legacy model so stale UI state doesn't
    400 the request."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-3-pro-preview",
        reasoning_effort = "medium",
    )
    assert captured["body"]["generationConfig"]["thinkingConfig"] == {"thinkingLevel": "high"}


def test_gemini_3_1_pro_medium_passes_through(monkeypatch):
    """Round 17 regression: 3.1+ Pro accepts medium; coercion must NOT apply
    when the model id is gemini-3.1-pro*."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-3.1-pro-preview",
        reasoning_effort = "medium",
    )
    assert captured["body"]["generationConfig"]["thinkingConfig"] == {"thinkingLevel": "medium"}


def test_tool_calls_extra_content_stripped_for_non_native_gemini():
    """Round 17: per-tool-call `extra_content` (Gemini thoughtSignature
    carrier) must not leak through `_build_external_messages` to
    non-native-Gemini providers; OpenAI / Anthropic / custom Gemini OAI-compat
    gateways would 400 on the unknown key."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    payload = {
        "model": "gpt-5.5",
        "messages": [
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_1",
                        "type": "function",
                        "function": {"name": "lookup", "arguments": "{}"},
                        "extra_content": {
                            "google": {"thought_signature": "SIG"},
                        },
                    }
                ],
            }
        ],
        "stream": True,
    }
    req = ChatCompletionRequest.model_validate(payload)

    # Non-native providers (openai, custom Gemini OAI-compat proxy) must have
    # extra_content stripped from the tool_call entry.
    for provider_type, base_url in [
        ("openai", None),
        ("gemini", "https://litellm.example/v1"),
    ]:
        result = _build_external_messages(
            req.messages,
            supports_vision = True,
            provider_type = provider_type,
            base_url = base_url,
        )
        assert len(result) == 1
        tc = result[0]["tool_calls"][0]
        assert "extra_content" not in tc, (provider_type, tc)

    # Native Gemini still receives extra_content for the round-trip.
    result_native = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "gemini",
        base_url = "https://generativelanguage.googleapis.com/v1beta",
    )
    tc_native = result_native[0]["tool_calls"][0]
    assert tc_native["extra_content"]["google"]["thought_signature"] == "SIG"


def test_user_function_named_with_server_tool_arg_not_dropped(monkeypatch):
    """Round 17: the OpenAI Responses translator must NOT drop a user function
    whose JSON arguments contain `_server_tool: true` UNLESS the function name
    is also a canonical builtin name. Otherwise a user schema with an
    `_server_tool` field becomes invisible to the model."""
    captured: dict = {"input_items": None}

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content.decode("utf-8"))
        captured["input_items"] = body.get("input")
        return httpx.Response(
            200,
            content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openai",
            base_url = "https://api.openai.com/v1",
            api_key = "sk-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [
                {"role": "user", "content": "hi"},
                {
                    "role": "assistant",
                    "content": "",
                    "tool_calls": [
                        {
                            "id": "call_user",
                            "type": "function",
                            "function": {
                                "name": "user_function",
                                "arguments": json.dumps({"_server_tool": True, "q": "x"}),
                            },
                        }
                    ],
                },
                {
                    "role": "tool",
                    "content": "result",
                    "tool_call_id": "call_user",
                    "name": "user_function",
                },
                {"role": "user", "content": "continue"},
            ],
            model = "gpt-5.5",
            temperature = 0.7,
            top_p = 1.0,
            max_tokens = 16,
        ):
            pass
        await client.close()

    _drive(run())

    items = captured["input_items"] or []
    fn_calls = [i for i in items if i.get("type") == "function_call"]
    fn_outs = [i for i in items if i.get("type") == "function_call_output"]
    # User function call must survive (call + output).
    assert any(c.get("name") == "user_function" for c in fn_calls), items
    assert len(fn_outs) == 1, items


def test_builtin_named_with_server_tool_marker_dropped(monkeypatch):
    """Round 17 control: a builtin (web_search) tagged with `_server_tool:
    true` continues to be filtered from outbound history."""
    captured: dict = {"input_items": None}

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content.decode("utf-8"))
        captured["input_items"] = body.get("input")
        return httpx.Response(
            200,
            content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openai",
            base_url = "https://api.openai.com/v1",
            api_key = "sk-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [
                {"role": "user", "content": "search please"},
                {
                    "role": "assistant",
                    "content": "",
                    "tool_calls": [
                        {
                            "id": "call_b",
                            "type": "function",
                            "function": {
                                "name": "web_search",
                                "arguments": json.dumps({"_server_tool": True, "query": "x"}),
                            },
                        }
                    ],
                },
                {"role": "user", "content": "continue"},
            ],
            model = "gpt-5.5",
            temperature = 0.7,
            top_p = 1.0,
            max_tokens = 16,
        ):
            pass
        await client.close()

    _drive(run())

    items = captured["input_items"] or []
    fn_calls = [i for i in items if i.get("type") == "function_call"]
    # Builtin server-side tool call must be filtered out.
    assert all(c.get("name") != "web_search" for c in fn_calls), items


def test_gemini_tool_choice_none_disables_hosted_builtins(monkeypatch):
    """Round 18: `tool_choice="none"` must drop hosted Google Search / code
    execution from the Gemini body, not just user function declarations.
    Otherwise an API client that opted out of tool use still triggers grounded
    search (privacy + billing)."""
    captured = _capture_body(
        monkeypatch,
        enabled_tools = ["web_search", "code_execution"],
        tool_choice = "none",
    )
    assert captured["body"].get("tools") is None, captured["body"]


def test_gemini_tool_choice_none_disables_function_declarations(monkeypatch):
    """Round 18: `tool_choice="none"` must drop user function declarations as
    well as hosted builtins from the Gemini body."""
    captured = _capture_body(
        monkeypatch,
        tool_choice = "none",
        tools = [
            {
                "type": "function",
                "function": {"name": "lookup", "parameters": {"type": "object"}},
            }
        ],
    )
    assert captured["body"].get("tools") is None, captured["body"]


def test_schema_anyof_multitype_with_null_keeps_anyof_and_nullable(monkeypatch):
    """Round 18: multi-branch unions with null (e.g. `Union[str, int, None]`)
    must keep the slim anyOf without the null branch and add `nullable: true`;
    Gemini rejects `{"type":"null"}` inside anyOf."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "lookup",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "either": {
                                "anyOf": [
                                    {"type": "string"},
                                    {"type": "integer"},
                                    {"type": "null"},
                                ]
                            },
                        },
                    },
                },
            }
        ],
    )
    decls = next(
        t["functionDeclarations"]
        for t in captured["body"].get("tools") or []
        if "functionDeclarations" in t
    )
    either = decls[0]["parameters"]["properties"]["either"]
    assert either.get("nullable") is True
    inner = either.get("anyOf")
    assert isinstance(inner, list) and len(inner) == 2, either
    assert all(not (isinstance(b, dict) and b.get("type") == "null") for b in inner), inner


def test_safe_fetch_image_redirect_malformed_url_no_crash(monkeypatch):
    """Round 18: when the upstream 302 Location is a malformed bracketed-IPv6
    URL, the helper must return None instead of letting a urlparse ValueError
    abort the chat stream."""
    import socket
    import urllib.error

    original_getaddrinfo = socket.getaddrinfo

    def fake_getaddrinfo(host, *args, **kwargs):
        if host == "cdn.example.com":
            return [
                (
                    socket.AF_INET,
                    socket.SOCK_STREAM,
                    0,
                    "",
                    ("1.1.1.1", 0),
                )
            ]
        return original_getaddrinfo(host, *args, **kwargs)

    monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)

    class _StubOpener:
        def open(
            self,
            req,
            timeout = None,
        ):
            raise urllib.error.HTTPError(
                req.full_url,
                302,
                "Found",
                {"Location": "https://[bad/x.png"},
                None,
            )

    monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())

    res = _drive(ep_mod._safe_fetch_image_for_gemini("https://cdn.example.com/x.png", "image/png"))
    assert res is None


def test_safe_fetch_image_malformed_port_no_crash():
    """Round 18: a URL with a non-numeric port (`https://h:bad/x.png`) must
    not raise; urlparse's port property lazily ValueErrors."""
    res = _drive(ep_mod._safe_fetch_image_for_gemini("https://example.com:bad/x.png", "image/png"))
    assert res is None


def test_safe_fetch_image_missing_content_type_uses_fallback(monkeypatch):
    """Round 18: when the server returns image bytes but no Content-Type
    header, the helper must use the caller-provided fallback MIME (guessed from
    URL extension) instead of dropping the image as `non-image
    content-type=<none>`."""
    import socket

    original_getaddrinfo = socket.getaddrinfo

    def fake_getaddrinfo(host, *args, **kwargs):
        if host == "cdn.example.com":
            return [
                (
                    socket.AF_INET,
                    socket.SOCK_STREAM,
                    0,
                    "",
                    ("1.1.1.1", 0),
                )
            ]
        return original_getaddrinfo(host, *args, **kwargs)

    monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)

    class _StubResp:
        status = 200
        headers = {"content-length": "3"}

        def __enter__(self):
            return self

        def __exit__(self, *a):
            return False

        def read(self, _n = None):
            return b"PNG"

    class _StubOpener:
        def open(
            self,
            req,
            timeout = None,
        ):
            return _StubResp()

    monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())

    res = _drive(
        ep_mod._safe_fetch_image_for_gemini("https://cdn.example.com/cat.png", "image/png")
    )
    assert res is not None
    assert res[0] == "image/png"


def test_anthropic_translates_openai_tool_calls_into_tool_use_blocks(monkeypatch):
    """Round 18: an assistant turn with OpenAI-style top-level `tool_calls`
    must be translated into Anthropic native `{type:"tool_use", id, name,
    input}` content blocks before forwarding. The OpenAI `role="tool"`
    follow-up must become a `role:"user"` message with a `tool_result`
    block."""
    captured: dict = {"messages": None}

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content.decode("utf-8"))
        captured["messages"] = body.get("messages")
        return httpx.Response(
            200,
            content = b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "anthropic",
            base_url = "https://api.anthropic.com",
            api_key = "sk-ant-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [
                {"role": "user", "content": "look up X"},
                {
                    "role": "assistant",
                    "content": "let me check",
                    "tool_calls": [
                        {
                            "id": "call_a",
                            "type": "function",
                            "function": {
                                "name": "lookup",
                                "arguments": '{"q":"x"}',
                            },
                        }
                    ],
                },
                {
                    "role": "tool",
                    "content": "result_text",
                    "tool_call_id": "call_a",
                    "name": "lookup",
                },
                {"role": "user", "content": "summarise"},
            ],
            model = "claude-sonnet-4-5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 64,
        ):
            pass
        await client.close()

    _drive(run())

    msgs = captured["messages"] or []
    # No top-level tool_calls should remain.
    assert all("tool_calls" not in m for m in msgs), msgs
    # The assistant turn must now have content blocks including a tool_use
    # block.
    asst = [m for m in msgs if m.get("role") == "assistant"]
    assert asst and isinstance(asst[0]["content"], list), asst
    tool_uses = [b for b in asst[0]["content"] if b.get("type") == "tool_use"]
    assert len(tool_uses) == 1, asst[0]
    assert tool_uses[0]["name"] == "lookup"
    assert tool_uses[0]["input"] == {"q": "x"}
    # The role="tool" message must become a user/tool_result message.
    tool_results: list[dict] = []
    for m in msgs:
        if m.get("role") == "user" and isinstance(m.get("content"), list):
            tool_results.extend(b for b in m["content"] if b.get("type") == "tool_result")
    assert any(
        tr.get("tool_use_id") == "call_a" and tr.get("content") == "result_text"
        for tr in tool_results
    ), msgs


def test_unmarked_user_web_search_function_survives_serialization():
    """Round 18: a user-defined function literally named `web_search` with NO
    `_server_tool` marker must survive `_build_external_messages` when
    forwarded to a non-native provider; only marked synthetic builtin cards may
    be dropped."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    payload = {
        "model": "gpt-5.5",
        "messages": [
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_user",
                        "type": "function",
                        "function": {
                            "name": "web_search",
                            "arguments": '{"query": "x"}',
                        },
                    }
                ],
            }
        ],
        "stream": True,
    }
    req = ChatCompletionRequest.model_validate(payload)
    result = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "openai",
        base_url = None,
    )
    assert len(result) == 1, result
    tcs = result[0].get("tool_calls") or []
    assert len(tcs) == 1, result
    assert tcs[0]["function"]["name"] == "web_search"


def test_marked_server_builtin_dropped_from_build_external_messages():
    """Round 18: when a Gemini-native turn carrying a marked `image_generation`
    server-tool card is forwarded to OpenAI / a custom Gemini OAI-compat proxy,
    the tool_call must be dropped, not just have its extra_content stripped.
    Forwarding an orphan `image_generation` tool_call would 400 the receiving
    API."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    marked_args = json.dumps({"_server_tool": True, "kind": "image"})
    payload = {
        "model": "gpt-5.5",
        "messages": [
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_b",
                        "type": "function",
                        "function": {
                            "name": "image_generation",
                            "arguments": marked_args,
                        },
                    }
                ],
            }
        ],
        "stream": True,
    }
    req = ChatCompletionRequest.model_validate(payload)
    # Non-native providers: marked builtin tool_call must be dropped, and if it
    # was the only payload, the whole message disappears.
    for provider_type, base_url in [
        ("openai", None),
        ("gemini", "https://litellm.example/v1"),
    ]:
        result = _build_external_messages(
            req.messages,
            supports_vision = True,
            provider_type = provider_type,
            base_url = base_url,
        )
        # Empty assistant turn with only synthetic tool_call dropped.
        assert result == [] or all(not (m.get("tool_calls") or []) for m in result), (
            provider_type,
            result,
        )

    # Native Gemini preserves it (round-trips via extra_content).
    result_native = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "gemini",
        base_url = "https://generativelanguage.googleapis.com/v1beta",
    )
    assert len(result_native) == 1
    assert result_native[0]["tool_calls"][0]["function"]["name"] == "image_generation"


def test_openai_responses_tool_choice_none_drops_hosted_tools(monkeypatch):
    """Round 18: `tool_choice="none"` must also drop hosted OpenAI Responses
    builtins (web_search, code execution shell, image generation), not just
    user function tools."""
    captured: dict = {"body": None}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["body"] = json.loads(request.content.decode("utf-8"))
        return httpx.Response(
            200,
            content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openai",
            base_url = "https://api.openai.com/v1",
            api_key = "sk-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "gpt-5.5",
            temperature = 0.7,
            top_p = 1.0,
            max_tokens = 16,
            enabled_tools = ["web_search", "code_execution", "image_generation"],
            tool_choice = "none",
        ):
            pass
        await client.close()

    _drive(run())
    body = captured["body"] or {}
    assert body.get("tools") in (None, []), body


def test_anthropic_tool_choice_none_drops_hosted_tools(monkeypatch):
    """Round 19: tool_choice="none" must opt out of Anthropic hosted builtins
    (web_search, web_fetch, code_execution) like it does for Gemini and OpenAI
    Responses."""
    captured: dict = {"body": None}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["body"] = json.loads(request.content.decode("utf-8"))
        return httpx.Response(
            200,
            content = b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "anthropic",
            base_url = "https://api.anthropic.com",
            api_key = "sk-ant-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "claude-sonnet-4-5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search", "web_fetch", "code_execution"],
            tool_choice = "none",
        ):
            pass
        await client.close()

    _drive(run())
    body = captured["body"] or {}
    assert body.get("tools") in (None, []), body


def test_openrouter_tool_choice_none_drops_web_plugin(monkeypatch):
    """Round 19: tool_choice="none" must drop the OpenRouter web plugin so a
    request that opted out of tool use doesn't still trigger hosted web
    search."""
    captured: dict = {"body": None}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["body"] = json.loads(request.content.decode("utf-8"))
        return httpx.Response(
            200,
            content = b"data: [DONE]\n\n",
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openrouter",
            base_url = "https://openrouter.ai/api/v1",
            api_key = "sk-or-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "openai/gpt-5.5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search"],
            tool_choice = "none",
        ):
            pass
        await client.close()

    _drive(run())
    body = captured["body"] or {}
    assert body.get("plugins") in (None, []), body


def test_kimi_tool_choice_none_skips_web_search_helper(monkeypatch):
    """Round 19: when tool_choice="none" plus enabled_tools=["web_search"] on
    Kimi, the dispatcher must NOT route into `_stream_kimi_web_search`. Falling
    through to the generic OAI-compat path is expected."""
    routed_to_helper = {"called": False}

    real_helper = ExternalProviderClient._stream_kimi_web_search

    async def fake_helper(self, *args, **kwargs):  # noqa: ARG001
        routed_to_helper["called"] = True
        if False:
            yield ""  # pragma: no cover

    monkeypatch.setattr(
        ExternalProviderClient,
        "_stream_kimi_web_search",
        fake_helper,
    )

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            content = b"data: [DONE]\n\n",
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "kimi",
            base_url = "https://api.moonshot.ai/v1",
            api_key = "sk-kimi-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "kimi-k2.6",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search"],
            tool_choice = "none",
        ):
            pass
        await client.close()

    _drive(run())
    assert routed_to_helper["called"] is False

    monkeypatch.setattr(
        ExternalProviderClient,
        "_stream_kimi_web_search",
        real_helper,
    )


def test_user_code_execution_function_not_dropped():
    """Round 19: a user-declared function literally named `code_execution` with
    normal `code` arguments must survive `_build_external_messages` -- round
    17's shape heuristic dropped it, breaking function-calling round-trips."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    payload = {
        "model": "gpt-5.5",
        "messages": [
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_user",
                        "type": "function",
                        "function": {
                            "name": "code_execution",
                            "arguments": '{"code": "print(1)"}',
                        },
                    }
                ],
            }
        ],
        "stream": True,
    }
    req = ChatCompletionRequest.model_validate(payload)
    result = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "openai",
        base_url = None,
    )
    assert len(result) == 1, result
    tcs = result[0].get("tool_calls") or []
    assert len(tcs) == 1, result
    assert tcs[0]["function"]["name"] == "code_execution"


def test_native_part_code_execution_treated_as_server_side():
    """Round 19: a Gemini `code_execution` card persists its replay payload at
    `args.google.native_part` (no `_server_tool` marker on pre-PR cards). The
    backend filter must still drop it for non-native providers because it's a
    synthetic card, not a real user function."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    args_with_native_part = json.dumps(
        {
            "google": {
                "native_part": {
                    "executableCode": {
                        "language": "PYTHON",
                        "code": "print(1)",
                    }
                }
            }
        }
    )
    payload = {
        "model": "gpt-5.5",
        "messages": [
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_x",
                        "type": "function",
                        "function": {
                            "name": "code_execution",
                            "arguments": args_with_native_part,
                        },
                    }
                ],
            }
        ],
        "stream": True,
    }
    req = ChatCompletionRequest.model_validate(payload)
    result = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "openai",
        base_url = None,
    )
    assert result == [] or all(not (m.get("tool_calls") or []) for m in result), result


def test_remote_image_fetch_attempt_cap_includes_failures(monkeypatch):
    """Round 19: the per-request image fetch count cap must count ATTEMPTS,
    not just successes. Otherwise a request with 100 failing/slow URLs runs 100
    fetches each up to the 15s timeout."""
    fetch_calls: list[str] = []

    async def fake_fetch(
        url,
        fallback_mime,
        max_bytes = None,
    ):
        fetch_calls.append(url)
        return None

    monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch)

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            content = _gemini_sse(
                [
                    {
                        "candidates": [
                            {
                                "content": {
                                    "role": "model",
                                    "parts": [{"text": "ok"}],
                                },
                                "finishReason": "STOP",
                            }
                        ],
                        "usageMetadata": {
                            "promptTokenCount": 1,
                            "candidatesTokenCount": 1,
                        },
                    }
                ]
            ),
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = _make_gemini_client()
        image_parts = [
            {
                "type": "image_url",
                "image_url": {"url": f"https://cdn.example.com/img{idx}.png"},
            }
            for idx in range(20)
        ]
        async for _ in client.stream_chat_completion(
            messages = [
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "describe"},
                        *image_parts,
                    ],
                }
            ],
            model = "gemini-2.5-flash",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 64,
        ):
            pass
        await client.close()

    _drive(run())
    assert len(fetch_calls) <= 8, len(fetch_calls)


def test_orphan_function_call_output_dropped_when_call_skipped(monkeypatch):
    """Round 19: when a marked server-side builtin `function_call` is dropped
    from OpenAI Responses input items, the matching role=tool follow-up must
    also be dropped to avoid an orphan `function_call_output`."""
    captured: dict = {"input_items": None}

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content.decode("utf-8"))
        captured["input_items"] = body.get("input")
        return httpx.Response(
            200,
            content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openai",
            base_url = "https://api.openai.com/v1",
            api_key = "sk-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [
                {"role": "user", "content": "search please"},
                {
                    "role": "assistant",
                    "content": "",
                    "tool_calls": [
                        {
                            "id": "call_b",
                            "type": "function",
                            "function": {
                                "name": "web_search",
                                "arguments": json.dumps({"_server_tool": True, "query": "x"}),
                            },
                        }
                    ],
                },
                {
                    "role": "tool",
                    "content": "result_text",
                    "tool_call_id": "call_b",
                    "name": "web_search",
                },
                {"role": "user", "content": "continue"},
            ],
            model = "gpt-5.5",
            temperature = 0.7,
            top_p = 1.0,
            max_tokens = 16,
        ):
            pass
        await client.close()

    _drive(run())

    items = captured["input_items"] or []
    fn_calls = [i for i in items if i.get("type") == "function_call"]
    fn_outs = [i for i in items if i.get("type") == "function_call_output"]
    assert all(c.get("call_id") != "call_b" for c in fn_calls), items
    assert all(o.get("call_id") != "call_b" for o in fn_outs), items


def test_schema_multitype_union_with_null_preserves_anyof(monkeypatch):
    """Round 19: a JSON Schema `"type": ["string","integer","null"]` must be
    sanitized to anyOf:[{string},{integer}] + nullable:true. Flattening to just
    `{"type":"string"}` silently drops the integer branch and changes the
    function contract."""
    captured = _capture_body(
        monkeypatch,
        tools = [
            {
                "type": "function",
                "function": {
                    "name": "lookup",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "either": {"type": ["string", "integer", "null"]},
                        },
                    },
                },
            }
        ],
    )
    decls = next(
        t["functionDeclarations"]
        for t in captured["body"].get("tools") or []
        if "functionDeclarations" in t
    )
    either = decls[0]["parameters"]["properties"]["either"]
    assert either.get("nullable") is True
    inner = either.get("anyOf")
    assert isinstance(inner, list) and len(inner) == 2, either
    types = sorted(b.get("type") for b in inner if isinstance(b, dict) and b.get("type"))
    assert types == ["integer", "string"], inner


def test_invalid_gemini_model_rejected_before_image_fetch(monkeypatch):
    """Round 19: invalid Gemini model IDs are rejected at the top of
    `_stream_gemini`, BEFORE any user-controlled remote image fetch runs."""
    fetch_calls: list[str] = []

    async def fake_fetch(
        url,
        fallback_mime,
        max_bytes = None,
    ):
        fetch_calls.append(url)
        return None

    monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch)

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            content = b"",
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = _make_gemini_client()
        async for _ in client.stream_chat_completion(
            messages = [
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "hi"},
                        {
                            "type": "image_url",
                            "image_url": {"url": "https://cdn.example.com/x.png"},
                        },
                    ],
                }
            ],
            model = "../cachedContents/leak",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 64,
        ):
            pass
        await client.close()

    _drive(run())
    assert fetch_calls == [], fetch_calls


def test_empty_assistant_turn_skipped_after_synthetic_tool_calls_dropped():
    """Round 20: when `_filter_tool_calls` drops every synthetic server-builtin
    tool_call on an empty-content assistant turn, the whole message must be
    skipped. Several providers reject `{"role":"assistant","content":""}` as an
    empty assistant turn."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    marked_args = json.dumps({"_server_tool": True, "kind": "image"})
    payload = {
        "model": "gpt-5.5",
        "messages": [
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_b",
                        "type": "function",
                        "function": {
                            "name": "image_generation",
                            "arguments": marked_args,
                        },
                    }
                ],
            }
        ],
        "stream": True,
    }
    req = ChatCompletionRequest.model_validate(payload)
    for provider_type, base_url in [
        ("openai", None),
        ("gemini", "https://litellm.example/v1"),
    ]:
        result = _build_external_messages(
            req.messages,
            supports_vision = True,
            provider_type = provider_type,
            base_url = base_url,
        )
        # The empty assistant turn (only a synthetic builtin) must NOT appear
        # in the output at all.
        assert result == [], (provider_type, result)


def test_role_tool_dropped_when_matching_synthetic_call_filtered():
    """Round 20: `_build_external_messages` drops the matching role=tool
    follow-up when its tool_call was a synthetic builtin that
    `_filter_tool_calls` removed. Otherwise the receiving provider sees an
    orphan tool_result with no tool_call."""
    from models.inference import ChatCompletionRequest
    from routes.inference import _build_external_messages

    marked_args = json.dumps({"_server_tool": True, "query": "x"})
    payload = {
        "model": "gpt-5.5",
        "messages": [
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_b",
                        "type": "function",
                        "function": {
                            "name": "web_search",
                            "arguments": marked_args,
                        },
                    }
                ],
            },
            {
                "role": "tool",
                "content": "result_text",
                "tool_call_id": "call_b",
                "name": "web_search",
            },
            {"role": "user", "content": "continue"},
        ],
        "stream": True,
    }
    req = ChatCompletionRequest.model_validate(payload)
    result = _build_external_messages(
        req.messages,
        supports_vision = True,
        provider_type = "openai",
        base_url = None,
    )
    # Only the user "continue" message survives.
    roles = [m.get("role") for m in result]
    assert roles == ["user"], result


def test_openrouter_no_synthetic_web_search_event_on_tool_choice_none(monkeypatch):
    """Round 20: OpenRouter dispatcher must not emit synthetic web_search
    tool_start / tool_end events when tool_choice="none"; otherwise the chat UI
    shows a search card for a search that never happened."""
    captured_events: list[dict] = []

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            content = b"data: [DONE]\n\n",
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openrouter",
            base_url = "https://openrouter.ai/api/v1",
            api_key = "sk-or-test",
        )
        async for line in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "openai/gpt-5.5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search"],
            tool_choice = "none",
        ):
            if not line.startswith("data: "):
                continue
            payload = line[len("data: ") :].strip()
            if not payload or payload == "[DONE]":
                continue
            try:
                obj = json.loads(payload)
            except Exception:
                continue
            # Backend emits synthetic tool events as a top-level `_toolEvent`
            # on the SSE payload (not nested inside `delta`). Read both shapes
            # so a future format change can't mask this regression.
            evt = obj.get("_toolEvent")
            if isinstance(evt, dict):
                captured_events.append(evt)
            for ch in obj.get("choices") or []:
                delta = ch.get("delta") or {}
                nested = delta.get("_toolEvent") if isinstance(delta, dict) else None
                if isinstance(nested, dict):
                    captured_events.append(nested)
        await client.close()

    _drive(run())
    # No synthetic web_search tool_start / tool_end emitted.
    assert all(e.get("tool_name") != "web_search" for e in captured_events), captured_events


def test_anthropic_role_tool_list_content_translates_to_tool_result(monkeypatch):
    """Round 20: an OpenAI-shape role=tool message with list content
    (`content=[{"type":"text","text":"result"}]`) must be translated into
    Anthropic's native tool_result block, not forwarded as an invalid role=tool
    message."""
    captured: dict = {"messages": None}

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content.decode("utf-8"))
        captured["messages"] = body.get("messages")
        return httpx.Response(
            200,
            content = b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "anthropic",
            base_url = "https://api.anthropic.com",
            api_key = "sk-ant-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [
                {"role": "user", "content": "look up X"},
                {
                    "role": "assistant",
                    "content": "let me check",
                    "tool_calls": [
                        {
                            "id": "call_a",
                            "type": "function",
                            "function": {
                                "name": "lookup",
                                "arguments": '{"q":"x"}',
                            },
                        }
                    ],
                },
                {
                    "role": "tool",
                    "content": [{"type": "text", "text": "result_text"}],
                    "tool_call_id": "call_a",
                    "name": "lookup",
                },
                {"role": "user", "content": "summarise"},
            ],
            model = "claude-sonnet-4-5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 64,
        ):
            pass
        await client.close()

    _drive(run())

    msgs = captured["messages"] or []
    assert all(m.get("role") != "tool" for m in msgs), msgs
    tool_results: list[dict] = []
    for m in msgs:
        if m.get("role") == "user" and isinstance(m.get("content"), list):
            tool_results.extend(b for b in m["content"] if b.get("type") == "tool_result")
    assert any(
        tr.get("tool_use_id") == "call_a" and tr.get("content") == "result_text"
        for tr in tool_results
    ), msgs


def test_data_url_non_image_mime_dropped(monkeypatch):
    """Round 20: a `data:text/html;base64,...` image_url must be dropped from
    the Gemini body, not forwarded as `inlineData.mimeType="text/html"` which
    Gemini rejects."""
    captured = _capture_body(
        monkeypatch,
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "look"},
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": "data:text/html;base64,PGgxPmhpPC9oMT4=",
                        },
                    },
                ],
            }
        ],
    )
    parts = captured["body"]["contents"][-1]["parts"]
    assert not any("inlineData" in p for p in parts), parts


def test_youtube_filedata_uses_video_mime(monkeypatch):
    """Round 20: YouTube `fileData.fileUri` must declare a video mimeType, not
    `image/jpeg` guessed from the URL path."""
    captured = _capture_body(
        monkeypatch,
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "summarise"},
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": "https://www.youtube.com/watch?v=abc",
                        },
                    },
                ],
            }
        ],
    )
    parts = captured["body"]["contents"][-1]["parts"]
    yt = next((p for p in parts if "fileData" in p), None)
    assert yt is not None, parts
    assert yt["fileData"]["mimeType"].startswith("video/"), yt


def test_openai_responses_assistant_text_serialized_before_function_call(monkeypatch):
    """Round 20: in OpenAI Responses history, the assistant's visible text for
    a turn that ALSO emitted a function_call must serialize BEFORE the
    function_call item, matching the prior response.output sequence. Otherwise
    function_call_output (the role=tool follow-up) appears to follow an
    unrelated assistant message."""
    captured: dict = {"input_items": None}

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content.decode("utf-8"))
        captured["input_items"] = body.get("input")
        return httpx.Response(
            200,
            content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openai",
            base_url = "https://api.openai.com/v1",
            api_key = "sk-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [
                {"role": "user", "content": "weather?"},
                {
                    "role": "assistant",
                    "content": "Let me check that.",
                    "tool_calls": [
                        {
                            "id": "call_w",
                            "type": "function",
                            "function": {
                                "name": "get_weather",
                                "arguments": "{}",
                            },
                        }
                    ],
                },
                {
                    "role": "tool",
                    "content": "sunny",
                    "tool_call_id": "call_w",
                    "name": "get_weather",
                },
                {"role": "user", "content": "thanks"},
            ],
            model = "gpt-5.5",
            temperature = 0.7,
            top_p = 1.0,
            max_tokens = 16,
        ):
            pass
        await client.close()

    _drive(run())

    items = captured["input_items"] or []
    types = [i.get("type") or i.get("role") for i in items]
    # Expected order:
    #   user ("weather?")
    #   assistant ("Let me check that.")
    #   function_call (get_weather)
    #   function_call_output (sunny)
    #   user ("thanks")
    assert types == ["user", "assistant", "function_call", "function_call_output", "user"], items


def test_gemini_tool_choice_none_disables_image_generation(monkeypatch):
    """Round 21: `tool_choice="none"` must also flip the implicit
    image-generation hosted tool off on image-tier models. Otherwise
    `responseModalities=["TEXT","IMAGE"]` still rides on the body and the
    provider can generate (and bill for) image output despite the explicit
    OpenAI tool opt-out."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = ["image_generation"],
        tool_choice = "none",
    )
    body = captured["body"]
    assert body["generationConfig"].get("responseModalities") == ["TEXT"], body


def test_gemini_forced_function_tool_choice_drops_hosted_builtins(monkeypatch):
    """Round 21: forced-function `tool_choice` (e.g.
    `{"type":"function","function":{"name":"lookup"}}`) must suppress hosted
    Google Search / code execution. Gemini's toolConfig only constrains
    function declarations, not hosted tools, so leaving
    `googleSearch`/`codeExecution` in `tools[]` lets them fire despite the
    caller pinning a specific user function."""
    captured = _capture_body(
        monkeypatch,
        enabled_tools = ["web_search", "code_execution"],
        tools = [
            {
                "type": "function",
                "function": {"name": "lookup", "parameters": {"type": "object"}},
            }
        ],
        tool_choice = {
            "type": "function",
            "function": {"name": "lookup"},
        },
    )
    body = captured["body"]
    tool_kinds = [list(t.keys())[0] for t in (body.get("tools") or [])]
    assert "googleSearch" not in tool_kinds, body
    assert "codeExecution" not in tool_kinds, body
    # User function declaration still survives.
    assert "functionDeclarations" in tool_kinds, body


def test_gemini_forced_function_tool_choice_drops_image_generation(monkeypatch):
    """Round 21: forced-function `tool_choice` must also flip the implicit
    image-generation hosted tool off on image-tier models."""
    captured = _capture_body(
        monkeypatch,
        model = "gemini-2.5-flash-image",
        enabled_tools = ["image_generation"],
        tool_choice = {
            "type": "function",
            "function": {"name": "lookup"},
        },
        tools = [
            {
                "type": "function",
                "function": {"name": "lookup", "parameters": {"type": "object"}},
            }
        ],
    )
    body = captured["body"]
    assert body["generationConfig"].get("responseModalities") == ["TEXT"], body


def test_gemini_code_execution_native_part_list_replays_per_part_signatures(monkeypatch):
    """Round 21: merged code-execution history must replay per-part
    `thoughtSignature`s, not fan one top-level signature across every native
    subpart. Gemini 3 strict validators reject a signature on the wrong
    part."""
    history = [
        {"role": "user", "content": "plot 1+1"},
        {
            "role": "assistant",
            "content": None,
            "tool_calls": [
                {
                    "id": "call_a",
                    "type": "function",
                    "function": {
                        "name": "code_execution",
                        "arguments": "{}",
                    },
                    "extra_content": {
                        "google": {
                            "native_part": {
                                "parts": [
                                    {
                                        "executableCode": {
                                            "id": "code_a",
                                            "language": "PYTHON",
                                            "code": "print(1+1)",
                                        },
                                        "thoughtSignature": "SIG-EXEC",
                                    },
                                    {
                                        "codeExecutionResult": {
                                            "id": "res_a",
                                            "outcome": "OUTCOME_OK",
                                            "output": "2\n",
                                        },
                                    },
                                ],
                            },
                        },
                    },
                }
            ],
        },
        {
            "role": "tool",
            "tool_call_id": "call_a",
            "name": "code_execution",
            "content": "2",
        },
        {"role": "user", "content": "next"},
    ]
    captured = _capture_body(monkeypatch, messages = history)
    contents = captured["body"]["contents"]
    # Find the assistant turn replayed as native code-exec parts.
    assistant_turn = next(c for c in contents if c["role"] == "model")
    parts = assistant_turn["parts"]
    exec_parts = [p for p in parts if "executableCode" in p]
    result_parts = [p for p in parts if "codeExecutionResult" in p]
    assert exec_parts and result_parts, parts
    assert exec_parts[0].get("thoughtSignature") == "SIG-EXEC", exec_parts[0]
    # codeExecutionResult had no signature -- must NOT inherit one.
    assert "thoughtSignature" not in result_parts[0], result_parts[0]


def test_gemini_code_execution_legacy_merged_signature_only_on_executable(monkeypatch):
    """Round 21: backward compat for pre-round-21 persisted history that stored
    merged `native_part` as a single object plus a top-level
    `thoughtSignature`. The replay branch must attach that signature only to
    `executableCode` (where Gemini 3 emits it), not fan it across
    `codeExecutionResult` / `inlineData`."""
    history = [
        {"role": "user", "content": "plot 1+1"},
        {
            "role": "assistant",
            "content": None,
            "tool_calls": [
                {
                    "id": "call_b",
                    "type": "function",
                    "function": {
                        "name": "code_execution",
                        "arguments": "{}",
                    },
                    "extra_content": {
                        "google": {
                            "native_part": {
                                "executableCode": {
                                    "id": "code_b",
                                    "language": "PYTHON",
                                    "code": "print(1+1)",
                                },
                                "codeExecutionResult": {
                                    "id": "res_b",
                                    "outcome": "OUTCOME_OK",
                                    "output": "2\n",
                                },
                                "thoughtSignature": "LEGACY-SIG",
                            },
                        },
                    },
                }
            ],
        },
        {
            "role": "tool",
            "tool_call_id": "call_b",
            "name": "code_execution",
            "content": "2",
        },
        {"role": "user", "content": "next"},
    ]
    captured = _capture_body(monkeypatch, messages = history)
    contents = captured["body"]["contents"]
    assistant_turn = next(c for c in contents if c["role"] == "model")
    exec_parts = [p for p in assistant_turn["parts"] if "executableCode" in p]
    result_parts = [p for p in assistant_turn["parts"] if "codeExecutionResult" in p]
    assert exec_parts[0].get("thoughtSignature") == "LEGACY-SIG", exec_parts[0]
    assert "thoughtSignature" not in result_parts[0], result_parts[0]


def test_gemini_role_tool_list_content_flattens_to_result_text(monkeypatch):
    """Round 21: OpenAI-shape role=tool messages may carry list content like
    `[{"type":"text","text":"result"}]`. Forwarding those parts verbatim into
    `functionResponse.response.result` yields a list of content-part objects
    instead of the actual tool output text."""
    history = [
        {"role": "user", "content": "look up"},
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "call_1",
                    "type": "function",
                    "function": {
                        "name": "lookup",
                        "arguments": json.dumps({"q": "x"}),
                    },
                }
            ],
        },
        {
            "role": "tool",
            "tool_call_id": "call_1",
            "name": "lookup",
            "content": [{"type": "text", "text": "answer-text"}],
        },
        {"role": "user", "content": "next"},
    ]
    captured = _capture_body(monkeypatch, messages = history)
    contents = captured["body"]["contents"]
    fn_response = None
    for c in contents:
        for p in c.get("parts") or []:
            if isinstance(p, dict) and "functionResponse" in p:
                fn_response = p["functionResponse"]
                break
        if fn_response:
            break
    assert fn_response is not None, contents
    assert fn_response["response"] == {"result": "answer-text"}, fn_response


def test_safe_fetch_image_threads_per_request_byte_budget(monkeypatch):
    """Round 21: the aggregate per-request byte cap must be passed into
    `_safe_fetch_image_for_gemini` so an oversize URL is refused via
    Content-Length (short-circuit) rather than fully downloaded then
    discarded."""
    import socket

    captured: dict = {"reads": 0, "content_length_seen": None}

    original_getaddrinfo = socket.getaddrinfo

    def fake_getaddrinfo(host, *args, **kwargs):
        if host == "cdn.example.com":
            return [
                (
                    socket.AF_INET,
                    socket.SOCK_STREAM,
                    0,
                    "",
                    ("8.8.8.8", 0),
                )
            ]
        return original_getaddrinfo(host, *args, **kwargs)

    monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)

    class _StubResp:
        status = 200
        # Declared 5 MiB, but caller passes a 1 MiB remaining budget.
        headers = {
            "content-type": "image/png",
            "content-length": str(5 * 1024 * 1024),
        }

        def __enter__(self):
            return self

        def __exit__(self, *a):
            return False

        def read(self, _n = None):
            captured["reads"] += 1
            return b"\x00" * (5 * 1024 * 1024)

    class _StubOpener:
        def open(
            self,
            req,
            timeout = None,
        ):
            return _StubResp()

    monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())

    res = _drive(
        ep_mod._safe_fetch_image_for_gemini(
            "https://cdn.example.com/big.png",
            "image/png",
            max_bytes = 1 * 1024 * 1024,
        )
    )
    assert res is None
    # Refused via Content-Length pre-check, never read.
    assert captured["reads"] == 0


def test_openai_chat_delta_type_includes_tool_calls_and_extra_content():
    """Round 21: the frontend `OpenAIChatDelta` interface must expose
    `tool_calls` and `extra_content` so TypeScript callers can consume the
    Gemini-native stream fields without `any` casts. A static-string assertion
    against the .ts source; mirrors how other frontend wire-contract tests are
    pinned from the backend suite."""
    import os

    here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    types_path = os.path.join(here, "frontend", "src", "features", "chat", "types", "api.ts")
    with open(types_path, "r", encoding = "utf-8") as f:
        src = f.read()
    assert "tool_calls?: OpenAIToolCallPart[]" in src, src[:200]
    assert "extra_content?: Record<string, unknown>" in src, src[:200]
    assert "boolean | string | null" in src, src[:200]


def test_anthropic_forced_function_tool_choice_drops_hosted_tools(monkeypatch):
    """Round 22: forced-function tool_choice must suppress Anthropic hosted
    builtins like it does for Gemini. Pinning a user function
    (`tool_choice={"type":"function","function":{"name":...}}`) while passing
    `enabled_tools=["web_search","web_fetch","code_execution"]` should not still
    fire those server-side."""
    captured: dict = {"body": None}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["body"] = json.loads(request.content.decode("utf-8"))
        return httpx.Response(
            200,
            content = b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "anthropic",
            base_url = "https://api.anthropic.com",
            api_key = "sk-ant-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "claude-sonnet-4-5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search", "web_fetch", "code_execution"],
            tool_choice = {
                "type": "function",
                "function": {"name": "lookup_record"},
            },
        ):
            pass
        await client.close()

    _drive(run())
    body = captured["body"] or {}
    # No hosted tools in the body — only the caller's user-function
    # declarations (none passed here).
    tools = body.get("tools") or []
    hosted_tool_names = {"web_search", "web_fetch", "code_execution"}
    for tool in tools:
        assert tool.get("name") not in hosted_tool_names, body


def test_openrouter_forced_function_tool_choice_drops_web_plugin(monkeypatch):
    """Round 22: forced-function tool_choice must drop the OpenRouter web
    plugin too — caller pinned a user function, so OpenRouter must not attach
    the hosted web-search plugin."""
    captured: dict = {"body": None}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["body"] = json.loads(request.content.decode("utf-8"))
        return httpx.Response(
            200,
            content = b"data: [DONE]\n\n",
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openrouter",
            base_url = "https://openrouter.ai/api/v1",
            api_key = "sk-or-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "openai/gpt-5.5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search"],
            tool_choice = {
                "type": "function",
                "function": {"name": "lookup_record"},
            },
        ):
            pass
        await client.close()

    _drive(run())
    body = captured["body"] or {}
    assert body.get("plugins") in (None, []), body


def test_kimi_forced_function_tool_choice_skips_web_search_helper(monkeypatch):
    """Round 22: forced-function tool_choice plus enabled_tools=["web_search"]
    on Kimi must NOT route into `_stream_kimi_web_search`. Caller pinned a user
    function; hosted $web_search should be suppressed for the same
    privacy/billing reason."""
    routed_to_helper = {"called": False}

    async def fake_helper(self, *args, **kwargs):  # noqa: ARG001
        routed_to_helper["called"] = True
        if False:
            yield ""  # pragma: no cover

    monkeypatch.setattr(
        ExternalProviderClient,
        "_stream_kimi_web_search",
        fake_helper,
    )

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            content = b"data: [DONE]\n\n",
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "kimi",
            base_url = "https://api.moonshot.ai/v1",
            api_key = "sk-kimi-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "kimi-k2.6",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search"],
            tool_choice = {
                "type": "function",
                "function": {"name": "lookup_record"},
            },
        ):
            pass
        await client.close()

    _drive(run())
    assert not routed_to_helper["called"]


def test_openai_responses_forced_function_tool_choice_drops_hosted_tools(monkeypatch):
    """Round 23: forced-function tool_choice on the OpenAI Responses path must
    suppress hosted builtins (web_search, shell, image_generation) like it does
    for Gemini / Anthropic / OpenRouter / Kimi. User-defined function tools
    still flow through so the pinned function can resolve."""
    captured: dict = {"body": None}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["body"] = json.loads(request.content.decode("utf-8"))
        return httpx.Response(
            200,
            content = b"event: response.completed\ndata: {}\n\n",
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openai",
            base_url = "https://api.openai.com/v1",
            api_key = "sk-openai-test",
        )
        async for _ in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "gpt-5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search", "code_execution", "image_generation"],
            tools = [
                {
                    "type": "function",
                    "function": {
                        "name": "lookup_record",
                        "parameters": {"type": "object", "properties": {}},
                    },
                },
            ],
            tool_choice = {
                "type": "function",
                "function": {"name": "lookup_record"},
            },
        ):
            pass
        await client.close()

    _drive(run())
    body = captured["body"] or {}
    tools = body.get("tools") or []
    hosted_types = {"web_search", "shell", "image_generation"}
    hosted_seen = {t.get("type") for t in tools if isinstance(t, dict)}
    assert not (hosted_seen & hosted_types), body
    # The user function declaration must still be present so the pin has a
    # target.
    user_function_seen = any(isinstance(t, dict) and t.get("type") == "function" for t in tools)
    assert user_function_seen, body
    # And the forced-function tool_choice must be forwarded in Responses shape:
    # `{type:"function", name:"..."}`.
    tc = body.get("tool_choice")
    assert isinstance(tc, dict) and tc.get("type") == "function", body
    assert tc.get("name") == "lookup_record", body


def test_strip_provider_synthetic_tool_history_drops_text_only_extra_content():
    """Round 24: a plain text Gemini reply (no tool_calls) carrying
    `extra_content.google.thought_signature` must still have that metadata
    stripped before being forwarded to a local llama-server backend. Without
    it, switching a Gemini thread mid-stream to a local GGUF model leaks
    Gemini-only fields to llama-server."""
    from routes.inference import _strip_provider_synthetic_tool_history

    messages = [
        {"role": "user", "content": "hi"},
        {
            "role": "assistant",
            "content": "Hello!",
            "extra_content": {"google": {"thought_signature": "SIG_ABC"}},
        },
        {"role": "user", "content": "now in pirate voice"},
    ]
    out = _strip_provider_synthetic_tool_history(messages)
    # Same three turns, but the assistant's `extra_content` is gone.
    assert [m["role"] for m in out] == ["user", "assistant", "user"]
    assistant = out[1]
    assert "extra_content" not in assistant, assistant
    assert assistant["content"] == "Hello!"


def test_validate_and_resolve_host_blocks_shared_address_space():
    """Round 24 SSRF P1: 100.64.0.0/10 carrier-grade NAT addresses are
    `is_private=False` AND `is_global=False` per Python's ipaddress docs. The
    old denylist (is_private/loopback/link_local/etc.) missed them. Adding `not
    ip.is_global` as the primary gate covers all non-public ranges, current and
    future."""
    import socket as _socket
    from core.inference import tools as _tools

    orig_getaddrinfo = _socket.getaddrinfo

    def fake_getaddrinfo(hostname, port, *args, **kwargs):
        if hostname == "shared.example":
            return [
                (
                    _socket.AF_INET,
                    _socket.SOCK_STREAM,
                    0,
                    "",
                    ("100.64.0.1", port),
                ),
            ]
        return orig_getaddrinfo(hostname, port, *args, **kwargs)

    _socket.getaddrinfo = fake_getaddrinfo
    try:
        ok, reason, _ip = _tools._validate_and_resolve_host("shared.example", 443)
    finally:
        _socket.getaddrinfo = orig_getaddrinfo
    assert ok is False, (ok, reason)
    assert "non-public" in reason.lower() or "100.64.0.1" in reason


def test_gemini_custom_oai_compat_base_skips_native_allowlist():
    """Round 24: a custom Gemini OAI-compatible base (LiteLLM/proxy) must NOT
    have its model list filtered through the native Gemini allowlist regex. A
    LiteLLM gateway returning
    `["google/gemini-2.5-flash", "my-team/gemini", "gemini-2.5-flash"]` should
    pass through; the native filter would strip the prefixed IDs even though
    chat dispatch routes them via the OpenAI-compatible client."""
    import asyncio as _asyncio

    from routes import providers as _providers
    from routes.providers import (
        ProviderModelsRequest,
        list_provider_models,
    )

    captured: dict = {"base": None}

    class _FakeClient:
        def __init__(self, *, base_url, **kwargs):
            captured["base"] = base_url

        async def list_models(self):
            return [
                {"id": "google/gemini-2.5-flash"},
                {"id": "my-team/gemini"},
                {"id": "gemini-2.5-flash"},
            ]

        async def close(self):
            return None

    orig = _providers.ExternalProviderClient
    _providers.ExternalProviderClient = _FakeClient
    try:
        req = ProviderModelsRequest(
            provider_type = "gemini",
            base_url = "https://litellm.example/v1",
        )
        result = _asyncio.run(list_provider_models(req, current_subject = "unsloth"))
    finally:
        _providers.ExternalProviderClient = orig
    ids = {m.id for m in result}
    # All three IDs survive — native allowlist bypassed.
    assert "google/gemini-2.5-flash" in ids, ids
    assert "my-team/gemini" in ids, ids
    assert "gemini-2.5-flash" in ids, ids


def test_strip_provider_synthetic_tool_history_drops_synthetic_only():
    """Round 22: switching a thread from native Gemini (code_execution /
    image_generation tool_cards in history) to a local GGUF backend must strip
    the synthetic tool_calls + matching role=tool replies before llama-server
    sees them. Real user-function tool_calls and their matching tool replies
    must survive."""
    from routes.inference import _strip_provider_synthetic_tool_history

    messages = [
        {"role": "user", "content": "hi"},
        {
            "role": "assistant",
            "content": "let me run it",
            "tool_calls": [
                {
                    "id": "synth_ce_1",
                    "type": "function",
                    "function": {
                        "name": "code_execution",
                        "arguments": json.dumps(
                            {
                                "_server_tool": True,
                                "google": {"native_part": {"parts": []}},
                            }
                        ),
                    },
                    "extra_content": {"google": {"thought_signature": "abc"}},
                },
                {
                    "id": "real_lookup",
                    "type": "function",
                    "function": {
                        "name": "lookup_user",
                        "arguments": json.dumps({"id": 42}),
                    },
                },
            ],
            "extra_content": {"google": {"thought_signature": "msglevel"}},
        },
        {
            "role": "tool",
            "tool_call_id": "synth_ce_1",
            "content": "Gemini-only result text",
        },
        {
            "role": "tool",
            "tool_call_id": "real_lookup",
            "content": '{"name": "alice"}',
        },
    ]
    out = _strip_provider_synthetic_tool_history(messages)
    assistant = next(m for m in out if m.get("role") == "assistant")
    tcs = assistant["tool_calls"]
    assert len(tcs) == 1, tcs
    assert tcs[0]["id"] == "real_lookup"
    assert "extra_content" not in tcs[0]
    assert "extra_content" not in assistant
    tool_msgs = [m for m in out if m.get("role") == "tool"]
    assert len(tool_msgs) == 1
    assert tool_msgs[0]["tool_call_id"] == "real_lookup"


def test_strip_provider_synthetic_tool_history_drops_empty_assistant():
    """If every tool_call was synthetic and the assistant turn had no content,
    the entire turn must be dropped (llama-server rejects empty assistant
    messages with no tool_calls)."""
    from routes.inference import _strip_provider_synthetic_tool_history

    messages = [
        {"role": "user", "content": "draw a sloth"},
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "synth_imggen",
                    "type": "function",
                    "function": {
                        "name": "image_generation",
                        "arguments": json.dumps(
                            {
                                "google": {
                                    "native_part": {
                                        "parts": [
                                            {
                                                "inlineData": {
                                                    "mimeType": "image/png",
                                                    "data": "Zm9v",
                                                }
                                            }
                                        ]
                                    }
                                }
                            }
                        ),
                    },
                }
            ],
        },
        {"role": "tool", "tool_call_id": "synth_imggen", "content": "(image)"},
        {"role": "user", "content": "now try in pirate voice"},
    ]
    out = _strip_provider_synthetic_tool_history(messages)
    roles = [m.get("role") for m in out]
    # Synthetic assistant + its tool reply are both gone; only the two user
    # turns survive.
    assert roles == ["user", "user"], out


def test_openrouter_no_synthetic_web_search_event_on_forced_function_tool_choice(monkeypatch):
    """Round 22 sibling of the round-20 `tool_choice='none'` test: when the
    caller forces a specific function via `tool_choice={"type":"function", ...}`
    AND passes `enabled_tools=["web_search"]`, the OpenRouter path must NOT
    synthesize a fake `web_search` tool card. The plugin wasn't attached
    upstream, so the UI must not see a server-tool card."""
    captured_events: list[dict] = []

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            content = (b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n' b"data: [DONE]\n\n"),
            headers = {"content-type": "text/event-stream"},
        )

    _mock_http(monkeypatch, handler)

    async def run():
        client = ExternalProviderClient(
            provider_type = "openrouter",
            base_url = "https://openrouter.ai/api/v1",
            api_key = "sk-or-test",
        )
        async for line in client.stream_chat_completion(
            messages = [{"role": "user", "content": "hi"}],
            model = "openai/gpt-5.5",
            temperature = 0.7,
            top_p = 0.95,
            max_tokens = 16,
            enabled_tools = ["web_search"],
            tool_choice = {
                "type": "function",
                "function": {"name": "lookup_record"},
            },
        ):
            payload = line.strip().removeprefix("data: ")
            if payload and payload != "[DONE]":
                try:
                    captured_events.append(json.loads(payload))
                except Exception:
                    pass
        await client.close()

    _drive(run())
    for evt in captured_events:
        for choice in evt.get("choices") or []:
            delta = choice.get("delta") or {}
            extra = delta.get("extra_content") or {}
            tool_event = extra.get("toolEvent") if isinstance(extra, dict) else None
            if isinstance(tool_event, dict):
                assert tool_event.get("tool_name") != "web_search", evt
