import datetime
import json
import os
import sys
from collections.abc import AsyncGenerator, Iterable, Iterator
from enum import Enum
from typing import Any

import click
import httpx
import openai
import sqlite_utils
import yaml
from pydantic import Field, ValidationError, create_model, field_validator

import llm
from llm import (
    AsyncConversation,
    AsyncKeyModel,
    AsyncResponse,
    Conversation,
    EmbeddingModel,
    KeyModel,
    Prompt,
    Response,
    hookimpl,
)
from llm.parts import StreamEvent
from llm.utils import (
    dicts_to_table_string,
    logging_client,
    remove_dict_none_values,
    simplify_usage_dict,
)


@hookimpl
def register_models(register):
    # GPT-4o
    register(
        Chat("gpt-4o", vision=True, supports_schema=True, supports_tools=True),
        AsyncChat("gpt-4o", vision=True, supports_schema=True, supports_tools=True),
        aliases=("4o",),
    )
    register(
        Chat("gpt-4o-mini", vision=True, supports_schema=True, supports_tools=True),
        AsyncChat(
            "gpt-4o-mini", vision=True, supports_schema=True, supports_tools=True
        ),
        aliases=("4o-mini",),
    )
    # GPT-4.1
    for model_id in ("gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"):
        register(
            Chat(model_id, vision=True, supports_schema=True, supports_tools=True),
            AsyncChat(model_id, vision=True, supports_schema=True, supports_tools=True),
            aliases=(model_id.replace("gpt-", ""),),
        )
    # 3.5 and 4
    register(
        Chat("gpt-3.5-turbo"), AsyncChat("gpt-3.5-turbo"), aliases=("3.5", "chatgpt")
    )
    register(
        Chat("gpt-3.5-turbo-16k"),
        AsyncChat("gpt-3.5-turbo-16k"),
        aliases=("chatgpt-16k", "3.5-16k"),
    )
    register(Chat("gpt-4"), AsyncChat("gpt-4"), aliases=("4", "gpt4"))
    # GPT-4 Turbo models
    register(Chat("gpt-4-turbo-2024-04-09"), AsyncChat("gpt-4-turbo-2024-04-09"))
    register(
        Chat("gpt-4-turbo"),
        AsyncChat("gpt-4-turbo"),
        aliases=("gpt-4-turbo-preview", "4-turbo", "4t"),
    )
    # o1
    for model_id in ("o1", "o1-2024-12-17"):
        register(
            Responses(
                model_id,
                vision=True,
                can_stream=False,
                reasoning=True,
                supports_schema=True,
                supports_tools=True,
            ),
            AsyncResponses(
                model_id,
                vision=True,
                can_stream=False,
                reasoning=True,
                supports_schema=True,
                supports_tools=True,
            ),
        )

    register(
        Responses("o3-mini", reasoning=True, supports_schema=True, supports_tools=True),
        AsyncResponses(
            "o3-mini", reasoning=True, supports_schema=True, supports_tools=True
        ),
    )
    register(
        Responses(
            "o3", vision=True, reasoning=True, supports_schema=True, supports_tools=True
        ),
        AsyncResponses(
            "o3", vision=True, reasoning=True, supports_schema=True, supports_tools=True
        ),
    )
    register(
        Responses(
            "o4-mini",
            vision=True,
            reasoning=True,
            supports_schema=True,
            supports_tools=True,
        ),
        AsyncResponses(
            "o4-mini",
            vision=True,
            reasoning=True,
            supports_schema=True,
            supports_tools=True,
        ),
    )
    # GPT-5
    for model_id in (
        "gpt-5",
        "gpt-5-mini",
        "gpt-5-nano",
        "gpt-5-2025-08-07",
        "gpt-5-mini-2025-08-07",
        "gpt-5-nano-2025-08-07",
    ):
        register(
            Responses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                supports_schema=True,
                supports_tools=True,
            ),
            AsyncResponses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                supports_schema=True,
                supports_tools=True,
            ),
        )
    # GPT-5.1
    register(
        Responses(
            "gpt-5.1",
            vision=True,
            reasoning=True,
            verbosity=True,
            supports_schema=True,
            supports_tools=True,
        ),
        AsyncResponses(
            "gpt-5.1",
            vision=True,
            reasoning=True,
            verbosity=True,
            supports_schema=True,
            supports_tools=True,
        ),
    )
    # GPT-5.2
    for model_id in ("gpt-5.2", "gpt-5.2-chat-latest"):
        register(
            Responses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                supports_schema=True,
                supports_tools=True,
            ),
            AsyncResponses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                supports_schema=True,
                supports_tools=True,
            ),
        )
        # "gpt-5.2-pro" is Responses API only

    # GPT-5.4
    for model_id in (
        "gpt-5.4",
        "gpt-5.4-2026-03-05",
        "gpt-5.4-mini",
        "gpt-5.4-mini-2026-03-17",
        "gpt-5.4-nano",
        "gpt-5.4-nano-2026-03-17",
    ):
        register(
            Responses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                image_detail_original=True,
                supports_schema=True,
                supports_tools=True,
            ),
            AsyncResponses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                image_detail_original=True,
                supports_schema=True,
                supports_tools=True,
            ),
        )
    # GPT-5.5 — routes through the Responses API by default; pass
    # ``-o chat_completions 1`` to fall back to /v1/chat/completions.
    for model_id in (
        "gpt-5.5",
        "gpt-5.5-2026-04-23",
    ):
        register(
            Responses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                image_detail_original=True,
                supports_schema=True,
                supports_tools=True,
            ),
            AsyncResponses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                image_detail_original=True,
                supports_schema=True,
                supports_tools=True,
            ),
        )

    # GPT-5.6
    for model_id in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
        register(
            Responses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                image_detail_original=True,
                supports_schema=True,
                supports_tools=True,
            ),
            AsyncResponses(
                model_id,
                vision=True,
                reasoning=True,
                verbosity=True,
                image_detail_original=True,
                supports_schema=True,
                supports_tools=True,
            ),
        )

    # The -instruct completion model
    register(
        Completion("gpt-3.5-turbo-instruct", default_max_tokens=256),
        aliases=("3.5-instruct", "chatgpt-instruct"),
    )

    # Load extra models
    extra_path = llm.user_dir() / "extra-openai-models.yaml"
    if not extra_path.exists():
        return
    with open(extra_path) as f:
        extra_models = yaml.safe_load(f)
    for extra_model in extra_models:
        model_id = extra_model["model_id"]
        aliases = extra_model.get("aliases", [])
        model_name = extra_model["model_name"]
        api_base = extra_model.get("api_base")
        api_type = extra_model.get("api_type")
        api_version = extra_model.get("api_version")
        api_engine = extra_model.get("api_engine")
        headers = extra_model.get("headers")
        reasoning = extra_model.get("reasoning")
        kwargs = {}
        if extra_model.get("can_stream") is False:
            kwargs["can_stream"] = False
        if extra_model.get("supports_schema") is True:
            kwargs["supports_schema"] = True
        if extra_model.get("supports_tools") is True:
            kwargs["supports_tools"] = True
        if extra_model.get("vision") is True:
            kwargs["vision"] = True
        if extra_model.get("audio") is True:
            kwargs["audio"] = True
        if extra_model.get("completion"):
            klass = Completion
            async_klass = None
        elif extra_model.get("responses"):
            klass = Responses
            async_klass = AsyncResponses
        else:
            klass = Chat
            async_klass = AsyncChat
        model_kwargs = dict(
            model_id=model_id,
            model_name=model_name,
            api_base=api_base,
            api_type=api_type,
            api_version=api_version,
            api_engine=api_engine,
            headers=headers,
            reasoning=reasoning,
            **kwargs,
        )
        chat_model = klass(**model_kwargs)
        async_model = async_klass(**model_kwargs) if async_klass else None
        if api_base:
            chat_model.needs_key = None
            if async_model:
                async_model.needs_key = None
        if extra_model.get("api_key_name"):
            chat_model.needs_key = extra_model["api_key_name"]
            if async_model:
                async_model.needs_key = extra_model["api_key_name"]
        register(
            chat_model,
            async_model,
            aliases=aliases,
        )


@hookimpl
def register_embedding_models(register):
    register(
        OpenAIEmbeddingModel("text-embedding-ada-002", "text-embedding-ada-002"),
        aliases=(
            "ada",
            "ada-002",
        ),
    )
    register(
        OpenAIEmbeddingModel("text-embedding-3-small", "text-embedding-3-small"),
        aliases=("3-small",),
    )
    register(
        OpenAIEmbeddingModel("text-embedding-3-large", "text-embedding-3-large"),
        aliases=("3-large",),
    )
    # With varying dimensions
    register(
        OpenAIEmbeddingModel(
            "text-embedding-3-small-512", "text-embedding-3-small", 512
        ),
        aliases=("3-small-512",),
    )
    register(
        OpenAIEmbeddingModel(
            "text-embedding-3-large-256", "text-embedding-3-large", 256
        ),
        aliases=("3-large-256",),
    )
    register(
        OpenAIEmbeddingModel(
            "text-embedding-3-large-1024", "text-embedding-3-large", 1024
        ),
        aliases=("3-large-1024",),
    )


class OpenAIEmbeddingModel(EmbeddingModel):
    needs_key = "openai"
    key_env_var = "OPENAI_API_KEY"
    batch_size = 100

    def __init__(self, model_id, openai_model_id, dimensions=None):
        self.model_id = model_id
        self.openai_model_id = openai_model_id
        self.dimensions = dimensions

    def embed_batch(self, items: Iterable[str | bytes]) -> Iterator[list[float]]:
        kwargs = {
            "input": items,
            "model": self.openai_model_id,
        }
        if self.dimensions:
            kwargs["dimensions"] = self.dimensions
        client = openai.OpenAI(api_key=self.get_key())
        results = client.embeddings.create(**kwargs).data
        return ([float(r) for r in result.embedding] for result in results)


@hookimpl
def register_commands(cli):
    from llm.cli import (
        AttachmentType,
        attachment_types_callback,
        schema_option,
        tool_options,
    )

    @cli.group(name="openai")
    def openai_():
        "Commands for working with OpenAI and OpenAI-compatible APIs"

    @openai_.command()
    @click.argument("url")
    @click.argument("prompt", required=False)
    @click.option(
        "model_id",
        "-m",
        "--model",
        help="Model ID (required unless --models or provided by template)",
    )
    @click.option("-s", "--system", help="System prompt to use")
    @click.option("-t", "--template", help="Template to use")
    @click.option(
        "param",
        "-p",
        "--param",
        multiple=True,
        type=(str, str),
        help="Parameters for template",
    )
    @click.option(
        "options",
        "-o",
        "--option",
        type=(str, str),
        multiple=True,
        help="key/value options for the model",
    )
    @schema_option
    @click.option(
        "--schema-multi",
        help="JSON schema to use for multiple results",
    )
    @click.option(
        "attachments",
        "-a",
        "--attachment",
        type=AttachmentType(),
        multiple=True,
        help="Attachment path or URL or -",
    )
    @click.option(
        "attachment_types",
        "--at",
        "--attachment-type",
        type=(str, str),
        multiple=True,
        callback=attachment_types_callback,
        help="\b\nAttachment with explicit mimetype,\n--at image.jpg image/jpeg",
    )
    @tool_options
    @click.option("--key", help="API key or stored key alias to send")
    @click.option(
        "headers",
        "-H",
        "--header",
        type=(str, str),
        multiple=True,
        help="Additional HTTP header",
    )
    @click.option(
        "use_responses",
        "--responses",
        is_flag=True,
        help="Use the Responses API instead of Chat Completions",
    )
    @click.option(
        "force_chat",
        "--chat",
        is_flag=True,
        help="Start an interactive chat",
    )
    @click.option(
        "list_models",
        "--models",
        is_flag=True,
        help="List model IDs from the endpoint and exit",
    )
    @click.option("--no-stream", is_flag=True, help="Do not stream output")
    @click.option("-R", "--hide-reasoning", is_flag=True, help="Hide reasoning output")
    def endpoint(
        url,
        prompt,
        model_id,
        system,
        template,
        param,
        options,
        schema_input,
        schema_multi,
        attachments,
        attachment_types,
        tools,
        python_tools,
        tools_debug,
        tools_approve,
        chain_limit,
        key,
        headers,
        use_responses,
        force_chat,
        list_models,
        no_stream,
        hide_reasoning,
    ):
        """
        Run against an OpenAI-compatible endpoint without logging.

        PROMPT or stdin is executed once. If neither is provided, wait for
        input on stdin. Use --chat to start an interactive chat. Templates run
        once by default; use --chat to apply one interactively. Use --models
        to list the available model IDs without running a prompt.
        """
        from llm.cli import (
            AttachmentError,
            LoadTemplateError,
            _apply_template,
            _merge_template_attachments,
            _merge_template_options,
            _merge_template_tools,
            _run_chat,
            _tool_chain_kwargs,
            display_stream_events,
            load_template,
            logs_db_path,
            migrate,
            multi_schema,
            render_errors,
            resolve_schema_input,
        )

        if list_models and prompt is not None:
            raise click.ClickException("--models cannot be used with a prompt")
        if list_models and template:
            raise click.ClickException("--models cannot be used with --template")
        if list_models and (tools or python_tools):
            raise click.ClickException("--models cannot be used with tools")
        if list_models and (schema_input or schema_multi):
            raise click.ClickException("--models cannot be used with schemas")
        if force_chat and prompt is not None:
            raise click.ClickException("--chat cannot be used with a prompt")

        if schema_multi:
            schema_input = schema_multi
        schema = None
        if schema_input:
            # Never create logs.db for this unlogged command. An existing
            # database can resolve stored schema IDs; all other schema input
            # is resolved using a temporary in-memory database.
            log_path = logs_db_path()
            if log_path.exists():
                schema_db = sqlite_utils.Database(log_path)
            else:
                schema_db = sqlite_utils.Database(memory=True)
            migrate(schema_db)
            schema = resolve_schema_input(schema_db, schema_input, load_template)
            if schema_multi:
                schema = multi_schema(schema)

        template_obj = None
        params = dict(param)
        if template:
            try:
                template_obj = load_template(template)
                attachments, attachment_types = _merge_template_attachments(
                    template_obj, attachments, attachment_types
                )
            except (AttachmentError, LoadTemplateError) as ex:
                raise click.ClickException(str(ex))
            if not model_id and template_obj.model:
                model_id = template_obj.model
            if template_obj.schema_object:
                schema = template_obj.schema_object
            if template_obj.options:
                options = _merge_template_options(template_obj, options)
            tools, python_tools = _merge_template_tools(
                template_obj, tools, python_tools
            )

        if not list_models and not model_id:
            raise click.ClickException(
                "--model is required unless --models or a template model is used"
            )

        model_class = Responses if use_responses else Chat
        model_kwargs = {
            "model_id": model_id or "",
            "model_name": model_id or "",
            "api_base": url,
            "headers": dict(headers),
            "vision": True,
            "audio": not use_responses,
            # Optimistically expose capabilities that have no effect until
            # the user explicitly exercises them.
            "reasoning": True,
            "verbosity": True,
            "image_detail_original": True,
            "supports_schema": True,
            "supports_tools": True,
        }
        if use_responses:
            model_kwargs["reasoning_summary"] = False
        model = model_class(**model_kwargs)

        # A configured api_base never receives the user's default OpenAI key.
        # Match that safety property here: only send credentials when --key
        # was explicitly provided for this invocation.
        if not key:
            model.needs_key = None

        try:
            validated_options = {
                option_name: option_value
                for option_name, option_value in model.Options(**dict(options))
                if option_value is not None
            }
        except ValidationError as ex:
            raise click.ClickException(render_errors(ex.errors()))

        prompt_kwargs = {
            "options": validated_options,
            "schema": schema,
            "stream": not no_stream,
            "hide_reasoning": hide_reasoning,
        }
        if key:
            prompt_kwargs["key"] = key

        tool_kwargs = _tool_chain_kwargs(
            tools, python_tools, tools_debug, tools_approve, chain_limit
        )
        resolved_attachments = [*attachments, *attachment_types]
        try:
            if list_models:
                available_models = model.get_client(key).models.list()
                error = getattr(available_models, "error", None)
                if error:
                    if isinstance(error, dict):
                        error = error.get("message") or json.dumps(error)
                    raise click.ClickException(str(error))
                for available_model in available_models:
                    click.echo(available_model.id)
                return

            if force_chat:
                conversation = model.conversation()

                def transform_chat_prompt(chat_prompt):
                    nonlocal system
                    if template_obj:
                        chat_prompt, system = _apply_template(
                            template_obj, chat_prompt, params, system
                        )
                    return chat_prompt

                def execute_chat_prompt(chat_prompt, _fragments, turn_attachments):
                    nonlocal system
                    prompt_method = (
                        conversation.chain if tool_kwargs else conversation.prompt
                    )
                    response = prompt_method(
                        chat_prompt,
                        system=system,
                        attachments=turn_attachments,
                        **prompt_kwargs,
                        **tool_kwargs,
                    )
                    system = None
                    return response

                _run_chat(
                    f"{model_id} at {url}",
                    execute_chat_prompt,
                    initial_attachments=resolved_attachments,
                    transform_prompt=transform_chat_prompt,
                    show_reasoning=not hide_reasoning,
                )
                return

            if not sys.stdin.isatty():
                stdin_prompt = sys.stdin.read()
                if stdin_prompt:
                    prompt = " ".join(
                        part for part in (stdin_prompt, prompt) if part is not None
                    )
            elif (
                prompt is None
                and not resolved_attachments
                and not schema
                and (template_obj is None or "input" in template_obj.vars())
            ):
                # Match `llm prompt`: wait for stdin until EOF instead of
                # implicitly starting an interactive chat.
                prompt = sys.stdin.read()
            if template_obj:
                prompt, system = _apply_template(template_obj, prompt, params, system)
            if prompt is None and not (resolved_attachments or schema):
                raise click.ClickException(
                    "A prompt is required when stdin is not interactive"
                )
            if tool_kwargs:
                response = model.conversation().chain(
                    prompt,
                    system=system,
                    attachments=resolved_attachments,
                    **prompt_kwargs,
                    **tool_kwargs,
                )
            else:
                response = model.prompt(
                    prompt,
                    system=system,
                    attachments=resolved_attachments,
                    **prompt_kwargs,
                )
            display_stream_events(
                response.stream_events(),
                show_reasoning=not hide_reasoning,
            )
            click.echo()
        except (click.Abort, click.ClickException):
            raise
        except (ValueError, NotImplementedError) as ex:
            raise click.ClickException(str(ex))
        except Exception as ex:
            if getattr(sys, "_called_from_test", False) or os.environ.get(
                "LLM_RAISE_ERRORS"
            ):
                raise
            raise click.ClickException(str(ex))

    @openai_.command()
    @click.option("json_", "--json", is_flag=True, help="Output as JSON")
    @click.option("--key", help="OpenAI API key")
    def models(json_, key):
        "List models available to you from the OpenAI API"
        from llm import get_key

        api_key = get_key(key, "openai", "OPENAI_API_KEY")
        response = httpx.get(
            "https://api.openai.com/v1/models",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        if response.status_code != 200:
            raise click.ClickException(
                f"Error {response.status_code} from OpenAI API: {response.text}"
            )
        models = response.json()["data"]
        if json_:
            click.echo(json.dumps(models, indent=4))
        else:
            to_print = []
            for model in models:
                # Print id, owned_by, root, created as ISO 8601
                created_str = datetime.datetime.fromtimestamp(
                    model["created"], datetime.timezone.utc
                ).isoformat()
                to_print.append(
                    {
                        "id": model["id"],
                        "owned_by": model["owned_by"],
                        "created": created_str,
                    }
                )
            done = dicts_to_table_string(["id", "owned_by", "created"], to_print)
            print("\n".join(done))


class SharedOptions(llm.Options):
    temperature: float | None = Field(
        description=(
            "What sampling temperature to use, between 0 and 2. Higher values like "
            "0.8 will make the output more random, while lower values like 0.2 will "
            "make it more focused and deterministic."
        ),
        ge=0,
        le=2,
        default=None,
    )
    max_tokens: int | None = Field(
        description="Maximum number of tokens to generate.", default=None
    )
    top_p: float | None = Field(
        description=(
            "An alternative to sampling with temperature, called nucleus sampling, "
            "where the model considers the results of the tokens with top_p "
            "probability mass. So 0.1 means only the tokens comprising the top "
            "10% probability mass are considered. Recommended to use top_p or "
            "temperature but not both."
        ),
        ge=0,
        le=1,
        default=None,
    )
    frequency_penalty: float | None = Field(
        description=(
            "Number between -2.0 and 2.0. Positive values penalize new tokens based "
            "on their existing frequency in the text so far, decreasing the model's "
            "likelihood to repeat the same line verbatim."
        ),
        ge=-2,
        le=2,
        default=None,
    )
    presence_penalty: float | None = Field(
        description=(
            "Number between -2.0 and 2.0. Positive values penalize new tokens based "
            "on whether they appear in the text so far, increasing the model's "
            "likelihood to talk about new topics."
        ),
        ge=-2,
        le=2,
        default=None,
    )
    stop: str | None = Field(
        description=("A string where the API will stop generating further tokens."),
        default=None,
    )
    logit_bias: dict | str | None = Field(
        description=(
            "Modify the likelihood of specified tokens appearing in the completion. "
            'Pass a JSON string like \'{"1712":-100, "892":-100, "1489":-100}\''
        ),
        default=None,
    )
    seed: int | None = Field(
        description="Integer seed to attempt to sample deterministically",
        default=None,
    )

    @field_validator("logit_bias")
    def validate_logit_bias(cls, logit_bias):
        if logit_bias is None:
            return None

        if isinstance(logit_bias, str):
            try:
                logit_bias = json.loads(logit_bias)
            except json.JSONDecodeError:
                raise ValueError("Invalid JSON in logit_bias string")

        validated_logit_bias = {}
        for key, value in logit_bias.items():
            try:
                int_key = int(key)
                int_value = int(value)
                if -100 <= int_value <= 100:
                    validated_logit_bias[int_key] = int_value
                else:
                    raise ValueError("Value must be between -100 and 100")
            except ValueError:
                raise ValueError("Invalid key-value pair in logit_bias dictionary")

        return validated_logit_bias


class ReasoningEffortEnum(str, Enum):
    none = "none"
    minimal = "minimal"
    low = "low"
    medium = "medium"
    high = "high"
    xhigh = "xhigh"
    max = "max"


class VerbosityEnum(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"


class ImageDetailEnum(str, Enum):
    low = "low"
    high = "high"
    auto = "auto"


class ImageDetailWithOriginalEnum(str, Enum):
    low = "low"
    high = "high"
    original = "original"
    auto = "auto"


def enum_values_sentence(enum_class):
    values = [item.value for item in enum_class]
    if len(values) == 1:
        return values[0]
    return "{}, and {}".format(", ".join(values[:-1]), values[-1])


def build_options_class(
    *,
    reasoning=False,
    verbosity=False,
    image_detail_original=False,
    chat_completions=False,
):
    fields = {
        "json_object": (
            bool | None,
            Field(
                description="Output a valid JSON object {...}. Prompt must mention JSON.",
                default=None,
            ),
        )
    }
    if chat_completions:
        fields["chat_completions"] = (
            bool | None,
            Field(
                description=(
                    "Force the use of the older /v1/chat/completions endpoint "
                    "instead of /v1/responses. Most callers should leave this "
                    "off; set to true to fall back to the Chat Completions code "
                    "path for compatibility."
                ),
                default=None,
            ),
        )
    image_detail_enum = (
        ImageDetailWithOriginalEnum if image_detail_original else ImageDetailEnum
    )
    image_detail_values = enum_values_sentence(image_detail_enum)
    fields["image_detail"] = (
        image_detail_enum | None,
        Field(
            description=(
                "Controls the detail level for image attachments. Supported values are "
                f"{image_detail_values}."
            ),
            default=None,
        ),
    )
    if reasoning:
        fields["reasoning_effort"] = (
            ReasoningEffortEnum | None,
            Field(
                description=(
                    "Constraints effort on reasoning for reasoning models. Currently "
                    "supported values are low, medium, and high. Reducing reasoning "
                    "effort can result in faster responses and fewer tokens used on "
                    "reasoning in a response."
                ),
                default=None,
            ),
        )
    if verbosity:
        fields["verbosity"] = (
            VerbosityEnum | None,
            Field(
                description=(
                    "Controls how verbose the model's response should be. Supported "
                    "values are low, medium, and high."
                ),
                default=None,
            ),
        )
    return create_model("Options", __base__=SharedOptions, **fields)


def _attachment(attachment, image_detail=None):
    url = attachment.url
    base64_content = ""
    if not url or attachment.resolve_type().startswith("audio/"):
        base64_content = attachment.base64_content()
        url = f"data:{attachment.resolve_type()};base64,{base64_content}"
    if attachment.resolve_type() == "application/pdf":
        if not base64_content:
            base64_content = attachment.base64_content()
        return {
            "type": "file",
            "file": {
                "filename": f"{attachment.id()}.pdf",
                "file_data": f"data:application/pdf;base64,{base64_content}",
            },
        }
    if attachment.resolve_type().startswith("image/"):
        image_url = {"url": url}
        if image_detail:
            image_url["detail"] = image_detail
        return {"type": "image_url", "image_url": image_url}
    else:
        format_ = "wav" if attachment.resolve_type() == "audio/wav" else "mp3"
        return {
            "type": "input_audio",
            "input_audio": {
                "data": base64_content,
                "format": format_,
            },
        }


class _Shared:
    def __init__(
        self,
        model_id,
        key=None,
        model_name=None,
        api_base=None,
        api_type=None,
        api_version=None,
        api_engine=None,
        headers=None,
        can_stream=True,
        vision=False,
        audio=False,
        reasoning=False,
        verbosity=False,
        image_detail_original=False,
        supports_schema=False,
        supports_tools=False,
        allows_system_prompt=True,
    ):
        self.model_id = model_id
        self.key = key
        self.supports_schema = supports_schema
        self.supports_tools = supports_tools
        self.model_name = model_name
        self.api_base = api_base
        self.api_type = api_type
        self.api_version = api_version
        self.api_engine = api_engine
        self.headers = headers
        self.can_stream = can_stream
        self.vision = vision
        self.allows_system_prompt = allows_system_prompt

        self.attachment_types = set()

        if reasoning or verbosity or image_detail_original:
            self.Options = build_options_class(
                reasoning=reasoning,
                verbosity=verbosity,
                image_detail_original=image_detail_original,
            )

        if vision:
            self.attachment_types.update(
                {
                    "image/png",
                    "image/jpeg",
                    "image/webp",
                    "image/gif",
                    "application/pdf",
                }
            )

        if audio:
            self.attachment_types.update(
                {
                    "audio/wav",
                    "audio/mpeg",
                }
            )

    def __str__(self) -> str:
        return f"OpenAI Chat: {self.model_id}"

    def _append_llm_message(self, out, message, current_system, image_detail=None):
        """Translate one llm.Message into one (or more) OpenAI message
        dicts and append them to ``out``.

        Returns the (possibly updated) current_system value so the caller
        can avoid re-emitting an unchanged system prompt.
        """
        from llm.parts import (
            AttachmentPart,
            TextPart,
            ToolCallPart,
            ToolResultPart,
        )

        text_bits = []
        attachment_items = []
        tool_calls = []
        tool_results = []

        for part in message.parts:
            if isinstance(part, TextPart):
                text_bits.append(part.text)
            elif isinstance(part, AttachmentPart) and part.attachment:
                attachment_items.append(
                    _attachment(part.attachment, image_detail=image_detail)
                )
            elif isinstance(part, ToolCallPart):
                tool_calls.append(
                    {
                        "type": "function",
                        "id": part.tool_call_id,
                        "function": {
                            "name": part.name,
                            "arguments": json.dumps(part.arguments),
                        },
                    }
                )
            elif isinstance(part, ToolResultPart):
                tool_results.append(
                    {
                        "role": "tool",
                        "tool_call_id": part.tool_call_id,
                        "content": part.output,
                    }
                )

        # Role "tool" emits one OpenAI "tool" message per ToolResultPart.
        if message.role == "tool":
            out.extend(tool_results)
            return current_system

        # System dedup: skip if this text is already the active system prompt.
        if message.role == "system":
            text = "".join(text_bits)
            if text == current_system:
                return current_system
            current_system = text

        if attachment_items:
            content = []
            if text_bits:
                content.append({"type": "text", "text": "".join(text_bits)})
            content.extend(attachment_items)
            entry = {"role": message.role, "content": content}
        else:
            entry = {
                "role": message.role,
                "content": "".join(text_bits) if text_bits else None,
            }

        if tool_calls:
            entry["tool_calls"] = tool_calls
            # OpenAI expects content=null when only tool_calls are present.
            if not text_bits:
                entry["content"] = None
        elif entry["content"] is None and message.role != "assistant":
            # For user/system, an empty message is pointless — drop it.
            return current_system

        out.append(entry)
        return current_system

    def build_messages(self, prompt, conversation, image_detail=None):
        """Translate prompt.messages into OpenAI's wire format."""
        messages: list[dict[str, Any]] = []
        if image_detail is not None:
            image_detail = image_detail.value
        current_system: str | None = None
        for msg in prompt.messages:
            current_system = self._append_llm_message(
                messages, msg, current_system, image_detail=image_detail
            )
        return messages

    def set_usage(self, response, usage):
        if not usage:
            return
        input_tokens = usage.pop("prompt_tokens")
        output_tokens = usage.pop("completion_tokens")
        usage.pop("total_tokens")
        response.set_usage(
            input=input_tokens, output=output_tokens, details=simplify_usage_dict(usage)
        )

    def get_client(self, key, *, async_=False):
        kwargs = {}
        if self.api_base:
            kwargs["base_url"] = self.api_base
        if self.api_type:
            kwargs["api_type"] = self.api_type
        if self.api_version:
            kwargs["api_version"] = self.api_version
        if self.api_engine:
            kwargs["engine"] = self.api_engine
        if self.needs_key:
            kwargs["api_key"] = self.get_key(key)
        else:
            # OpenAI-compatible models don't need a key, but the
            # openai client library requires one
            kwargs["api_key"] = "DUMMY_KEY"
        if self.headers:
            kwargs["default_headers"] = self.headers
        if os.environ.get("LLM_OPENAI_SHOW_RESPONSES"):
            kwargs["http_client"] = logging_client()
        if async_:
            return openai.AsyncOpenAI(**kwargs)
        else:
            return openai.OpenAI(**kwargs)

    def build_kwargs(self, prompt, stream):
        kwargs = dict(not_nulls(prompt.options))
        json_object = kwargs.pop("json_object", None)
        kwargs.pop("image_detail", None)
        kwargs.pop("chat_completions", None)
        if "max_tokens" not in kwargs and self.default_max_tokens is not None:
            kwargs["max_tokens"] = self.default_max_tokens
        if json_object:
            kwargs["response_format"] = {"type": "json_object"}
        if prompt.schema:
            kwargs["response_format"] = {
                "type": "json_schema",
                "json_schema": {"name": "output", "schema": prompt.schema},
            }
        if prompt.tools:
            kwargs["tools"] = [
                {
                    "type": "function",
                    "function": {
                        "name": tool.name,
                        "description": tool.description or None,
                        "parameters": tool.input_schema,
                    },
                }
                for tool in prompt.tools
            ]
        if stream:
            kwargs["stream_options"] = {"include_usage": True}
        return kwargs


class Chat(_Shared, KeyModel):
    needs_key = "openai"
    key_env_var = "OPENAI_API_KEY"
    default_max_tokens = None

    Options = build_options_class()

    def execute(
        self,
        prompt: Prompt,
        stream: bool,
        response: Response,
        conversation: Conversation | None = None,
        key: str | None = None,
    ) -> Iterator[str | StreamEvent]:
        if prompt.system and not self.allows_system_prompt:
            raise NotImplementedError("Model does not support system prompts")
        messages = self.build_messages(
            prompt,
            conversation,
            image_detail=getattr(prompt.options, "image_detail", None),
        )
        kwargs = self.build_kwargs(prompt, stream)
        client = self.get_client(key)
        usage = None
        if stream:
            completion = client.chat.completions.create(
                model=self.model_name or self.model_id,
                messages=messages,
                stream=True,
                **kwargs,
            )
            chunks = []
            tool_calls = {}
            for chunk in completion:
                chunks.append(chunk)
                if chunk.usage:
                    usage = chunk.usage.model_dump()
                if chunk.choices and chunk.choices[0].delta:
                    for tool_call in chunk.choices[0].delta.tool_calls or []:
                        if tool_call.function.arguments is None:
                            tool_call.function.arguments = ""
                        idx = tool_call.index
                        if idx not in tool_calls:
                            tool_calls[idx] = tool_call
                            yield StreamEvent(
                                type="tool_call_name",
                                chunk=tool_call.function.name or "",
                                tool_call_id=tool_call.id,
                            )
                        else:
                            tool_calls[
                                idx
                            ].function.arguments += tool_call.function.arguments
                        if tool_call.function.arguments:
                            yield StreamEvent(
                                type="tool_call_args",
                                chunk=tool_call.function.arguments,
                                tool_call_id=tool_calls[idx].id,
                            )
                try:
                    content = chunk.choices[0].delta.content
                except IndexError:
                    content = None
                if content:
                    # Empty strings are noise (OpenAI's first chunk
                    # with role=assistant has content="").
                    yield StreamEvent(type="text", chunk=content)
            response.response_json = remove_dict_none_values(combine_chunks(chunks))
            if tool_calls:
                for value in tool_calls.values():
                    response.add_tool_call(
                        llm.ToolCall(
                            tool_call_id=value.id,
                            name=value.function.name,
                            arguments=json.loads(value.function.arguments or "{}"),
                        )
                    )
        else:
            completion = client.chat.completions.create(
                model=self.model_name or self.model_id,
                messages=messages,
                stream=False,
                **kwargs,
            )
            usage = completion.usage.model_dump()
            response.response_json = remove_dict_none_values(completion.model_dump())
            for tool_call in completion.choices[0].message.tool_calls or []:
                response.add_tool_call(
                    llm.ToolCall(
                        tool_call_id=tool_call.id,
                        name=tool_call.function.name,
                        arguments=json.loads(tool_call.function.arguments or "{}"),
                    )
                )
                yield StreamEvent(
                    type="tool_call_name",
                    chunk=tool_call.function.name or "",
                    tool_call_id=tool_call.id,
                )
                yield StreamEvent(
                    type="tool_call_args",
                    chunk=tool_call.function.arguments or "",
                    tool_call_id=tool_call.id,
                )
            if completion.choices[0].message.content is not None:
                yield StreamEvent(
                    type="text",
                    chunk=completion.choices[0].message.content,
                )
        self.set_usage(response, usage)
        if usage and (usage.get("completion_tokens_details") or {}).get(
            "reasoning_tokens"
        ):
            yield StreamEvent(type="reasoning", chunk="", redacted=True)
        response._prompt_json = redact_data({"messages": messages})


class AsyncChat(_Shared, AsyncKeyModel):
    needs_key = "openai"
    key_env_var = "OPENAI_API_KEY"
    default_max_tokens = None

    Options = build_options_class()

    async def execute(
        self,
        prompt: Prompt,
        stream: bool,
        response: AsyncResponse,
        conversation: AsyncConversation | None = None,
        key: str | None = None,
    ) -> AsyncGenerator[str | StreamEvent, None]:
        if prompt.system and not self.allows_system_prompt:
            raise NotImplementedError("Model does not support system prompts")
        messages = self.build_messages(
            prompt,
            conversation,
            image_detail=getattr(prompt.options, "image_detail", None),
        )
        kwargs = self.build_kwargs(prompt, stream)
        client = self.get_client(key, async_=True)
        usage = None
        if stream:
            completion = await client.chat.completions.create(
                model=self.model_name or self.model_id,
                messages=messages,
                stream=True,
                **kwargs,
            )
            chunks = []
            tool_calls = {}
            async for chunk in completion:
                if chunk.usage:
                    usage = chunk.usage.model_dump()
                chunks.append(chunk)
                if chunk.choices and chunk.choices[0].delta:
                    for tool_call in chunk.choices[0].delta.tool_calls or []:
                        if tool_call.function.arguments is None:
                            tool_call.function.arguments = ""
                        idx = tool_call.index
                        if idx not in tool_calls:
                            tool_calls[idx] = tool_call
                            yield StreamEvent(
                                type="tool_call_name",
                                chunk=tool_call.function.name or "",
                                tool_call_id=tool_call.id,
                            )
                        else:
                            tool_calls[
                                idx
                            ].function.arguments += tool_call.function.arguments
                        if tool_call.function.arguments:
                            yield StreamEvent(
                                type="tool_call_args",
                                chunk=tool_call.function.arguments,
                                tool_call_id=tool_calls[idx].id,
                            )
                try:
                    content = chunk.choices[0].delta.content
                except IndexError:
                    content = None
                if content:
                    yield StreamEvent(type="text", chunk=content)
            if tool_calls:
                for value in tool_calls.values():
                    response.add_tool_call(
                        llm.ToolCall(
                            tool_call_id=value.id,
                            name=value.function.name,
                            arguments=json.loads(value.function.arguments or "{}"),
                        )
                    )
            response.response_json = remove_dict_none_values(combine_chunks(chunks))
        else:
            completion = await client.chat.completions.create(
                model=self.model_name or self.model_id,
                messages=messages,
                stream=False,
                **kwargs,
            )
            response.response_json = remove_dict_none_values(completion.model_dump())
            usage = completion.usage.model_dump()
            for tool_call in completion.choices[0].message.tool_calls or []:
                response.add_tool_call(
                    llm.ToolCall(
                        tool_call_id=tool_call.id,
                        name=tool_call.function.name,
                        arguments=json.loads(tool_call.function.arguments or "{}"),
                    )
                )
                yield StreamEvent(
                    type="tool_call_name",
                    chunk=tool_call.function.name or "",
                    tool_call_id=tool_call.id,
                )
                yield StreamEvent(
                    type="tool_call_args",
                    chunk=tool_call.function.arguments or "",
                    tool_call_id=tool_call.id,
                )
            if completion.choices[0].message.content is not None:
                yield StreamEvent(
                    type="text",
                    chunk=completion.choices[0].message.content,
                )
        self.set_usage(response, usage)
        if usage and (usage.get("completion_tokens_details") or {}).get(
            "reasoning_tokens"
        ):
            yield StreamEvent(type="reasoning", chunk="", redacted=True)
        response._prompt_json = redact_data({"messages": messages})


def _responses_attachment(attachment, image_detail=None):
    """Translate an llm Attachment into a Responses-API content part."""
    url = attachment.url
    base64_content = ""
    if not url or attachment.resolve_type().startswith("audio/"):
        base64_content = attachment.base64_content()
        url = f"data:{attachment.resolve_type()};base64,{base64_content}"
    if attachment.resolve_type() == "application/pdf":
        if not base64_content:
            base64_content = attachment.base64_content()
        return {
            "type": "input_file",
            "filename": f"{attachment.id()}.pdf",
            "file_data": f"data:application/pdf;base64,{base64_content}",
        }
    if attachment.resolve_type().startswith("image/"):
        item = {"type": "input_image", "image_url": url}
        if image_detail:
            item["detail"] = image_detail
        return item
    # Audio is not yet supported on the Responses input shape we use; fall
    # back to image_url for unknown types so we don't silently drop content.
    return {"type": "input_image", "image_url": url}


class _SharedResponses(_Shared):
    """Mixin that translates llm.Prompt into Responses API parameters."""

    def __str__(self) -> str:
        return f"OpenAI Responses: {self.model_id}"

    def _delegate_chat_kwargs(self):
        """Return constructor kwargs that mirror this Responses model so we
        can build a sibling Chat / AsyncChat instance for the
        ``-o chat_completions 1`` opt-out path."""
        return {
            "model_id": self.model_id,
            "key": self.key,
            "model_name": self.model_name,
            "api_base": self.api_base,
            "api_type": self.api_type,
            "api_version": self.api_version,
            "api_engine": self.api_engine,
            "headers": self.headers,
            "can_stream": self.can_stream,
            "vision": self.vision,
            "reasoning": self._reasoning,
            "verbosity": self._verbosity,
            "image_detail_original": self._image_detail_original,
            "supports_schema": self.supports_schema,
            "supports_tools": self.supports_tools,
            "allows_system_prompt": self.allows_system_prompt,
        }

    def _build_responses_input(self, prompt, image_detail=None):
        """Translate prompt.messages into a (input_items, instructions) tuple
        for the Responses API.

        The most recent system Message is hoisted into ``instructions``;
        earlier system messages are dropped (mirroring the way the Chat
        path collapses repeated identical system prompts).
        """
        from llm.parts import (
            AttachmentPart,
            ReasoningPart,
            TextPart,
            ToolCallPart,
            ToolResultPart,
        )

        items: list[dict[str, Any]] = []
        instructions: str | None = None

        for msg in prompt.messages:
            if msg.role == "system":
                text = "".join(p.text for p in msg.parts if isinstance(p, TextPart))
                if text:
                    instructions = text
                continue

            text_bits: list[str] = []
            attachment_items: list[dict[str, Any]] = []
            tool_call_items: list[dict[str, Any]] = []
            tool_result_items: list[dict[str, Any]] = []
            reasoning_items: list[dict[str, Any]] = []

            for part in msg.parts:
                if isinstance(part, TextPart):
                    text_bits.append(part.text)
                elif isinstance(part, AttachmentPart) and part.attachment:
                    attachment_items.append(
                        _responses_attachment(
                            part.attachment, image_detail=image_detail
                        )
                    )
                elif isinstance(part, ToolCallPart):
                    tool_call_items.append(
                        {
                            "type": "function_call",
                            "call_id": part.tool_call_id,
                            "name": part.name,
                            "arguments": json.dumps(part.arguments),
                        }
                    )
                elif isinstance(part, ToolResultPart):
                    tool_result_items.append(
                        {
                            "type": "function_call_output",
                            "call_id": part.tool_call_id,
                            "output": part.output,
                        }
                    )
                elif isinstance(part, ReasoningPart):
                    pm = (part.provider_metadata or {}).get("openai") or {}
                    enc = pm.get("encrypted_content")
                    rid = pm.get("id")
                    if enc or rid:
                        # Round-trip a previous reasoning item so the model
                        # can pick up where it left off mid-tool-call.
                        item: dict[str, Any] = {"type": "reasoning"}
                        if rid:
                            item["id"] = rid
                        if enc:
                            item["encrypted_content"] = enc
                        if pm.get("summary"):
                            item["summary"] = pm["summary"]
                        else:
                            item["summary"] = []
                        reasoning_items.append(item)

            # Reasoning items must precede the assistant message / function
            # call they belonged to.
            items.extend(reasoning_items)

            if msg.role == "tool":
                items.extend(tool_result_items)
                continue

            if msg.role == "user":
                if attachment_items:
                    content: list[dict[str, Any]] = []
                    if text_bits:
                        content.append(
                            {"type": "input_text", "text": "".join(text_bits)}
                        )
                    content.extend(attachment_items)
                    items.append({"role": "user", "content": content})
                elif text_bits:
                    items.append({"role": "user", "content": "".join(text_bits)})
            elif msg.role == "assistant":
                if text_bits:
                    items.append({"role": "assistant", "content": "".join(text_bits)})
                items.extend(tool_call_items)

        return items, instructions

    def _build_responses_kwargs(self, prompt, stream):
        """Build the keyword arguments for client.responses.create()."""
        opts = dict(not_nulls(prompt.options))
        # Strip options that are either internal to llm or not accepted by
        # the Responses API.
        opts.pop("json_object", None)
        opts.pop("chat_completions", None)
        opts.pop("image_detail", None)
        max_tokens = opts.pop("max_tokens", None)
        reasoning_effort = opts.pop("reasoning_effort", None)
        verbosity = opts.pop("verbosity", None)
        temperature = opts.pop("temperature", None)
        top_p = opts.pop("top_p", None)
        seed = opts.pop("seed", None)

        kwargs: dict[str, Any] = {}
        if max_tokens is None and self.default_max_tokens is not None:
            max_tokens = self.default_max_tokens
        if max_tokens is not None:
            kwargs["max_output_tokens"] = max_tokens
        if temperature is not None:
            kwargs["temperature"] = temperature
        if top_p is not None:
            kwargs["top_p"] = top_p
        if seed is not None:
            kwargs["seed"] = seed
        if self._reasoning:
            reasoning = {}
            if self._reasoning_summary and not getattr(prompt, "hide_reasoning", False):
                reasoning["summary"] = "auto"
            if reasoning_effort:
                reasoning["effort"] = reasoning_effort
            if reasoning:
                kwargs["reasoning"] = reasoning

        text: dict[str, Any] = {}
        if verbosity:
            text["verbosity"] = verbosity
        if prompt.options.json_object:
            text["format"] = {"type": "json_object"}
        if prompt.schema:
            # ``strict: False`` mirrors the looser behaviour of the
            # /v1/chat/completions json_schema response_format - required
            # because the Responses API otherwise insists on
            # ``additionalProperties: false`` everywhere.
            text["format"] = {
                "type": "json_schema",
                "name": "output",
                "schema": prompt.schema,
                "strict": False,
            }
        if text:
            kwargs["text"] = text

        if prompt.tools:
            kwargs["tools"] = [
                {
                    "type": "function",
                    "name": tool.name,
                    "description": tool.description or None,
                    "parameters": tool.input_schema,
                }
                for tool in prompt.tools
            ]

        # Pass anything we did not consume through verbatim - this lets
        # extras like ``parallel_tool_calls`` flow into the API.
        kwargs.update(opts)
        return kwargs

    def _set_usage_responses(self, response, usage):
        if not usage:
            return
        input_tokens = usage.get("input_tokens", 0) or 0
        output_tokens = usage.get("output_tokens", 0) or 0
        details = {}
        for key in ("input_tokens_details", "output_tokens_details"):
            value = usage.get(key)
            if value:
                details[key] = value
        response.set_usage(
            input=input_tokens, output=output_tokens, details=details or None
        )

    def _reasoning_text_from_item(self, item):
        bits = []
        for attr in ("summary", "content"):
            for part in getattr(item, attr, None) or []:
                if isinstance(part, dict):
                    text = part.get("text")
                else:
                    text = getattr(part, "text", None)
                if text:
                    bits.append(text)
        return "".join(bits)

    def _reasoning_event(self, item, *, include_text=True):
        """Build a redacted-reasoning StreamEvent that carries the opaque
        ``id`` and ``encrypted_content`` from a Responses-API reasoning
        item. Echoing this metadata back on the next request via
        ``_build_responses_input`` lets the model pick up its prior chain
        of thought - critical for tool-using reasoning models, since
        without it the model loses ~3% on SWE-bench (per OpenAI)."""
        rid = getattr(item, "id", None)
        enc = getattr(item, "encrypted_content", None)
        summary = getattr(item, "summary", None)
        text = self._reasoning_text_from_item(item) if include_text else ""
        meta: dict[str, Any] = {}
        if rid:
            meta["id"] = rid
        if enc:
            meta["encrypted_content"] = enc
        if summary:
            # ``summary`` is a list of {type:"summary_text", text:"..."}
            # objects when reasoning summaries are enabled.
            try:
                meta["summary"] = [
                    s.model_dump() if hasattr(s, "model_dump") else dict(s)
                    for s in summary
                ]
            except Exception:  # noqa: BLE001
                meta["summary"] = list(summary)
        return StreamEvent(
            type="reasoning",
            chunk=text,
            redacted=include_text and not text,
            provider_metadata={"openai": meta} if meta else None,
        )


class Responses(_SharedResponses, KeyModel):
    needs_key = "openai"
    key_env_var = "OPENAI_API_KEY"
    default_max_tokens = None

    def __init__(
        self,
        model_id,
        key=None,
        model_name=None,
        api_base=None,
        api_type=None,
        api_version=None,
        api_engine=None,
        headers=None,
        can_stream=True,
        vision=False,
        audio=False,
        reasoning=False,
        verbosity=False,
        image_detail_original=False,
        supports_schema=False,
        supports_tools=False,
        allows_system_prompt=True,
        reasoning_summary=True,
    ):
        super().__init__(
            model_id,
            key=key,
            model_name=model_name,
            api_base=api_base,
            api_type=api_type,
            api_version=api_version,
            api_engine=api_engine,
            headers=headers,
            can_stream=can_stream,
            vision=vision,
            audio=audio,
            reasoning=reasoning,
            verbosity=verbosity,
            image_detail_original=image_detail_original,
            supports_schema=supports_schema,
            supports_tools=supports_tools,
            allows_system_prompt=allows_system_prompt,
        )
        self._reasoning = reasoning
        self._reasoning_summary = reasoning_summary
        self._verbosity = verbosity
        self._image_detail_original = image_detail_original
        # Override the Options class so that ``-o chat_completions 1`` is
        # always available on Responses-routed models.
        self.Options = build_options_class(
            reasoning=reasoning,
            verbosity=verbosity,
            image_detail_original=image_detail_original,
            chat_completions=True,
        )

    def execute(
        self,
        prompt: Prompt,
        stream: bool,
        response: Response,
        conversation: Conversation | None = None,
        key: str | None = None,
    ) -> Iterator[str | StreamEvent]:
        if getattr(prompt.options, "chat_completions", None):
            chat = Chat(**self._delegate_chat_kwargs())
            yield from chat.execute(prompt, stream, response, conversation, key)
            return

        if prompt.system and not self.allows_system_prompt:
            raise NotImplementedError("Model does not support system prompts")

        image_detail = getattr(prompt.options, "image_detail", None)
        if image_detail is not None:
            image_detail = image_detail.value
        input_items, instructions = self._build_responses_input(
            prompt, image_detail=image_detail
        )
        kwargs = self._build_responses_kwargs(prompt, stream)
        if instructions is not None:
            kwargs["instructions"] = instructions
        kwargs["store"] = False
        if self._reasoning and (
            self._reasoning_summary or getattr(prompt.options, "reasoning_effort", None)
        ):
            kwargs["include"] = ["reasoning.encrypted_content"]

        client = self.get_client(key)
        usage = None
        had_reasoning = False
        if stream:
            stream_obj = client.responses.create(
                model=self.model_name or self.model_id,
                input=input_items,
                stream=True,
                **kwargs,
            )
            tool_call_meta: dict[str, dict[str, str]] = {}
            final_response_dict: dict[str, Any] | None = None
            reasoning_items_with_streamed_text = set()
            for event in stream_obj:
                etype = getattr(event, "type", None)
                if etype == "response.output_item.added":
                    item = event.item
                    if item.type == "function_call":
                        tool_call_meta[item.id] = {
                            "id": item.id,
                            "call_id": item.call_id,
                            "name": item.name,
                        }
                        yield StreamEvent(
                            type="tool_call_name",
                            chunk=item.name or "",
                            tool_call_id=item.call_id,
                        )
                elif etype == "response.output_text.delta":
                    yield StreamEvent(type="text", chunk=event.delta or "")
                elif etype == "response.function_call_arguments.delta":
                    item_id = getattr(event, "item_id", None)
                    meta = tool_call_meta.get(item_id) if item_id else None
                    call_id = meta["call_id"] if meta else None
                    yield StreamEvent(
                        type="tool_call_args",
                        chunk=event.delta or "",
                        tool_call_id=call_id,
                    )
                elif etype in (
                    "response.reasoning_summary_text.delta",
                    "response.reasoning_text.delta",
                ):
                    item_id = getattr(event, "item_id", None)
                    if item_id:
                        reasoning_items_with_streamed_text.add(item_id)
                    yield StreamEvent(type="reasoning", chunk=event.delta or "")
                elif etype in (
                    "response.reasoning_summary_text.done",
                    "response.reasoning_text.done",
                ):
                    item_id = getattr(event, "item_id", None)
                    if item_id not in reasoning_items_with_streamed_text:
                        text = getattr(event, "text", None) or ""
                        if text:
                            if item_id:
                                reasoning_items_with_streamed_text.add(item_id)
                            yield StreamEvent(type="reasoning", chunk=text)
                elif etype == "response.output_item.done":
                    item = event.item
                    if item.type == "reasoning":
                        had_reasoning = True
                        item_id = getattr(item, "id", None)
                        yield self._reasoning_event(
                            item,
                            include_text=(
                                item_id not in reasoning_items_with_streamed_text
                            ),
                        )
                    elif item.type == "function_call":
                        try:
                            args = json.loads(item.arguments) if item.arguments else {}
                        except json.JSONDecodeError:
                            args = {"_raw": item.arguments}
                        response.add_tool_call(
                            llm.ToolCall(
                                tool_call_id=item.call_id,
                                name=item.name,
                                arguments=args,
                            )
                        )
                elif etype == "response.completed":
                    final_response_dict = event.response.model_dump()
                    if final_response_dict.get("usage"):
                        usage = final_response_dict["usage"]
            if final_response_dict is not None:
                response.response_json = remove_dict_none_values(final_response_dict)
        else:
            completion = client.responses.create(
                model=self.model_name or self.model_id,
                input=input_items,
                stream=False,
                **kwargs,
            )
            dumped = completion.model_dump()
            response.response_json = remove_dict_none_values(dumped)
            usage = dumped.get("usage")
            for item in completion.output:
                if item.type == "reasoning":
                    had_reasoning = True
                    yield self._reasoning_event(item)
                elif item.type == "function_call":
                    try:
                        args = json.loads(item.arguments) if item.arguments else {}
                    except json.JSONDecodeError:
                        args = {"_raw": item.arguments}
                    response.add_tool_call(
                        llm.ToolCall(
                            tool_call_id=item.call_id,
                            name=item.name,
                            arguments=args,
                        )
                    )
                    yield StreamEvent(
                        type="tool_call_name",
                        chunk=item.name or "",
                        tool_call_id=item.call_id,
                    )
                    yield StreamEvent(
                        type="tool_call_args",
                        chunk=item.arguments or "",
                        tool_call_id=item.call_id,
                    )
                elif item.type == "message":
                    for content in item.content or []:
                        ctype = getattr(content, "type", None)
                        if ctype == "output_text" and content.text:
                            yield StreamEvent(type="text", chunk=content.text)

        self._set_usage_responses(response, usage)
        # Fallback: usage said reasoning happened but the API gave us no
        # reasoning items to harvest encrypted_content from. Emit the
        # opaque "reasoning happened" marker for UI / token accounting.
        if (
            not had_reasoning
            and usage
            and ((usage.get("output_tokens_details") or {}).get("reasoning_tokens"))
        ):
            yield StreamEvent(type="reasoning", chunk="", redacted=True)
        response._prompt_json = redact_data(
            {"input": input_items, "instructions": instructions}
        )


class AsyncResponses(_SharedResponses, AsyncKeyModel):
    needs_key = "openai"
    key_env_var = "OPENAI_API_KEY"
    default_max_tokens = None

    def __init__(
        self,
        model_id,
        key=None,
        model_name=None,
        api_base=None,
        api_type=None,
        api_version=None,
        api_engine=None,
        headers=None,
        can_stream=True,
        vision=False,
        audio=False,
        reasoning=False,
        verbosity=False,
        image_detail_original=False,
        supports_schema=False,
        supports_tools=False,
        allows_system_prompt=True,
        reasoning_summary=True,
    ):
        super().__init__(
            model_id,
            key=key,
            model_name=model_name,
            api_base=api_base,
            api_type=api_type,
            api_version=api_version,
            api_engine=api_engine,
            headers=headers,
            can_stream=can_stream,
            vision=vision,
            audio=audio,
            reasoning=reasoning,
            verbosity=verbosity,
            image_detail_original=image_detail_original,
            supports_schema=supports_schema,
            supports_tools=supports_tools,
            allows_system_prompt=allows_system_prompt,
        )
        self._reasoning = reasoning
        self._reasoning_summary = reasoning_summary
        self._verbosity = verbosity
        self._image_detail_original = image_detail_original
        self.Options = build_options_class(
            reasoning=reasoning,
            verbosity=verbosity,
            image_detail_original=image_detail_original,
            chat_completions=True,
        )

    async def execute(
        self,
        prompt: Prompt,
        stream: bool,
        response: AsyncResponse,
        conversation: AsyncConversation | None = None,
        key: str | None = None,
    ) -> AsyncGenerator[str | StreamEvent, None]:
        if getattr(prompt.options, "chat_completions", None):
            chat = AsyncChat(**self._delegate_chat_kwargs())
            async for event in chat.execute(
                prompt, stream, response, conversation, key
            ):
                yield event
            return

        if prompt.system and not self.allows_system_prompt:
            raise NotImplementedError("Model does not support system prompts")

        image_detail = getattr(prompt.options, "image_detail", None)
        if image_detail is not None:
            image_detail = image_detail.value
        input_items, instructions = self._build_responses_input(
            prompt, image_detail=image_detail
        )
        kwargs = self._build_responses_kwargs(prompt, stream)
        if instructions is not None:
            kwargs["instructions"] = instructions
        kwargs["store"] = False
        if self._reasoning and (
            self._reasoning_summary or getattr(prompt.options, "reasoning_effort", None)
        ):
            kwargs["include"] = ["reasoning.encrypted_content"]

        client = self.get_client(key, async_=True)
        usage = None
        had_reasoning = False
        if stream:
            stream_obj = await client.responses.create(
                model=self.model_name or self.model_id,
                input=input_items,
                stream=True,
                **kwargs,
            )
            tool_call_meta: dict[str, dict[str, str]] = {}
            final_response_dict: dict[str, Any] | None = None
            reasoning_items_with_streamed_text = set()
            async for event in stream_obj:
                etype = getattr(event, "type", None)
                if etype == "response.output_item.added":
                    item = event.item
                    if item.type == "function_call":
                        tool_call_meta[item.id] = {
                            "id": item.id,
                            "call_id": item.call_id,
                            "name": item.name,
                        }
                        yield StreamEvent(
                            type="tool_call_name",
                            chunk=item.name or "",
                            tool_call_id=item.call_id,
                        )
                elif etype == "response.output_text.delta":
                    yield StreamEvent(type="text", chunk=event.delta or "")
                elif etype == "response.function_call_arguments.delta":
                    item_id = getattr(event, "item_id", None)
                    meta = tool_call_meta.get(item_id) if item_id else None
                    call_id = meta["call_id"] if meta else None
                    yield StreamEvent(
                        type="tool_call_args",
                        chunk=event.delta or "",
                        tool_call_id=call_id,
                    )
                elif etype in (
                    "response.reasoning_summary_text.delta",
                    "response.reasoning_text.delta",
                ):
                    item_id = getattr(event, "item_id", None)
                    if item_id:
                        reasoning_items_with_streamed_text.add(item_id)
                    yield StreamEvent(type="reasoning", chunk=event.delta or "")
                elif etype in (
                    "response.reasoning_summary_text.done",
                    "response.reasoning_text.done",
                ):
                    item_id = getattr(event, "item_id", None)
                    if item_id not in reasoning_items_with_streamed_text:
                        text = getattr(event, "text", None) or ""
                        if text:
                            if item_id:
                                reasoning_items_with_streamed_text.add(item_id)
                            yield StreamEvent(type="reasoning", chunk=text)
                elif etype == "response.output_item.done":
                    item = event.item
                    if item.type == "reasoning":
                        had_reasoning = True
                        item_id = getattr(item, "id", None)
                        yield self._reasoning_event(
                            item,
                            include_text=(
                                item_id not in reasoning_items_with_streamed_text
                            ),
                        )
                    elif item.type == "function_call":
                        try:
                            args = json.loads(item.arguments) if item.arguments else {}
                        except json.JSONDecodeError:
                            args = {"_raw": item.arguments}
                        response.add_tool_call(
                            llm.ToolCall(
                                tool_call_id=item.call_id,
                                name=item.name,
                                arguments=args,
                            )
                        )
                elif etype == "response.completed":
                    final_response_dict = event.response.model_dump()
                    if final_response_dict.get("usage"):
                        usage = final_response_dict["usage"]
            if final_response_dict is not None:
                response.response_json = remove_dict_none_values(final_response_dict)
        else:
            completion = await client.responses.create(
                model=self.model_name or self.model_id,
                input=input_items,
                stream=False,
                **kwargs,
            )
            dumped = completion.model_dump()
            response.response_json = remove_dict_none_values(dumped)
            usage = dumped.get("usage")
            for item in completion.output:
                if item.type == "reasoning":
                    had_reasoning = True
                    yield self._reasoning_event(item)
                elif item.type == "function_call":
                    try:
                        args = json.loads(item.arguments) if item.arguments else {}
                    except json.JSONDecodeError:
                        args = {"_raw": item.arguments}
                    response.add_tool_call(
                        llm.ToolCall(
                            tool_call_id=item.call_id,
                            name=item.name,
                            arguments=args,
                        )
                    )
                    yield StreamEvent(
                        type="tool_call_name",
                        chunk=item.name or "",
                        tool_call_id=item.call_id,
                    )
                    yield StreamEvent(
                        type="tool_call_args",
                        chunk=item.arguments or "",
                        tool_call_id=item.call_id,
                    )
                elif item.type == "message":
                    for content in item.content or []:
                        ctype = getattr(content, "type", None)
                        if ctype == "output_text" and content.text:
                            yield StreamEvent(type="text", chunk=content.text)

        self._set_usage_responses(response, usage)
        if (
            not had_reasoning
            and usage
            and ((usage.get("output_tokens_details") or {}).get("reasoning_tokens"))
        ):
            yield StreamEvent(type="reasoning", chunk="", redacted=True)
        response._prompt_json = redact_data(
            {"input": input_items, "instructions": instructions}
        )


class Completion(Chat):
    class Options(SharedOptions):
        logprobs: int | None = Field(
            description="Include the log probabilities of most likely N per token",
            default=None,
            le=5,
        )

    def __init__(self, *args, default_max_tokens=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.default_max_tokens = default_max_tokens

    def __str__(self) -> str:
        return f"OpenAI Completion: {self.model_id}"

    def execute(
        self,
        prompt: Prompt,
        stream: bool,
        response: Response,
        conversation: Conversation | None = None,
        key: str | None = None,
    ) -> Iterator[str | StreamEvent]:
        if prompt.system:
            raise NotImplementedError(
                "System prompts are not supported for OpenAI completion models"
            )
        from llm.parts import TextPart

        # prompt.messages carries the full history - including history
        # reloaded from storage, which conversation.responses does not.
        messages = []
        for message in prompt.messages:
            if message.role not in ("user", "assistant"):
                continue
            text = "".join(
                part.text
                for part in message.parts
                if isinstance(part, TextPart) and part.text
            )
            if text:
                messages.append(text)
        kwargs = self.build_kwargs(prompt, stream)
        client = self.get_client(key)
        if stream:
            completion = client.completions.create(
                model=self.model_name or self.model_id,
                prompt="\n".join(messages),
                stream=True,
                **kwargs,
            )
            chunks = []
            for chunk in completion:
                chunks.append(chunk)
                try:
                    content = chunk.choices[0].text
                except IndexError:
                    content = None
                if content is not None:
                    yield content
            combined = combine_chunks(chunks)
            cleaned = remove_dict_none_values(combined)
            response.response_json = cleaned
        else:
            completion = client.completions.create(
                model=self.model_name or self.model_id,
                prompt="\n".join(messages),
                stream=False,
                **kwargs,
            )
            response.response_json = remove_dict_none_values(completion.model_dump())
            yield completion.choices[0].text
        response._prompt_json = redact_data({"messages": messages})


def not_nulls(data) -> dict:
    return {key: value for key, value in data if value is not None}


def combine_chunks(chunks: list) -> dict:
    content = ""
    role = None
    finish_reason = None
    # If any of them have log probability, we're going to persist
    # those later on
    logprobs = []
    usage = {}

    for item in chunks:
        if item.usage:
            usage = item.usage.model_dump()
        for choice in item.choices:
            if choice.logprobs and hasattr(choice.logprobs, "top_logprobs"):
                logprobs.append(
                    {
                        "text": choice.text if hasattr(choice, "text") else None,
                        "top_logprobs": choice.logprobs.top_logprobs,
                    }
                )

            if not hasattr(choice, "delta"):
                content += choice.text
                continue
            role = choice.delta.role
            if choice.delta.content is not None:
                content += choice.delta.content
            if choice.finish_reason is not None:
                finish_reason = choice.finish_reason

    # Imitations of the OpenAI API may be missing some of these fields
    combined = {
        "content": content,
        "role": role,
        "finish_reason": finish_reason,
        "usage": usage,
    }
    if logprobs:
        combined["logprobs"] = logprobs
    if chunks:
        for key in ("id", "object", "model", "created", "index"):
            value = getattr(chunks[0], key, None)
            if value is not None:
                combined[key] = value

    return combined


def redact_data(input_dict):
    """
    Recursively search through the input dictionary for any 'image_url' keys
    and modify the 'url' value to be just 'data:...'.

    Also redact input_audio.data keys
    """
    if isinstance(input_dict, dict):
        for key, value in input_dict.items():
            if (
                key == "image_url"
                and isinstance(value, dict)
                and "url" in value
                and value["url"].startswith("data:")
            ):
                value["url"] = "data:..."
            elif key == "input_audio" and isinstance(value, dict) and "data" in value:
                value["data"] = "..."
            else:
                redact_data(value)
    elif isinstance(input_dict, list):
        for item in input_dict:
            redact_data(item)
    return input_dict
